diff --git a/.CI/CreateAppImage.sh b/.CI/CreateAppImage.sh index d245ec9f1e7..12995b33a4b 100755 --- a/.CI/CreateAppImage.sh +++ b/.CI/CreateAppImage.sh @@ -2,13 +2,28 @@ set -e +# Print all commands as they are run +set -x + if [ ! -f ./bin/chatterino ] || [ ! -x ./bin/chatterino ]; then echo "ERROR: No chatterino binary file found. This script must be run in the build folder, and chatterino must be built first." exit 1 fi -export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/opt/qt512/lib/" -export PATH="/opt/qt512/bin:$PATH" +if [ -n "$Qt5_DIR" ]; then + echo "Using Qt DIR from Qt5_DIR: $Qt5_DIR" + _QT_DIR="$Qt5_DIR" +elif [ -n "$Qt6_DIR" ]; then + echo "Using Qt DIR from Qt6_DIR: $Qt6_DIR" + _QT_DIR="$Qt6_DIR" +fi + +if [ -n "$_QT_DIR" ]; then + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${_QT_DIR}/lib" + export PATH="${_QT_DIR}/bin:$PATH" +else + echo "No Qt environment variable set, assuming system-installed Qt" +fi script_path=$(readlink -f "$0") script_dir=$(dirname "$script_path") @@ -25,20 +40,32 @@ echo "" cp "$chatterino_dir"/resources/icon.png ./appdir/chatterino.png -linuxdeployqt_path="linuxdeployqt-6-x86_64.AppImage" -linuxdeployqt_url="https://github.com/probonopd/linuxdeployqt/releases/download/6/linuxdeployqt-6-x86_64.AppImage" +linuxdeployqt_path="linuxdeployqt-x86_64.AppImage" +linuxdeployqt_url="https://github.com/probonopd/linuxdeployqt/releases/download/continuous/linuxdeployqt-continuous-x86_64.AppImage" if [ ! -f "$linuxdeployqt_path" ]; then - wget -nv "$linuxdeployqt_url" + echo "Downloading LinuxDeployQT from $linuxdeployqt_url to $linuxdeployqt_path" + curl --location --fail --silent "$linuxdeployqt_url" -o "$linuxdeployqt_path" chmod a+x "$linuxdeployqt_path" fi -if [ ! -f appimagetool-x86_64.AppImage ]; then - wget -nv "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod a+x appimagetool-x86_64.AppImage + +appimagetool_path="appimagetool-x86_64.AppImage" +appimagetool_url="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + +if [ ! -f "$appimagetool_path" ]; then + echo "Downloading AppImageTool from $appimagetool_url to $appimagetool_path" + curl --location --fail --silent "$appimagetool_url" -o "$appimagetool_path" + chmod a+x "$appimagetool_path" fi + +# For some reason, the copyright file for libc was not found. We need to manually copy it from the host system +mkdir -p appdir/usr/share/doc/libc6/ +cp /usr/share/doc/libc6/copyright appdir/usr/share/doc/libc6/ + echo "Run LinuxDeployQT" ./"$linuxdeployqt_path" \ - appdir/usr/share/applications/*.desktop \ + --appimage-extract-and-run \ + appdir/usr/share/applications/com.chatterino.chatterino.desktop \ -no-translations \ -bundle-non-qt-libs \ -unsupported-allow-new-glibc @@ -56,7 +83,9 @@ cd "$here/usr" exec "$here/usr/bin/chatterino" "$@"' > appdir/AppRun chmod a+x appdir/AppRun -./appimagetool-x86_64.AppImage appdir +./"$appimagetool_path" \ + --appimage-extract-and-run \ + appdir # TODO: Create appimage in a unique directory instead maybe idk? rm -rf appdir diff --git a/.CI/CreateDMG.sh b/.CI/CreateDMG.sh index 3eb2202c4b8..7174eb6052d 100755 --- a/.CI/CreateDMG.sh +++ b/.CI/CreateDMG.sh @@ -1,18 +1,38 @@ -#!/bin/sh +#!/usr/bin/env bash -if [ -d bin/chatterino.app ] && [ ! -d chatterino.app ]; then - >&2 echo "Moving bin/chatterino.app down one directory" - mv bin/chatterino.app chatterino.app +set -eo pipefail + +if [ ! -d chatterino.app ]; then + echo "ERROR: No 'chatterino.app' dir found in the build directory. Make sure you've run ./CI/MacDeploy.sh" + exit 1 +fi + +if [ -z "$OUTPUT_DMG_PATH" ]; then + echo "ERROR: Must specify the path for where to save the final .dmg. Make sure you've set the OUTPUT_DMG_PATH environment variable." + exit 1 +fi + +if [ -z "$SKIP_VENV" ]; then + echo "Creating python3 virtual environment" + python3 -m venv venv + echo "Entering python3 virtual environment" + . venv/bin/activate + echo "Installing dmgbuild" + python3 -m pip install dmgbuild +fi + +if [ -n "$MACOS_CODESIGN_CERTIFICATE" ]; then + echo "Codesigning force deep inside the app" + codesign -s "$MACOS_CODESIGN_CERTIFICATE" --deep --force chatterino.app + echo "Done!" fi -echo "Running MACDEPLOYQT" -$Qt5_DIR/bin/macdeployqt chatterino.app -echo "Creating python3 virtual environment" -python3 -m venv venv -echo "Entering python3 virtual environment" -. venv/bin/activate -echo "Installing dmgbuild" -python3 -m pip install dmgbuild echo "Running dmgbuild.." -dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 chatterino-osx.dmg +dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 "$OUTPUT_DMG_PATH" echo "Done!" + +if [ -n "$MACOS_CODESIGN_CERTIFICATE" ]; then + echo "Codesigning the dmg" + codesign -s "$MACOS_CODESIGN_CERTIFICATE" --deep --force "$OUTPUT_DMG_PATH" + echo "Done!" +fi diff --git a/.CI/CreateUbuntuDeb.sh b/.CI/CreateUbuntuDeb.sh index 9b89fddb8e9..edfc26c6ca5 100755 --- a/.CI/CreateUbuntuDeb.sh +++ b/.CI/CreateUbuntuDeb.sh @@ -24,7 +24,12 @@ case "$ubuntu_release" in dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.71.0" ;; 22.04) - dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.74.0" + if [ -n "$Qt6_DIR" ]; then + echo "Qt6_DIR set, assuming Qt6" + dependencies="libc6, libstdc++6, libqt6core6, libqt6widgets6, libqt6network6, libqt6core5compat6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins" + else + dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.74.0" + fi ;; *) echo "Unsupported Ubuntu release $ubuntu_release" diff --git a/.CI/MacDeploy.sh b/.CI/MacDeploy.sh new file mode 100755 index 00000000000..c798bfe166c --- /dev/null +++ b/.CI/MacDeploy.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# Bundle relevant qt & system dependencies into the ./chatterino.app folder + +set -eo pipefail + +if [ -d bin/chatterino.app ] && [ ! -d chatterino.app ]; then + >&2 echo "Moving bin/chatterino.app down one directory" + mv bin/chatterino.app chatterino.app +fi + +if [ -n "$Qt5_DIR" ]; then + echo "Using Qt DIR from Qt5_DIR: $Qt5_DIR" + _QT_DIR="$Qt5_DIR" +elif [ -n "$Qt6_DIR" ]; then + echo "Using Qt DIR from Qt6_DIR: $Qt6_DIR" + _QT_DIR="$Qt6_DIR" +fi + +if [ -n "$_QT_DIR" ]; then + export PATH="${_QT_DIR}/bin:$PATH" +else + echo "No Qt environment variable set, assuming system-installed Qt" +fi + +echo "Running MACDEPLOYQT" + +_macdeployqt_args=() + +if [ -n "$MACOS_CODESIGN_CERTIFICATE" ]; then + _macdeployqt_args+=("-codesign=$MACOS_CODESIGN_CERTIFICATE") +fi + +macdeployqt chatterino.app "${_macdeployqt_args[@]}" + +if [ -n "$MACOS_CODESIGN_CERTIFICATE" ]; then + # Validate that chatterino.app was codesigned correctly + codesign -v chatterino.app +fi diff --git a/.CI/build-installer.ps1 b/.CI/build-installer.ps1 new file mode 100644 index 00000000000..756a1503f29 --- /dev/null +++ b/.CI/build-installer.ps1 @@ -0,0 +1,51 @@ +if (-not (Test-Path -PathType Container Chatterino2)) { + Write-Error "Couldn't find a folder called 'Chatterino2' in the current directory."; + exit 1 +} + +# Check if we're on a tag +$OldErrorActionPref = $ErrorActionPreference; +$ErrorActionPreference = 'Continue'; +git describe --exact-match --match 'v*' *> $null; +$isTagged = $?; +$ErrorActionPreference = $OldErrorActionPref; + +$defines = $null; +if ($isTagged) { + # This is a release. + # Make sure, any existing `modes` file is overwritten for the user, + # for example when updating from nightly to stable. + Write-Output "" | Out-File Chatterino2/modes -Encoding ASCII; + $installerBaseName = "Chatterino.Installer"; +} +else { + Write-Output nightly | Out-File Chatterino2/modes -Encoding ASCII; + $defines = "/DIS_NIGHTLY=1"; + $installerBaseName = "Chatterino.Nightly.Installer"; +} + +if ($Env:GITHUB_OUTPUT) { + # This is used in CI when creating the artifact + "C2_INSTALLER_BASE_NAME=$installerBaseName" >> "$Env:GITHUB_OUTPUT" +} + +# Copy vc_redist.x64.exe +if ($null -eq $Env:VCToolsRedistDir) { + Write-Error "VCToolsRedistDir is not set. Forgot to set Visual Studio environment variables?"; + exit 1 +} +Copy-Item "$Env:VCToolsRedistDir\vc_redist.x64.exe" .; + +$VCRTVersion = (Get-Item "$Env:VCToolsRedistDir\vc_redist.x64.exe").VersionInfo; + +# Build the installer +ISCC ` + /DWORKING_DIR="$($pwd.Path)\" ` + /DINSTALLER_BASE_NAME="$installerBaseName" ` + /DSHIPPED_VCRT_BUILD="$($VCRTVersion.FileBuildPart)" ` + /DSHIPPED_VCRT_VERSION="$($VCRTVersion.FileDescription)" ` + $defines ` + /O. ` + "$PSScriptRoot\chatterino-installer.iss"; + +Move-Item "$installerBaseName.exe" "$installerBaseName$($Env:VARIANT_SUFFIX).exe" diff --git a/.CI/chatterino-installer.iss b/.CI/chatterino-installer.iss new file mode 100644 index 00000000000..2e3edbf5202 --- /dev/null +++ b/.CI/chatterino-installer.iss @@ -0,0 +1,134 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "Chatterino" +#define MyAppVersion "2.4.6" +#define MyAppPublisher "Chatterino Team" +#define MyAppURL "https://www.chatterino.com" +#define MyAppExeName "chatterino.exe" + +; used in build-installer.ps1 +; if set, must end in a backslash +#ifndef WORKING_DIR +#define WORKING_DIR "" +#endif + +; Set to the build part of the VCRT version +#ifndef SHIPPED_VCRT_BUILD +#define SHIPPED_VCRT_BUILD 0 +#endif +; Set to the string representation of the VCRT version +#ifndef SHIPPED_VCRT_VERSION +#define SHIPPED_VCRT_VERSION ? +#endif + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{F5FE6614-04D4-4D32-8600-0ABA0AC113A4} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +VersionInfoVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +ArchitecturesInstallIn64BitMode=x64 +;Uncomment the following line to run in non administrative install mode (install for current user only.) +;PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=out +; This is defined by the build-installer.ps1 script, +; but kept optional for regular use. +#ifdef INSTALLER_BASE_NAME +OutputBaseFilename={#INSTALLER_BASE_NAME} +#else +OutputBaseFilename=Chatterino.Installer +#endif +Compression=lzma +SolidCompression=yes +WizardStyle=modern +UsePreviousTasks=no +UninstallDisplayIcon={app}\{#MyAppExeName} +RestartIfNeededByRun=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +#ifdef IS_NIGHTLY +[Messages] +SetupAppTitle=Setup (Nightly) +SetupWindowTitle=Setup - %1 (Nightly) +#endif + +[Tasks] +; Only show this option if the VCRT can be updated. +Name: "vcredist"; Description: "Install the required {#SHIPPED_VCRT_VERSION} ({code:VCRTDescription})"; Check: NeedsNewVCRT(); +; GroupDescription: "{cm:AdditionalIcons}"; +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; Flags: unchecked +Name: "freshinstall"; Description: "Fresh install (delete old settings/logs)"; Flags: unchecked + +[Files] +Source: "{#WORKING_DIR}Chatterino2\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#WORKING_DIR}vc_redist.x64.exe"; DestDir: "{tmp}"; Tasks: vcredist; +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +; VC++ redistributable +Filename: {tmp}\vc_redist.x64.exe; Parameters: "/install /passive /norestart"; StatusMsg: "Installing 64-bit Windows Universal Runtime..."; Flags: waituntilterminated; Tasks: vcredist +; Run chatterino +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[InstallDelete] +; Delete cache on install +Type: filesandordirs; Name: "{userappdata}\Chatterino2\Cache" +; Delete %appdata%\Chatterino2 on freshinstall +Type: filesandordirs; Name: "{userappdata}\Chatterino2"; Tasks: freshinstall + +[UninstallDelete] +; Delete cache on uninstall +Type: filesandordirs; Name: "{userappdata}\Chatterino2\Cache" + +[Code] +// Get the VCRT version as a string. Null if the version could not be found. +function GetVCRT(): Variant; +var + VCRTVersion: String; +begin + Result := Null; + if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Version', VCRTVersion) then + Result := VCRTVersion; +end; + +// Gets a description about the VCRT installed vs shipped. +// This doesn't compare the versions. +function VCRTDescription(Param: String): String; +var + VCRTVersion: Variant; +begin + VCRTVersion := GetVCRT; + if VarIsNull(VCRTVersion) then + Result := 'none is installed' + else + Result := VCRTVersion + ' is installed'; +end; + +// Checks if a new VCRT is needed by comparing the builds. +function NeedsNewVCRT(): Boolean; +var + VCRTBuild: Cardinal; +begin + Result := True; + if RegQueryDWordValue(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Bld', VCRTBuild) then + begin + if VCRTBuild >= {#SHIPPED_VCRT_BUILD} then + Result := False; + end; +end; diff --git a/.CI/chatterino-nightly.flatpakref b/.CI/chatterino-nightly.flatpakref new file mode 100644 index 00000000000..61cf409e3d7 --- /dev/null +++ b/.CI/chatterino-nightly.flatpakref @@ -0,0 +1,9 @@ +[Flatpak Ref] +Name=com.chatterino.chatterino +Branch=beta +Title=com.chatterino.chatterino from flathub +IsRuntime=false +Url=https://dl.flathub.org/beta-repo/ +SuggestRemoteName=flathub-beta +GPGKey=mQINBFlD2sABEADsiUZUOYBg1UdDaWkEdJYkTSZD68214m8Q1fbrP5AptaUfCl8KYKFMNoAJRBXn9FbE6q6VBzghHXj/rSnA8WPnkbaEWR7xltOqzB1yHpCQ1l8xSfH5N02DMUBSRtD/rOYsBKbaJcOgW0K21sX+BecMY/AI2yADvCJEjhVKrjR9yfRX+NQEhDcbXUFRGt9ZT+TI5yT4xcwbvvTu7aFUR/dH7+wjrQ7lzoGlZGFFrQXSs2WI0WaYHWDeCwymtohXryF8lcWQkhH8UhfNJVBJFgCY8Q6UHkZG0FxMu8xnIDBMjBmSZKwKQn0nwzwM2afskZEnmNPYDI8nuNsSZBZSAw+ThhkdCZHZZRwzmjzyRuLLVFpOj3XryXwZcSefNMPDkZAuWWzPYjxS80cm2hG1WfqrG0Gl8+iX69cbQchb7gbEb0RtqNskTo9DDmO0bNKNnMbzmIJ3/rTbSahKSwtewklqSP/01o0WKZiy+n/RAkUKOFBprjJtWOZkc8SPXV/rnoS2dWsJWQZhuPPtv3tefdDiEyp7ePrfgfKxuHpZES0IZRiFI4J/nAUP5bix+srcIxOVqAam68CbAlPvWTivRUMRVbKjJiGXIOJ78wAMjqPg3QIC0GQ0EPAWwAOzzpdgbnG7TCQetaVV8rSYCuirlPYN+bJIwBtkOC9SWLoPMVZTwQARAQABtC5GbGF0aHViIFJlcG8gU2lnbmluZyBLZXkgPGZsYXRodWJAZmxhdGh1Yi5vcmc+iQJUBBMBCAA+FiEEblwF2XnHba+TwIE1QYTdTZB6fK4FAllD2sACGwMFCRLMAwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQQYTdTZB6fK5RJQ/+Ptd4sWxaiAW91FFk7+wmYOkEe1NY2UDNJjEEz34PNP/1RoxveHDt43kYJQ23OWaPJuZAbu+fWtjRYcMBzOsMCaFcRSHFiDIC9aTp4ux/mo+IEeyarYt/oyKb5t5lta6xaAqg7rwt65jW5/aQjnS4h7eFZ+dAKta7Y/fljNrOznUp81/SMcx4QA5G2Pw0hs4Xrxg59oONOTFGBgA6FF8WQghrpR7SnEe0FSEOVsAjwQ13Cfkfa7b70omXSWp7GWfUzgBKyoWxKTqzMN3RQHjjhPJcsQnrqH5enUu4Pcb2LcMFpzimHnUgb9ft72DP5wxfzHGAWOUiUXHbAekfq5iFks8cha/RST6wkxG3Rf44Zn09aOxh1btMcGL+5xb1G0BuCQnA0fP/kDYIPwh9z22EqwRQOspIcvGeLVkFeIfubxpcMdOfQqQnZtHMCabV5Q/Rk9K1ZGc8M2hlg8gHbXMFch2xJ0Wu72eXbA/UY5MskEeBgawTQnQOK/vNm7t0AJMpWK26Qg6178UmRghmeZDj9uNRc3EI1nSbgvmGlpDmCxaAGqaGL1zW4KPW5yN25/qeqXcgCvUjZLI9PNq3Kvizp1lUrbx7heRiSoazCucvHQ1VHUzcPVLUKKTkoTP8okThnRRRsBcZ1+jI4yMWIDLOCT7IW3FePr+3xyuy5eEo9a25Ag0EWUPa7AEQALT/CmSyZ8LWlRYQZKYw417p7Z2hxqd6TjwkwM3IQ1irumkWcTZBZIbBgrSOg6CcXD2oWydCQHWi9qaxhuhEl2bJL5LskmBcMxVdQeD0LLHd8QUnbnnIby8ocvWN1alPfvJFjCUTrmD22U1ycOzRw2lIe4kiQONbOZtdWrVImQQSndjFlisitbmlWHvHm2lOOYy8+GJB7YffVV193hmnBSJffCy4bvkuLxsI+n1DhOzc7MPV3z6HGk4HiEcF0yyt9tCYhpsxHFdBoq2h771HfAcS0s98EVAqYMFnf9em+4cnYpdI6mhIfS1FQiKl6DBAYA8tT3ggla00DurPo0JwX/zN+PaO5h/6O9aCZwV7G6rbkgMuqMergXaf8oP38gr0z+MqWnkfM63Bodq68GP4l4hd02BoFBbDf38TMuGQB14+twJMdfbAxo2MbgluvQgfwHfZ2ca6gyEY+9s/YD1gugLjV+S6CB51WkFNe1z4tAPgJZNxUcKCbeaHNbthl8Hks/pY9RCEseX/EdfzF18epbSjJMPh4DPQXbUoFwmyuYcoBOPmvZHNl9hK7B/1RP8w1ZrXk8qdupC0SNbafX7270B7lMMVImzZetGsM9ypXJ6llhp3FwW09iseNyGJGPsr/dvTMGDXqOPfU/9SAS1LSTY4K9PbRtdrBE318YX8mIk5ABEBAAGJBHIEGAEIACYWIQRuXAXZecdtr5PAgTVBhN1NkHp8rgUCWUPa7AIbAgUJEswDAAJACRBBhN1NkHp8rsF0IAQZAQgAHRYhBFSmzd2JGfsgQgDYrFYnAunj7X7oBQJZQ9rsAAoJEFYnAunj7X7oR6AP/0KYmiAFeqx14Z43/6s2gt3VhxlSd8bmcVV7oJFbMhdHBIeWBp2BvsUf00I0Zl14ZkwCKfLwbbORC2eIxvzJ+QWjGfPhDmS4XUSmhlXxWnYEveSek5Tde+fmu6lqKM8CHg5BNx4GWIX/vdLi1wWJZyhrUwwICAxkuhKxuP2Z1An48930eslTD2GGcjByc27+9cIZjHKa07I/aLffo04V+oMT9/tgzoquzgpVV4jwekADo2MJjhkkPveSNI420bgT+Q7Fi1l0X1aFUniBvQMsaBa27PngWm6xE2ZYvh7nWCdd5g0c0eLIHxWwzV1lZ4Ryx4ITO/VL25ItECcjhTRdYa64sA62MYSaB0x3eR+SihpgP3wSNPFu3MJo6FKTFdi4CBAEmpWHFW7FcRmd+cQXeFrHLN3iNVWryy0HK/CUEJmiZEmpNiXecl4vPIIuyF0zgSCztQtKoMr+injpmQGC/rF/ELBVZTUSLNB350S0Ztvw0FKWDAJSxFmoxt3xycqvvt47rxTrhi78nkk6jATKGyvP55sO+K7Q7Wh0DXA69hvPrYW2eu8jGCdVGxi6HX7L1qcfEd0378S71dZ3g9o6KKl1OsDWWQ6MJ6FGBZedl/ibRfs8p5+sbCX3lQSjEFy3rx6n0rUrXx8U2qb+RCLzJlmC5MNBOTDJwHPcX6gKsUcXZrEQALmRHoo3SrewO41RCr+5nUlqiqV3AohBMhnQbGzyHf2+drutIaoh7Rj80XRh2bkkuPLwlNPf+bTXwNVGse4bej7B3oV6Ae1N7lTNVF4Qh+1OowtGjmfJPWo0z1s6HFJVxoIof9z58Msvgao0zrKGqaMWaNQ6LUeC9g9Aj/9Uqjbo8X54aLiYs8Z1WNc06jKP+gv8AWLtv6CR+l2kLez1YMDucjm7v6iuCMVAmZdmxhg5I/X2+OM3vBsqPDdQpr2TPDLX3rCrSBiS0gOQ6DwN5N5QeTkxmY/7QO8bgLo/Wzu1iilH4vMKW6LBKCaRx5UEJxKpL4wkgITsYKneIt3NTHo5EOuaYk+y2+Dvt6EQFiuMsdbfUjs3seIHsghX/cbPJa4YUqZAL8C4OtVHaijwGo0ymt9MWvS9yNKMyT0JhN2/BdeOVWrHk7wXXJn/ZjpXilicXKPx4udCF76meE+6N2u/T+RYZ7fP1QMEtNZNmYDOfA6sViuPDfQSHLNbauJBo/n1sRYAsL5mcG22UDchJrlKvmK3EOADCQg+myrm8006LltubNB4wWNzHDJ0Ls2JGzQZCd/xGyVmUiidCBUrD537WdknOYE4FD7P0cHaM9brKJ/M8LkEH0zUlo73bY4XagbnCqve6PvQb5G2Z55qhWphd6f4B6DGed86zJEa/RhS +RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo diff --git a/src/.clang-format b/.clang-format similarity index 91% rename from src/.clang-format rename to .clang-format index 7bae09f2ce3..0feaad9dc10 100644 --- a/src/.clang-format +++ b/.clang-format @@ -32,9 +32,6 @@ IncludeCategories: # Project includes - Regex: '^"[a-zA-Z\._-]+(/[a-zA-Z0-9\._-]+)*"$' Priority: 1 - # Third party library includes - - Regex: '<[[:alnum:].]+/[a-zA-Z0-9\._\/-]+>' - Priority: 3 # Qt includes - Regex: '^$' Priority: 3 @@ -42,12 +39,12 @@ IncludeCategories: # LibCommuni includes - Regex: "^$" Priority: 3 - # Misc libraries - - Regex: '^<[a-zA-Z_0-9]+\.h(pp)?>$' - Priority: 3 # Standard library includes - Regex: "^<[a-zA-Z_]+>$" Priority: 4 + # Third party library includes + - Regex: "^<([a-zA-Z_0-9-]+/)*[a-zA-Z_0-9-]+.h(pp)?>$" + Priority: 3 NamespaceIndentation: Inner PointerBindsToType: false SpacesBeforeTrailingComments: 2 diff --git a/.clang-tidy b/.clang-tidy index e1d6bfca6cb..170ad019a41 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -16,41 +16,58 @@ Checks: "-*, -cppcoreguidelines-pro-type-cstyle-cast, -cppcoreguidelines-pro-bounds-pointer-arithmetic, -cppcoreguidelines-pro-bounds-array-to-pointer-decay, - -cppcoreguidelines-pro-type-member-init, -cppcoreguidelines-owning-memory, -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-avoid-const-or-ref-data-members, -readability-magic-numbers, -performance-noexcept-move-constructor, -misc-non-private-member-variables-in-classes, -cppcoreguidelines-non-private-member-variables-in-classes, - -cppcoreguidelines-special-member-functions, -modernize-use-nodiscard, -modernize-use-trailing-return-type, -readability-identifier-length, -readability-function-cognitive-complexity, -bugprone-easily-swappable-parameters, -cert-err58-cpp, + -modernize-avoid-c-arrays " CheckOptions: - key: readability-identifier-naming.ClassCase value: CamelCase - key: readability-identifier-naming.EnumCase value: CamelCase + - key: readability-identifier-naming.FunctionCase value: camelBack + - key: readability-identifier-naming.FunctionIgnoredRegexp + value: ^TEST$ + - key: readability-identifier-naming.MemberCase value: camelBack - key: readability-identifier-naming.PrivateMemberIgnoredRegexp - value: .* - - key: readability-identifier-naming.PrivateMemberSuffix - value: _ - - key: readability-identifier-naming.ProtectedMemberSuffix - value: _ + value: ^.*_$ + - key: readability-identifier-naming.ProtectedMemberIgnoredRegexp + value: ^.*_$ - key: readability-identifier-naming.UnionCase value: CamelCase - key: readability-identifier-naming.GlobalConstantCase value: UPPER_CASE + - key: readability-identifier-naming.GlobalVariableCase + value: UPPER_CASE - key: readability-identifier-naming.VariableCase value: camelBack - key: readability-implicit-bool-conversion.AllowPointerConditions value: true + + # Lua state + - key: readability-identifier-naming.LocalPointerIgnoredRegexp + value: ^L$ + + # Benchmarks + - key: readability-identifier-naming.FunctionIgnoredRegexp + value: ^BM_[^_]+$ + - key: readability-identifier-naming.ClassIgnoredRegexp + value: ^BM_[^_]+$ + + - key: misc-const-correctness.AnalyzeValues + value: false diff --git a/.docker/Dockerfile-ubuntu-20.04-base b/.docker/Dockerfile-ubuntu-20.04-base new file mode 100644 index 00000000000..d193850f085 --- /dev/null +++ b/.docker/Dockerfile-ubuntu-20.04-base @@ -0,0 +1,38 @@ +FROM ubuntu:20.04 + +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt-get update && apt-get -y install --no-install-recommends \ + cmake \ + virtualenv \ + rapidjson-dev \ + libfuse2 \ + libssl-dev \ + libboost-dev \ + libxcb-randr0-dev \ + libboost-system-dev \ + libboost-filesystem-dev \ + libpulse-dev \ + libxkbcommon-x11-0 \ + build-essential \ + libgl1-mesa-dev \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-xinerama0 + +RUN apt-get -y install \ + git \ + lsb-release \ + python3-pip && \ + apt-get clean all + +# Install Qt as we do in CI + +RUN pip3 install -U pip && \ + pip3 install aqtinstall && \ + aqt install-qt linux desktop 5.12.12 && \ + mkdir -p /opt/qt512 && \ + mv /5.12.12/gcc_64/* /opt/qt512 diff --git a/.docker/Dockerfile-ubuntu-20.04-build b/.docker/Dockerfile-ubuntu-20.04-build index f5a8ffa7ca7..4e566bb0d46 100644 --- a/.docker/Dockerfile-ubuntu-20.04-build +++ b/.docker/Dockerfile-ubuntu-20.04-build @@ -1,41 +1,4 @@ -FROM ubuntu:20.04 - -ENV TZ=UTC -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN apt-get update && apt-get -y install --no-install-recommends \ - cmake \ - virtualenv \ - rapidjson-dev \ - libfuse2 \ - libssl-dev \ - libboost-dev \ - libxcb-randr0-dev \ - libboost-system-dev \ - libboost-filesystem-dev \ - libpulse-dev \ - libxkbcommon-x11-0 \ - build-essential \ - libgl1-mesa-dev \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-render-util0 \ - libxcb-xinerama0 - -RUN apt-get -y install \ - git \ - lsb-release \ - python3-pip && \ - apt-get clean all - -# Install Qt as we do in CI - -RUN pip3 install -U pip && \ - pip3 install aqtinstall && \ - aqt install-qt linux desktop 5.12.12 && \ - mkdir -p /opt/qt512 && \ - mv /5.12.12/gcc_64/* /opt/qt512 +FROM chatterino-ubuntu-20.04-base ADD . /src diff --git a/.docker/Dockerfile-ubuntu-20.04-package b/.docker/Dockerfile-ubuntu-20.04-package index 6c41156f3d2..4d0a7b189b4 100644 --- a/.docker/Dockerfile-ubuntu-20.04-package +++ b/.docker/Dockerfile-ubuntu-20.04-package @@ -1,13 +1,21 @@ FROM chatterino-ubuntu-20.04-build -ADD .CI /src/.CI +# In CI, this is set from the aqtinstall action +ENV Qt5_DIR=/opt/qt512 WORKDIR /src/build -# RUN apt-get install -y wget +ADD .CI /src/.CI -# create appimage -# RUN pwd && ./../.CI/CreateAppImage.sh +# Install dependencies necessary for AppImage packaging +RUN apt-get update && apt-get -y install --no-install-recommends \ + curl \ + libfontconfig \ + libxrender1 \ + file # package deb -RUN pwd && ./../.CI/CreateUbuntuDeb.sh +RUN ./../.CI/CreateUbuntuDeb.sh + +# package appimage +RUN ./../.CI/CreateAppImage.sh diff --git a/.docker/Dockerfile-ubuntu-22.04-base b/.docker/Dockerfile-ubuntu-22.04-base new file mode 100644 index 00000000000..5ce5b583d3c --- /dev/null +++ b/.docker/Dockerfile-ubuntu-22.04-base @@ -0,0 +1,44 @@ +FROM ubuntu:22.04 + +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt-get update && apt-get -y install --no-install-recommends \ + cmake \ + virtualenv \ + rapidjson-dev \ + libfuse2 \ + libssl-dev \ + libboost-dev \ + libxcb-randr0-dev \ + libboost-system-dev \ + libboost-filesystem-dev \ + libpulse-dev \ + libxkbcommon-x11-0 \ + build-essential \ + libgl1-mesa-dev \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-xinerama0 \ + libfontconfig + +RUN apt-get -y install \ + git \ + lsb-release \ + python3-pip && \ + apt-get clean all + +# Install Qt as we do in CI + +RUN pip3 install -U pip && \ + pip3 install aqtinstall && \ + aqt install-qt linux desktop 5.15.2 && \ + mkdir -p /opt/qt515 && \ + mv /5.15.2/gcc_64/* /opt/qt515 + +ADD ./.patches /tmp/.patches + +# Apply Qt patches +RUN patch "/opt/qt515/include/QtConcurrent/qtconcurrentthreadengine.h" /tmp/.patches/qt5-on-newer-gcc.patch diff --git a/.docker/Dockerfile-ubuntu-22.04-build b/.docker/Dockerfile-ubuntu-22.04-build index 21f2ceb1568..5b16f6842af 100644 --- a/.docker/Dockerfile-ubuntu-22.04-build +++ b/.docker/Dockerfile-ubuntu-22.04-build @@ -1,49 +1,9 @@ -FROM ubuntu:22.04 - -ENV TZ=UTC -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN apt-get update && apt-get -y install --no-install-recommends \ - cmake \ - virtualenv \ - rapidjson-dev \ - libfuse2 \ - libssl-dev \ - libboost-dev \ - libxcb-randr0-dev \ - libboost-system-dev \ - libboost-filesystem-dev \ - libpulse-dev \ - libxkbcommon-x11-0 \ - build-essential \ - libgl1-mesa-dev \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-render-util0 \ - libxcb-xinerama0 - -RUN apt-get -y install \ - git \ - lsb-release \ - python3-pip && \ - apt-get clean all - -# Install Qt as we do in CI - -RUN pip3 install -U pip && \ - pip3 install aqtinstall && \ - aqt install-qt linux desktop 5.15.2 && \ - mkdir -p /opt/qt515 && \ - mv /5.15.2/gcc_64/* /opt/qt515 +FROM chatterino-ubuntu-22.04-base ADD . /src RUN mkdir /src/build -# Apply Qt patches -RUN patch "/opt/qt515/include/QtConcurrent/qtconcurrentthreadengine.h" /src/.patches/qt5-on-newer-gcc.patch - # cmake RUN cd /src/build && \ CXXFLAGS=-fno-sized-deallocation cmake \ diff --git a/.docker/Dockerfile-ubuntu-22.04-package b/.docker/Dockerfile-ubuntu-22.04-package index 193c666a2a5..e3b54691810 100644 --- a/.docker/Dockerfile-ubuntu-22.04-package +++ b/.docker/Dockerfile-ubuntu-22.04-package @@ -1,8 +1,21 @@ FROM chatterino-ubuntu-22.04-build -ADD .CI /src/.CI +# In CI, this is set from the aqtinstall action +ENV Qt5_DIR=/opt/qt515 WORKDIR /src/build +ADD .CI /src/.CI + +# Install dependencies necessary for AppImage packaging +RUN apt-get update && apt-get -y install --no-install-recommends \ + curl \ + libxcb-shape0 \ + libfontconfig1 \ + file + # package deb RUN ./../.CI/CreateUbuntuDeb.sh + +# package appimage +RUN ./../.CI/CreateAppImage.sh diff --git a/.docker/Dockerfile-ubuntu-22.04-qt6-build b/.docker/Dockerfile-ubuntu-22.04-qt6-build new file mode 100644 index 00000000000..3fd5b8a20b4 --- /dev/null +++ b/.docker/Dockerfile-ubuntu-22.04-qt6-build @@ -0,0 +1,59 @@ +ARG UBUNTU_VERSION=22.04 + +FROM ubuntu:$UBUNTU_VERSION + +ARG QT_VERSION=6.2.4 +ARG BUILD_WITH_QT6=ON + +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt-get update && apt-get -y install --no-install-recommends \ + cmake \ + virtualenv \ + rapidjson-dev \ + libfuse2 \ + libssl-dev \ + libboost-dev \ + libxcb-randr0-dev \ + libboost-system-dev \ + libboost-filesystem-dev \ + libpulse-dev \ + libxkbcommon-x11-0 \ + build-essential \ + libgl1-mesa-dev \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-xinerama0 \ + libfontconfig1-dev + +RUN apt-get -y install \ + git \ + lsb-release \ + python3-pip && \ + apt-get clean all + +# Install Qt as we do in CI + +RUN pip3 install -U pip && \ + pip3 install aqtinstall && \ + aqt install-qt linux desktop $QT_VERSION -O /opt/qt --modules qt5compat + +ADD . /src + +RUN mkdir /src/build + +# cmake +RUN cd /src/build && \ + CXXFLAGS=-fno-sized-deallocation cmake \ + -DBUILD_WITH_QT6=$BUILD_WITH_QT6 \ + -DCMAKE_INSTALL_PREFIX=appdir/usr/ \ + -DCMAKE_PREFIX_PATH=/opt/qt/$QT_VERSION/gcc_64/lib/cmake \ + -DBUILD_WITH_QTKEYCHAIN=OFF \ + .. + +# build +RUN cd /src/build && \ + make -j8 diff --git a/.docker/Dockerfile-ubuntu-22.04-qt6-package b/.docker/Dockerfile-ubuntu-22.04-qt6-package new file mode 100644 index 00000000000..265754915c9 --- /dev/null +++ b/.docker/Dockerfile-ubuntu-22.04-qt6-package @@ -0,0 +1,23 @@ +ARG UBUNTU_VERSION=22.04 + +FROM chatterino-ubuntu-$UBUNTU_VERSION-qt6-build + +# In CI, this is set from the aqtinstall action +ENV Qt6_DIR=/opt/qt/6.2.4/gcc_64 + +WORKDIR /src/build + +ADD .CI /src/.CI + +# Install dependencies necessary for AppImage packaging +RUN apt-get update && apt-get -y install --no-install-recommends \ + curl \ + libxcb-shape0 \ + libfontconfig1 \ + file + +# package deb +RUN ./../.CI/CreateUbuntuDeb.sh + +# package appimage +RUN ./../.CI/CreateAppImage.sh diff --git a/.docker/Dockerfile-ubuntu-22.04-test b/.docker/Dockerfile-ubuntu-22.04-test new file mode 100644 index 00000000000..d3d2b50f4ea --- /dev/null +++ b/.docker/Dockerfile-ubuntu-22.04-test @@ -0,0 +1,24 @@ +FROM chatterino-ubuntu-22.04-base + +ADD . /src + +RUN mkdir /src/build + +# cmake +RUN cd /src/build && \ + CXXFLAGS=-fno-sized-deallocation cmake \ + -DCMAKE_PREFIX_PATH=/opt/qt515/lib/cmake \ + -DUSE_PRECOMPILED_HEADERS=OFF \ + -DBUILD_WITH_QTKEYCHAIN=OFF \ + -DBUILD_TESTS=ON \ + .. + +# build +RUN cd /src/build && \ + make -j8 + +ENV QT_QPA_PLATFORM=minimal +ENV QT_PLUGIN_PATH=/opt/qt515/plugins + +# test +CMD /src/build/bin/chatterino-test diff --git a/.docker/README.md b/.docker/README.md index 869a1e3913f..08fc9261bdb 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -6,10 +6,12 @@ To build, from the repo root +1. Build a docker image that contains all the dependencies necessary to build Chatterino on Ubuntu 20.04 + `docker buildx build -t chatterino-ubuntu-20.04-base -f .docker/Dockerfile-ubuntu-20.04-base .` 1. Build a docker image that contains all the build artifacts and source from building Chatterino on Ubuntu 20.04 - `docker build -t chatterino-ubuntu-20.04-build -f .docker/Dockerfile-ubuntu-20.04-build .` + `docker buildx build -t chatterino-ubuntu-20.04-build -f .docker/Dockerfile-ubuntu-20.04-build .` 1. Build a docker image that uses the above-built image & packages it into a .deb file - `docker build -t chatterino-ubuntu-20.04-package -f .docker/Dockerfile-ubuntu-20.04-package .` + `docker buildx build -t chatterino-ubuntu-20.04-package -f .docker/Dockerfile-ubuntu-20.04-package .` To extract the final package, you can run the following command: `docker run -v $PWD:/opt/mount --rm -it chatterino-ubuntu-20.04-package bash -c "cp /src/build/Chatterino-x86_64.deb /opt/mount/"` @@ -20,10 +22,21 @@ To extract the final package, you can run the following command: To build, from the repo root +1. Build a docker image that contains all the dependencies necessary to build Chatterino on Ubuntu 22.04 + `docker buildx build -t chatterino-ubuntu-22.04-base -f .docker/Dockerfile-ubuntu-22.04-base .` 1. Build a docker image that contains all the build artifacts and source from building Chatterino on Ubuntu 22.04 - `docker build -t chatterino-ubuntu-22.04-build -f .docker/Dockerfile-ubuntu-22.04-build .` + `docker buildx build -t chatterino-ubuntu-22.04-build -f .docker/Dockerfile-ubuntu-22.04-build .` 1. Build a docker image that uses the above-built image & packages it into a .deb file - `docker build -t chatterino-ubuntu-22.04-package -f .docker/Dockerfile-ubuntu-22.04-package .` + `docker buildx build -t chatterino-ubuntu-22.04-package -f .docker/Dockerfile-ubuntu-22.04-package .` To extract the final package, you can run the following command: `docker run -v $PWD:/opt/mount --rm -it chatterino-ubuntu-22.04-package bash -c "cp /src/build/Chatterino-x86_64.deb /opt/mount/"` + +NOTE: The AppImage from Ubuntu 22.04 is broken. Approach with caution + +#### Testing + +1. Build a docker image builds the Chatterino tests + `docker buildx build -t chatterino-ubuntu-22.04-test -f .docker/Dockerfile-ubuntu-22.04-test .` +1. Run the tests + `docker run --rm --network=host chatterino-ubuntu-22.04-test` diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..e5ae6a09763 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,14 @@ +# If a commit modifies a ton of files and doesn't really contribute to the +# output of git-blame, please add it here +# +# Don't add commits from the same PR you are creating. We squash PRs into a +# single commit, so references to those commits will be lost +# +# 2018 - changed to 80 max column +f71ff08e686ae76c3dd4084d0f05f27ba9b3fdcb +# +# 2018 - added brace wrapping after if and for +e259b9e39f46f3cb0e4838c988d4f320a03dfaa4 +# +# 2019 - Normalize line endings in already existing files +b06eb9df835c25154899fbcf43e9b37addcea1b1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d42cd316451..2c20ec10089 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,5 @@ -Pull request checklist: - -- [ ] `CHANGELOG.md` was updated, if applicable - -# Description - - + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index abba1d943aa..62b7228da94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,64 +5,91 @@ on: push: branches: - master + - "bugfix-release/*" + - "release/*" pull_request: workflow_dispatch: + merge_group: concurrency: group: build-${{ github.ref }} cancel-in-progress: true env: - C2_ENABLE_LTO: ${{ github.ref == 'refs/heads/master' }} + C2_ENABLE_LTO: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/bugfix-release/') || startsWith(github.ref, 'refs/heads/release/') }} CHATTERINO_REQUIRE_CLEAN_GIT: On + C2_BUILD_WITH_QT6: Off + # Last known good conan version + # 2.0.3 has a bug on Windows (conan-io/conan#13606) + CONAN_VERSION: 2.0.2 jobs: build: - name: "Build ${{ matrix.os }}, Qt ${{ matrix.qt-version }} (PCH:${{ matrix.pch }}, LTO:${{ matrix.force-lto }})" + name: "Build ${{ matrix.os }}, Qt ${{ matrix.qt-version }} (LTO:${{ matrix.force-lto }}, crashpad:${{ matrix.skip-crashpad && 'off' || 'on' }})" runs-on: ${{ matrix.os }} strategy: matrix: - os: [windows-latest, macos-latest] - qt-version: [5.15.2, 5.12.12] - pch: [true] - force-lto: [false] - skip_artifact: ["no"] - crashpad: [true] include: # Ubuntu 20.04, Qt 5.12 - os: ubuntu-20.04 qt-version: 5.12.12 - pch: true force-lto: false + plugins: false + skip-artifact: false + skip-crashpad: false # Ubuntu 22.04, Qt 5.15 - os: ubuntu-22.04 qt-version: 5.15.2 - pch: true force-lto: false - # Test for disabling Precompiled Headers & enabling link-time optimization + plugins: false + skip-artifact: false + skip-crashpad: false + # Ubuntu 22.04, Qt 6.2.4 - tests LTO & plugins - os: ubuntu-22.04 - qt-version: 5.15.2 - pch: false + qt-version: 6.2.4 force-lto: true - skip_artifact: "yes" - # Test for disabling crashpad on Windows + plugins: true + skip-artifact: false + skip-crashpad: false + # macOS + - os: macos-latest + qt-version: 5.15.2 + force-lto: false + plugins: false + skip-artifact: false + skip-crashpad: false + # Windows + - os: windows-latest + qt-version: 6.5.0 + force-lto: false + plugins: false + skip-artifact: false + skip-crashpad: false + # Windows 7/8 - os: windows-latest qt-version: 5.15.2 - pch: false - force-lto: true - skip_artifact: "yes" - crashpad: false + force-lto: false + plugins: false + skip-artifact: false + skip-crashpad: true + fail-fast: false steps: - name: Force LTO - if: matrix.force-lto == true + if: matrix.force-lto run: | echo "C2_ENABLE_LTO=ON" >> "$GITHUB_ENV" shell: bash + - name: Enable plugin support + if: matrix.plugins + run: | + echo "C2_PLUGINS=ON" >> "$GITHUB_ENV" + shell: bash + - name: Set Crashpad - if: matrix.crashpad == true + if: matrix.skip-crashpad == false run: | echo "C2_ENABLE_CRASHPAD=ON" >> "$GITHUB_ENV" shell: bash @@ -73,104 +100,178 @@ jobs: echo "vs_version=2022" >> "$GITHUB_ENV" shell: bash - - uses: actions/checkout@v3 + - name: Set BUILD_WITH_QT6 + if: startsWith(matrix.qt-version, '6.') + run: | + echo "C2_BUILD_WITH_QT6=ON" >> "$GITHUB_ENV" + shell: bash + + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 # allows for tags access - - name: Install Qt - uses: jurplel/install-qt-action@v3.0.0 + - name: Install Qt5 + if: startsWith(matrix.qt-version, '5.') + uses: jurplel/install-qt-action@v3.3.0 with: cache: true cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 version: ${{ matrix.qt-version }} + - name: Install Qt 6.5.3 imageformats + if: startsWith(matrix.qt-version, '6.') + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: false + modules: qtimageformats + set-env: false + version: 6.5.3 + extra: --noarchives + + - name: Find Qt 6.5.3 Path + if: startsWith(matrix.qt-version, '6.') && startsWith(matrix.os, 'windows') + shell: pwsh + id: find-good-imageformats + run: | + cd "$Env:RUNNER_WORKSPACE/Qt/6.5.3" + cd (Get-ChildItem)[0].Name + cd plugins/imageformats + echo "PLUGIN_PATH=$(pwd)" | Out-File -Path "$Env:GITHUB_OUTPUT" -Encoding ASCII + + - name: Install Qt6 + if: startsWith(matrix.qt-version, '6.') + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: qt5compat qtimageformats + version: ${{ matrix.qt-version }} + # WINDOWS - - name: Cache conan packages part 1 + - name: Enable Developer Command Prompt (Windows) if: startsWith(matrix.os, 'windows') - uses: actions/cache@v3 - with: - key: ${{ runner.os }}-${{ matrix.crashpad }}-conan-user-${{ hashFiles('**/conanfile.txt') }} - path: ~/.conan/ + uses: ilammy/msvc-dev-cmd@v1.13.0 - - name: Cache conan packages part 2 + - name: Setup conan variables (Windows) if: startsWith(matrix.os, 'windows') - uses: actions/cache@v3 - with: - key: ${{ runner.os }}-${{ matrix.crashpad }}-conan-root-${{ hashFiles('**/conanfile.txt') }} - path: C:/.conan/ + run: | + "C2_USE_OPENSSL3=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "True" } else { "False" })" >> "$Env:GITHUB_ENV" + "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" + shell: powershell - - name: Add Conan to path + - name: Setup sccache (Windows) + # sccache v0.5.3 + uses: nerixyz/ccache-action@9a7e8d00116ede600ee7717350c6594b8af6aaa5 if: startsWith(matrix.os, 'windows') - run: echo "C:\Program Files\Conan\conan\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + with: + variant: sccache + # only save on on the default (master) branch + save: ${{ github.event_name == 'push' }} + key: sccache-build-${{ matrix.os }}-${{ matrix.qt-version }}-${{ matrix.skip-crashpad }} + restore-keys: | + sccache-build-${{ matrix.os }}-${{ matrix.qt-version }} + + - name: Cache conan packages (Windows) + if: startsWith(matrix.os, 'windows') + uses: actions/cache@v4 + with: + key: ${{ runner.os }}-conan-user-${{ hashFiles('**/conanfile.py') }}${{ env.C2_CONAN_CACHE_SUFFIX }} + path: ~/.conan2/ - - name: Install dependencies (Windows) + - name: Install Conan (Windows) if: startsWith(matrix.os, 'windows') run: | - choco install conan -y + python3 -c "import site; import sys; print(f'{site.USER_BASE}\\Python{sys.version_info.major}{sys.version_info.minor}\\Scripts')" >> "$GITHUB_PATH" + pip3 install --user "conan==${{ env.CONAN_VERSION }}" + shell: powershell - - name: Enable Developer Command Prompt + - name: Setup Conan (Windows) if: startsWith(matrix.os, 'windows') - uses: ilammy/msvc-dev-cmd@v1.12.1 + run: | + conan --version + conan profile detect -f + shell: powershell - - name: Setup Conan (Windows) + - name: Install dependencies (Windows) if: startsWith(matrix.os, 'windows') run: | - conan profile new --detect --force default - conan profile update conf.tools.cmake.cmaketoolchain:generator="NMake Makefiles" default + mkdir build + cd build + conan install .. ` + -s build_type=RelWithDebInfo ` + -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" ` + -b missing ` + --output-folder=. ` + -o with_openssl3="$Env:C2_USE_OPENSSL3" + shell: powershell - name: Build (Windows) if: startsWith(matrix.os, 'windows') + shell: pwsh run: | - mkdir build cd build - conan install .. -s build_type=RelWithDebInfo -b missing -pr:b=default cmake ` -G"NMake Makefiles" ` -DCMAKE_BUILD_TYPE=RelWithDebInfo ` -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ` + -DUSE_PRECOMPILED_HEADERS=ON ` -DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" ` -DCHATTERINO_LTO="$Env:C2_ENABLE_LTO" ` + -DCHATTERINO_PLUGINS="$Env:C2_PLUGINS" ` + -DBUILD_WITH_QT6="$Env:C2_BUILD_WITH_QT6" ` .. set cl=/MP nmake /S /NOLOGO - name: Build crashpad (Windows) - if: startsWith(matrix.os, 'windows') && matrix.crashpad + if: startsWith(matrix.os, 'windows') && !matrix.skip-crashpad + shell: pwsh run: | cd build set cl=/MP - nmake /S /NOLOGO crashpad_handler + nmake /S /NOLOGO chatterino-crash-handler mkdir Chatterino2/crashpad - cp bin/crashpad/crashpad_handler.exe Chatterino2/crashpad/crashpad_handler.exe - 7z a bin/chatterino.pdb.7z bin/chatterino.pdb + cp bin/crashpad/crashpad-handler.exe Chatterino2/crashpad/crashpad-handler.exe + 7z a bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z bin/chatterino.pdb - - name: Package (windows) + - name: Prepare build dir (windows) if: startsWith(matrix.os, 'windows') run: | cd build windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir Chatterino2/ cp bin/chatterino.exe Chatterino2/ echo nightly > Chatterino2/modes - 7z a chatterino-windows-x86-64.zip Chatterino2/ + + - name: Fix Qt6 (windows) + if: startsWith(matrix.qt-version, '6.') && startsWith(matrix.os, 'windows') + working-directory: build + run: | + cp ${{ steps.find-good-imageformats.outputs.PLUGIN_PATH }}/qwebp.dll Chatterino2/imageformats/qwebp.dll + + - name: Package (windows) + if: startsWith(matrix.os, 'windows') + working-directory: build + run: | + 7z a chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip Chatterino2/ - name: Upload artifact (Windows - binary) - if: startsWith(matrix.os, 'windows') && matrix.skip_artifact != 'yes' - uses: actions/upload-artifact@v3 + if: startsWith(matrix.os, 'windows') && !matrix.skip-artifact + uses: actions/upload-artifact@v4 with: - name: chatterino-windows-x86-64-${{ matrix.qt-version }}.zip - path: build/chatterino-windows-x86-64.zip + name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip + path: build/chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip - name: Upload artifact (Windows - symbols) - if: startsWith(matrix.os, 'windows') && matrix.skip_artifact != 'yes' - uses: actions/upload-artifact@v3 + if: startsWith(matrix.os, 'windows') && !matrix.skip-artifact + uses: actions/upload-artifact@v4 with: - name: chatterino-windows-x86-64-${{ matrix.qt-version }}-symbols.pdb.7z - path: build/bin/chatterino.pdb.7z + name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}-symbols.pdb.7z + path: build/bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z - - name: Clean Conan pkgs + - name: Clean Conan cache if: startsWith(matrix.os, 'windows') - run: conan remove "*" -fsb + run: conan cache clean --source --build --download "*" shell: bash # LINUX @@ -213,55 +314,39 @@ jobs: -DCMAKE_INSTALL_PREFIX=appdir/usr/ \ -DCMAKE_BUILD_TYPE=Release \ -DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On \ - -DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} \ + -DUSE_PRECOMPILED_HEADERS=OFF \ -DCMAKE_EXPORT_COMPILE_COMMANDS=On \ -DCHATTERINO_LTO="$C2_ENABLE_LTO" \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ .. make -j"$(nproc)" shell: bash - - name: clang-tidy review - if: (startsWith(matrix.os, 'ubuntu') && matrix.pch == false && matrix.qt-version == '5.15.2' && github.event_name == 'pull_request') - uses: ZedThree/clang-tidy-review@v0.10.1 - id: review - with: - build_dir: build - config_file: ".clang-tidy" - split_workflow: true - exclude: "tests/*,lib/*" - - - uses: actions/upload-artifact@v3 - if: (startsWith(matrix.os, 'ubuntu') && matrix.pch == false && matrix.qt-version == '5.15.2' && github.event_name == 'pull_request') - with: - name: clang-tidy-review - path: | - clang-tidy-review-output.json - clang-tidy-review-metadata.json - - name: Package - AppImage (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') && matrix.skip_artifact != 'yes' + if: startsWith(matrix.os, 'ubuntu-20.04') && !matrix.skip-artifact run: | cd build sh ./../.CI/CreateAppImage.sh shell: bash - name: Package - .deb (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') && matrix.skip_artifact != 'yes' + if: startsWith(matrix.os, 'ubuntu') && !matrix.skip-artifact run: | cd build sh ./../.CI/CreateUbuntuDeb.sh shell: bash - name: Upload artifact - AppImage (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') && matrix.skip_artifact != 'yes' - uses: actions/upload-artifact@v3 + if: startsWith(matrix.os, 'ubuntu-20.04') && !matrix.skip-artifact + uses: actions/upload-artifact@v4 with: name: Chatterino-x86_64-${{ matrix.qt-version }}.AppImage path: build/Chatterino-x86_64.AppImage - name: Upload artifact - .deb (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') && matrix.skip_artifact != 'yes' - uses: actions/upload-artifact@v3 + if: startsWith(matrix.os, 'ubuntu') && !matrix.skip-artifact + uses: actions/upload-artifact@v4 with: name: Chatterino-${{ matrix.os }}-Qt-${{ matrix.qt-version }}.deb path: build/Chatterino-${{ matrix.os }}-x86_64.deb @@ -282,72 +367,113 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \ -DOPENSSL_ROOT_DIR=/usr/local/opt/openssl \ - -DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} \ + -DUSE_PRECOMPILED_HEADERS=OFF \ -DCHATTERINO_LTO="$C2_ENABLE_LTO" \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ .. make -j"$(sysctl -n hw.logicalcpu)" shell: bash - name: Package (MacOS) if: startsWith(matrix.os, 'macos') + env: + OUTPUT_DMG_PATH: chatterino-macos-Qt-${{ matrix.qt-version}}.dmg run: | ls -la pwd ls -la build || true cd build - sh ./../.CI/CreateDMG.sh + ./../.CI/MacDeploy.sh + ./../.CI/CreateDMG.sh shell: bash - name: Upload artifact (MacOS) if: startsWith(matrix.os, 'macos') - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: chatterino-osx-${{ matrix.qt-version }}.dmg - path: build/chatterino-osx.dmg - + name: chatterino-macos-Qt-${{ matrix.qt-version }}.dmg + path: build/chatterino-macos-Qt-${{ matrix.qt-version }}.dmg create-release: needs: build runs-on: ubuntu-latest if: (github.event_name == 'push' && github.ref == 'refs/heads/master') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 # allows for tags access - - uses: actions/download-artifact@v3 + + - uses: actions/download-artifact@v4 + name: Ubuntu 22.04 Qt6.2.4 deb + with: + name: Chatterino-ubuntu-22.04-Qt-6.2.4.deb + path: release-artifacts/ + + - uses: actions/download-artifact@v4 + name: Windows Qt6.5.0 with: - name: chatterino-windows-x86-64-5.15.2.zip + name: chatterino-windows-x86-64-Qt-6.5.0.zip path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 + name: Windows Qt6.5.0 symbols with: - name: chatterino-windows-x86-64-5.15.2-symbols.pdb.7z + name: chatterino-windows-x86-64-Qt-6.5.0-symbols.pdb.7z path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 + name: Windows Qt5.15.2 with: - name: Chatterino-x86_64-5.15.2.AppImage + name: chatterino-windows-x86-64-Qt-5.15.2.zip path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 + name: Linux Qt5.12.12 AppImage + with: + name: Chatterino-x86_64-5.12.12.AppImage + path: release-artifacts/ + + - uses: actions/download-artifact@v4 + name: Ubuntu 20.04 Qt5.12.12 deb with: name: Chatterino-ubuntu-20.04-Qt-5.12.12.deb path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 + name: Ubuntu 22.04 Qt5.15.2 deb with: name: Chatterino-ubuntu-22.04-Qt-5.15.2.deb path: release-artifacts/ - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 + name: macOS x86_64 Qt5.15.2 dmg with: - name: chatterino-osx-5.15.2.dmg + name: chatterino-macos-Qt-5.15.2.dmg path: release-artifacts/ + - name: Copy flatpakref + run: | + cp .CI/chatterino-nightly.flatpakref release-artifacts/ + shell: bash + + - name: Rename artifacts + run: | + ls -l + # Rename the macos build to indicate that it's for macOS 10.15 users + mv chatterino-macos-Qt-5.15.2.dmg Chatterino-macOS-10.15.dmg + + mv Chatterino-ubuntu-22.04-x86_64.deb EXPERIMENTAL-Chatterino-ubuntu-22.04-Qt-6.2.4.deb + + # Mark all Windows Qt5 builds as old + mv chatterino-windows-x86-64-Qt-5.15.2.zip chatterino-windows-old-x86-64-Qt-5.15.2.zip + working-directory: release-artifacts + shell: bash + - name: Create release - uses: ncipollo/release-action@v1.12.0 + uses: ncipollo/release-action@v1.13.0 with: - removeArtifacts: true + replacesArtifacts: true allowUpdates: true artifactErrorsFailBuild: true artifacts: "release-artifacts/*" diff --git a/.github/workflows/changelog-category-check.yml b/.github/workflows/changelog-category-check.yml new file mode 100644 index 00000000000..fb4bd9b23da --- /dev/null +++ b/.github/workflows/changelog-category-check.yml @@ -0,0 +1,33 @@ +--- +name: Changelog Category Check + +on: + pull_request: + types: + - labeled + - unlabeled + - opened + - synchronize + - reopened + +jobs: + changelog-category-check: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + id: label-checker + with: + result-encoding: "string" + script: | + const response = await github.rest.issues.listLabelsOnIssue({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo + }); + if (new Set(response.data.map(label => label.name)).has("skip-changelog-checker")) { + return "skip"; + } + return ""; + + - uses: pajlads/changelog-checker@v1.0.1 + if: steps.label-checker.outputs.result != 'skip' diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml index f4e02450710..9a3a36685ad 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -6,6 +6,7 @@ on: branches: - master pull_request: + merge_group: concurrency: group: check-formatting-${{ github.ref }} @@ -16,16 +17,20 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: apt-get update run: sudo apt-get update - name: Install clang-format - run: sudo apt-get -y install clang-format dos2unix + run: sudo apt-get -y install dos2unix - name: Check formatting - run: ./tools/check-format.sh + uses: DoozyX/clang-format-lint-action@v0.16.2 + with: + source: "./src ./tests/src ./benchmarks/src ./mocks/include" + extensions: "hpp,cpp" + clangFormatVersion: 16 - name: Check line-endings - run: ./tools/check-line-endings.sh + run: ./scripts/check-line-endings.sh diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml new file mode 100644 index 00000000000..111a91296cf --- /dev/null +++ b/.github/workflows/clang-tidy.yml @@ -0,0 +1,148 @@ +--- +name: clang-tidy + +on: + pull_request: + +concurrency: + group: clang-tidy-${{ github.ref }} + cancel-in-progress: true + +env: + CHATTERINO_REQUIRE_CLEAN_GIT: On + C2_BUILD_WITH_QT6: Off + +jobs: + build: + name: "clang-tidy ${{ matrix.os }}, Qt ${{ matrix.qt-version }})" + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + # Ubuntu 22.04, Qt 5.15 + - os: ubuntu-22.04 + qt-version: 5.15.2 + plugins: false + + fail-fast: false + + steps: + - name: Enable plugin support + if: matrix.plugins + run: | + echo "C2_PLUGINS=ON" >> "$GITHUB_ENV" + shell: bash + + - name: Set BUILD_WITH_QT6 + if: startsWith(matrix.qt-version, '6.') + run: | + echo "C2_BUILD_WITH_QT6=ON" >> "$GITHUB_ENV" + shell: bash + + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 # allows for tags access + + - name: Install Qt5 + if: startsWith(matrix.qt-version, '5.') + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + version: ${{ matrix.qt-version }} + + - name: Install Qt 6.5.3 imageformats + if: startsWith(matrix.qt-version, '6.') + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: false + modules: qtimageformats + set-env: false + version: 6.5.3 + extra: --noarchives + + - name: Install Qt6 + if: startsWith(matrix.qt-version, '6.') + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: qt5compat qtimageformats + version: ${{ matrix.qt-version }} + + # LINUX + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get -y install \ + cmake \ + virtualenv \ + rapidjson-dev \ + libfuse2 \ + libssl-dev \ + libboost-dev \ + libxcb-randr0-dev \ + libboost-system-dev \ + libboost-filesystem-dev \ + libpulse-dev \ + libxkbcommon-x11-0 \ + build-essential \ + libgl1-mesa-dev \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-xinerama0 + + - name: Apply Qt5 patches + if: startsWith(matrix.qt-version, '5.') + run: | + patch "$Qt5_DIR/include/QtConcurrent/qtconcurrentthreadengine.h" .patches/qt5-on-newer-gcc.patch + shell: bash + + - name: Build + run: | + mkdir build + cd build + CXXFLAGS=-fno-sized-deallocation cmake \ + -DCMAKE_INSTALL_PREFIX=appdir/usr/ \ + -DCMAKE_BUILD_TYPE=Release \ + -DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On \ + -DUSE_PRECOMPILED_HEADERS=OFF \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=On \ + -DCHATTERINO_LTO="$C2_ENABLE_LTO" \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ + .. + shell: bash + + - name: clang-tidy review + timeout-minutes: 20 + uses: ZedThree/clang-tidy-review@v0.17.1 + with: + build_dir: build-clang-tidy + config_file: ".clang-tidy" + split_workflow: true + exclude: "lib/*,tools/crash-handler/*" + cmake_command: >- + cmake -S. -Bbuild-clang-tidy + -DCMAKE_BUILD_TYPE=Release + -DPAJLADA_SETTINGS_USE_BOOST_FILESYSTEM=On + -DUSE_PRECOMPILED_HEADERS=OFF + -DCMAKE_EXPORT_COMPILE_COMMANDS=On + -DCHATTERINO_LTO=Off + -DCHATTERINO_PLUGINS=On + -DBUILD_WITH_QT6=Off + -DBUILD_TESTS=On + -DBUILD_BENCHMARKS=On + apt_packages: >- + qttools5-dev, qt5-image-formats-plugins, libqt5svg5-dev, + libsecret-1-dev, + libboost-dev, libboost-system-dev, libboost-filesystem-dev, + libssl-dev, + rapidjson-dev, + libbenchmark-dev + + - name: clang-tidy-review upload + uses: ZedThree/clang-tidy-review/upload@v0.17.1 diff --git a/.github/workflows/create-installer.yml b/.github/workflows/create-installer.yml new file mode 100644 index 00000000000..626d3e50363 --- /dev/null +++ b/.github/workflows/create-installer.yml @@ -0,0 +1,58 @@ +name: Create installer + +on: + workflow_run: + workflows: ["Build"] + types: [completed] + # make sure this only runs on the default branch + branches: + - master + - "bugfix-release/*" + - "release/*" + workflow_dispatch: + +jobs: + create-installer: + runs-on: windows-latest + # Only run manually or when a build succeeds + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + strategy: + matrix: + qt-version: ["6.5.0"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # allows for tags access + + - name: Download artifact + uses: dawidd6/action-download-artifact@v3 + with: + workflow: build.yml + name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip + commit: ${{ github.sha }} + path: build/ + + - name: Unzip + run: 7z e -spf chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip + working-directory: build + + - name: Install InnoSetup + run: choco install innosetup + + - name: Add InnoSetup to path + run: echo "C:\Program Files (x86)\Inno Setup 6\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Enable Developer Command Prompt + uses: ilammy/msvc-dev-cmd@v1.13.0 + + - name: Build installer + id: build-installer + working-directory: build + run: ..\.CI\build-installer.ps1 + shell: powershell + + - name: Upload installer + uses: actions/upload-artifact@v4 + with: + path: build/${{ steps.build-installer.outputs.C2_INSTALLER_BASE_NAME }}.exe + name: ${{ steps.build-installer.outputs.C2_INSTALLER_BASE_NAME }}.exe diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index a0a5195ec0f..b455baaec44 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -11,6 +11,7 @@ on: env: # This gets updated later on in the run by a bash script to strip the prefix C2_CASK_NAME: chatterino + # The full version of Chatterino (e.g. v2.4.1) C2_TAGGED_VERSION: ${{ github.ref_name }} HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} @@ -23,4 +24,6 @@ jobs: - name: Execute brew bump-cask-pr with version run: | echo "Running bump-cask-pr for cask '$C2_CASK_NAME' and version '$C2_TAGGED_VERSION'" - brew bump-cask-pr --version "$C2_TAGGED_VERSION" "$C2_CASK_NAME" + C2_TAGGED_VERSION_STRIPPED="${C2_TAGGED_VERSION:1}" + echo "Stripped version: '$C2_TAGGED_VERSION_STRIPPED'" + brew bump-cask-pr --version "$C2_TAGGED_VERSION_STRIPPED" "$C2_CASK_NAME" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0bd37a71c38..c26015e4ebc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,7 @@ on: branches: - master pull_request: + merge_group: concurrency: group: lint-${{ github.ref }} @@ -16,10 +17,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check formatting with Prettier - uses: actionsx/prettier@e90ec5455552f0f640781bdd5f5d2415acb52f1a + uses: actionsx/prettier@3d9f7c3fa44c9cb819e68292a328d7f4384be206 with: # prettier CLI arguments. - args: --check . + args: --write . + - name: Show diff + run: git --no-pager diff --exit-code --color=never + shell: bash + - name: Check Theme files + run: | + npm i ajv-cli + npx -- ajv validate -s docs/ChatterinoTheme.schema.json -d "resources/themes/*.json" diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml index 6d8f733e0d9..ad1523523dc 100644 --- a/.github/workflows/post-clang-tidy-review.yml +++ b/.github/workflows/post-clang-tidy-review.yml @@ -3,39 +3,17 @@ name: Post clang-tidy review comments on: workflow_run: - workflows: ["Build"] + workflows: ["clang-tidy"] types: - completed jobs: build: runs-on: ubuntu-latest + # Only when a build succeeds + if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - name: "Download artifact" - uses: actions/github-script@v6 - with: - script: | - const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: ${{github.event.workflow_run.id }}, - }); - const matchArtifact = artifacts.data.artifacts.filter((artifact) => { - return artifact.name == "clang-tidy-review" - })[0]; - const download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: matchArtifact.id, - archive_format: 'zip', - }); - const fs = require('fs'); - fs.writeFileSync('${{github.workspace}}/clang-tidy-review.zip', Buffer.from(download.data)); - - name: "Unzip artifact" - run: unzip clang-tidy-review.zip - - - uses: ZedThree/clang-tidy-review/post@v0.10.1 - id: review + - uses: ZedThree/clang-tidy-review/post@v0.17.1 with: lgtm_comment_body: "" diff --git a/.github/workflows/push-aur.yml b/.github/workflows/push-aur.yml deleted file mode 100644 index 4323e098737..00000000000 --- a/.github/workflows/push-aur.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: Build on Arch Linux - -on: - push: - branches: - - master - -concurrency: - group: build-archlinux-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Sync AUR package with current version - uses: pajlada/aur-sync-action@master - with: - package_name: chatterino2-git - commit_username: chatterino2-ci - commit_email: chatterino2-ci@pajlada.com - ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - dry_run: true diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml new file mode 100644 index 00000000000..530f1169bf0 --- /dev/null +++ b/.github/workflows/test-macos.yml @@ -0,0 +1,96 @@ +--- +name: Test MacOS + +on: + pull_request: + workflow_dispatch: + merge_group: + +env: + TWITCH_PUBSUB_SERVER_TAG: v1.0.7 + QT_QPA_PLATFORM: minimal + +concurrency: + group: test-macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-macos: + name: "Test ${{ matrix.os }}, Qt ${{ matrix.qt-version }}" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-13] + qt-version: [5.15.2, 6.5.0] + plugins: [false] + fail-fast: false + env: + C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} + QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} + + steps: + - name: Enable plugin support + if: matrix.plugins + run: | + echo "C2_PLUGINS=ON" >> "$GITHUB_ENV" + + - name: Set BUILD_WITH_QT6 + if: startsWith(matrix.qt-version, '6.') + run: | + echo "C2_BUILD_WITH_QT6=ON" >> "$GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 # allows for tags access + + - name: Install Qt + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: ${{ env.QT_MODULES }} + version: ${{ matrix.qt-version }} + + - name: Install dependencies + run: | + brew install boost openssl rapidjson p7zip create-dmg cmake + + - name: Build + run: | + mkdir build-test + cd build-test + cmake \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DBUILD_TESTS=On \ + -DBUILD_APP=OFF \ + -DUSE_PRECOMPILED_HEADERS=OFF \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ + .. + make -j"$(sysctl -n hw.logicalcpu)" + + - name: Download and extract Twitch PubSub Server Test + run: | + mkdir pubsub-server-test + curl -L -o pubsub-server.tar.gz "https://github.com/Chatterino/twitch-pubsub-server-test/releases/download/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/server-${{ env.TWITCH_PUBSUB_SERVER_TAG }}-darwin-amd64.tar.gz" + tar -xzf pubsub-server.tar.gz -C pubsub-server-test + rm pubsub-server.tar.gz + cd pubsub-server-test + curl -L -o server.crt "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.crt" + curl -L -o server.key "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.key" + cd .. + + - name: Cargo Install httpbox + run: | + cargo install --git https://github.com/kevinastone/httpbox --rev 89b971f + + - name: Test + timeout-minutes: 30 + run: | + httpbox --port 9051 & + cd ../pubsub-server-test + ./server 127.0.0.1:9050 & + cd ../build-test + ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + working-directory: build-test diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml new file mode 100644 index 00000000000..e47938ac369 --- /dev/null +++ b/.github/workflows/test-windows.yml @@ -0,0 +1,155 @@ +--- +name: Test Windows + +on: + pull_request: + workflow_dispatch: + merge_group: + +env: + TWITCH_PUBSUB_SERVER_TAG: v1.0.7 + QT_QPA_PLATFORM: minimal + # Last known good conan version + # 2.0.3 has a bug on Windows (conan-io/conan#13606) + CONAN_VERSION: 2.0.2 + +concurrency: + group: test-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-windows: + name: "Test ${{ matrix.os }}, Qt ${{ matrix.qt-version }}" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest] + qt-version: [5.15.2, 6.5.0] + plugins: [false] + skip-artifact: [false] + skip-crashpad: [false] + fail-fast: false + env: + C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} + QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} + + steps: + - name: Enable plugin support + if: matrix.plugins + run: | + echo "C2_PLUGINS=ON" >> "$Env:GITHUB_ENV" + + - name: Set Crashpad + if: matrix.skip-crashpad == false + run: | + echo "C2_ENABLE_CRASHPAD=ON" >> "$Env:GITHUB_ENV" + + - name: Set BUILD_WITH_QT6 + if: startsWith(matrix.qt-version, '6.') + run: | + echo "C2_BUILD_WITH_QT6=ON" >> "$Env:GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 # allows for tags access + + - name: Install Qt + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: ${{ env.QT_MODULES }} + version: ${{ matrix.qt-version }} + + - name: Enable Developer Command Prompt + uses: ilammy/msvc-dev-cmd@v1.13.0 + + - name: Setup conan variables + if: startsWith(matrix.os, 'windows') + run: | + "C2_USE_OPENSSL3=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "True" } else { "False" })" >> "$Env:GITHUB_ENV" + "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" + + - name: Setup sccache + # sccache v0.5.3 + uses: nerixyz/ccache-action@9a7e8d00116ede600ee7717350c6594b8af6aaa5 + with: + variant: sccache + # only save on the default (master) branch + save: ${{ github.event_name == 'push' }} + key: sccache-test-${{ matrix.os }}-${{ matrix.qt-version }}-${{ matrix.skip-crashpad }} + restore-keys: | + sccache-test-${{ matrix.os }}-${{ matrix.qt-version }} + + - name: Cache conan packages + uses: actions/cache@v4 + with: + key: ${{ runner.os }}-conan-user-${{ hashFiles('**/conanfile.py') }}${{ env.C2_CONAN_CACHE_SUFFIX }} + path: ~/.conan2/ + + - name: Install Conan + run: | + python3 -c "import site; import sys; print(f'{site.USER_BASE}\\Python{sys.version_info.major}{sys.version_info.minor}\\Scripts')" >> "$Env:GITHUB_PATH" + pip3 install --user "conan==${{ env.CONAN_VERSION }}" + + - name: Setup Conan + run: | + conan --version + conan profile detect -f + + - name: Install dependencies + run: | + mkdir build-test + cd build-test + conan install .. ` + -s build_type=RelWithDebInfo ` + -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" ` + -b missing ` + --output-folder=. ` + -o with_openssl3="$Env:C2_USE_OPENSSL3" + + - name: Build + run: | + cmake ` + -G"NMake Makefiles" ` + -DCMAKE_BUILD_TYPE=RelWithDebInfo ` + -DBUILD_TESTS=On ` + -DBUILD_APP=OFF ` + -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ` + -DUSE_PRECOMPILED_HEADERS=On ` + -DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" ` + -DCHATTERINO_PLUGINS="$Env:C2_PLUGINS" ` + -DBUILD_WITH_QT6="$Env:C2_BUILD_WITH_QT6" ` + .. + set cl=/MP + nmake /S /NOLOGO + working-directory: build-test + + - name: Download and extract Twitch PubSub Server Test + run: | + mkdir pubsub-server-test + Invoke-WebRequest -Uri "https://github.com/Chatterino/twitch-pubsub-server-test/releases/download/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/server-${{ env.TWITCH_PUBSUB_SERVER_TAG }}-windows-amd64.zip" -outfile "pubsub-server.zip" + Expand-Archive pubsub-server.zip -DestinationPath pubsub-server-test + rm pubsub-server.zip + cd pubsub-server-test + Invoke-WebRequest -Uri "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.crt" -outfile "server.crt" + Invoke-WebRequest -Uri "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.key" -outfile "server.key" + cd .. + + - name: Cargo Install httpbox + run: | + cargo install --git https://github.com/kevinastone/httpbox --rev 89b971f + + - name: Test + timeout-minutes: 30 + run: | + httpbox --port 9051 & + cd ..\pubsub-server-test + .\server.exe 127.0.0.1:9050 & + cd ..\build-test + ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + working-directory: build-test + + - name: Clean Conan cache + run: conan cache clean --source --build --download "*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1c9df85bd3..acfe026022d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,11 @@ name: Test on: pull_request: workflow_dispatch: + merge_group: env: TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.6 + QT_QPA_PLATFORM: minimal concurrency: group: test-${{ github.ref }} @@ -17,29 +19,36 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] - qt-version: [5.15.2] + include: + - os: "ubuntu-22.04" + qt-version: "5.15.2" + - os: "ubuntu-22.04" + qt-version: "5.12.12" + - os: "ubuntu-22.04" + qt-version: "6.2.4" fail-fast: false + env: + C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} + QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - - name: Cache Qt - id: cache-qt - uses: actions/cache@v3 - with: - path: "${{ github.workspace }}/qt/" - key: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} - - name: Install Qt - uses: jurplel/install-qt-action@v3.0.0 + uses: jurplel/install-qt-action@v3.3.0 with: cache: true - cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: ${{ env.QT_MODULES }} version: ${{ matrix.qt-version }} - dir: "${{ github.workspace }}/qt/" + + - name: Apply Qt patches (Ubuntu) + if: startsWith(matrix.os, 'ubuntu') && startsWith(matrix.qt-version, '5.') + run: | + patch "$Qt5_DIR/include/QtConcurrent/qtconcurrentthreadengine.h" .patches/qt5-on-newer-gcc.patch + shell: bash # LINUX - name: Install dependencies (Ubuntu) @@ -47,6 +56,7 @@ jobs: run: | sudo apt-get update sudo apt-get -y install \ + libbenchmark-dev \ cmake \ rapidjson-dev \ libssl-dev \ @@ -73,18 +83,24 @@ jobs: - name: Build (Ubuntu) if: startsWith(matrix.os, 'ubuntu') run: | - cmake -DBUILD_TESTS=On -DBUILD_APP=OFF .. - cmake --build . --config Release + cmake \ + -DBUILD_TESTS=On \ + -DBUILD_BENCHMARKS=On \ + -DBUILD_APP=OFF \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ + .. + cmake --build . working-directory: build-test shell: bash - name: Test (Ubuntu) if: startsWith(matrix.os, 'ubuntu') + timeout-minutes: 30 run: | docker pull kennethreitz/httpbin docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} docker run --network=host --detach ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} docker run -p 9051:80 --detach kennethreitz/httpbin - ./bin/chatterino-test --platform minimal || ./bin/chatterino-test --platform minimal || ./bin/chatterino-test --platform minimal + ctest --repeat until-pass:4 --output-on-failure working-directory: build-test shell: bash diff --git a/.gitignore b/.gitignore index 9e58592350e..9a55427ca80 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ Thumbs.db dependencies .cache .editorconfig +vim.log ### CMake ### CMakeLists.txt.user @@ -121,3 +122,6 @@ resources/resources_autogenerated.qrc # Leftovers from running `aqt install` aqtinstall.log + +# sccache (CI) +.sccache diff --git a/.gitmodules b/.gitmodules index 741e3104110..cb1235a8582 100644 --- a/.gitmodules +++ b/.gitmodules @@ -35,6 +35,9 @@ [submodule "lib/miniaudio"] path = lib/miniaudio url = https://github.com/mackron/miniaudio.git -[submodule "lib/crashpad"] - path = lib/crashpad - url = https://github.com/getsentry/crashpad +[submodule "lib/lua/src"] + path = lib/lua/src + url = https://github.com/lua/lua +[submodule "tools/crash-handler"] + path = tools/crash-handler + url = https://github.com/Chatterino/crash-handler diff --git a/.prettierignore b/.prettierignore index df4877b881f..89270b789cb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,10 +1,15 @@ -# emoji.json should remain minified -resources/emoji.json +# JSON resources should not be prettified... +resources/*.json +benchmarks/resources/*.json +tests/resources/*.json +# ...themes should be prettified for readability. +!resources/themes/*.json # Ignore submodule files lib/*/ conan-pkgs/*/ cmake/sanitizers-cmake/ +tools/crash-handler # Build folders *build-*/ @@ -21,3 +26,6 @@ dependencies # vcpkg vcpkg_installed/ + +# Compile commands generated by CMake +compile_commands.json diff --git a/.sanitizers/asan-suppressions b/.sanitizers/asan-suppressions new file mode 100644 index 00000000000..8361de18441 --- /dev/null +++ b/.sanitizers/asan-suppressions @@ -0,0 +1,2 @@ +# Ignore openssl issues +interceptor_via_lib:libcrypto.so.3 diff --git a/.sanitizers/lsan-suppressions b/.sanitizers/lsan-suppressions new file mode 100644 index 00000000000..bd4eb5fbf48 --- /dev/null +++ b/.sanitizers/lsan-suppressions @@ -0,0 +1,3 @@ +# Ignore openssl issues +leak:libcrypto.so.3 +leak:CRYPTO_zalloc diff --git a/.sanitizers/tsan-suppressions b/.sanitizers/tsan-suppressions new file mode 100644 index 00000000000..1f9b93e6fec --- /dev/null +++ b/.sanitizers/tsan-suppressions @@ -0,0 +1,17 @@ +race:libdbus-1.so.3 +deadlock:libdbus-1.so.3 +race:libglib-2.0.so.0 +race:libgio-2.0.so.0 + +# Not sure about these suppression +# race:qscopedpointer.h +# race:qarraydata.cpp +# race:qarraydata.h +# race:qarraydataops.h +# race:libQt6Core.so.6 +# race:libQt6Gui.so.6 +# race:libQt6XcbQpa.so.6 +# race:libQt6Network.so.6 + +# very not sure about this one +# race:qstring.h diff --git a/.sanitizers/ubsan-suppressions b/.sanitizers/ubsan-suppressions new file mode 100644 index 00000000000..4d35c4bef4b --- /dev/null +++ b/.sanitizers/ubsan-suppressions @@ -0,0 +1,2 @@ +enum:NetworkResult.hpp +enum:gtest.h diff --git a/BUILDING_ON_LINUX.md b/BUILDING_ON_LINUX.md index 5f80b2d39fa..67ae8fe79d7 100644 --- a/BUILDING_ON_LINUX.md +++ b/BUILDING_ON_LINUX.md @@ -6,13 +6,13 @@ Note on Qt version compatibility: If you are installing Qt from a package manage ### Ubuntu 20.04 -_Most likely works the same for other Debian-like distros_ +_Most likely works the same for other Debian-like distros._ -Install all of the dependencies using `sudo apt install qttools5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++` +Install all the dependencies using `sudo apt install qttools5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++ libsecret-1-dev` ### Arch Linux -Install all of the dependencies using `sudo pacman -S --needed qt5-base qt5-imageformats qt5-svg qt5-tools boost rapidjson pkgconf openssl cmake` +Install all the dependencies using `sudo pacman -S --needed qt5-base qt5-imageformats qt5-svg qt5-tools boost rapidjson pkgconf openssl cmake` Alternatively you can use the [chatterino2-git](https://aur.archlinux.org/packages/chatterino2-git/) package to build and install Chatterino for you. @@ -20,11 +20,11 @@ Alternatively you can use the [chatterino2-git](https://aur.archlinux.org/packag _Most likely works the same for other Red Hat-like distros. Substitute `dnf` with `yum`._ -Install all of the dependencies using `sudo dnf install qt5-qtbase-devel qt5-imageformats qt5-qtsvg-devel qt5-linguist libsecret-devel openssl-devel boost-devel cmake` +Install all the dependencies using `sudo dnf install qt5-qtbase-devel qt5-qtimageformats qt5-qtsvg-devel qt5-linguist libsecret-devel openssl-devel boost-devel cmake` ### NixOS 18.09+ -Enter the development environment with all of the dependencies: `nix-shell -p openssl boost qt5.full pkg-config cmake` +Enter the development environment with all the dependencies: `nix-shell -p openssl boost qt5.full pkg-config cmake` ## Compile diff --git a/BUILDING_ON_MAC.md b/BUILDING_ON_MAC.md index cedfc5c0ead..78f94c7e9ec 100644 --- a/BUILDING_ON_MAC.md +++ b/BUILDING_ON_MAC.md @@ -1,32 +1,32 @@ # Building on macOS -#### Note - If you want to develop Chatterino 2 you might also want to install Qt Creator (make sure to install **Qt 5.12 or newer**), it is not required though and any C++ IDE (might require additional setup for cmake to find Qt libraries) or a normal text editor + running cmake from terminal should work as well +Chatterino2 is built in CI on Intel on macOS 12. +Local dev machines for testing are available on Apple Silicon on macOS 13. -#### Note - Chatterino 2 is only tested on macOS 10.14 and above - anything below that is considered unsupported. It may or may not work on earlier versions +## Installing dependencies 1. Install Xcode and Xcode Command Line Utilities 1. Start Xcode, go into Settings -> Locations, and activate your Command Line Tools -1. Install brew https://brew.sh/ -1. Install the dependencies using `brew install boost openssl rapidjson cmake` -1. Install Qt5 using `brew install qt@5` -1. (_OPTIONAL_) Install [ccache](https://ccache.dev) (used to speed up compilation by using cached results from previous builds) using `brew install ccache` -1. Go into the project directory -1. Create a build folder and go into it (`mkdir build && cd build`) -1. Compile using `cmake .. && make` +1. Install [Homebrew](https://brew.sh/#install) + We use this for dependency management on macOS +1. Install all dependencies: + `brew install boost openssl@1.1 rapidjson cmake qt@5` -If the Project does not build at this point, you might need to add additional Paths/Libs, because brew does not install openssl and boost in the common path. You can get their path using +## Building -`brew info openssl` -`brew info boost` +### Building from terminal -If brew doesn't link OpenSSL properly then you should be able to link it yourself by using these two commands: +1. Open a terminal +1. Go to the project directory where you cloned Chatterino2 & its submodules +1. Create a build directory and go into it: + `mkdir build && cd build` +1. Run CMake: + `cmake -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt@5 -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@1.1 ..` +1. Build: + `make` -- `ln -s /usr/local/opt/openssl/lib/* /usr/local/lib` -- `ln -s /usr/local/opt/openssl/include/openssl /usr/local/include/openssl` +Your binary can now be found under bin/chatterino.app/Contents/MacOS/chatterino directory -The lines which you need to add to your project file should look similar to this +### Other building methods -``` -INCLUDEPATH += /usr/local/opt/openssl/include -LIBS += -L/usr/local/opt/openssl/lib -``` +You can achieve similar results by using an IDE like Qt Creator, although this is undocumented but if you know the IDE you should have no problems applying the terminal instructions to your IDE. diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index ef16c14572b..1ddbfbd54aa 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -1,19 +1,65 @@ # Building on Windows -**Note that installing all of the development prerequisites and libraries will require about 30 GB of free disk space. Please ensure this space is available on your `C:` drive before proceeding.** +**Note that installing all the development prerequisites and libraries will require about 12 GB of free disk space. Please ensure this space is available on your `C:` drive before proceeding.** This guide assumes you are on a 64-bit system. You might need to manually search out alternate download links should you desire to build Chatterino on a 32-bit system. -## Visual Studio 2022 +## Prerequisites -Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/). In the installer, select "Desktop development with C++" and "Universal Windows Platform development". +### Visual Studio + +Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/). In the installer, select "Desktop development with C++". Notes: -- This installation will take about 17 GB of disk space +- This installation will take about 8 GB of disk space - You do not need to sign in with a Microsoft account after setup completes. You may simply exit the login dialog. -## Boost +### Qt + +1. Visit the [Qt Open Source Page](https://www.qt.io/download-open-source). +2. Scroll down to the bottom +3. Then select "Download the Qt Online Installer" +4. Within the installer, Qt will prompt you to create an account to access Qt downloads. + +Notes: + +- Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.12 or later**. + +#### Components + +When prompted which components to install, do the following: + +1. Unfold the tree element that says "Qt" +2. Unfold the top most tree element (latest stable Qt version, e.g. `Qt 6.5.3`) +3. Under this version, select the following entries: + - `MSVC 2019 64-bit` (or alternative version if you are using that) + - `Qt 5 Compatibility Module` + - `Additional Libraries` > `Qt Image Formats` +4. Under the "Tools" tree element (at the bottom), ensure that `Qt Creator X.X.X` and `Debugging Tools for Windows` are selected. (they should be checked by default) +5. Continue through the installer and let the installer finish installing Qt. + +Note: This installation will take about 2 GB of disk space. + +Once Qt is done installing, make sure you add its bin directory to your `PATH` (e.g. `C:\Qt\6.5.3\msvc2019_64\bin`) + +
+ How to add Qt to PATH + +1. Type "path" in the Windows start menu and click `Edit the system environment variables`. +2. Click the `Environment Variables...` button bottom right. +3. In the `User variables` (scoped to the current user) or `System variables` (system-wide) section, scroll down until you find `Path` and double click it. +4. Click the `New` button top right and paste in the file path for your Qt installation (e.g. `C:\Qt\6.5.3\msvc2019_64\bin` by default). +5. Click `Ok` + +
+ +### Advanced dependencies + +These dependencies are only required if you are not using a package manager + +
+Boost 1. First, download a boost installer appropriate for your version of Visual Studio. @@ -29,112 +75,118 @@ Notes: Note: This installation will take about 2.1 GB of disk space. -## OpenSSL +
+ +
+OpenSSL -### For our websocket library, we need OpenSSL 1.1 +For our websocket library, we need OpenSSL 1.1. -1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)** +1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://web.archive.org/web/20221101204129/https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)** 2. When prompted, install OpenSSL to `C:\local\openssl` 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". -### For Qt SSL, we need OpenSSL 1.0 +Note: This installation will take about 200 MB of disk space. -1. Download OpenSSL for Windows, version `1.0.2u`: **[Download](https://web.archive.org/web/20211109231823/https://slproweb.com/download/Win64OpenSSL-1_0_2u.exe)** -2. When prompted, install it to any arbitrary empty directory. -3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". -4. Copy the OpenSSL 1.0 files from its `\bin` folder to `C:\local\bin` (You will need to create the folder) -5. Then copy the OpenSSL 1.1 files from its `\bin` folder to `C:\local\bin` (Overwrite any duplicate files) -6. Add `C:\local\bin` to your path folder ([Follow the guide here if you don't know how to do it](https://www.computerhope.com/issues/ch000549.htm#windows10)) +
-**If the 1.1.x download link above does not work, try downloading the similar 1.1.x version found [here](https://slproweb.com/products/Win32OpenSSL.html). Note: Don't download the "light" installer, it does not have the required files.** -![Screenshot Slproweb layout](https://user-images.githubusercontent.com/41973452/175827529-97802939-5549-4ab1-95c4-d39f012d06e9.png) +## Building -Note: This installation will take about 200 MB of disk space. +### Using CMake -## Qt +#### Install conan 2 -1. Visit the [Qt Open Source Page](https://www.qt.io/download-open-source). -2. Scroll down to the bottom -3. Then select "Download the Qt Online Installer" +Install [conan 2](https://conan.io/downloads.html) and make sure it's in your `PATH` (default setting). -Notes: +
+ Adding conan to your PATH if you installed it with pip -- Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.12 or later**. +_Note: This will add all Python-scripts to your `PATH`, conan being one of them._ -### When prompted which components to install: +1. Type "path" in the Windows start menu and click `Edit the system environment variables`. +2. Click the `Environment Variables...` button bottom right. +3. In the `System variables` section, scroll down until you find `Path` and double click it. +4. Click the `New` button top right. +5. Open up a terminal `where.exe conan` to find the file path (the folder that contains the conan.exe) to add. +6. Add conan 2's file path (e.g. `C:\Users\example\AppData\Roaming\Python\Python311\Scripts`) to the blank text box that shows up. This is your current Python installation's scripts folder. +7. Click `Ok` -1. Unfold the tree element that says "Qt" -2. Unfold the top most tree element (latest stable Qt version, e.g. `Qt 5.15.2`) -3. Under this version, select the following entries: - - `MSVC 2019 64-bit` (or alternative version if you are using that) - - `Qt WebEngine` (optional) -4. Under the "Tools" tree element (at the bottom), ensure that `Qt Creator X.X.X` and `Debugging Tools for Windows` are selected. (they should be checked by default) -5. Continue through the installer and let the installer finish installing Qt. +
-Note: This installation will take about 2 GB of disk space. +Then in a terminal, configure conan to use `NMake Makefiles` as its generator: -## Compile with Breakpad support (Optional) +1. Generate a new profile + `conan profile detect` + +#### Build + +Open up your terminal with the Visual Studio environment variables (e.g. `x64 Native Tools Command Prompt for VS 2022`), cd to the cloned chatterino2 directory and run the following commands: + +```cmd +mkdir build +cd build +conan install .. -s build_type=Release -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" --build=missing --output-folder=. +cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" -DCMAKE_PREFIX_PATH="C:\Qt\6.5.3\msvc2019_64" .. +nmake +``` -Compiling with Breakpad support enables crash reports that can be of use for developing/beta versions of Chatterino. If you have no interest in reporting crashes anyways, this optional dependency will probably be of no use to you. +To build a debug build, you'll also need to add the `-s compiler.runtime_type=Debug` flag to the `conan install` invocation. See [this StackOverflow post](https://stackoverflow.com/questions/59828611/windeployqt-doesnt-deploy-qwindowsd-dll-for-a-debug-application/75607313#75607313) -1. Open up `lib/qBreakpad/handler/handler.pro`in Qt Creator -2. Build it in whichever mode you want to build Chatterino in (Debug/Profile/Release) -3. Copy the newly built `qBreakpad.lib` to the following directory: `lib/qBreakpad/build/handler` (You will have to manually create this directory) +#### Deploying Qt libraries -## Run the build in Qt Creator +Once Chatterino has finished building, to ensure all .dll's are available you can run this from the build directory: +`windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir bin/` + +Can't find windeployqt? You forgot to add your Qt bin directory (e.g. `C:\Qt\6.5.3\msvc2019_64\bin`) to your `PATH` + +### Developing in Qt Creator 1. Open the `CMakeLists.txt` file by double-clicking it, or by opening it via Qt Creator. 2. You will be presented with a screen that is titled "Configure Project". In this screen, you should have at least one option present ready to be configured, like this: ![Qt Create Configure Project screenshot](https://user-images.githubusercontent.com/69117321/169887645-2ae0871a-fe8a-4eb9-98db-7b996dea3a54.png) 3. Select the profile(s) you want to build with and click "Configure Project". -### How to run and produce builds +#### Building and running - In the main screen, click the green "play symbol" on the bottom left to run the project directly. - Click the hammer on the bottom left to generate a build (does not run the build though). -Build results will be placed in a folder at the same level as the "chatterino2" project folder (e.g. if your sources are at `C:\Users\example\src\chatterino2`, then the build will be placed in an automatically generated folder under `C:\Users\example\src`, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_5_15_2_MSVC2019_64bit-Release`.) +Build results will be placed in a folder at the same level as the "chatterino2" project folder (e.g. if your sources are at `C:\Users\example\src\chatterino2`, then the build will be placed in an automatically generated folder under `C:\Users\example\src`, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release`.) -- Note that if you are building chatterino purely for usage, not for development, it is recommended that you click the "PC" icon above the play icon and select "Release" instead of "Debug". +- Note that if you are building Chatterino purely for usage, not for development, it is recommended that you click the "PC" icon above the play icon and select "Release" instead of "Debug". - Output and error messages produced by the compiler can be seen under the "4 Compile Output" tab in Qt Creator. -## Producing standalone builds +#### Producing standalone builds -If you build chatterino, the result directories will contain a `chatterino.exe` file in the `$OUTPUTDIR\release\` directory. This `.exe` file will not directly run on any given target system, because it will be lacking various Qt runtimes. +If you build Chatterino, the result directories will contain a `chatterino.exe` file in the `$OUTPUTDIR\release\` directory. This `.exe` file will not directly run on any given target system, because it will be lacking various Qt runtimes. -To produce a standalone package, you need to generate all required files using the tool `windeployqt`. This tool can be found in the `bin` directory of your Qt installation, e.g. at `C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe`. +To produce a standalone package, you need to generate all required files using the tool `windeployqt`. This tool can be found in the `bin` directory of your Qt installation, e.g. at `C:\Qt\6.5.3\msvc2019_64\bin\windeployqt.exe`. To produce all supplement files for a standalone build, follow these steps (adjust paths as required): -1. Navigate to your build output directory with Windows Explorer, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_5_15_2_MSVC2019_64bit-Release` -2. Enter the `release` directory -3. Delete all files except the `chatterino.exe` file. You should be left with a directory only containing `chatterino.exe`. -4. Open a command prompt and execute: - - cd C:\Users\example\src\build-chatterino-Desktop_Qt_5_15_2_MSVC2019_64bit-Release\release - C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe chatterino.exe - -5. Go to `C:\local\bin\` and copy these dll's into your `release folder`. +1. Navigate to your build output directory with Windows Explorer, e.g. `C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release` +2. Enter the `release` directory +3. Delete all files except the `chatterino.exe` file. You should be left with a directory only containing `chatterino.exe`. +4. Open a command prompt and execute: + ```cmd + cd C:\Users\example\src\build-chatterino-Desktop_Qt_6.5.3_MSVC2019_64bit-Release\release + windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir bin/ + ``` +5. The `releases` directory will now be populated with all the required files to make the Chatterino build standalone. - libssl-1_1-x64.dll - libcrypto-1_1-x64.dll - ssleay32.dll - libeay32.dll +You can now create a zip archive of all the contents in `releases` and distribute the program as is, without requiring any development tools to be present on the target system. (However, the CRT must be present, as usual - see the [README](README.md)). -6. The `releases` directory will now be populated with all the required files to make the chatterino build standalone. +#### Formatting -You can now create a zip archive of all the contents in `releases` and distribute the program as is, without requiring any development tools to be present on the target system. (However, the vcredist package must be present, as usual - see the [README](README.md)). +To automatically format your code, do the following: -## Using CMake +1. Download [LLVM 16.0.6](https://github.com/llvm/llvm-project/releases/download/llvmorg-16.0.6/LLVM-16.0.6-win64.exe) +2. During the installation, make sure to add it to your path +3. In Qt Creator, Select `Tools` > `Options` > `Beautifier` +4. Under `General` select `Tool: ClangFormat` and enable `Automatic Formatting on File Save` +5. Under `Clang Format` select `Use predefined style: File` and `Fallback style: None` -Open up your terminal with the Visual Studio environment variables, then enter the following commands: - -1. `mkdir build` -2. `cd build` -3. `cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ..` -4. `nmake` - -## Building on MSVC with AddressSanitizer +### Building on MSVC with AddressSanitizer Make sure you installed `C++ AddressSanitizer` in your VisualStudio installation like described in the [Microsoft Docs](https://learn.microsoft.com/en-us/cpp/sanitizers/asan#install-the-addresssanitizer). @@ -145,7 +197,7 @@ copy the file found in `\VC\Tools\MSVC\ To learn more about AddressSanitizer and MSVC, visit the [Microsoft Docs](https://learn.microsoft.com/en-us/cpp/sanitizers/asan). -## Building/Running in CLion +### Developing in CLion _Note:_ We're using `build` instead of the CLion default `cmake-build-debug` folder. @@ -158,8 +210,8 @@ Clone the repository as described in the readme. Open a terminal in the cloned f Now open the project in CLion. You will be greeted with the _Open Project Wizard_. Set the _CMake Options_ to -``` --DCMAKE_PREFIX_PATH=C:\Qt\5.15.2\msvc2019_64\lib\cmake\Qt5 +```text +-DCMAKE_PREFIX_PATH=C:\Qt\6.5.3\msvc2019_64\lib\cmake\Qt6 -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ``` @@ -176,7 +228,7 @@ After the CMake project is loaded, open the _Run/Debug Configurations_. Select the `CMake Applications > chatterino` configuration and add a new _Run External tool_ task to _Before launch_. -- Set the _Program_ to `C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe` +- Set the _Program_ to `C:\Qt\6.5.3\msvc2019_64\bin\windeployqt.exe` - Set the _Arguments_ to `$CMakeCurrentProductFile$ --debug --no-compiler-runtime --no-translations --no-opengl-sw --dir bin/` - Set the _Working directory_ to `$ProjectFileDir$\build` @@ -189,9 +241,9 @@ Select the `CMake Applications > chatterino` configuration and add a new _Run Ex
-Screenshot of chatterino configuration +Screenshot of Chatterino configuration -![Screenshot of chatterino configuration](https://user-images.githubusercontent.com/41973452/160240843-dc0c603c-227f-4f56-98ca-57f03989dfb4.png) +![Screenshot of Chatterino configuration](https://user-images.githubusercontent.com/41973452/160240843-dc0c603c-227f-4f56-98ca-57f03989dfb4.png)
@@ -200,28 +252,26 @@ Now you can run the `chatterino | Debug` configuration. If you want to run the portable version of Chatterino, create a file called `modes` inside of `build/bin` and write `portable` into it. -### Debugging +#### Debugging -To visualize QT types like `QString`, you need to inform CLion and LLDB +To visualize Qt types like `QString`, you need to inform CLion and LLDB about these types. 1. Set `Enable NatVis renderers for LLDB option` in `Settings | Build, Execution, Deployment | Debugger | Data Views | C/C++` (should be enabled by default). -2. Use the official NatVis file for QT from [`qt-labs/vstools`](https://github.com/qt-labs/vstools) by saving them to +2. Use the official NatVis file for Qt from [`qt-labs/vstools`](https://github.com/qt-labs/vstools) by saving them to the project root using PowerShell: ```powershell -(iwr "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt5.natvis.xml").Content -replace '##NAMESPACE##::', '' | Out-File qt5.natvis +(iwr "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt6.natvis.xml").Content.Replace('##NAMESPACE##::', '') | Out-File qt6.natvis # [OR] using the permalink -(iwr "https://github.com/qt-labs/vstools/raw/0769d945f8d0040917d654d9731e6b65951e102c/QtVsTools.Package/qt5.natvis.xml").Content -replace '##NAMESPACE##::', '' | Out-File qt5.natvis +(iwr "https://github.com/qt-labs/vstools/raw/1c8ba533bd88d935be3724667e0087fd0796102c/QtVsTools.Package/qt6.natvis.xml").Content.Replace('##NAMESPACE##::', '') | Out-File qt6.natvis ``` -Now you can debug the application and see QT types rendered correctly. +Now you can debug the application and see Qt types rendered correctly. If this didn't work for you, try following the [tutorial from JetBrains](https://www.jetbrains.com/help/clion/qt-tutorial.html#debug-renderers). diff --git a/BUILDING_ON_WINDOWS_WITH_VCPKG.md b/BUILDING_ON_WINDOWS_WITH_VCPKG.md index 4bfac943dc5..b998094311c 100644 --- a/BUILDING_ON_WINDOWS_WITH_VCPKG.md +++ b/BUILDING_ON_WINDOWS_WITH_VCPKG.md @@ -1,35 +1,53 @@ # Building on Windows with vcpkg +This will require more than 30GB of free space on your hard drive. + ## Prerequisites -1. Install [Visual Studio](https://visualstudio.microsoft.com/) with "Desktop development with C++" (~9.66 GB) -1. Install [CMake](https://cmake.org/) (~109 MB) -1. Install [git](https://git-scm.com/) (~264 MB) -1. Install [vcpkg](https://vcpkg.io/) (~80 MB) - - `git clone https://github.com/Microsoft/vcpkg.git` - - `cd .\vcpkg\` - - `.\bootstrap-vcpkg.bat` - - `.\vcpkg integrate install` - - `.\vcpkg integrate powershell` - - `cd ..` -1. Configure the environment for vcpkg - - `set VCPKG_DEFAULT_TRIPLET=x64-windows` - - [default](https://github.com/microsoft/vcpkg/blob/master/docs/users/triplets.md#additional-remarks) is `x86-windows` - - `set VCPKG_ROOT=C:\path\to\vcpkg\` - - `set PATH=%PATH%;%VCPKG_ROOT%` +1. Install [Visual Studio](https://visualstudio.microsoft.com/) with "Desktop development with C++" +1. Install [CMake](https://cmake.org/) +1. Install [git](https://git-scm.com/) +1. Install [vcpkg](https://vcpkg.io/) + + ```shell + git clone https://github.com/Microsoft/vcpkg.git + cd vcpkg + .\bootstrap-vcpkg.bat + .\vcpkg integrate install + .\vcpkg integrate powershell + cd .. + ``` + +1. Configure the environment variables for vcpkg. + Check [this document](https://gist.github.com/mitchmindtree/92c8e37fa80c8dddee5b94fc88d1288b#setting-an-environment-variable-on-windows) for more information for how to set environment variables on Windows. + - Ensure your dependencies are built as 64-bit + e.g. `setx VCPKG_DEFAULT_TRIPLET x64-windows` + See [documentation about Triplets](https://learn.microsoft.com/en-gb/vcpkg/users/triplets) + [default](https://github.com/microsoft/vcpkg/blob/master/docs/users/triplets.md#additional-remarks) is `x86-windows` + - Set VCPKG_ROOT to the vcpkg path + e.g. `setx VCPKG_ROOT ` + See [VCPKG_ROOT documentation](https://learn.microsoft.com/en-gb/vcpkg/users/config-environment#vcpkg_root) + - Append the vcpkg path to your path + e.g. `setx PATH "%PATH%;"` + - For more configurations, see https://learn.microsoft.com/en-gb/vcpkg/users/config-environment +1. You may need to restart your computer to ensure all your environment variables and what-not are loaded everywhere. ## Building 1. Clone - - `git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git` -1. Install dependencies (~21 GB) - - `cd .\chatterino2\` - - `vcpkg install` + ```shell + git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git + ``` +1. Install dependencies + ```powershell + cd .\chatterino2\ + vcpkg install + ``` 1. Build - - `mkdir .\build\` - - `cd .\build\` - - (cmd) `cmake .. -DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake` - - (ps1) `cmake .. -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"` - - `cmake --build . --parallel --config Release` -1. Run - - `.\bin\chatterino2.exe` + ```powershell + cmake -B build -DCMAKE_TOOLCHAIN_FILE="$Env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" + cd build + cmake --build . --parallel --config Release + ``` + When using CMD, use `-DCMAKE_TOOLCHAIN_FILE=%VCPKG_ROOT%/scripts/buildsystems/vcpkg.cmake` to specify the toolchain. +1. Run `.\bin\chatterino2.exe` diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ad2e0612c..7704220688f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,284 @@ ## Unversioned +- Major: Allow use of Twitch follower emotes in other channels if subscribed. (#4922) +- Major: Add `/automod` split to track automod caught messages across all open channels the user moderates. (#4986, #5026) +- Major: Show restricted chat messages and suspicious treatment updates. (#5056, #5060) +- Minor: Migrate to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809) +- Minor: The account switcher is now styled to match your theme. (#4817) +- Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) +- Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) +- Minor: Allow running `/ban`, `/timeout`, `/unban`, and `/untimeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945, #4956, #4957) +- Minor: The `/usercard` command now accepts user ids. (#4934) +- Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) +- Minor: The `/reply` command now replies to the latest message of the user. (#4919) +- Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978) +- Minor: Add an option to use new experimental smarter emote completion. (#4987) +- Minor: Add `--safe-mode` command line option that can be used for troubleshooting when Chatterino is misbehaving or is misconfigured. It disables hiding the settings button & prevents plugins from loading. (#4985) +- Minor: Updated the flatpakref link included with nightly builds to point to up-to-date flathub-beta builds. (#5008) +- Minor: Add a new completion API for experimental plugins feature. (#5000, #5047) +- Minor: Re-enabled _Restart on crash_ option on Windows. (#5012) +- Minor: The whisper highlight color can now be configured through the settings. (#5053) +- Minor: Added missing periods at various moderator messages and commands. (#5061) +- Minor: Improved color selection and display. (#5057) +- Minor: Improved Streamlink documentation in the settings dialog. (#5076) +- Minor: Normalized the input padding between light & dark themes. (#5095) +- Minor: Add `--activate ` (or `-a`) command line option to activate or add a Twitch channel. (#5111) +- Minor: Chatters from recent-messages are now added to autocompletion. (#5116) +- Minor: Added a _System_ theme that updates according to the system's color scheme (requires Qt 6.5). (#5118) +- Minor: Added support for the `{input.text}` placeholder in the **Split** -> **Run a command** hotkey. (#5130) +- Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) +- Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) +- Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) +- Bugfix: Fixed a performance issue when displaying replies to certain messages. (#4807) +- Bugfix: Fixed an issue where certain parts of the split input wouldn't focus the split when clicked. (#4958) +- Bugfix: Fixed a data race when disconnecting from Twitch PubSub. (#4771) +- Bugfix: Fixed `/shoutout` command not working with usernames starting with @'s (e.g. `/shoutout @forsen`). (#4800) +- Bugfix: Fixed Usercard popup not floating on tiling WMs on Linux when "Automatically close user popup when it loses focus" setting is enabled. (#3511) +- Bugfix: Fixed selection of tabs after closing a tab when using "Live Tabs Only". (#4770) +- Bugfix: Fixed input in reply thread popup losing focus when dragging. (#4815) +- Bugfix: Fixed the Quick Switcher (CTRL+K) from sometimes showing up on the wrong window. (#4819) +- Bugfix: Fixed too much text being copied when copying chat messages. (#4812, #4830, #4839) +- Bugfix: Fixed an issue where the setting `Only search for emote autocompletion at the start of emote names` wouldn't disable if it was enabled when the client started. (#4855) +- Bugfix: Fixed empty page being added when showing out of bounds dialog. (#4849) +- Bugfix: Fixed issue on Windows preventing the title bar from being dragged in the top left corner. (#4873) +- Bugfix: Fixed an issue where reply context didn't render correctly if an emoji was touching text. (#4875, #4977) +- Bugfix: Fixed the input completion popup from disappearing when clicking on it on Windows and macOS. (#4876) +- Bugfix: Fixed double-click text selection moving its position with each new message. (#4898) +- Bugfix: Fixed an issue where notifications on Windows would contain no or an old avatar. (#4899) +- Bugfix: Fixed headers of tables in the settings switching to bold text when selected. (#4913) +- Bugfix: Fixed tooltips appearing too large and/or away from the cursor. (#4920) +- Bugfix: Fixed a crash when clicking `More messages below` button in a usercard and closing it quickly. (#4933) +- Bugfix: Fixed thread popup window missing messages for nested threads. (#4923) +- Bugfix: Fixed an occasional crash for channel point redemptions with text input. (#4949) +- Bugfix: Fixed triple click on message also selecting moderation buttons. (#4961) +- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965, #5126) +- Bugfix: Fixed badge highlight changes not immediately being reflected. (#5110) +- Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965, #5126) +- Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965, #5126) +- Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971) +- Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971) +- Bugfix: Fixed an issue on macOS where the image uploader would keep prompting the user even after they clicked "Yes, don't ask again". (#5011) +- Bugfix: Hide the Usercard button in the User Info Popup when in special channels. (#4972) +- Bugfix: Fixed support for Windows 11 Snap layouts. (#4994) +- Bugfix: Fixed some windows appearing between screens. (#4797) +- Bugfix: Fixed a crash that could occur when using certain features in a Usercard after closing the split from which it was created. (#5034, #5051) +- Bugfix: Fixed a crash that could occur when using certain features in a Reply popup after closing the split from which it was created. (#5036, #5051) +- Bugfix: Fixed a bug on Wayland where tooltips would spawn as separate windows instead of behaving like tooltips. (#4998, #5040) +- Bugfix: Fixes to section deletion in text input fields. (#5013) +- Bugfix: Show user text input within watch streak notices. (#5029) +- Bugfix: Fixed avatar in usercard and moderation button triggering when releasing the mouse outside their area. (#5052) +- Bugfix: Fixed moderator-only topics being subscribed to for non-moderators. (#5056) +- Bugfix: Fixed a bug where buttons would remain in a hovered state after leaving them. (#5077) +- Bugfix: Fixed popup windows not persisting between restarts. (#5081) +- Bugfix: Fixed splits not retaining their focus after minimizing. (#5080) +- Bugfix: Fixed _Copy message_ copying the channel name in global search. (#5106) +- Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978) +- Dev: Change clang-format from v14 to v16. (#4929) +- Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) +- Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) +- Dev: Tests now run on Ubuntu 22.04 instead of 20.04 to loosen C++ restrictions in tests. (#4774) +- Dev: Do a pretty major refactor of the Settings classes. List settings (e.g. highlights) are most heavily modified, and should have an extra eye kept on them. (#4775) +- Dev: conan: Update Boost to 1.83 & OpenSSL to 3.2.0. (#5007) +- Dev: Remove `boost::noncopyable` use & `boost_random` dependency. (#4776) +- Dev: Fix clang-tidy `cppcoreguidelines-pro-type-member-init` warnings. (#4426) +- Dev: Immediate layout for invisible `ChannelView`s is skipped. (#4811) +- Dev: Refactor `Image` & Image's `Frames`. (#4773) +- Dev: Add `WindowManager::getLastSelectedWindow()` to replace `getMainWindow()`. (#4816) +- Dev: Clarify signal connection lifetimes where applicable. (#4818) +- Dev: Laid the groundwork for advanced input completion strategies. (#4639, #4846, #4853, #4893) +- Dev: Fixed flickering when running with Direct2D on Windows. (#4851) +- Dev: Fix qtkeychain include for Qt6 users. (#4863) +- Dev: Add a compile-time flag `CHATTERINO_UPDATER` which can be turned off to disable update checks. (#4854) +- Dev: Add a compile-time flag `USE_SYSTEM_MINIAUDIO` which can be turned on to use the system miniaudio. (#4867) +- Dev: Update vcpkg to use Qt6. (#4872) +- Dev: Update `magic_enum` to v0.9.5. (#4992) +- Dev: Replace `boost::optional` with `std::optional`. (#4877) +- Dev: Improve performance of selecting text. (#4889, #4911) +- Dev: Removed direct dependency on Qt 5 compatibility module. (#4906) +- Dev: Refactor `Emoji`'s EmojiMap into a vector. (#4980) +- Dev: Refactor `DebugCount` and add copy button to debug popup. (#4921) +- Dev: Refactor `common/Credentials`. (#4979) +- Dev: Refactor chat logger. (#5058) +- Dev: Refactor Twitch PubSub client. (#5059) +- Dev: Changed lifetime of context menus. (#4924) +- Dev: Renamed `tools` directory to `scripts`. (#5035) +- Dev: Refactor `ChannelView`, removing a bunch of clang-tidy warnings. (#4926) +- Dev: Refactor `IrcMessageHandler`, removing a bunch of clang-tidy warnings & changing its public API. (#4927) +- Dev: Removed almost all raw accesses into Application. (#5104) +- Dev: `Details` file properties tab is now populated on Windows. (#4912) +- Dev: Removed `Outcome` from network requests. (#4959) +- Dev: Added Tests for Windows and MacOS in CI. (#4970, #5032) +- Dev: Move `clang-tidy` checker to its own CI job. (#4996) +- Dev: Refactored the Image Uploader feature. (#4971) +- Dev: Refactored the SplitOverlay code. (#5082) +- Dev: Refactored the TwitchBadges structure, making it less of a singleton. (#5096) +- Dev: Refactored emotes out of TwitchIrcServer. (#5120) +- Dev: Refactored the ChatterinoBadges structure, making it less of a singleton. (#5103) +- Dev: Refactored the ColorProvider class a bit. (#5112) +- Dev: Moved the Network files to their own folder. (#5089) +- Dev: Fixed deadlock and use-after-free in tests. (#4981) +- Dev: Moved all `.clang-format` files to the root directory. (#5037) +- Dev: Load less message history upon reconnects. (#5001, #5018) +- Dev: Removed the `NullablePtr` class. (#5091) +- Dev: BREAKING: Replace custom `import()` with normal Lua `require()`. (#5014, #5108) +- Dev: Fixed most compiler warnings. (#5028, #5137) +- Dev: Added the ability to show `ChannelView`s without a `Split`. (#4747) +- Dev: Refactor Args to be less of a singleton. (#5041) +- Dev: Channels without any animated elements on screen will skip updates from the GIF timer. (#5042, #5043, #5045) +- Dev: Autogenerate docs/plugin-meta.lua. (#5055) +- Dev: Refactor `NetworkPrivate`. (#5063) +- Dev: Refactor `Paths` & `Updates`, focusing on reducing their singletoniability. (#5092, #5102) +- Dev: Removed duplicate scale in settings dialog. (#5069) +- Dev: Fix `NotebookTab` emitting updates for every message. (#5068) +- Dev: Added benchmark for parsing and building recent messages. (#5071) +- Dev: Boost is depended on as a header-only library when using conan. (#5107) +- Dev: Added signal to invalidate paint buffers of channel views without forcing a relayout. (#5123) +- Dev: Specialize `Atomic>` if underlying standard library supports it. (#5133) + +## 2.4.6 + +- Minor: Migrate to the new Get Channel Followers Helix endpoint, fixing follower count not showing up in usercards. (#4809) +- Bugfix: Update Qt version, fixing a security issue with webp loading (see https://www.qt.io/blog/two-qt-security-advisorys-gdi-font-engine-webp-image-format) (#4843) +- Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) + +## 2.4.5 + +- Major: AutoMod term management messages (e.g. testaccount added "noob" as a blocked term on AutoMod.) are now hidden in Streamer Mode if you have the "Hide moderation actions" setting enabled. (#4758) +- Minor: Added `/shoutout ` command to shoutout a specified user. Note: This is only the /command, we are still unable to display when a shoutout happens. (#4638) +- Minor: Added a setting to only show tabs with live channels (default toggle hotkey: Ctrl+Shift+L). (#4358) +- Minor: Added an option to subscribe to and unsubscribe from reply threads. (#4680, #4739) +- Minor: Added the ability to pin Reply threads to stay open while using the setting "Automatically close reply thread popup when it loses focus". (#4680) +- Minor: Highlights loaded from message history will now correctly appear in the /mentions tab. (#4475) +- Minor: Added hotkey Action for pinning usercards and reply threads. (#4692) +- Minor: Added missing hotkey Action for Open Player in Browser. (#4756) +- Minor: Added an icon showing when streamer mode is enabled (#4410, #4690) +- Minor: Message input is now focused when clicking on emotes. (#4719) +- Minor: Changed viewer list to chatter list to more match Twitch's terminology. (#4732) +- Minor: Added currency & duration to Hype Chat messages. (#4715) +- Minor: Added `is:hype-chat` search option. (#4766) +- Minor: Added `flags.hype_chat` filter variable. (#4766) +- Minor: Nicknames are now taken into consideration when searching for messages. (#4663, #4742) +- Minor: Added a message for when Chatterino joins a channel (#4616) +- Minor: 7TV badges now automatically update upon changing them. (#4512) +- Minor: Removed restriction on Go To Message on system messages from search. (#4614) +- Minor: Channel point redemptions without custom text are now shown in the usercard. (#4557) +- Minor: Added settings for customizing the behavior of `Right Click`ing a usernames. (#4622, #4751) +- Minor: The input completion and quick switcher are now styled to match your theme. (#4671) +- Minor: All channels opened in browser tabs are synced when using the extension for quicker switching between tabs. (#4741) +- Minor: Added support for opening incognito links in firefox-esr and chromium. (#4745) +- Minor: Added support for opening incognito links under Linux/BSD using XDG. (#4745) +- Minor: Added accelerators to the right click menu for messages (#4705) +- Minor: Improved editing hotkeys. (#4628) +- Minor: Added `/c2-theme-autoreload` command to automatically reload a custom theme. This is useful for when you're developing your own theme. (#4718) +- Bugfix: Fixed an issue where Subscriptions & Announcements that contained ignored phrases would still appear if the Block option was enabled. (#4748) +- Bugfix: Increased amount of blocked users loaded from 100 to 1,000. (#4721) +- Bugfix: Fixed pings firing for the "Your username" highlight when not signed in. (#4698) +- Bugfix: Fixed a crash that could happen when closing splits before their display name was updated. This was especially noticeable after the live controller changes. (#4731) +- Bugfix: Fixed highlights sometimes not working after changing sound device, or switching users in your operating system. (#4729) +- Bugfix: Fixed a spacing issue with mentions inside RTL text. (#4677) +- Bugfix: Fixed a crash when opening and closing a reply thread and switching the user. (#4675) +- Bugfix: Fixed a crash that could occur when closing the usercard too quickly after blocking or unblocking a user. (#4711) +- Bugfix: Fixed visual glitches with smooth scrolling. (#4501) +- Bugfix: Fixed key bindings not showing in context menus on Mac. (#4722) +- Bugfix: Fixed timeouts from message history not behaving consistently. (#4760) +- Bugfix: Fixed partially broken filters on Qt 6 builds. (#4702) +- Bugfix: Fixed tooltips & popups sometimes showing up on the wrong monitor. (#4740) +- Bugfix: Fixed some network errors having `0` as their HTTP status. (#4704) +- Bugfix: Fixed tab completion rarely completing the wrong word. (#4735) +- Bugfix: Fixed generation of crashdumps by the browser-extension process when the browser was closed. (#4667) +- Dev: Stream status requests are now batched. (#4713) +- Dev: Added command to set Qt's logging filter/rules at runtime (`/c2-set-logging-rules`). (#4637) +- Dev: Added the ability to see & load custom themes from the Themes directory. No stable promises are made of this feature, changes might be made that breaks custom themes without notice. (#4570) +- Dev: Added test cases for emote and tab completion. (#4644) +- Dev: Fixed `clang-tidy-review` action not picking up dependencies. (#4648) +- Dev: Expanded upon `$$$` test channels. (#4655) +- Dev: Added tools to help debug image GC. (#4578) +- Dev: Removed duplicate license when having plugins enabled. (#4665) +- Dev: Replace our QObjectRef class with Qt's QPointer class. (#4666) +- Dev: Fixed warnings about QWidgets already having a QLayout. (#4672) +- Dev: Fixed undefined behavior when loading non-existent credentials. (#4673) +- Dev: Small refactor of the recent-messages API, splitting its internal API and its internal implementation up into separate files. (#4763) +- Dev: Added support for compiling with `sccache`. (#4678) +- Dev: Added `sccache` in Windows CI. (#4678) +- Dev: Moved preprocessor Git and date definitions to executables only. (#4681) +- Dev: Refactored tests to be able to use `ctest` and run in debug builds. (#4700) +- Dev: Added the ability to use an alternate linker using the `-DUSE_ALTERNATE_LINKER=...` CMake parameter. (#4711) +- Dev: The Windows installer is now built in CI. (#4408) +- Dev: Removed `getApp` and `getSettings` calls from message rendering. (#4535) +- Dev: Get the default browser executable instead of the entire command line when opening incognito links. (#4745) +- Dev: Removed unused code hidden behind the USEWEBENGINE define (#4757) + +## 2.4.4 + +- Minor: Added a Send button in the input box so you can click to send a message. This is disabled by default and can be enabled with the "Show send message button" setting. (#4607) +- Minor: Improved error messages when the updater fails a download. (#4594) +- Minor: Added `/shield` and `/shieldoff` commands to toggle shield mode. (#4580) +- Bugfix: Fixed the menu warping on macOS on Qt6. (#4595) +- Bugfix: Fixed link tooltips not showing unless the thumbnail setting was enabled. (#4597) +- Bugfix: Domains starting with `http` are now parsed as links again. (#4598) +- Bugfix: Reduced the size of the update prompt to prevent it from going off the users screen. (#4626) +- Bugfix: Fixed click effects on buttons not being antialiased. (#4473) +- Bugfix: Fixed Ctrl+Backspace not working after Select All in chat search popup. (#4461) +- Bugfix: Fixed crash when scrolling up really fast. (#4621) +- Dev: Added the ability to control the `followRedirect` mode for requests. (#4594) + +## 2.4.3 + +- Major: Added support for FrankerFaceZ animated emotes. (#4434) +- Minor: Added the ability to reply to a message by `Shift + Right Click`ing the username. (#4424) +- Minor: Reply context now censors blocked users. (#4502) +- Minor: Migrated the viewer list to Helix API. (#4117) +- Minor: Migrated badges to Helix API. (#4537) +- Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) +- Minor: Added better filter validation and error messages. (#4364) +- Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) +- Minor: Re-added leading @mentions from replies in chat logs. These were accidentally removed during the reply overhaul. (#4420) +- Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) +- Minor: Updated the macOS icon to be consistent with the design of other applications on macOS. (#4577) +- Bugfix: Fixed an issue where Chatterino could lose track of the sound device in certain scenarios. (#4549) +- Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) +- Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) +- Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460) +- Bugfix: Fixed an issue where the "Enable zero-width emotes" setting was showing the inverse state. (#4462) +- Bugfix: Fixed blocked user list being empty when opening the settings dialog for the first time. (#4437) +- Bugfix: Fixed blocked user list sticking around when switching from a logged in user to being logged out. (#4437) +- Bugfix: Fixed search popup ignoring setting for message scrollback limit. (#4496) +- Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) +- Bugfix: Fixed Twitch channel-specific filters not being applied correctly. (#4529) +- Bugfix: Fixed `/mods` displaying incorrectly when the channel has no mods. (#4546) +- Bugfix: Fixed emote & badge tooltips not showing up when thumbnails were hidden. (#4509) +- Bugfix: Fixed links with invalid IPv4 addresses being parsed. (#4576) +- Bugfix: Fixed the macOS icon changing to the wrong icon when the application is open. (#4577) +- Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) +- Dev: Themes are now stored as JSON files in `resources/themes`. (#4471, #4533) +- Dev: Ignore unhandled BTTV user-events. (#4438) +- Dev: Only log debug messages when NDEBUG is not defined. (#4442) +- Dev: Cleaned up theme related code. (#4450) +- Dev: Ensure tests have default-initialized settings. (#4498) +- Dev: Add scripting capabilities with Lua (#4341, #4504) +- Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) +- Dev: Added tests and benchmarks for `LinkParser`. (#4436) +- Dev: Removed redundant parsing of links. (#4507) +- Dev: Experimental builds with Qt 6 are now provided. (#4522, #4551, #4553, #4554, #4555, #4556) +- Dev: Fixed username rendering in Qt 6. (#4476, #4568) +- Dev: Fixed placeholder color in Qt 6. (#4477) +- Dev: Removed `CHATTERINO_TEST` definitions. (#4526) +- Dev: Builds for macOS now have `macos` in their name (previously: `osx`). (#4550) +- Dev: Fixed a crash when dragging rows in table-views in builds with Qt 6. (#4567) + +## 2.4.2 + +- Minor: Added `/banid` command that allows banning by user ID. (#4411) +- Bugfix: Fixed FrankerFaceZ emotes/badges not loading due to an API change. (#4432) +- Bugfix: Fixed uploaded AppImage not being able to execute most web requests. (#4400) +- Bugfix: Fixed a potential race condition due to using the wrong lock when loading 7TV badges. (#4402) +- Dev: Delete all but the last 5 crashdumps on application start. (#4392) +- Dev: Added capability to build Chatterino with Qt6. (#4393) +- Dev: Fixed homebrew update action. (#4394) + +## 2.4.1 + - Major: Added live emote updates for BTTV. (#4147) - Minor: Added setting to turn off rendering of reply context. (#4224) - Minor: Changed the highlight order to prioritize Message highlights over User highlights. (#4303) @@ -15,6 +293,7 @@ - Minor: Added link to streamlink docs for easier user setup. (#4217) - Minor: Added support for HTTP and Socks5 proxies through environment variables. (#4321) - Minor: Added crashpad to capture crashes on Windows locally. See PR for build/crash analysis instructions. (#4351) +- Minor: Github releases now include flatpakref files for nightly builds - Bugfix: Fixed User Card moderation actions not working after Twitch IRC chat command deprecation. (#4378) - Bugfix: Fixed User Card broadcaster actions (mod, unmod, vip, unvip) not working after Twitch IRC chat command deprecation. (#4387) - Bugfix: Fixed crash that would occur when performing certain actions after removing all tabs. (#4271) @@ -148,6 +427,7 @@ - Bugfix: Fixed crash happening when QuickSwitcher is used with a popout window. (#4187) - Bugfix: Fixed low contrast of text in settings tooltips. (#4188) - Bugfix: Fixed being unable to see the usercard of VIPs who have Asian language display names. (#4174) +- Bugfix: Fixed whispers always being shown in the /mentions split. (#4389) - Bugfix: Fixed messages where Right-to-Left order is mixed in multiple lines. (#4173) - Bugfix: Fixed the wrong right-click menu showing in the chat input box. (#4177) - Bugfix: Fixed popup windows not appearing/minimizing correctly on the Windows taskbar. (#4181) diff --git a/CMakeLists.txt b/CMakeLists.txt index 09020672821..f6b8281e115 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,8 +8,6 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/sanitizers-cmake/cmake" ) -project(chatterino VERSION 2.4.0) - option(BUILD_APP "Build Chatterino" ON) option(BUILD_TESTS "Build the tests for Chatterino" OFF) option(BUILD_BENCHMARKS "Build the benchmarks for Chatterino" OFF) @@ -17,6 +15,7 @@ option(USE_SYSTEM_PAJLADA_SETTINGS "Use system pajlada settings library" OFF) option(USE_SYSTEM_LIBCOMMUNI "Use system communi library" OFF) option(USE_SYSTEM_QTKEYCHAIN "Use system QtKeychain library" OFF) option(BUILD_WITH_QTKEYCHAIN "Build Chatterino with support for your system key chain" ON) +option(USE_SYSTEM_MINIAUDIO "Build Chatterino with your system miniaudio" OFF) option(BUILD_WITH_CRASHPAD "Build chatterino with crashpad" OFF) option(USE_PRECOMPILED_HEADERS "Use precompiled headers" ON) option(BUILD_WITH_QT6 "Use Qt6 instead of default Qt5" OFF) @@ -25,11 +24,28 @@ option(CHATTERINO_GENERATE_COVERAGE "Generate coverage files" OFF) option(BUILD_TRANSLATIONS "" OFF) option(BUILD_SHARED_LIBS "" OFF) option(CHATTERINO_LTO "Enable LTO for all targets" OFF) +option(CHATTERINO_PLUGINS "Enable EXPERIMENTAL plugin support in Chatterino" OFF) + +option(CHATTERINO_UPDATER "Enable update checks" ON) +mark_as_advanced(CHATTERINO_UPDATER) + +if(BUILD_TESTS) + list(APPEND VCPKG_MANIFEST_FEATURES "tests") +endif() +if(BUILD_BENCHMARKS) + list(APPEND VCPKG_MANIFEST_FEATURES "benchmarks") +endif() + +project(chatterino + VERSION 2.4.6 + DESCRIPTION "Chat client for twitch.tv" + HOMEPAGE_URL "https://chatterino.com/" +) if(CHATTERINO_LTO) include(CheckIPOSupported) check_ipo_supported(RESULT CHATTERINO_ENABLE_LTO OUTPUT IPO_ERROR) - message(STATUS "LTO: Enabled (Supported: ${CHATTERINO_ENABLE_LTO})") + message(STATUS "LTO: Enabled (Supported: ${CHATTERINO_ENABLE_LTO} - ${IPO_ERROR})") else() message(STATUS "LTO: Disabled") endif() @@ -41,11 +57,51 @@ else() endif() find_program(CCACHE_PROGRAM ccache) -if (CCACHE_PROGRAM) - set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") - message("Using ${CCACHE_PROGRAM} for speeding up build") +find_program(SCCACHE_PROGRAM sccache) +if (SCCACHE_PROGRAM) + set(_compiler_launcher ${SCCACHE_PROGRAM}) +elseif (CCACHE_PROGRAM) + set(_compiler_launcher ${CCACHE_PROGRAM}) endif () + +# Alternate linker code taken from heavyai/heavydb +# https://github.com/heavyai/heavydb/blob/0517d99b467806f6af7b4c969e351368a667497d/CMakeLists.txt#L87-L103 +macro(set_alternate_linker linker) + find_program(LINKER_EXECUTABLE ld.${USE_ALTERNATE_LINKER} ${USE_ALTERNATE_LINKER}) + if(LINKER_EXECUTABLE) + if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang" AND "${CMAKE_CXX_COMPILER_VERSION}" VERSION_LESS 12.0.0) + add_link_options("-ld-path=${USE_ALTERNATE_LINKER}") + else() + add_link_options("-fuse-ld=${USE_ALTERNATE_LINKER}") + endif() + else() + set(USE_ALTERNATE_LINKER "" CACHE STRING "Use alternate linker" FORCE) + endif() +endmacro() + +set(USE_ALTERNATE_LINKER "" CACHE STRING "Use alternate linker. Leave empty for system default; alternatives are 'gold', 'lld', 'bfd', 'mold'") +if(NOT "${USE_ALTERNATE_LINKER}" STREQUAL "") + set_alternate_linker(${USE_ALTERNATE_LINKER}) +endif() + +if (_compiler_launcher) + set(CMAKE_CXX_COMPILER_LAUNCHER "${_compiler_launcher}" CACHE STRING "CXX compiler launcher") + message(STATUS "Using ${_compiler_launcher} for speeding up build") + + if (MSVC) + # /Zi can't be used with (s)ccache + # Use /Z7 instead (debug info in object files) + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + string(REPLACE "/Zi" "/Z7" CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG}") + elseif(CMAKE_BUILD_TYPE STREQUAL "Release") + string(REPLACE "/Zi" "/Z7" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}") + elseif(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") + string(REPLACE "/Zi" "/Z7" CMAKE_CXX_FLAGS_RELWITHDEBINFO "${CMAKE_CXX_FLAGS_RELWITHDEBINFO}") + endif() + endif() +endif() + include(${CMAKE_CURRENT_LIST_DIR}/cmake/GIT.cmake) find_package(Qt${MAJOR_QT_VERSION} REQUIRED @@ -61,15 +117,13 @@ find_package(Qt${MAJOR_QT_VERSION} REQUIRED message(STATUS "Qt version: ${Qt${MAJOR_QT_VERSION}_VERSION}") if (WIN32) - find_package(WinToast REQUIRED) + add_subdirectory(lib/WinToast EXCLUDE_FROM_ALL) endif () -find_package(Sanitizers) +find_package(Sanitizers QUIET) # Find boost on the system -# `OPTIONAL_COMPONENTS random` is required for vcpkg builds to link. -# `OPTIONAL` is required, because conan doesn't set `boost_random_FOUND`. -find_package(Boost REQUIRED OPTIONAL_COMPONENTS random) +find_package(Boost REQUIRED OPTIONAL_COMPONENTS headers) # Find OpenSSL on the system find_package(OpenSSL REQUIRED) @@ -111,6 +165,7 @@ find_package(RapidJSON REQUIRED) find_package(Websocketpp REQUIRED) if (BUILD_TESTS) + include(GoogleTest) # For MSVC: Prevent overriding the parent project's compiler/linker settings # See https://github.com/google/googletest/blob/main/googletest/README.md#visual-studio-dynamic-vs-static-runtimes set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) @@ -149,22 +204,37 @@ else() add_subdirectory("${CMAKE_SOURCE_DIR}/lib/settings" EXCLUDE_FROM_ALL) endif() +if (CHATTERINO_PLUGINS) + set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src") + add_subdirectory(lib/lua) +endif() + if (BUILD_WITH_CRASHPAD) - add_subdirectory("${CMAKE_SOURCE_DIR}/lib/crashpad" EXCLUDE_FROM_ALL) + add_subdirectory("${CMAKE_SOURCE_DIR}/tools/crash-handler") endif() +# Used to provide a date of build in the About page (for nightly builds). Getting the actual time of +# compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. +# For CI runs, however, the date of build file generation should be consistent with the date of +# compilation so this approximation is "good enough" for our purpose. +if (DEFINED ENV{CHATTERINO_SKIP_DATE_GEN}) + set(cmake_gen_date "1970-01-01") +else () + string(TIMESTAMP cmake_gen_date "%Y-%m-%d") +endif () + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -if (BUILD_TESTS OR BUILD_BENCHMARKS) - add_definitions(-DCHATTERINO_TEST) -endif () - # Generate resource files include(cmake/resources/generate_resources.cmake) add_subdirectory(src) +if (BUILD_TESTS OR BUILD_BENCHMARKS) + add_subdirectory(mocks) +endif () + if (BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af533919635..52bdd8f4979 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ This is a set of guidelines for contributing to Chatterino. The goal is to teach programmers without a C++ background (java/python/etc.), people who haven't used Qt, or otherwise have different experience, the idioms of the codebase. Thus we will focus on those which are different from those other environments. There are extra guidelines available [here](https://hackmd.io/@fourtf/chatterino-pendantic-guidelines) but they are considered as extras and not as important. +### General (non-code related) guidelines for contributing to Chatterino + +- Make a specific branch for your pull request instead of using the master, main, or mainline branch. This will prevent future problems with updating your branch after your PR is merged. + # Tooling ## Formatting @@ -31,7 +35,7 @@ int compare(const QString &a, const QString &b); ```cpp /* - * Matches a link and returns boost::none if it failed and a + * Matches a link and returns std::nullopt if it failed and a * QRegularExpressionMatch on success. * ^^^ This comment just repeats the function signature!!! * @@ -39,7 +43,7 @@ int compare(const QString &a, const QString &b); * link * ^^^ No need to repeat the obvious. */ -boost::optional matchLink(const QString &text); +std::optional matchLink(const QString &text); ``` # Code diff --git a/README.md b/README.md index 47986f53414..0a046198d19 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![alt text](https://fourtf.com/img/chatterino-icon-64.png) +![chatterinoLogo](https://user-images.githubusercontent.com/41973452/272541622-52457e89-5f16-4c83-93e7-91866c25b606.png) Chatterino 2 [![GitHub Actions Build (Windows, Ubuntu, MacOS)](https://github.com/Chatterino/chatterino2/workflows/Build/badge.svg?branch=master)](https://github.com/Chatterino/chatterino2/actions?query=workflow%3ABuild+branch%3Amaster) [![Cirrus CI Build (FreeBSD only)](https://api.cirrus-ci.com/github/Chatterino/chatterino2.svg?branch=master)](https://cirrus-ci.com/github/Chatterino/chatterino2/master) [![Chocolatey Package](https://img.shields.io/chocolatey/v/chatterino?include_prereleases)](https://chocolatey.org/packages/chatterino) [![Flatpak Package](https://img.shields.io/flathub/v/com.chatterino.chatterino)](https://flathub.org/apps/details/com.chatterino.chatterino) ============ @@ -22,43 +22,40 @@ If you still receive an error about `MSVCR120.dll missing`, then you should inst To get source code with required submodules run: -``` +```shell git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git ``` or -``` +```shell git clone https://github.com/Chatterino/chatterino2.git cd chatterino2 git submodule update --init --recursive ``` -[Building on Windows](../master/BUILDING_ON_WINDOWS.md) +- [Building on Windows](../master/BUILDING_ON_WINDOWS.md) +- [Building on Windows with vcpkg](../master/BUILDING_ON_WINDOWS_WITH_VCPKG.md) +- [Building on Linux](../master/BUILDING_ON_LINUX.md) +- [Building on macOS](../master/BUILDING_ON_MAC.md) +- [Building on FreeBSD](../master/BUILDING_ON_FREEBSD.md) -[Building on Windows with vcpkg](../master/BUILDING_ON_WINDOWS_WITH_VCPKG.md) +## Git blame -[Building on Linux](../master/BUILDING_ON_LINUX.md) +This project has big commits in the history which touch most files while only doing stylistic changes. To improve the output of git-blame, consider setting: -[Building on Mac](../master/BUILDING_ON_MAC.md) +```shell +git config blame.ignoreRevsFile .git-blame-ignore-revs +``` -[Building on FreeBSD](../master/BUILDING_ON_FREEBSD.md) +This will ignore all revisions mentioned in the [`.git-blame-ignore-revs` +file](./.git-blame-ignore-revs). GitHub does this by default. ## Code style -The code is formatted using clang format in Qt Creator. [.clang-format](src/.clang-format) contains the style file for clang format. - -### Get it automated with QT Creator + Beautifier + Clang Format - -1. Download LLVM: https://github.com/llvm/llvm-project/releases/download/llvmorg-15.0.5/LLVM-15.0.5-win64.exe -2. During the installation, make sure to add it to your path -3. In QT Creator, select `Help` > `About Plugins` > `C++` > `Beautifier` to enable the plugin -4. Restart QT Creator -5. Select `Tools` > `Options` > `Beautifier` -6. Under `General` select `Tool: ClangFormat` and enable `Automatic Formatting on File Save` -7. Under `Clang Format` select `Use predefined style: File` and `Fallback style: None` +The code is formatted using [clang-format](https://clang.llvm.org/docs/ClangFormat.html). Our configuration is found in the [.clang-format](.clang-format) file in the repository root directory. -Qt creator should now format the documents when saving it. +For more contribution guidelines, take a look at [the wiki](https://wiki.chatterino.com/Contributing%20for%20Developers/). ## Doxygen diff --git a/benchmarks/.clang-format b/benchmarks/.clang-format deleted file mode 100644 index 7bae09f2ce3..00000000000 --- a/benchmarks/.clang-format +++ /dev/null @@ -1,55 +0,0 @@ -Language: Cpp - -AccessModifierOffset: -4 -AlignEscapedNewlinesLeft: true -AllowShortFunctionsOnASingleLine: false -AllowShortIfStatementsOnASingleLine: false -AllowShortLambdasOnASingleLine: Empty -AllowShortLoopsOnASingleLine: false -AlwaysBreakAfterDefinitionReturnType: false -AlwaysBreakBeforeMultilineStrings: false -BasedOnStyle: Google -BraceWrapping: - AfterClass: "true" - AfterControlStatement: "true" - AfterFunction: "true" - AfterNamespace: "false" - BeforeCatch: "true" - BeforeElse: "true" -BreakBeforeBraces: Custom -BreakConstructorInitializersBeforeComma: true -ColumnLimit: 80 -ConstructorInitializerAllOnOneLineOrOnePerLine: false -DerivePointerBinding: false -FixNamespaceComments: true -IndentCaseLabels: true -IndentWidth: 4 -IndentWrappedFunctionNames: true -IndentPPDirectives: AfterHash -SortIncludes: CaseInsensitive -IncludeBlocks: Regroup -IncludeCategories: - # Project includes - - Regex: '^"[a-zA-Z\._-]+(/[a-zA-Z0-9\._-]+)*"$' - Priority: 1 - # Third party library includes - - Regex: '<[[:alnum:].]+/[a-zA-Z0-9\._\/-]+>' - Priority: 3 - # Qt includes - - Regex: '^$' - Priority: 3 - CaseSensitive: true - # LibCommuni includes - - Regex: "^$" - Priority: 3 - # Misc libraries - - Regex: '^<[a-zA-Z_0-9]+\.h(pp)?>$' - Priority: 3 - # Standard library includes - - Regex: "^<[a-zA-Z_]+>$" - Priority: 4 -NamespaceIndentation: Inner -PointerBindsToType: false -SpacesBeforeTrailingComments: 2 -Standard: Auto -ReflowComments: false diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 1bc975fd0bf..209c0115b74 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -1,12 +1,16 @@ project(chatterino-benchmark) set(benchmark_SOURCES - ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp - ${CMAKE_CURRENT_LIST_DIR}/src/Emojis.cpp - ${CMAKE_CURRENT_LIST_DIR}/src/Highlights.cpp - ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp - ${CMAKE_CURRENT_LIST_DIR}/src/Helpers.cpp - ${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp + src/main.cpp + resources/bench.qrc + + src/Emojis.cpp + src/Highlights.cpp + src/FormatTime.cpp + src/Helpers.cpp + src/LimitedQueue.cpp + src/LinkParser.cpp + src/RecentMessages.cpp # Add your new file above this line! ) @@ -14,13 +18,10 @@ add_executable(${PROJECT_NAME} ${benchmark_SOURCES}) add_sanitizers(${PROJECT_NAME}) target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-lib) +target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-mocks) target_link_libraries(${PROJECT_NAME} PRIVATE benchmark::benchmark) -target_compile_definitions(${PROJECT_NAME} PRIVATE - CHATTERINO_TEST - ) - set_target_properties(${PROJECT_NAME} PROPERTIES ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" @@ -29,4 +30,5 @@ set_target_properties(${PROJECT_NAME} RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" + AUTORCC ON ) diff --git a/benchmarks/resources/bench.qrc b/benchmarks/resources/bench.qrc new file mode 100644 index 00000000000..557b32e6920 --- /dev/null +++ b/benchmarks/resources/bench.qrc @@ -0,0 +1,6 @@ + + + recentmessages-nymn.json + seventvemotes-nymn.json + + diff --git a/benchmarks/resources/recentmessages-nymn.json b/benchmarks/resources/recentmessages-nymn.json new file mode 100644 index 00000000000..cd8bc7f4325 --- /dev/null +++ b/benchmarks/resources/recentmessages-nymn.json @@ -0,0 +1 @@ +{"messages":["@returning-chatter=0;rm-received-ts=1704557984273;first-msg=0;color=#8A2BE2;historical=1;room-id=62300805;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;flags=;badges=no_audio/1;mod=0;client-nonce=ec682d2912f0ae95f60b53ce821ee88e;user-id=253596827;tmi-sent-ts=1704557984095;turbo=0;subscriber=0;display-name=Purple_Geco;user-type=;id=33445373-4dcf-46a5-b2a9-2eb9db2386a5;badge-info= :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty","@room-id=62300805;historical=1;mod=0;badges=;subscriber=0;rm-received-ts=1704557984412;emotes=;user-id=417575252;display-name=mon_dieud;color=#D2691E;tmi-sent-ts=1704557984239;first-msg=0;id=4e6674ad-8d04-4171-a8cc-0454332d415c;user-type=;turbo=0;badge-info=;client-nonce=6de2caeea0c45faad38061f86f463ff1;flags=;returning-chatter=0 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@tmi-sent-ts=1704557984398;turbo=0;mod=0;badge-info=subscriber/38;returning-chatter=0;historical=1;user-type=;badges=subscriber/36,twitch-recap-2023/1;color=#63BD68;rm-received-ts=1704557984567;subscriber=1;id=8f921b5d-29b1-4c70-bc87-7a88a9d32ba8;display-name=jontEmillian;flags=;emotes=;user-id=433352132;first-msg=0;room-id=62300805 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@tmi-sent-ts=1704557984491;room-id=62300805;client-nonce=acb3234f197f58640cd9515dfd41fcdc;user-id=51967700;badges=;color=#FF0000;display-name=Patixxl;flags=;rm-received-ts=1704557984666;id=def31555-deb2-49f6-97fb-304bd233cf3f;first-msg=0;emotes=;turbo=0;badge-info=;historical=1;user-type=;returning-chatter=0;subscriber=0;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;returning-chatter=0;mod=0;first-msg=0;badges=subscriber/0,premium/1;client-nonce=e66501430b4420408e38fa1cf21d0f67;subscriber=1;badge-info=subscriber/2;flags=;id=bb26e6c4-4056-48f4-90fb-7ae9c6f06737;display-name=mnqn18;user-type=;historical=1;rm-received-ts=1704557985211;user-id=474204887;tmi-sent-ts=1704557985016;emotes=;room-id=62300805;color=#1E90FF :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn :forsen people","@first-msg=0;historical=1;flags=;user-id=417575252;user-type=;mod=0;tmi-sent-ts=1704557985841;returning-chatter=0;rm-received-ts=1704557986007;badge-info=;display-name=mon_dieud;color=#D2691E;room-id=62300805;turbo=0;id=d9c2ceae-09f7-4fdf-9261-7234f0900800;emotes=;subscriber=0;client-nonce=1c42d8d6d4fee35cef628973bd8dc1ea;badges= :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM 󠀀","@vip=1;color=#D52AFF;user-type=;rm-received-ts=1704557986137;historical=1;flags=;first-msg=0;turbo=0;display-name=Joshlad;subscriber=1;badges=vip/1,subscriber/72,rplace-2023/1;mod=0;room-id=62300805;badge-info=subscriber/77;emotes=;user-id=87120320;tmi-sent-ts=1704557985954;returning-chatter=0;id=84223bac-92d3-4859-876d-95a6446514b0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;first-msg=0;badge-info=;room-id=62300805;returning-chatter=0;user-id=216144449;client-nonce=ef3bd8f842f993fd74398ba06c624482;badges=turbo/1;flags=;mod=0;subscriber=0;color=#00FF7F;rm-received-ts=1704557986610;turbo=1;display-name=FollowProtoBuddy;historical=1;id=3bfff24b-2558-4ea2-a7eb-fb92830a2cdc;tmi-sent-ts=1704557986421;emotes= :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn nimeDance","@first-msg=0;tmi-sent-ts=1704557986461;client-nonce=8bd58a70f05ba89714ae8ca6834ab3f6;color=#7B00FF;user-type=;flags=;display-name=imav1ctor;badges=premium/1;badge-info=;historical=1;room-id=62300805;turbo=0;rm-received-ts=1704557986681;id=4e37eeed-c138-4672-96d5-4e2dd5661c73;returning-chatter=0;subscriber=0;mod=0;emotes=;user-id=120451539 :imav1ctor!imav1ctor@imav1ctor.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime Ratge RaveTime Ratge RaveTime Ratge RaveTime Ratge RaveTime","@tmi-sent-ts=1704557987015;emotes=;badges=;color=#D2691E;client-nonce=7ede2a5fadadc7f92b35fd5696c40cfd;rm-received-ts=1704557987196;id=18ee5b36-02c0-4fe1-a8cf-42b4934fa3e0;historical=1;user-type=;subscriber=0;badge-info=;first-msg=0;flags=;mod=0;display-name=mon_dieud;turbo=0;room-id=62300805;user-id=417575252;returning-chatter=0 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@mod=0;id=ed93d522-7a36-4727-83cb-1033711ff242;turbo=0;subscriber=0;user-type=;rm-received-ts=1704557987243;first-msg=0;room-id=62300805;user-id=232078107;badges=no_audio/1;historical=1;display-name=BastunGuy1;emotes=;flags=;returning-chatter=0;badge-info=;color=#008000;client-nonce=423588d77ef5b064925c6a8f5fb50319;tmi-sent-ts=1704557987056 :bastunguy1!bastunguy1@bastunguy1.tmi.twitch.tv PRIVMSG #nymn deadassPls","@user-id=51967700;display-name=Patixxl;id=bb07d0d4-6c39-4ecd-962a-4a749cd45706;user-type=;mod=0;first-msg=0;returning-chatter=0;badges=;tmi-sent-ts=1704557987523;emotes=;color=#FF0000;turbo=0;historical=1;flags=;rm-received-ts=1704557987689;room-id=62300805;badge-info=;client-nonce=2cd274776cf33689752d1c3b9adbb4ce;subscriber=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badges=subscriber/9,turbo/1;room-id=62300805;rm-received-ts=1704557988576;id=d9b7133c-6a97-4308-855f-72d286a7e643;client-nonce=304b11f12b785482c42b5f2338263531;badge-info=subscriber/9;user-type=;tmi-sent-ts=1704557988374;color=#FFFF00;subscriber=1;flags=;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;display-name=Kotzblitz20;returning-chatter=0;user-id=40037186;historical=1;first-msg=0;turbo=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@display-name=mon_dieud;room-id=62300805;color=#D2691E;id=5819375b-b4b8-4d51-92c7-cee7feda5609;emotes=;historical=1;rm-received-ts=1704557988684;user-id=417575252;tmi-sent-ts=1704557988522;first-msg=0;badge-info=;flags=;turbo=0;subscriber=0;mod=0;user-type=;client-nonce=b5ee53eaa0c3d50bb5b2ab3c29fb4ec4;returning-chatter=0;badges= :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM 󠀀","@rm-received-ts=1704557990215;badges=;tmi-sent-ts=1704557990036;first-msg=0;emotes=;historical=1;client-nonce=a6876056b3fad57628aa921e21cd6408;turbo=0;id=0a8b223f-94f8-4e29-978a-2f8fc93e2390;user-id=417575252;subscriber=0;display-name=mon_dieud;badge-info=;mod=0;color=#D2691E;returning-chatter=0;user-type=;room-id=62300805;flags= :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@turbo=0;user-type=;id=bcc61d76-afc4-472e-aac3-915e54f06989;emotes=;user-id=51967700;flags=;returning-chatter=0;rm-received-ts=1704557990289;tmi-sent-ts=1704557990105;client-nonce=8345d33b6e38c943a8bfdbd769016e29;badges=;room-id=62300805;mod=0;color=#FF0000;display-name=Patixxl;badge-info=;historical=1;subscriber=0;first-msg=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=0;user-type=;user-id=103665668;emotes=;id=f1ae4c8f-9949-4ceb-b6a4-ed2b54ac6f2a;display-name=Intel_power;badges=bits-charity/1;mod=0;tmi-sent-ts=1704557990704;color=#0000FF;returning-chatter=0;flags=;historical=1;room-id=62300805;rm-received-ts=1704557990887;badge-info=;first-msg=0;turbo=0;client-nonce=fa12c0d9435ab3ac61deb047475983cc :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn CiGrip","@emotes=;flags=;user-type=mod;first-msg=0;subscriber=1;room-id=62300805;badge-info=subscriber/67;user-id=41157245;turbo=0;rm-received-ts=1704557991255;badges=moderator/1,subscriber/60,rplace-2023/1;returning-chatter=0;color=#9146FF;mod=1;display-name=Mr0lle;id=fa40a3ab-c746-460e-8c16-e0203207a453;tmi-sent-ts=1704557991080;historical=1 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@user-id=253596827;turbo=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;flags=;mod=0;badges=no_audio/1;badge-info=;tmi-sent-ts=1704557991166;room-id=62300805;rm-received-ts=1704557991345;subscriber=0;display-name=Purple_Geco;client-nonce=9c371e28f86cb9d4d39ca8e40d13aa6b;returning-chatter=0;first-msg=0;id=0185c023-3c26-4c0d-9b70-2689c3c77a45;color=#8A2BE2;user-type=;historical=1 :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;display-name=Kotzblitz20;returning-chatter=0;tmi-sent-ts=1704557991172;client-nonce=9903d8256de6710e8ac83ae36d722d6b;mod=0;historical=1;user-id=40037186;id=506ab750-aa2a-4c1f-88d6-24c748c4737d;badge-info=subscriber/9;rm-received-ts=1704557991358;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122;color=#FFFF00;turbo=1;room-id=62300805;subscriber=1;flags=;user-type= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#DAA520;first-msg=0;room-id=62300805;badges=;historical=1;returning-chatter=0;id=230487e9-c54c-4efb-8934-5d4c07ef701f;mod=0;emotes=;user-type=;client-nonce=7e7b25253a46f0d481c3857d756ac487;user-id=428888588;badge-info=;flags=;display-name=3amo_magdy;subscriber=0;rm-received-ts=1704557991585;turbo=0;tmi-sent-ts=1704557991426 :3amo_magdy!3amo_magdy@3amo_magdy.tmi.twitch.tv PRIVMSG #nymn :this is a good game","@room-id=62300805;user-id=433352132;mod=0;emotes=;subscriber=1;turbo=0;tmi-sent-ts=1704557991423;first-msg=0;returning-chatter=0;color=#63BD68;badge-info=subscriber/38;user-type=;historical=1;flags=;id=c0393303-4786-4b8e-8505-9c281b48f3eb;display-name=jontEmillian;rm-received-ts=1704557991601;badges=subscriber/36,twitch-recap-2023/1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@tmi-sent-ts=1704557991551;badges=turbo/1;id=dcd1dd03-409a-4750-a4ae-5fff3f4c4c75;display-name=FollowProtoBuddy;returning-chatter=0;subscriber=0;mod=0;badge-info=;client-nonce=019b560830972e4b42600df04bacdb10;turbo=1;first-msg=0;room-id=62300805;flags=;user-id=216144449;color=#00FF7F;user-type=;emotes=;historical=1;rm-received-ts=1704557991703 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance 󠀀","@mod=0;badges=;user-type=;id=a9fd3702-dc3b-4c98-8915-5d88c1b2b7ff;color=#D2691E;returning-chatter=0;first-msg=0;room-id=62300805;user-id=417575252;flags=;badge-info=;historical=1;turbo=0;emotes=;display-name=mon_dieud;subscriber=0;rm-received-ts=1704557991781;tmi-sent-ts=1704557991620;client-nonce=5ff313ccf46c19310e907133a2ae2801 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM 󠀀","@client-nonce=166aa46f441d99819e1e4b25d26be078;tmi-sent-ts=1704557993019;first-msg=0;rm-received-ts=1704557993207;turbo=1;historical=1;badges=subscriber/9,turbo/1;badge-info=subscriber/9;user-id=40037186;display-name=Kotzblitz20;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218;room-id=62300805;color=#FFFF00;id=1fafd51e-693e-43d7-8568-f5c07959bc33;subscriber=1;user-type=;mod=0;flags=;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=417575252;subscriber=0;color=#D2691E;display-name=mon_dieud;mod=0;tmi-sent-ts=1704557993098;flags=;rm-received-ts=1704557993281;first-msg=0;user-type=;badges=;historical=1;emotes=;id=127d7915-b6a8-47cb-9107-941701e8b8d7;returning-chatter=0;client-nonce=0ab3c100f1f6ce2f2e1b019e2ea999dc;room-id=62300805;turbo=0;badge-info= :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@user-id=417575252;flags=;tmi-sent-ts=1704557994778;badge-info=;mod=0;id=9a46a043-5707-42c4-875d-5acbdcd1cf33;room-id=62300805;color=#D2691E;client-nonce=efca80e3fb1f92a3586b69f4d67f94c1;returning-chatter=0;first-msg=0;historical=1;rm-received-ts=1704557994985;user-type=;badges=;display-name=mon_dieud;subscriber=0;emotes=;turbo=0 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM 󠀀","@color=#D52AFF;returning-chatter=0;flags=;rm-received-ts=1704557995631;display-name=Joshlad;user-type=;subscriber=1;id=c7260e08-aef7-43ce-a645-d637c3f760af;badges=vip/1,subscriber/72,rplace-2023/1;tmi-sent-ts=1704557995447;user-id=87120320;turbo=0;mod=0;emotes=;room-id=62300805;first-msg=0;vip=1;badge-info=subscriber/77;historical=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=2a1c62b2-1563-4a66-8ffd-70a8bb0fb3e4;badges=;client-nonce=c78b3277523c7a97f35dddff37ed7a30;badge-info=;display-name=Patixxl;tmi-sent-ts=1704557995496;first-msg=0;turbo=0;flags=;returning-chatter=0;color=#FF0000;subscriber=0;emotes=;user-id=51967700;room-id=62300805;user-type=;historical=1;rm-received-ts=1704557995678;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@room-id=62300805;subscriber=1;rm-received-ts=1704557996656;returning-chatter=0;mod=0;turbo=1;color=#FFFF00;display-name=Kotzblitz20;id=230bff50-0808-41ee-ae9a-83673110162a;historical=1;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282,288-298,304-314;user-id=40037186;flags=;client-nonce=27a522a1e13ade81f9f92b93b230c2ee;tmi-sent-ts=1704557996472;badge-info=subscriber/9;user-type=;first-msg=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emotes=;display-name=Intel_power;first-msg=0;user-type=;client-nonce=e3059469d8c3d8b7fdcc7a19ab4ab020;returning-chatter=0;user-id=103665668;rm-received-ts=1704557996898;id=c8353b25-bddf-4048-a8d6-0ba6700111d8;turbo=0;badges=bits-charity/1;color=#0000FF;historical=1;badge-info=;mod=0;tmi-sent-ts=1704557996719;subscriber=0;room-id=62300805;flags= :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn DonkSass","@mod=1;turbo=0;badge-info=subscriber/67;tmi-sent-ts=1704557996810;color=#9146FF;historical=1;badges=moderator/1,subscriber/60,rplace-2023/1;emotes=;returning-chatter=0;room-id=62300805;flags=;subscriber=1;user-id=41157245;id=7e5ba407-e616-49cd-998f-84384b86aa4a;user-type=mod;rm-received-ts=1704557996977;display-name=Mr0lle;first-msg=0 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@color=#00FF7F;mod=0;returning-chatter=0;badge-info=;id=b40886e1-140e-4fbb-b76e-c65836992b2e;display-name=FollowProtoBuddy;flags=;first-msg=0;emotes=;user-type=;client-nonce=8c8ca6a4a778beb315a3329ca670e49e;subscriber=0;user-id=216144449;rm-received-ts=1704557997445;room-id=62300805;turbo=1;historical=1;tmi-sent-ts=1704557997276;badges=turbo/1 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn nimeDance","@id=aebd5676-2e69-4847-9c26-34f347ba5d1b;flags=;badge-info=;room-id=62300805;tmi-sent-ts=1704557997458;user-id=417575252;user-type=;rm-received-ts=1704557997647;turbo=0;client-nonce=60b389debdba4fe307d86fcd0c8a5f51;display-name=mon_dieud;subscriber=0;badges=;mod=0;returning-chatter=0;emotes=;first-msg=0;color=#D2691E;historical=1 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@id=ff15824b-3217-4231-9064-9b4ee5fdc48e;user-id=433352132;historical=1;emotes=;mod=0;color=#63BD68;display-name=jontEmillian;returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;room-id=62300805;rm-received-ts=1704557997706;subscriber=1;user-type=;first-msg=0;tmi-sent-ts=1704557997520;flags=;badge-info=subscriber/38;turbo=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@emotes=;display-name=MaxThurian;historical=1;id=4f72611e-3ca3-4f45-8703-818757fbec7a;flags=;badge-info=subscriber/43;first-msg=0;user-id=60181947;subscriber=1;mod=0;turbo=0;user-type=;color=#00ED2A;tmi-sent-ts=1704557997998;badges=subscriber/42,twitch-recap-2023/1;room-id=62300805;returning-chatter=0;client-nonce=a78a33084735b9a68231d896da549eef;rm-received-ts=1704557998168 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn eww","@room-id=62300805;display-name=orange_bean;badges=subscriber/48;first-msg=0;emotes=;flags=;turbo=0;user-type=;mod=0;tmi-sent-ts=1704557999129;subscriber=1;historical=1;rm-received-ts=1704557999305;badge-info=subscriber/53;color=#FF7F50;returning-chatter=0;user-id=29649547;id=b326070a-5f36-4589-aaf9-c32ef898fcbb :orange_bean!orange_bean@orange_bean.tmi.twitch.tv PRIVMSG #nymn CiGrip","@display-name=mon_dieud;turbo=0;badges=;first-msg=0;user-id=417575252;mod=0;tmi-sent-ts=1704557999374;id=1e26272a-fa52-4215-a60f-22d15dff98dc;returning-chatter=0;historical=1;color=#D2691E;flags=;user-type=;client-nonce=f511f48d12a3e7080722a8760daf344b;room-id=62300805;emotes=;badge-info=;subscriber=0;rm-received-ts=1704557999528 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM 󠀀","@color=#FF0000;returning-chatter=0;rm-received-ts=1704557999849;id=f4bb63c6-b621-41eb-a136-9df81cf3223a;first-msg=0;tmi-sent-ts=1704557999676;badge-info=;client-nonce=d0b3b564ed74f99ab9a36f126e1e6156;display-name=Patixxl;subscriber=0;room-id=62300805;flags=;user-type=;mod=0;badges=;turbo=0;emotes=;user-id=51967700;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#FFFF00;historical=1;rm-received-ts=1704558000776;id=e97ac476-2caa-46e1-a988-ba521547fd62;badges=subscriber/9,turbo/1;user-id=40037186;tmi-sent-ts=1704558000593;first-msg=0;turbo=1;flags=;returning-chatter=0;client-nonce=7ec45a704b329629df711316464b5ffe;display-name=Kotzblitz20;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154;room-id=62300805;user-type=;badge-info=subscriber/9;mod=0;subscriber=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;badges=no_audio/1;user-type=;mod=0;id=2a302114-af71-49ec-a963-0e25f9d49164;client-nonce=2d783d514caa880eeb225528c71cc7a3;tmi-sent-ts=1704558000678;display-name=Purple_Geco;user-id=253596827;room-id=62300805;flags=;badge-info=;subscriber=0;rm-received-ts=1704558000870;returning-chatter=0;first-msg=0;turbo=0;historical=1;color=#8A2BE2 :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty","@flags=;color=#CC007A;mod=0;rm-received-ts=1704558001213;client-nonce=af3230efdc41428528cc30149982ffd4;tmi-sent-ts=1704558001035;user-type=;user-id=24556440;room-id=62300805;id=8aebfab2-9d26-4c55-a64a-656f5b552510;display-name=BaireiPL;turbo=0;badge-info=subscriber/5;returning-chatter=0;emotes=;first-msg=0;badges=subscriber/3;subscriber=1;historical=1 :baireipl!baireipl@baireipl.tmi.twitch.tv PRIVMSG #nymn eww","@room-id=62300805;emotes=;historical=1;rm-received-ts=1704558001290;subscriber=0;badge-info=;first-msg=0;display-name=mon_dieud;color=#D2691E;id=3fe233ef-8c04-4093-ac3f-4c5da5704fa9;user-type=;client-nonce=bb83ff31758bdac4d0d36ee91dcd92c9;turbo=0;flags=;returning-chatter=0;badges=;user-id=417575252;tmi-sent-ts=1704558001122;mod=0 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@emotes=;room-id=62300805;flags=;returning-chatter=0;display-name=FollowProtoBuddy;turbo=1;badge-info=;first-msg=0;user-type=;rm-received-ts=1704558002365;client-nonce=a7262d67f1c0d41e2aa578a88e1f9924;tmi-sent-ts=1704558002191;user-id=216144449;subscriber=0;mod=0;color=#00FF7F;historical=1;badges=turbo/1;id=2bf87d8e-a236-4caa-8710-ee6e3badfc3e :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance 󠀀","@user-type=;display-name=Joshlad;emotes=;historical=1;rm-received-ts=1704558003044;tmi-sent-ts=1704558002867;first-msg=0;vip=1;user-id=87120320;subscriber=1;id=06094525-101c-46c4-bc29-0d9d41c8d605;room-id=62300805;turbo=0;flags=;color=#D52AFF;badges=vip/1,subscriber/72,rplace-2023/1;badge-info=subscriber/77;returning-chatter=0;mod=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@historical=1;color=#D2691E;turbo=0;user-id=417575252;emotes=;badge-info=;user-type=;rm-received-ts=1704558003291;id=ff96194a-5bd0-4ed8-9cfc-b687b4171a3c;subscriber=0;client-nonce=5e78fb34007e14ddf2620e6c8e1e826e;mod=0;room-id=62300805;display-name=mon_dieud;badges=;flags=;returning-chatter=0;first-msg=0;tmi-sent-ts=1704558003107 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM 󠀀","@rm-received-ts=1704558003756;returning-chatter=0;display-name=jontEmillian;color=#63BD68;subscriber=1;mod=0;user-id=433352132;flags=;badges=subscriber/36,twitch-recap-2023/1;emotes=;tmi-sent-ts=1704558003596;turbo=0;user-type=;room-id=62300805;historical=1;id=472ad1eb-0112-4425-9c14-622495222183;badge-info=subscriber/38;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@display-name=Patixxl;subscriber=0;badge-info=;room-id=62300805;mod=0;turbo=0;flags=;badges=;rm-received-ts=1704558004015;returning-chatter=0;client-nonce=3216a3ec6f920de48a36dae221309cbe;id=d190574e-e8fd-4898-94fc-dd20ef561fd2;emotes=;first-msg=0;color=#FF0000;user-id=51967700;historical=1;user-type=;tmi-sent-ts=1704558003848 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badges=moderator/1,subscriber/60,rplace-2023/1;color=#9146FF;badge-info=subscriber/67;mod=1;id=ee39481c-fc74-4359-a0f0-48649b1c9c33;first-msg=0;display-name=Mr0lle;rm-received-ts=1704558004133;user-id=41157245;turbo=0;room-id=62300805;emotes=;flags=;subscriber=1;historical=1;returning-chatter=0;user-type=mod;tmi-sent-ts=1704558003961 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@first-msg=0;turbo=0;returning-chatter=0;user-type=;user-id=190828982;badges=subscriber/18,twitch-recap-2023/1;room-id=62300805;emotes=;subscriber=1;tmi-sent-ts=1704558004863;display-name=Near____________;badge-info=subscriber/20;mod=0;color=#23CE3F;flags=;rm-received-ts=1704558005064;client-nonce=a9721aff359d4e401827030e87200fd8;id=59fcc234-d701-49c6-af55-49ec172a3c6d;historical=1 :near____________!near____________@near____________.tmi.twitch.tv PRIVMSG #nymn CiGrip","@flags=;tmi-sent-ts=1704558005426;user-type=;badges=;display-name=mon_dieud;room-id=62300805;badge-info=;id=1ccf04ab-c88a-4951-a4e8-dafeb920c7b0;user-id=417575252;returning-chatter=0;color=#D2691E;client-nonce=8ae1b700fd218282a757b649cd037a34;first-msg=0;mod=0;emotes=;turbo=0;subscriber=0;historical=1;rm-received-ts=1704558005601 :mon_dieud!mon_dieud@mon_dieud.tmi.twitch.tv PRIVMSG #nymn :AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM AlienTechno RaveTime EDM","@first-msg=0;color=#008000;id=a656baa8-1ec3-4cfd-a827-813a606db4ec;emotes=;room-id=62300805;user-id=278896263;user-type=;flags=;turbo=0;display-name=Phant0mBlades;historical=1;badges=subscriber/9,chatter-cs-go-2022/1;badge-info=subscriber/9;rm-received-ts=1704558006805;mod=0;returning-chatter=0;tmi-sent-ts=1704558006634;subscriber=1 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :MEGALUL 👇 COME TO THE BOG","@returning-chatter=0;emotes=;id=4979b8f0-eefa-4e7a-aca1-61a12e2274c3;mod=0;subscriber=1;turbo=0;display-name=jqxlol;user-id=80542722;badges=subscriber/0,no_video/1;user-type=;client-nonce=877481e211c51ab4934f8a969c2a5368;rm-received-ts=1704558008593;badge-info=subscriber/2;room-id=62300805;flags=;tmi-sent-ts=1704558008350;color=#00FF7F;first-msg=0;historical=1 :jqxlol!jqxlol@jqxlol.tmi.twitch.tv PRIVMSG #nymn :———————————————————————— You have been permanently danked from this channel FeelsDankMan ————————————————————————","@vip=1;flags=;historical=1;turbo=0;mod=0;subscriber=1;room-id=62300805;tmi-sent-ts=1704558010496;id=dad54acd-090a-4c8d-8685-485ff2796ed6;emotes=;rm-received-ts=1704558010661;color=#D52AFF;user-type=;display-name=Joshlad;user-id=87120320;badges=vip/1,subscriber/72,rplace-2023/1;badge-info=subscriber/77;returning-chatter=0;first-msg=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn nice","@subscriber=1;color=#9146FF;id=3cdedab1-f8b2-488f-b1e7-ba018daf5ca9;display-name=Mr0lle;mod=1;returning-chatter=0;flags=;historical=1;rm-received-ts=1704558011651;room-id=62300805;emotes=;tmi-sent-ts=1704558011457;badges=moderator/1,subscriber/60,rplace-2023/1;user-type=mod;turbo=0;user-id=41157245;badge-info=subscriber/67;first-msg=0 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badge-info=;subscriber=0;user-type=;first-msg=0;user-id=45424213;display-name=vaka7a_;flags=;id=2769ecf0-0f4f-4526-947b-07d05e96e1bf;turbo=0;badges=twitch-recap-2023/1;room-id=62300805;mod=0;tmi-sent-ts=1704558011827;emotes=;color=#FF0000;client-nonce=3ce320613658448479c4ecd5ac064b1c;historical=1;rm-received-ts=1704558012025;returning-chatter=0 :vaka7a_!vaka7a_@vaka7a_.tmi.twitch.tv PRIVMSG #nymn nnysJam","@turbo=0;id=6c5ab78e-eeac-438e-a861-7d16a583c2c0;user-type=;rm-received-ts=1704558012415;emotes=;user-id=51967700;badges=;tmi-sent-ts=1704558012244;first-msg=0;client-nonce=e2e71df927d4b25302eac39b7288dba8;badge-info=;color=#FF0000;subscriber=0;mod=0;returning-chatter=0;historical=1;display-name=Patixxl;room-id=62300805;flags= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn nice","@historical=1;color=#008000;rm-received-ts=1704558012459;returning-chatter=0;mod=0;subscriber=1;tmi-sent-ts=1704558012280;id=d960514c-5fbe-46fa-82c7-059235baa092;turbo=0;user-id=522665984;user-type=;emotes=;room-id=62300805;display-name=SylvrOne;badge-info=subscriber/1;badges=subscriber/0,premium/1;flags=;first-msg=0 :sylvrone!sylvrone@sylvrone.tmi.twitch.tv PRIVMSG #nymn nice","@user-id=159210800;badge-info=subscriber/49;flags=;id=3a7fb120-92d7-437e-ab72-1118aa2ea886;badges=subscriber/48,bits/25000;color=#FF2424;historical=1;turbo=0;tmi-sent-ts=1704558012677;room-id=62300805;rm-received-ts=1704558012857;user-type=;client-nonce=3123da7329d7d27de459ec30bcba243d;subscriber=1;returning-chatter=0;mod=0;first-msg=0;display-name=ME_ME;emotes= :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn Ratge","@client-nonce=92f1f6efce0113e27514d8c99eb38e3d;mod=0;badges=no_audio/1;display-name=Obiwun;historical=1;tmi-sent-ts=1704558013056;user-type=;turbo=0;returning-chatter=0;color=#8A2BE2;room-id=62300805;first-msg=0;rm-received-ts=1704558013245;flags=5-7:S.5;subscriber=0;badge-info=;emotes=;id=5f28d547-1330-4099-90d8-4b79e6a623d7;user-id=46199261 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn :haha sex","@badges=subscriber/9,chatter-cs-go-2022/1;flags=;id=8d79f525-ba1d-426b-a557-1176909932d9;room-id=62300805;subscriber=1;user-id=278896263;first-msg=0;user-type=;display-name=Phant0mBlades;tmi-sent-ts=1704558013093;historical=1;badge-info=subscriber/9;returning-chatter=0;mod=0;turbo=0;color=#008000;emotes=;rm-received-ts=1704558013281 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :FeelsDankMan woah","@flags=;returning-chatter=0;subscriber=1;historical=1;tmi-sent-ts=1704558013350;user-id=92529125;emotes=;mod=0;user-type=;rm-received-ts=1704558013528;color=#FF0000;client-nonce=c213fdbca720f4adac75c4ee0d20f08d;badges=subscriber/42,twitch-recap-2023/1;display-name=Mawsonator;turbo=0;room-id=62300805;id=5508f583-15f7-4958-88b4-45181a93b25b;badge-info=subscriber/47;first-msg=0 :mawsonator!mawsonator@mawsonator.tmi.twitch.tv PRIVMSG #nymn PagMan","@subscriber=0;color=#10E2E2;turbo=0;tmi-sent-ts=1704558013427;badges=twitch-recap-2023/1;room-id=62300805;flags=;first-msg=0;badge-info=;emotes=;user-id=167633177;historical=1;mod=0;returning-chatter=0;client-nonce=e59164adf564eff1641d28918e66de80;rm-received-ts=1704558013623;user-type=;id=450ceb46-02b9-45b5-9371-f18a72430c02;display-name=ALotOfChickens :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn PagMan","@id=c434541a-df33-40f5-8b15-a7533ba8312c;turbo=0;subscriber=1;user-type=;client-nonce=1b69c74519ca839c7347ac1e74e6c8a7;returning-chatter=0;flags=;room-id=62300805;color=#8A2BE2;badge-info=subscriber/21;user-id=57104832;badges=subscriber/18,no_video/1;historical=1;rm-received-ts=1704558014199;emotes=;tmi-sent-ts=1704558014007;mod=0;first-msg=0;display-name=Tomer247 :tomer247!tomer247@tomer247.tmi.twitch.tv PRIVMSG #nymn nicw","@tmi-sent-ts=1704558014024;user-id=83365099;badges=glitchcon2020/1;client-nonce=7f06610d323d66cf4e292310f86cc5f2;badge-info=;user-type=;returning-chatter=0;emotes=;flags=;first-msg=0;display-name=OmniValor;historical=1;turbo=0;room-id=62300805;id=3216c7c2-b0a9-4e9d-93a7-b3423533a2bd;rm-received-ts=1704558014216;mod=0;subscriber=0;color=#00FF7F :omnivalor!omnivalor@omnivalor.tmi.twitch.tv PRIVMSG #nymn nice","@room-id=62300805;tmi-sent-ts=1704558014457;badges=;first-msg=0;id=d5182849-ef34-49f1-a7a3-3850f9eb8995;turbo=0;display-name=3amo_magdy;historical=1;badge-info=;rm-received-ts=1704558014624;mod=0;returning-chatter=0;flags=;user-type=;emotes=;color=#DAA520;subscriber=0;client-nonce=9721493e5ad8995a4a1194c3dd9d8174;user-id=428888588 :3amo_magdy!3amo_magdy@3amo_magdy.tmi.twitch.tv PRIVMSG #nymn nice","@user-type=;client-nonce=2bf1672a8de2d2e768b3cc7c1bed1714;color=#00FF7F;subscriber=0;user-id=216144449;flags=;emotes=;display-name=FollowProtoBuddy;badges=turbo/1;tmi-sent-ts=1704558015154;turbo=1;returning-chatter=0;historical=1;id=63ceb7ca-65b7-47cd-a137-788adffc8756;rm-received-ts=1704558015333;badge-info=;mod=0;first-msg=0;room-id=62300805 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn MegaLUL","@tmi-sent-ts=1704558015379;historical=1;user-type=;returning-chatter=0;flags=;mod=0;emotes=;client-nonce=b62d3e2ed3a21dc135106f7372fc9527;first-msg=0;display-name=DontCagePlebs;badge-info=subscriber/37;room-id=62300805;subscriber=1;badges=subscriber/36,no_audio/1;rm-received-ts=1704558015559;color=#DAA520;id=5781b828-c442-4e3e-9682-dff515eda839;turbo=0;user-id=85837900 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn SOLO","@turbo=0;emotes=;color=#7A56A3;first-msg=0;tmi-sent-ts=1704558016033;display-name=d0mr;subscriber=0;id=0786e35f-63d4-43f1-b1f9-807e39148c90;mod=0;flags=;badges=gold-pixel-heart/1;user-type=;badge-info=;user-id=58819477;room-id=62300805;historical=1;rm-received-ts=1704558016285;client-nonce=30357690aaa55b2e7fc6da87104b5afb;returning-chatter=0 :d0mr!d0mr@d0mr.tmi.twitch.tv PRIVMSG #nymn nice","@id=8f03e236-7700-4b04-9d69-d002ee8c557d;first-msg=0;user-type=;rm-received-ts=1704558016930;subscriber=1;client-nonce=0d6591fbb5bc13045531de3ab726f6c1;user-id=57104832;flags=;badge-info=subscriber/21;room-id=62300805;display-name=Tomer247;color=#8A2BE2;emotes=;returning-chatter=0;tmi-sent-ts=1704558016761;mod=0;turbo=0;historical=1;badges=subscriber/18,no_video/1 :tomer247!tomer247@tomer247.tmi.twitch.tv PRIVMSG #nymn nice","@flags=;badge-info=subscriber/2;historical=1;client-nonce=b7963a3b7ae7995e53c6aad70e3cfc7d;emotes=;id=99bf3d3a-69e9-49f5-80ad-a378c0a570b6;display-name=mnqn18;rm-received-ts=1704558017884;turbo=0;room-id=62300805;user-id=474204887;badges=subscriber/0,premium/1;first-msg=0;color=#1E90FF;user-type=;tmi-sent-ts=1704558017693;mod=0;returning-chatter=0;subscriber=1 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn :SOLO bejba","@id=429a1ba9-bc02-4507-9627-a7fcc5965e64;badge-info=;color=#FF0000;badges=twitch-recap-2023/1;emotes=;subscriber=0;tmi-sent-ts=1704558017775;rm-received-ts=1704558017990;client-nonce=509880927d35ba879249721f67130c02;turbo=0;first-msg=0;user-id=91515163;historical=1;room-id=62300805;flags=;display-name=Rattge;mod=0;returning-chatter=0;user-type= :rattge!rattge@rattge.tmi.twitch.tv PRIVMSG #nymn nice","@rm-received-ts=1704558018845;historical=1;flags=;room-id=62300805;id=a1999d28-3302-4091-a491-94b8dae5b3c8;user-id=423574282;subscriber=1;tmi-sent-ts=1704558018651;mod=0;returning-chatter=0;turbo=0;badges=subscriber/9,gold-pixel-heart/1;emotes=;color=#D2FFD6;rm-deleted=1;badge-info=subscriber/9;first-msg=0;display-name=e7om;user-type= :e7om!e7om@e7om.tmi.twitch.tv PRIVMSG #nymn FDM","@historical=1;id=2217882b-ec83-4245-88ac-7e684f695e09;turbo=0;user-id=151423066;mod=0;tmi-sent-ts=1704558019447;user-type=;emotes=;color=#FF69B4;badge-info=;display-name=forsenkkona_;room-id=62300805;returning-chatter=0;badges=;rm-received-ts=1704558019646;flags=;subscriber=0;first-msg=0 :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn 🔨","@color=#7B00FF;mod=0;room-id=62300805;badge-info=;emotes=;id=2d8d69ae-711f-4d3b-a1ca-0eb2a082196d;returning-chatter=0;historical=1;rm-received-ts=1704558019901;subscriber=0;turbo=0;first-msg=0;flags=;client-nonce=281586a259405039efca0c4c2990baba;user-type=;tmi-sent-ts=1704558019735;badges=premium/1;display-name=imav1ctor;user-id=120451539 :imav1ctor!imav1ctor@imav1ctor.tmi.twitch.tv PRIVMSG #nymn EZ","@flags=;badges=subscriber/36,twitch-recap-2023/1;display-name=jontEmillian;badge-info=subscriber/38;color=#63BD68;returning-chatter=0;turbo=0;first-msg=0;mod=0;tmi-sent-ts=1704558020337;user-type=;subscriber=1;room-id=62300805;user-id=433352132;id=04e594cc-dcf1-43b6-97c4-de66c4211525;emotes=;rm-received-ts=1704558020531;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn zulul","@emotes=;color=#0000FF;first-msg=0;rm-received-ts=1704558021122;id=c2533aaf-3f15-4e02-ab7e-81cc196ae724;user-type=;user-id=103665668;client-nonce=ddbc9fc47d199749d08cf1fa970e87d8;turbo=0;historical=1;display-name=Intel_power;mod=0;badge-info=;room-id=62300805;flags=;tmi-sent-ts=1704558020942;subscriber=0;badges=bits-charity/1;returning-chatter=0 :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn ZULUL","@badge-info=subscriber/9;user-id=423574282;color=#D2FFD6;tmi-sent-ts=1704558021113;turbo=0;rm-deleted=1;emotes=;id=affa2505-e5e5-43f8-a343-e119b8f937a5;historical=1;subscriber=1;room-id=62300805;display-name=e7om;mod=0;rm-received-ts=1704558021284;flags=;first-msg=0;badges=subscriber/9,gold-pixel-heart/1;returning-chatter=0;user-type= :e7om!e7om@e7om.tmi.twitch.tv PRIVMSG #nymn ZULUL","@id=6fe0b0c0-d1f2-4eb2-9f0e-022cbe9ff40b;first-msg=0;user-type=;mod=0;rm-received-ts=1704558021289;client-nonce=419911fb3221efddc8738a32aa950fbc;returning-chatter=0;subscriber=0;flags=;user-id=810718356;tmi-sent-ts=1704558021127;badge-info=;emotes=;badges=;room-id=62300805;turbo=0;color=#FF0000;display-name=holy4uck;historical=1 :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn ZULUL","@returning-chatter=0;historical=1;turbo=0;rm-received-ts=1704558021474;id=6bd664e1-0047-4ccf-a040-be85ee3756ce;display-name=Barbap;flags=;first-msg=0;badges=subscriber/48,twitch-recap-2023/1;tmi-sent-ts=1704558021290;color=#00FF7F;emotes=;user-id=59674528;user-type=;mod=0;badge-info=subscriber/50;room-id=62300805;subscriber=1;client-nonce=ac90bcb97a612501e003da5db61320c6 :barbap!barbap@barbap.tmi.twitch.tv PRIVMSG #nymn ZULUL","@flags=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;turbo=1;badge-info=subscriber/9;user-type=;rm-received-ts=1704558023525;badges=subscriber/9,turbo/1;mod=0;client-nonce=38e68c55ab4d2d8ef1508808be0889f9;room-id=62300805;display-name=Kotzblitz20;tmi-sent-ts=1704558023219;color=#FFFF00;first-msg=0;returning-chatter=0;id=17dda281-4dce-4b45-93ec-80084c0f7bc2;user-id=40037186;subscriber=1;historical=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;returning-chatter=0;client-nonce=5ae9d9dd701b1f8c29209bb63ea360d1;badges=;id=f01ed706-ff78-4882-b61f-541122cf16d2;mod=0;user-id=51967700;emotes=;user-type=;display-name=Patixxl;historical=1;badge-info=;rm-received-ts=1704558025169;subscriber=0;color=#FF0000;first-msg=0;tmi-sent-ts=1704558024981;flags=;room-id=62300805 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@mod=1;badges=moderator/1,subscriber/60,rplace-2023/1;color=#9146FF;subscriber=1;turbo=0;badge-info=subscriber/67;id=9305ed9d-0a1d-421f-91fb-2b8d9da40d6f;historical=1;flags=;tmi-sent-ts=1704558025353;first-msg=0;user-type=mod;emotes=;display-name=Mr0lle;user-id=41157245;returning-chatter=0;rm-received-ts=1704558025527;room-id=62300805 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@flags=;id=ba43500e-1616-4011-97d3-05ee5af6bc27;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;color=#FFFF00;first-msg=0;user-id=40037186;tmi-sent-ts=1704558025651;historical=1;user-type=;badges=subscriber/9,turbo/1;room-id=62300805;display-name=Kotzblitz20;mod=0;subscriber=1;turbo=1;badge-info=subscriber/9;client-nonce=3cbc02126e77913ca22d8b9d0a32f90d;rm-received-ts=1704558025828 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;emotes=;flags=;badges=turbo/1;client-nonce=706b5514213672abd347a23b99797857;mod=0;turbo=1;returning-chatter=0;id=e1d9c71e-d83e-496f-8932-b3ef7e3c8430;color=#00FF7F;first-msg=0;badge-info=;rm-received-ts=1704558027062;tmi-sent-ts=1704558026857;user-id=216144449;historical=1;display-name=FollowProtoBuddy;subscriber=0;room-id=62300805 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn nimeDance","@tmi-sent-ts=1704558026932;badges=;room-id=62300805;flags=;returning-chatter=0;badge-info=;color=#FF0000;client-nonce=a3a25cb9768411360980ae5adb4490d3;historical=1;id=0cdebf66-aced-45a1-8984-d0493282effd;rm-received-ts=1704558027109;user-id=51967700;turbo=0;user-type=;first-msg=0;emotes=;subscriber=0;display-name=Patixxl;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@turbo=1;color=#FFFF00;user-type=;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282;user-id=40037186;badges=subscriber/9,turbo/1;id=6c0165dd-54db-4cf6-be81-2176dbc6f237;rm-received-ts=1704558028571;first-msg=0;client-nonce=c01848bdbd9eb679d2e7b467bdb1d63e;badge-info=subscriber/9;flags=;subscriber=1;tmi-sent-ts=1704558028378;room-id=62300805;display-name=Kotzblitz20;mod=0;historical=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;mod=0;user-id=159210800;flags=;rm-received-ts=1704558028613;badge-info=subscriber/49;client-nonce=3751d2954c6916384762012de5635f7c;returning-chatter=0;display-name=ME_ME;user-type=;historical=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;color=#FF2424;id=b3675782-ba49-4335-88da-9425c31a8ff6;first-msg=0;badges=subscriber/48,bits/25000;turbo=0;tmi-sent-ts=1704558028432;subscriber=1 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenParty ASYLUM RAVE","@user-id=75144877;badges=subscriber/3,no_video/1;display-name=buong1;rm-received-ts=1704558028620;tmi-sent-ts=1704558028449;client-nonce=7636ed0d0d89f9e42ae4227049bfad4d;flags=;user-type=;color=#FF0000;first-msg=0;id=92b43266-0adc-4d45-82ee-8b6ed3f30b29;historical=1;mod=0;emotes=;room-id=62300805;returning-chatter=0;badge-info=subscriber/5;turbo=0;subscriber=1 :buong1!buong1@buong1.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@rm-received-ts=1704558028621;first-msg=0;id=93a058e0-6c51-48fa-8bc7-fb0129658096;vip=1;user-type=;historical=1;display-name=Joshlad;tmi-sent-ts=1704558028450;turbo=0;emotes=;badges=vip/1,subscriber/72,rplace-2023/1;room-id=62300805;badge-info=subscriber/77;returning-chatter=0;mod=0;flags=;subscriber=1;user-id=87120320;color=#D52AFF :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;flags=;badges=subscriber/36,twitch-recap-2023/1;display-name=jontEmillian;user-type=;id=82ce266b-58de-4970-b274-33eaa54c2027;mod=0;room-id=62300805;user-id=433352132;emotes=;returning-chatter=0;subscriber=1;color=#63BD68;first-msg=0;tmi-sent-ts=1704558028622;rm-received-ts=1704558028798;badge-info=subscriber/38;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@first-msg=0;emotes=;mod=0;turbo=0;id=5655953f-16de-45ef-800e-07d392779c3f;color=#008000;tmi-sent-ts=1704558028690;client-nonce=9dbb3929a3a9386fa64393019ce28767;room-id=62300805;badges=twitch-recap-2023/1;historical=1;display-name=bovabova;rm-received-ts=1704558028876;badge-info=;user-id=11654373;user-type=;flags=;subscriber=0;returning-chatter=0 :bovabova!bovabova@bovabova.tmi.twitch.tv PRIVMSG #nymn forsen","@badge-info=;historical=1;emotes=;turbo=0;mod=0;first-msg=0;returning-chatter=0;rm-received-ts=1704558029126;tmi-sent-ts=1704558028948;subscriber=0;user-id=810718356;flags=;client-nonce=676c8bc7be7d070150febd27bb2f6d17;id=f58031f7-eaaf-4f92-9181-a08f5a0f5655;user-type=;room-id=62300805;display-name=holy4uck;color=#FF0000;badges= :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn forsenParty","@flags=;display-name=Patixxl;first-msg=0;color=#FF0000;badge-info=;id=c425fbfd-2b0b-4e68-8484-c5b9ddcd63f3;emotes=;room-id=62300805;subscriber=0;user-id=51967700;user-type=;badges=;returning-chatter=0;tmi-sent-ts=1704558028969;mod=0;historical=1;rm-received-ts=1704558029148;client-nonce=a5257d469f1c32fb23582cf567bd4687;turbo=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=120451539;badges=premium/1;color=#7B00FF;mod=0;returning-chatter=0;historical=1;subscriber=0;client-nonce=7e91d88e582a0d50584ec986f080d557;turbo=0;rm-received-ts=1704558029193;user-type=;flags=;first-msg=0;tmi-sent-ts=1704558029021;display-name=imav1ctor;badge-info=;id=80252541-a1bc-414e-94ee-e039def29008;emotes=;room-id=62300805 :imav1ctor!imav1ctor@imav1ctor.tmi.twitch.tv PRIVMSG #nymn :EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime","@display-name=DontCagePlebs;badge-info=subscriber/37;id=ccdadfca-8c7e-47c8-b1f6-9f97322ddba1;user-id=85837900;historical=1;tmi-sent-ts=1704558029039;returning-chatter=0;turbo=0;color=#DAA520;user-type=;badges=subscriber/36,no_audio/1;emotes=emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18,32-38,52-58,72-78;rm-received-ts=1704558029230;room-id=62300805;client-nonce=81cafe8c3a5fc28211ac15c5af0b2633;flags=;subscriber=1;mod=0;first-msg=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM","@first-msg=0;rm-received-ts=1704558029520;room-id=62300805;subscriber=0;color=#00FF7F;historical=1;badges=turbo/1;returning-chatter=0;turbo=1;display-name=FollowProtoBuddy;user-id=216144449;emotes=;mod=0;id=58cc3dab-6266-4dd6-95fb-68b56480650c;tmi-sent-ts=1704558029363;user-type=;badge-info=;client-nonce=65c02289086270dba12e88e3ed6ccc82;flags= :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance 󠀀","@user-id=278896263;flags=;tmi-sent-ts=1704558030263;display-name=Phant0mBlades;mod=0;room-id=62300805;turbo=0;id=3a2f9416-2380-4993-8684-41e14537f5d3;color=#008000;badges=subscriber/9,chatter-cs-go-2022/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;rm-received-ts=1704558030438;returning-chatter=0;user-type=;badge-info=subscriber/9;subscriber=1;first-msg=0;historical=1 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@badges=;user-type=;id=d0b1e9a3-c1fe-4118-83da-1445daea3674;display-name=Patixxl;first-msg=0;returning-chatter=0;subscriber=0;emotes=;rm-received-ts=1704558030661;historical=1;badge-info=;flags=;client-nonce=8dbcddf9498c0a2398a307e686221500;user-id=51967700;color=#FF0000;turbo=0;room-id=62300805;mod=0;tmi-sent-ts=1704558030479 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@tmi-sent-ts=1704558030901;flags=;badge-info=;returning-chatter=0;user-id=205837377;rm-received-ts=1704558031068;display-name=Duchene;turbo=0;first-msg=0;subscriber=0;user-type=;client-nonce=f1ed8e4ed90f2065b6b627b7daaa27fe;mod=0;id=196e5a3c-00d4-49a7-b081-2a5fffde74b9;color=#000000;room-id=62300805;historical=1;badges=;emotes= :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@historical=1;badges=subscriber/48,twitch-recap-2023/1;turbo=0;badge-info=subscriber/50;subscriber=1;first-msg=0;user-id=59674528;client-nonce=6b7a9511d1c73fd4e902f38da9cf0f02;returning-chatter=0;flags=;user-type=;display-name=Barbap;emotes=;id=b2a5c5bc-5d27-4315-a157-a014e1e0802a;color=#00FF7F;tmi-sent-ts=1704558031008;rm-received-ts=1704558031197;mod=0;room-id=62300805 :barbap!barbap@barbap.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170;tmi-sent-ts=1704558031357;subscriber=1;client-nonce=5ce491281ca3f403412c3e633bd959eb;first-msg=0;mod=0;user-id=40037186;badge-info=subscriber/9;id=ee46210f-52b7-4e1a-a1a1-e60ab7754e30;user-type=;flags=;display-name=Kotzblitz20;rm-received-ts=1704558031537;color=#FFFF00;room-id=62300805;returning-chatter=0;historical=1;turbo=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=94b585c7-e3e8-4960-b46b-a7f9c89282ee;tmi-sent-ts=1704558031579;first-msg=0;mod=0;color=#008000;historical=1;display-name=terning;user-type=;client-nonce=aeffb7553dc6c932ef6da297a272755d;subscriber=1;rm-received-ts=1704558031751;emotes=;returning-chatter=0;turbo=0;badges=subscriber/9,rplace-2023/1;user-id=135571016;flags=;badge-info=subscriber/11;room-id=62300805 :terning!terning@terning.tmi.twitch.tv PRIVMSG #nymn BOOMIES","@user-type=;first-msg=0;flags=;badge-info=subscriber/9;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;user-id=278896263;mod=0;turbo=0;display-name=Phant0mBlades;subscriber=1;room-id=62300805;id=0f65912b-bf73-4f45-b938-089c44b5ae7c;tmi-sent-ts=1704558031893;badges=subscriber/9,chatter-cs-go-2022/1;returning-chatter=0;historical=1;rm-received-ts=1704558032078;color=#008000 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@user-id=51967700;client-nonce=e388bf68406561a507e5b8334e0ff436;subscriber=0;mod=0;badge-info=;room-id=62300805;display-name=Patixxl;tmi-sent-ts=1704558032051;turbo=0;historical=1;user-type=;returning-chatter=0;first-msg=0;flags=;emotes=;id=cf632154-1501-481e-910e-950fed34064e;rm-received-ts=1704558032229;color=#FF0000;badges= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@rm-received-ts=1704558033234;display-name=Joshlad;turbo=0;flags=;room-id=62300805;subscriber=1;tmi-sent-ts=1704558033052;color=#D52AFF;emotes=;badge-info=subscriber/77;badges=vip/1,subscriber/72,rplace-2023/1;user-type=;mod=0;historical=1;user-id=87120320;vip=1;returning-chatter=0;id=63b30720-5149-482d-b3ec-d7467bb984a1;first-msg=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;mod=0;id=b0205480-7c9e-4954-953d-984b28b4c739;subscriber=1;user-type=;room-id=62300805;user-id=433352132;badge-info=subscriber/38;turbo=0;returning-chatter=0;tmi-sent-ts=1704558033276;badges=subscriber/36,twitch-recap-2023/1;display-name=jontEmillian;historical=1;emotes=;rm-received-ts=1704558033451;color=#63BD68;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@historical=1;client-nonce=2fc10af2e22479ac486657faecda946e;color=#FF0000;flags=;badge-info=;room-id=62300805;mod=0;user-type=;emotes=;rm-received-ts=1704558033996;first-msg=0;display-name=Patixxl;badges=;tmi-sent-ts=1704558033832;subscriber=0;returning-chatter=0;user-id=51967700;turbo=0;id=3e05dd46-22f4-4b57-b33e-cd70520fbdfc :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@first-msg=0;badge-info=subscriber/9;id=5a41b2c5-128a-45ca-ade7-f2cc0b5836c2;historical=1;client-nonce=a21ddd820f57ad20c03e7855adbe0b83;turbo=1;tmi-sent-ts=1704558033822;color=#FFFF00;returning-chatter=0;subscriber=1;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250;user-id=40037186;room-id=62300805;flags=;mod=0;user-type=;display-name=Kotzblitz20;rm-received-ts=1704558034004 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;historical=1;client-nonce=64acd9ccd3454654365ba8643900de23;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;room-id=62300805;badge-info=subscriber/49;badges=subscriber/48,bits/25000;turbo=0;user-id=159210800;user-type=;returning-chatter=0;mod=0;rm-received-ts=1704558034080;id=0d3b6b8e-7fb0-40b3-b59a-ed7394d0dec8;subscriber=1;display-name=ME_ME;color=#FF2424;first-msg=0;tmi-sent-ts=1704558033916 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenParty ASYLUM RAVE 󠀀","@flags=;color=#DAA520;client-nonce=9e505ea825ceac4ad63ff1ca9874660f;returning-chatter=0;turbo=0;subscriber=1;historical=1;room-id=62300805;badges=subscriber/36,no_audio/1;mod=0;tmi-sent-ts=1704558034303;rm-received-ts=1704558034498;user-id=85837900;badge-info=subscriber/37;emotes=emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18,32-38,52-58,72-78,92-98,112-118,132-138;first-msg=0;user-type=;id=9f7c81c9-2994-4f1c-ab81-c50cdbc8d350;display-name=DontCagePlebs :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM","@subscriber=0;client-nonce=e772f97754ad088032bb776e17460619;badge-info=;user-id=216144449;historical=1;turbo=1;id=71f5ea26-ff8f-475f-95cd-cac668094862;first-msg=0;mod=0;badges=turbo/1;room-id=62300805;display-name=FollowProtoBuddy;emotes=;flags=;user-type=;color=#00FF7F;returning-chatter=0;rm-received-ts=1704558034595;tmi-sent-ts=1704558034423 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn nimeDance","@returning-chatter=0;color=#7B00FF;client-nonce=425adea45108f51970486fd4f1eead88;rm-received-ts=1704558034617;user-type=;badges=premium/1;subscriber=0;first-msg=0;id=358e604f-6e0b-4291-a0c3-ce511e428e64;mod=0;badge-info=;emotes=;tmi-sent-ts=1704558034446;turbo=0;historical=1;user-id=120451539;display-name=imav1ctor;flags=;room-id=62300805 :imav1ctor!imav1ctor@imav1ctor.tmi.twitch.tv PRIVMSG #nymn :EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime","@client-nonce=3c99caa1d14ecc222e3473d42dcccd1f;emotes=;historical=1;room-id=62300805;subscriber=1;badges=subscriber/6,chatter-cs-go-2022/1;tmi-sent-ts=1704558034490;color=#B22222;first-msg=0;flags=;user-id=222340799;rm-received-ts=1704558034676;returning-chatter=0;mod=0;user-type=;turbo=0;display-name=crazyjuni0r_;badge-info=subscriber/7;id=02d673e7-433e-4064-9f50-4fd139acc525 :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn docalmostnotL","@user-type=mod;rm-received-ts=1704558034731;room-id=62300805;emotes=;first-msg=0;id=ba3285b6-cadc-4820-b8f5-c788a3a9bdd5;display-name=Mr0lle;flags=;badge-info=subscriber/67;mod=1;returning-chatter=0;color=#9146FF;badges=moderator/1,subscriber/60,rplace-2023/1;subscriber=1;turbo=0;tmi-sent-ts=1704558034561;historical=1;user-id=41157245 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@display-name=3amo_magdy;returning-chatter=0;id=2c50cfeb-16f8-4ab2-b21a-93950ada20c1;user-id=428888588;first-msg=0;emotes=;rm-received-ts=1704558035018;client-nonce=cbe29c8b5ddd74d78bdaf8208cdf92ca;badges=;mod=0;color=#DAA520;subscriber=0;flags=;historical=1;user-type=;room-id=62300805;tmi-sent-ts=1704558034850;badge-info=;turbo=0 :3amo_magdy!3amo_magdy@3amo_magdy.tmi.twitch.tv PRIVMSG #nymn :EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime","@tmi-sent-ts=1704558035165;subscriber=1;emote-only=1;display-name=Near____________;mod=0;historical=1;user-type=;returning-chatter=0;color=#23CE3F;badges=subscriber/18,twitch-recap-2023/1;id=1acb1299-837c-4e7e-9444-2344317ecdbc;first-msg=0;room-id=62300805;rm-received-ts=1704558035363;flags=;user-id=190828982;turbo=0;client-nonce=0638b522c108236879816713649bc8c7;badge-info=subscriber/20;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10/emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18 :near____________!near____________@near____________.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM","@turbo=0;mod=0;first-msg=0;badge-info=;id=26b95220-6cf2-4e36-b92b-d5e1787556c8;historical=1;subscriber=0;tmi-sent-ts=1704558035379;badges=;display-name=bomberman2442;user-type=;color=;flags=;user-id=59060199;client-nonce=a8ac9f75873f8166afec09cf7cebc319;room-id=62300805;emotes=;rm-received-ts=1704558035549;returning-chatter=0 :bomberman2442!bomberman2442@bomberman2442.tmi.twitch.tv PRIVMSG #nymn :i mean makes sense","@badge-info=;user-type=;subscriber=0;returning-chatter=0;emotes=;color=#FF0000;turbo=0;first-msg=0;badges=;id=2beefbc3-5c98-4625-92b2-7b12b1abae24;client-nonce=70c499e679b2fcdaf434745236f8f92d;flags=;historical=1;room-id=62300805;user-id=51967700;mod=0;tmi-sent-ts=1704558035404;rm-received-ts=1704558035589;display-name=Patixxl :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=;rm-received-ts=1704558035873;client-nonce=2542a019019051fab6c6c04ac5a63930;room-id=62300805;mod=0;turbo=0;emotes=;flags=;user-id=133862911;user-type=;id=840a0d0b-5c49-46be-8d11-ba07f5de6533;subscriber=0;display-name=123homo;badges=;tmi-sent-ts=1704558035709;first-msg=0;returning-chatter=0;historical=1;color=#FF0000 :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn rat","@badges=;flags=;historical=1;user-id=810718356;id=19e82d4d-aaf0-4ef9-9180-3114497ad078;user-type=;turbo=0;returning-chatter=0;tmi-sent-ts=1704558035935;color=#FF0000;emotes=;display-name=holy4uck;client-nonce=2bda1986c136c5b04b70b794f5ff82fa;subscriber=0;first-msg=0;rm-received-ts=1704558036104;mod=0;badge-info=;room-id=62300805 :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@mod=0;rm-received-ts=1704558036731;color=#FFFF00;client-nonce=fac473d6de046648a2836c55565864f9;badges=subscriber/9,turbo/1;returning-chatter=0;tmi-sent-ts=1704558036484;user-type=;first-msg=0;subscriber=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106;user-id=40037186;flags=;display-name=Kotzblitz20;room-id=62300805;id=beaa183b-bd94-4f5b-9320-1f941b3e0e07;turbo=1;historical=1;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@tmi-sent-ts=1704558036623;first-msg=0;user-id=46199261;subscriber=0;color=#8A2BE2;client-nonce=8aa6b9908b0f9bea9d46e751fc272380;badge-info=;returning-chatter=0;flags=;rm-received-ts=1704558036803;mod=0;user-type=;display-name=Obiwun;badges=no_audio/1;turbo=0;room-id=62300805;historical=1;emotes=;id=2a04fc29-19d6-4f5b-bd57-63e567c2caae :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn :just like in real life","@subscriber=0;turbo=0;room-id=62300805;emotes=;mod=0;badge-info=;tmi-sent-ts=1704558036696;display-name=Patixxl;color=#FF0000;returning-chatter=0;flags=;badges=;first-msg=0;client-nonce=54a1e78e843230a1dfdb9ed8e311ea72;user-id=51967700;rm-received-ts=1704558036868;user-type=;id=84877796-0bad-410a-90a2-1cb433ad3ad0;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@historical=1;mod=0;turbo=0;flags=;client-nonce=d3ac86dd052dd537377a0ef5ddfcd55e;color=#FF0000;emotes=;display-name=Patixxl;user-type=;first-msg=0;id=7955cdf6-66a1-4285-9859-aaae192b4ec5;badge-info=;subscriber=0;badges=;returning-chatter=0;room-id=62300805;rm-received-ts=1704558038009;tmi-sent-ts=1704558037830;user-id=51967700 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;rm-received-ts=1704558038666;color=#63BD68;turbo=0;id=8eeda1c5-96cc-4898-ba0d-54d90b393779;display-name=jontEmillian;user-id=433352132;user-type=;badge-info=subscriber/38;subscriber=1;first-msg=0;emotes=;historical=1;mod=0;room-id=62300805;badges=subscriber/36,twitch-recap-2023/1;tmi-sent-ts=1704558038483;flags= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@room-id=62300805;subscriber=1;flags=;returning-chatter=0;badges=subscriber/9,turbo/1;turbo=1;id=826b4765-eec2-49f3-a49a-d55cb0cc4913;user-type=;badge-info=subscriber/9;user-id=40037186;first-msg=0;historical=1;client-nonce=8f10f9e81b153fa2467b3b7ed601b7ac;display-name=Kotzblitz20;tmi-sent-ts=1704558038586;mod=0;rm-received-ts=1704558038781;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250;color=#FFFF00 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#376180;client-nonce=d3a4f64be02efe618d7e29b8ab5b3838;display-name=Storm;id=49656e4e-60a2-47fc-8801-1186d28fe8de;badge-info=;room-id=62300805;historical=1;rm-received-ts=1704558039139;first-msg=0;user-id=13107998;user-type=mod;emotes=;turbo=1;flags=0-12:A.5;badges=moderator/1,turbo/1;returning-chatter=0;subscriber=0;mod=1;tmi-sent-ts=1704558038907 :storm!storm@storm.tmi.twitch.tv PRIVMSG #nymn :you are a rat","@historical=1;color=#FF0000;user-id=51967700;subscriber=0;badges=;returning-chatter=0;mod=0;badge-info=;first-msg=0;display-name=Patixxl;flags=;client-nonce=c6d0ad584fdd8cf6d70308bed68ddbeb;tmi-sent-ts=1704558039096;room-id=62300805;id=05f1afe3-bf93-47d1-bc58-aac61ef512fb;emotes=;user-type=;turbo=0;rm-received-ts=1704558039268 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-type=;room-id=62300805;mod=0;vip=1;display-name=Joshlad;id=6c87fd57-28fe-436a-806f-037bedb46585;subscriber=1;turbo=0;rm-received-ts=1704558039316;badge-info=subscriber/77;tmi-sent-ts=1704558039131;first-msg=0;flags=;color=#D52AFF;historical=1;badges=vip/1,subscriber/72,rplace-2023/1;user-id=87120320;emotes=;returning-chatter=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;user-id=40037186;display-name=Kotzblitz20;room-id=62300805;first-msg=0;id=ddfe4717-e97f-446b-a793-eec573792f46;client-nonce=8254c7a1008bce87fbd0e92ee7fa6818;badges=subscriber/9,turbo/1;color=#FFFF00;turbo=1;rm-received-ts=1704558040553;historical=1;subscriber=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42;returning-chatter=0;user-type=;tmi-sent-ts=1704558040345;mod=0;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM","@client-nonce=6f00b4100d0bed9042c1419eea2f8de5;historical=1;flags=;user-id=51967700;returning-chatter=0;display-name=Patixxl;rm-received-ts=1704558040781;badge-info=;color=#FF0000;subscriber=0;tmi-sent-ts=1704558040601;first-msg=0;id=c1c13e15-b366-4377-871d-17acea6e41dc;room-id=62300805;mod=0;emotes=;turbo=0;user-type=;badges= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#00FF7F;emotes=;returning-chatter=0;historical=1;id=ada4e539-b480-4877-bb98-fee7ff7a67d0;first-msg=0;flags=;rm-received-ts=1704558041596;mod=0;client-nonce=5c2276be7eb33eb6f6e60126d3345fc8;badges=turbo/1;badge-info=;tmi-sent-ts=1704558041430;user-type=;room-id=62300805;display-name=FollowProtoBuddy;subscriber=0;turbo=1;user-id=216144449 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance 󠀀","@rm-received-ts=1704558042012;tmi-sent-ts=1704558041839;first-msg=0;display-name=DontCagePlebs;user-id=85837900;flags=;turbo=0;color=#DAA520;subscriber=1;client-nonce=4eda69aaf85b4c415b890cc68d896302;badges=subscriber/36,no_audio/1;returning-chatter=0;historical=1;emotes=emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18,32-38,52-58,72-78,92-98,112-118;id=de9c97ea-fe87-4659-8592-f53b113f0e7b;room-id=62300805;mod=0;user-type=;badge-info=subscriber/37 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM forsenParty nymnEDM","@badge-info=subscriber/49;client-nonce=fce907e25324c4abe6e750b6afe26f03;id=6834e77b-7bd5-4249-9ca2-571730669006;tmi-sent-ts=1704558042155;room-id=62300805;subscriber=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;mod=0;returning-chatter=0;display-name=ME_ME;turbo=0;historical=1;color=#FF2424;user-type=;rm-received-ts=1704558042357;user-id=159210800;flags=;first-msg=0;badges=subscriber/48,bits/25000 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenParty ASYLUM RAVE","@tmi-sent-ts=1704558042544;room-id=62300805;subscriber=1;badge-info=subscriber/9;user-type=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282,288-298;client-nonce=bec9a2ed28fe16915adbc4539c888c93;turbo=1;color=#FFFF00;badges=subscriber/9,turbo/1;flags=;rm-received-ts=1704558042804;returning-chatter=0;user-id=40037186;display-name=Kotzblitz20;mod=0;historical=1;id=76fc5fa6-9300-468d-b1e8-69908a0366e6;first-msg=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@mod=0;client-nonce=c5962853e04e915bf950fe15df440729;historical=1;display-name=imav1ctor;user-id=120451539;rm-received-ts=1704558042930;returning-chatter=0;tmi-sent-ts=1704558042722;flags=;badges=premium/1;subscriber=0;id=74f0ebce-8680-41cb-bda0-225903ae0615;first-msg=0;room-id=62300805;emotes=;color=#7B00FF;turbo=0;user-type=;badge-info= :imav1ctor!imav1ctor@imav1ctor.tmi.twitch.tv PRIVMSG #nymn :EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime 󠀀","@id=d2d3243c-2dda-4679-b8b6-d9235ca989c4;flags=0-3:P.3;badge-info=;emotes=;first-msg=0;badges=;mod=0;color=#FF0000;subscriber=0;rm-received-ts=1704558042936;returning-chatter=0;client-nonce=67523fbb2f966959dc56573303911350;tmi-sent-ts=1704558042711;room-id=62300805;historical=1;user-id=25483756;turbo=0;display-name=ZpLit;user-type= :zplit!zplit@zplit.tmi.twitch.tv PRIVMSG #nymn ::tf: dev guy","@mod=0;display-name=terning;id=bc37a6bd-41ea-49b7-8889-2717c3e684e8;badge-info=subscriber/11;subscriber=1;client-nonce=36da8b4b0e75cfecc696db939ee268d7;historical=1;badges=subscriber/9,rplace-2023/1;color=#008000;turbo=0;tmi-sent-ts=1704558042816;user-type=;flags=;user-id=135571016;first-msg=0;returning-chatter=0;room-id=62300805;rm-received-ts=1704558043034;emotes= :terning!terning@terning.tmi.twitch.tv PRIVMSG #nymn :BOOMIES EDM","@turbo=0;room-id=62300805;badge-info=subscriber/38;mod=0;rm-received-ts=1704558043429;returning-chatter=0;historical=1;user-type=;flags=;color=#63BD68;subscriber=1;first-msg=0;tmi-sent-ts=1704558043263;display-name=jontEmillian;user-id=433352132;emotes=;id=2fdb47b6-ff5f-49c1-861f-57b4f88b1ddd;badges=subscriber/36,twitch-recap-2023/1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@turbo=0;color=#8A2BE2;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;subscriber=0;tmi-sent-ts=1704558044209;mod=0;id=42151a86-72e6-472b-9a45-0c11ad485e0e;user-type=;badge-info=;room-id=62300805;client-nonce=8830db21f24381399e6a0d6e1f045463;display-name=Purple_Geco;user-id=253596827;rm-received-ts=1704558044408;first-msg=0;historical=1;flags=;badges=no_audio/1 :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emotes=;display-name=Patixxl;historical=1;tmi-sent-ts=1704558044283;client-nonce=5dd7bed2778d03dbe788e793d8780d38;rm-received-ts=1704558044462;color=#FF0000;badge-info=;room-id=62300805;flags=;user-id=51967700;subscriber=0;first-msg=0;id=7e110244-0bf1-4f53-86a5-1ffa1f7c43fe;mod=0;turbo=0;badges=;returning-chatter=0;user-type= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@flags=;first-msg=0;rm-received-ts=1704558045327;tmi-sent-ts=1704558045154;turbo=0;subscriber=0;color=#DAA520;id=72d072c5-c2ca-49ab-9af5-e618346ef5db;display-name=3amo_magdy;emotes=;returning-chatter=0;badges=;room-id=62300805;mod=0;user-type=;badge-info=;user-id=428888588;client-nonce=35d8e567d4b97b4bcbb039f5e39457de;historical=1 :3amo_magdy!3amo_magdy@3amo_magdy.tmi.twitch.tv PRIVMSG #nymn :EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime EDM Ratge RaveTime 󠀀","@mod=0;display-name=Purple_Geco;id=2ce2ef15-a27c-46fa-99ae-e404ec5ebc3c;flags=;first-msg=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;color=#8A2BE2;badge-info=;returning-chatter=0;subscriber=0;client-nonce=acb78da756800fc9cc9276eb40a20b58;room-id=62300805;badges=no_audio/1;user-id=253596827;tmi-sent-ts=1704558045837;rm-received-ts=1704558046030;turbo=0;user-type=;historical=1 :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty","@subscriber=1;turbo=0;first-msg=0;room-id=62300805;flags=;rm-received-ts=1704558046087;user-type=;color=#23CE3F;user-id=190828982;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10/emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18;id=42d51753-8cd8-4d3c-a80b-ab69eca02def;tmi-sent-ts=1704558045902;badges=subscriber/18,twitch-recap-2023/1;display-name=Near____________;client-nonce=1b2bd641c727c7c302e54c1fa692b610;badge-info=subscriber/20;historical=1;mod=0 :near____________!near____________@near____________.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM 󠀀","@display-name=Patixxl;historical=1;badges=;client-nonce=2ecc4c6714f15b01a199a5c8351aae08;user-id=51967700;badge-info=;color=#FF0000;tmi-sent-ts=1704558046655;room-id=62300805;turbo=0;rm-received-ts=1704558046870;mod=0;user-type=;subscriber=0;emotes=;returning-chatter=0;id=16711817-e1c4-4af8-b998-e0de102995db;first-msg=0;flags= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badges=subscriber/36,twitch-recap-2023/1;user-type=;tmi-sent-ts=1704558047715;turbo=0;badge-info=subscriber/38;returning-chatter=0;emotes=;id=5256ced8-298c-4751-bf55-25d28a1f08ad;rm-received-ts=1704558047897;flags=;user-id=433352132;mod=0;display-name=jontEmillian;subscriber=1;historical=1;color=#63BD68;room-id=62300805;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@display-name=DontCagePlebs;badges=subscriber/36,no_audio/1;user-id=85837900;first-msg=0;client-nonce=d8bbbd864f8f1a9dcb29d3fc2bf62a34;tmi-sent-ts=1704558047941;badge-info=subscriber/37;rm-received-ts=1704558048140;room-id=62300805;emotes=;historical=1;turbo=0;mod=0;subscriber=1;returning-chatter=0;color=#DAA520;id=cabe23fe-481d-4d51-ac7a-0d863d34c6c2;flags=;user-type= :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;badges=subscriber/9,chatter-cs-go-2022/1;rm-received-ts=1704558048947;user-type=;mod=0;turbo=0;room-id=62300805;returning-chatter=0;tmi-sent-ts=1704558048712;display-name=Phant0mBlades;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;user-id=278896263;id=16a6448d-7c22-4377-a1ce-f2e7615ecb98;badge-info=subscriber/9;historical=1;color=#008000;subscriber=1;flags= :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@flags=;rm-received-ts=1704558049305;client-nonce=b76159c23971e3a7d576837c006e32fc;turbo=1;mod=0;returning-chatter=0;color=#FFFF00;historical=1;badges=subscriber/9,turbo/1;first-msg=0;emote-only=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;badge-info=subscriber/9;room-id=62300805;user-type=;user-id=40037186;id=2b24078e-ded9-4b99-9ab2-794ec1180c72;subscriber=1;display-name=Kotzblitz20;tmi-sent-ts=1704558049108 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn forsenParty","@turbo=0;badge-info=subscriber/47;emote-only=1;user-id=92529125;mod=0;tmi-sent-ts=1704558049430;emotes=emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18/emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;historical=1;badges=subscriber/42,twitch-recap-2023/1;user-type=;room-id=62300805;id=e6b652ca-639c-4b3a-9a26-527c6a9ee73e;flags=;color=#FF0000;returning-chatter=0;display-name=Mawsonator;rm-received-ts=1704558049652;client-nonce=5c86ea25ef84080845c6cd59877d0429;first-msg=0;subscriber=1 :mawsonator!mawsonator@mawsonator.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM","@historical=1;user-type=;badge-info=subscriber/38;badges=subscriber/36,twitch-recap-2023/1;flags=;tmi-sent-ts=1704558051734;subscriber=1;user-id=433352132;display-name=jontEmillian;emotes=;returning-chatter=0;mod=0;color=#63BD68;room-id=62300805;id=3111c620-c729-4ffd-81a3-701ff071387b;rm-received-ts=1704558051902;turbo=0;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@user-type=;historical=1;badges=;display-name=Patixxl;turbo=0;room-id=62300805;color=#FF0000;subscriber=0;emotes=;flags=;returning-chatter=0;user-id=51967700;badge-info=;tmi-sent-ts=1704558051732;id=76bdc9f3-bb29-414d-9452-ec57de8e7cbd;first-msg=0;client-nonce=0f02f812dfef6cd685d9a637266f8b77;mod=0;rm-received-ts=1704558051906 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@color=#D52AFF;tmi-sent-ts=1704558051841;rm-received-ts=1704558052021;id=d4b41416-e12a-45fe-ba78-06e33e5b3087;turbo=0;subscriber=1;historical=1;badge-info=subscriber/77;badges=vip/1,subscriber/72,rplace-2023/1;flags=;returning-chatter=0;user-id=87120320;display-name=Joshlad;emotes=;user-type=;room-id=62300805;first-msg=0;vip=1;mod=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=38936358;first-msg=0;id=a99b6cf2-fa40-420f-bb38-10c5c7709725;turbo=0;rm-received-ts=1704558052979;emotes=;display-name=havelsring;mod=0;badge-info=subscriber/45;room-id=62300805;flags=;color=#FFFFFF;badges=subscriber/42,bits/100;tmi-sent-ts=1704558052785;user-type=;client-nonce=a59f75ed1a639ff38efad356f072e3bb;subscriber=1;returning-chatter=0;historical=1 :havelsring!havelsring@havelsring.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;subscriber=1;user-type=mod;first-msg=0;badges=moderator/1,subscriber/60,rplace-2023/1;historical=1;flags=;tmi-sent-ts=1704558052992;color=#9146FF;id=08c20b55-4c44-478c-bc23-5a2ffc5afdc8;mod=1;user-id=41157245;emotes=;display-name=Mr0lle;turbo=0;badge-info=subscriber/67;rm-received-ts=1704558053171;returning-chatter=0 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@tmi-sent-ts=1704558053610;mod=0;returning-chatter=0;subscriber=1;user-id=85837900;badge-info=subscriber/37;client-nonce=f81d48ff08f0f120f3d76237f12dcf53;first-msg=0;display-name=DontCagePlebs;id=481c3bde-0d6e-4813-9356-7780ce3e8def;badges=subscriber/36,no_audio/1;historical=1;rm-received-ts=1704558053872;emotes=;color=#DAA520;turbo=0;user-type=;flags=;room-id=62300805 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;display-name=FollowProtoBuddy;color=#00FF7F;room-id=62300805;rm-received-ts=1704558054270;historical=1;subscriber=0;tmi-sent-ts=1704558054032;first-msg=0;badges=turbo/1;emotes=;client-nonce=f109345b5890e3da16c5df0aca244b85;user-id=216144449;id=f77fa4a4-782d-4143-acf1-5c4f5140eb02;mod=0;returning-chatter=0;badge-info=;turbo=1;flags= :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance forsen can i dance with you?","@rm-received-ts=1704558054600;first-msg=0;returning-chatter=0;user-id=433352132;room-id=62300805;mod=0;badges=subscriber/36,twitch-recap-2023/1;turbo=0;user-type=;subscriber=1;emotes=;historical=1;id=13783e35-2015-45aa-8be2-7a0c69ca6aae;flags=;badge-info=subscriber/38;color=#63BD68;display-name=jontEmillian;tmi-sent-ts=1704558054424 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@rm-received-ts=1704558054638;id=57c063b5-1525-4240-b4e7-1937ad19c26c;badges=subscriber/3,no_audio/1;user-type=;subscriber=1;tmi-sent-ts=1704558054454;turbo=0;display-name=Sudnim;mod=0;color=#FFC000;user-id=49365214;flags=;badge-info=subscriber/4;first-msg=0;returning-chatter=0;historical=1;client-nonce=56bb43055e74ebbeb18009bcc7cf9a01;room-id=62300805;emotes= :sudnim!sudnim@sudnim.tmi.twitch.tv PRIVMSG #nymn monkaGIGA","@emotes=;color=#FF0000;room-id=62300805;subscriber=0;id=1e662854-37ef-4218-b4ee-654a300572bf;first-msg=0;historical=1;tmi-sent-ts=1704558054467;badge-info=;user-type=;client-nonce=640b0b3d52006a1d649eb4fa2e330b39;turbo=0;badges=;returning-chatter=0;user-id=51967700;rm-received-ts=1704558054650;display-name=Patixxl;flags=;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;color=#FFFF00;subscriber=1;room-id=62300805;tmi-sent-ts=1704558054987;emote-only=1;badge-info=subscriber/9;id=0d695970-3f50-40b9-a142-b46b55c93bda;badges=subscriber/9,turbo/1;user-id=40037186;historical=1;display-name=Kotzblitz20;flags=;rm-received-ts=1704558055186;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,12-22;turbo=1;user-type=;client-nonce=02780d0a5b8f7d67bb6a65e5a2a04e4b;mod=0;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty forsenParty","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;client-nonce=be0c9e5a3ecea4d19917c9bb26b9712b;first-msg=0;rm-received-ts=1704558055619;display-name=Purple_Geco;tmi-sent-ts=1704558055434;room-id=62300805;flags=;turbo=0;color=#8A2BE2;subscriber=0;mod=0;user-id=253596827;user-type=;historical=1;id=061e37dd-b6ed-4929-990d-3d3c862e6f71;badges=no_audio/1;badge-info=;returning-chatter=0 :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;display-name=Joshlad;turbo=0;emotes=;tmi-sent-ts=1704558056679;rm-received-ts=1704558056870;flags=;historical=1;user-type=;subscriber=1;vip=1;badge-info=subscriber/77;mod=0;user-id=87120320;id=4f07504c-b176-4458-8738-94c5f5fdef7a;badges=vip/1,subscriber/72,rplace-2023/1;color=#D52AFF;room-id=62300805;first-msg=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@client-nonce=ccfbadd29da250bc58a687ced26b1b60;first-msg=0;room-id=62300805;flags=;turbo=0;id=a1dc219d-e21a-4fa1-a9fc-8e25a1a04569;rm-received-ts=1704558057337;emotes=;tmi-sent-ts=1704558057164;returning-chatter=0;badges=;badge-info=;display-name=Patixxl;user-type=;color=#FF0000;user-id=51967700;subscriber=0;historical=1;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-type=;rm-received-ts=1704558058585;subscriber=1;mod=0;tmi-sent-ts=1704558058380;turbo=0;room-id=62300805;returning-chatter=0;color=#63BD68;id=74884179-68bd-40df-8b40-937c938e3114;flags=;first-msg=0;badge-info=subscriber/38;emotes=;historical=1;badges=subscriber/36,twitch-recap-2023/1;user-id=433352132;display-name=jontEmillian :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@user-type=;badge-info=subscriber/9;mod=0;display-name=Kotzblitz20;color=#FFFF00;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106;user-id=40037186;room-id=62300805;tmi-sent-ts=1704558058875;id=7f23923e-079a-4e42-903f-2d5b3ae7808e;turbo=1;client-nonce=b930daedc0b80a0c275a792f284d6d05;historical=1;badges=subscriber/9,turbo/1;flags=;rm-received-ts=1704558059100;returning-chatter=0;subscriber=1;first-msg=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=66b040ed-9c85-4edd-8012-0b8d609e2274;room-id=62300805;returning-chatter=0;badges=turbo/1;client-nonce=afd4cb11fca458ad8e38ea02edd1c6d0;historical=1;first-msg=0;user-type=;badge-info=;subscriber=0;flags=;user-id=216144449;emotes=;turbo=1;color=#00FF7F;tmi-sent-ts=1704558059241;rm-received-ts=1704558059411;mod=0;display-name=FollowProtoBuddy :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance forsen can i dance with you? 󠀀","@subscriber=1;user-type=;historical=1;user-id=159210800;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;id=bf6bec95-d5c3-4c29-a289-f638700ee0d2;badge-info=subscriber/49;flags=;badges=subscriber/48,bits/25000;tmi-sent-ts=1704558059545;room-id=62300805;turbo=0;color=#FF2424;client-nonce=27fae86f793027efdbfee1b84d1f8358;emote-only=1;display-name=ME_ME;rm-received-ts=1704558059758;first-msg=0;mod=0 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenParty","@flags=;turbo=1;id=07b158ac-5794-4393-abd0-d678c08a0eb3;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;mod=0;first-msg=0;returning-chatter=0;badges=subscriber/9,turbo/1;user-id=40037186;display-name=Kotzblitz20;room-id=62300805;subscriber=1;historical=1;user-type=;client-nonce=876541ffbccf3345a402e8e573ca4bc4;tmi-sent-ts=1704558060658;color=#FFFF00;rm-received-ts=1704558060848;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=51967700;first-msg=0;room-id=62300805;flags=;subscriber=0;returning-chatter=0;user-type=;badge-info=;color=#FF0000;display-name=Patixxl;client-nonce=9b0d610b8cb5948e1554c05d83005ed4;tmi-sent-ts=1704558060677;mod=0;emotes=;badges=;id=f62e9bc3-bbad-4bc9-893f-02cd3de660ba;historical=1;rm-received-ts=1704558060858;turbo=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@mod=0;user-type=;subscriber=1;historical=1;room-id=62300805;display-name=h_h410;badges=subscriber/54,chatter-cs-go-2022/1;rm-received-ts=1704558061149;returning-chatter=0;flags=;first-msg=0;id=53d45865-2575-4fd2-8071-a5b3af069095;tmi-sent-ts=1704558060957;color=#00FF7F;turbo=0;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;badge-info=subscriber/54;user-id=117088592;emote-only=1 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn forsenPossessed","@subscriber=1;historical=1;turbo=0;color=#D52AFF;room-id=62300805;mod=0;rm-received-ts=1704558062698;user-type=;tmi-sent-ts=1704558062517;user-id=87120320;id=f0090a00-3ecf-42e5-9771-80ebbf31fff1;display-name=Joshlad;flags=;badges=vip/1,subscriber/72,rplace-2023/1;badge-info=subscriber/77;vip=1;returning-chatter=0;emotes=;first-msg=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=51809775;flags=;tmi-sent-ts=1704558062973;first-msg=0;user-type=;subscriber=0;mod=0;client-nonce=c62c0d211e90a292300ab39d1e7f2067;display-name=Yrmyli;color=#FF1493;badges=;turbo=0;historical=1;rm-received-ts=1704558063150;badge-info=;returning-chatter=0;id=b4080521-87e2-49cd-82d2-99139f9468a7;emotes=;room-id=62300805 :yrmyli!yrmyli@yrmyli.tmi.twitch.tv PRIVMSG #nymn :nymn what is this game about DOCING","@badge-info=subscriber/9;display-name=Kotzblitz20;user-type=;turbo=1;id=1ecd6998-dadf-46f8-b2cf-5fbbf0c92edf;subscriber=1;user-id=40037186;badges=subscriber/9,turbo/1;client-nonce=d060ee2211e85e604b9d855ab403108d;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186;room-id=62300805;historical=1;returning-chatter=0;flags=;rm-received-ts=1704558063248;first-msg=0;color=#FFFF00;tmi-sent-ts=1704558063067 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;tmi-sent-ts=1704558063489;emotes=;client-nonce=e86b9dd5ae88bd34534323f237cae559;display-name=SecretCarrot;first-msg=0;subscriber=1;turbo=0;badges=subscriber/54,bits/1000;id=5b8bb19a-9943-409a-899c-7f1243e949e8;flags=;user-id=103592036;room-id=62300805;badge-info=subscriber/55;user-type=;mod=0;historical=1;rm-received-ts=1704558063683;color=#00615C :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badges=;flags=;id=d2da9cb3-81a4-47ae-8cd6-cedd4911c9b3;client-nonce=04185bca630f418cbc70d1d5ac0c68eb;emotes=;returning-chatter=0;historical=1;rm-received-ts=1704558063704;user-id=51967700;display-name=Patixxl;color=#FF0000;user-type=;tmi-sent-ts=1704558063524;badge-info=;room-id=62300805;mod=0;first-msg=0;subscriber=0;turbo=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badges=subscriber/36,twitch-recap-2023/1;tmi-sent-ts=1704558063790;first-msg=0;turbo=0;badge-info=subscriber/38;rm-received-ts=1704558063975;color=#63BD68;historical=1;user-type=;room-id=62300805;flags=;user-id=433352132;id=0905f6d0-7609-4dab-94b1-3dd38d22e0ca;emotes=;mod=0;returning-chatter=0;display-name=jontEmillian;subscriber=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@first-msg=0;turbo=0;room-id=62300805;id=2263d9da-83e8-45fe-8e19-9e29f9417e32;subscriber=0;returning-chatter=0;user-type=;display-name=Purple_Geco;flags=;badge-info=;tmi-sent-ts=1704558063826;rm-received-ts=1704558064008;client-nonce=4b2fd0f237f67e94416a0d75959b1025;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;color=#8A2BE2;user-id=253596827;badges=no_audio/1;mod=0;historical=1 :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty","@rm-received-ts=1704558064517;emote-only=1;client-nonce=3518f4dff12eaa39feb13bfc36116a00;flags=;user-type=;first-msg=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10/emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18;mod=0;historical=1;badge-info=subscriber/20;turbo=0;subscriber=1;badges=subscriber/18,twitch-recap-2023/1;tmi-sent-ts=1704558064293;id=9777e02d-cdb6-4172-bbd4-2500b06ee490;room-id=62300805;returning-chatter=0;user-id=190828982;color=#23CE3F;display-name=Near____________ :near____________!near____________@near____________.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM","@badge-info=subscriber/5;subscriber=1;color=#FF0000;rm-received-ts=1704558065295;first-msg=0;turbo=0;user-type=;historical=1;client-nonce=0f210a728db7218b298c8c85b5528af5;room-id=62300805;emotes=;user-id=75144877;mod=0;badges=subscriber/3,no_video/1;flags=;tmi-sent-ts=1704558065124;returning-chatter=0;display-name=buong1;id=5820d645-a8e7-45ff-ac47-e5db276f5363 :buong1!buong1@buong1.tmi.twitch.tv PRIVMSG #nymn WAYTOODANK","@historical=1;subscriber=1;badge-info=subscriber/67;id=dad4e1e6-736d-48e0-8f29-35c329346e14;flags=;badges=moderator/1,subscriber/60,rplace-2023/1;user-id=41157245;mod=1;room-id=62300805;returning-chatter=0;first-msg=0;tmi-sent-ts=1704558065152;emotes=;turbo=0;user-type=mod;display-name=Mr0lle;color=#9146FF;rm-received-ts=1704558065328 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@historical=1;user-id=103665668;client-nonce=7bd6756ef7e7252c576e53b2a6398980;rm-received-ts=1704558065562;mod=0;user-type=;subscriber=0;room-id=62300805;badges=bits-charity/1;flags=;id=44bc83ce-473a-4c5e-a420-f092d865082e;returning-chatter=0;badge-info=;turbo=0;display-name=Intel_power;emotes=;tmi-sent-ts=1704558065383;first-msg=0;color=#0000FF :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn forseninsane","@rm-received-ts=1704558065835;display-name=ME_ME;first-msg=0;turbo=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;client-nonce=a1f8c2b2ee9e81ef36a1a69654e011f7;id=1de83ebb-f26a-4090-850b-0db29b62c7c1;color=#FF2424;room-id=62300805;badge-info=subscriber/49;user-id=159210800;returning-chatter=0;mod=0;badges=subscriber/48,bits/25000;user-type=;tmi-sent-ts=1704558065671;historical=1;flags=;subscriber=1 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@user-type=;mod=0;historical=1;returning-chatter=0;badges=subscriber/9,turbo/1;color=#FFFF00;tmi-sent-ts=1704558066054;turbo=1;rm-received-ts=1704558066246;first-msg=0;subscriber=1;display-name=Kotzblitz20;room-id=62300805;client-nonce=9bdd2f161f8ee8e0adf191964e9198de;flags=;id=cfb64e34-10f0-43d0-b563-69eb8382f018;user-id=40037186;badge-info=subscriber/9;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;turbo=0;subscriber=0;mod=0;room-id=62300805;emotes=;user-id=51967700;id=26da472f-bd3a-4d6a-abab-da56d49ef425;returning-chatter=0;display-name=Patixxl;client-nonce=8f992d6c1a4e04348ede81c015ac5cc1;rm-received-ts=1704558066348;user-type=;badges=;first-msg=0;badge-info=;color=#FF0000;tmi-sent-ts=1704558066143;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@tmi-sent-ts=1704558066393;subscriber=0;display-name=sehtt_;badge-info=;historical=1;turbo=0;returning-chatter=0;room-id=62300805;user-id=133344079;flags=;color=#5F9EA0;user-type=;rm-received-ts=1704558066584;first-msg=0;mod=0;emotes=;badges=no_audio/1;id=aca54e25-e5b8-4e08-b547-eff4bbfb4b5c;client-nonce=4fc5e82de2d25405ddd6374a19a33ded :sehtt_!sehtt_@sehtt_.tmi.twitch.tv PRIVMSG #nymn forsenPossessed","@returning-chatter=0;badges=no_audio/1;user-id=579006454;room-id=62300805;mod=0;flags=;rm-received-ts=1704558067173;client-nonce=264f46e99efbf58e7626e929446e8d6a;display-name=jezeroc;subscriber=0;historical=1;first-msg=0;color=#FF0000;tmi-sent-ts=1704558066974;turbo=0;user-type=;badge-info=;id=349ad7cb-19d4-4d67-94fd-8c157ce4b47d;emotes= :jezeroc!jezeroc@jezeroc.tmi.twitch.tv PRIVMSG #nymn :haHAA NOT RATATOUILLE","@tmi-sent-ts=1704558067336;emotes=;id=2ec9d9c9-9c2b-48dc-b9b8-03431591e726;display-name=Obiwun;mod=0;rm-received-ts=1704558067533;client-nonce=2d49a931dced3f90e3945dd161638453;first-msg=0;badges=no_audio/1;room-id=62300805;color=#8A2BE2;returning-chatter=0;badge-info=;user-id=46199261;turbo=0;flags=;historical=1;user-type=;subscriber=0 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn :doctorWTF what is this game about???","@subscriber=0;tmi-sent-ts=1704558067604;rm-received-ts=1704558067777;id=7c20130e-af2d-444a-95f0-dc29d29472b2;returning-chatter=0;room-id=62300805;emotes=;flags=;client-nonce=aa1162a8b82e3a207c60b30f4d6fd802;color=#FF0000;user-type=;first-msg=0;badge-info=;user-id=810718356;historical=1;display-name=holy4uck;badges=;turbo=0;mod=0 :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn forsenParty","@display-name=DontCagePlebs;tmi-sent-ts=1704558068291;user-type=;rm-received-ts=1704558068460;mod=0;badges=subscriber/36,no_audio/1;flags=;emotes=;user-id=85837900;turbo=0;first-msg=0;id=a7ef22af-b446-4c59-9d6c-809264a91c7f;badge-info=subscriber/37;client-nonce=98aca8b387661f0cd1d3df60df7e1d38;historical=1;subscriber=1;room-id=62300805;color=#DAA520;returning-chatter=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn AlienPls","@tmi-sent-ts=1704558068636;user-id=51967700;room-id=62300805;subscriber=0;display-name=Patixxl;color=#FF0000;mod=0;rm-received-ts=1704558068812;id=91ffdfca-d356-413a-bbce-614e4a90379f;emotes=;user-type=;flags=;turbo=0;badge-info=;client-nonce=432693930b82d241e331bbfaecb23495;first-msg=0;badges=;returning-chatter=0;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@id=356436dc-07f8-4bf9-8e78-723cf3beed96;flags=;room-id=62300805;rm-received-ts=1704558069692;display-name=Phant0mBlades;returning-chatter=0;badge-info=subscriber/9;tmi-sent-ts=1704558069520;subscriber=1;turbo=0;user-type=;user-id=278896263;first-msg=0;badges=subscriber/9,chatter-cs-go-2022/1;mod=0;color=#008000;historical=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@tmi-sent-ts=1704558070675;client-nonce=e5ac6342f739600daed3a41552a85b31;id=5ea8f615-0025-4ef0-bce6-a835ac40cd42;returning-chatter=0;display-name=ME_ME;mod=0;user-type=;turbo=0;subscriber=1;user-id=159210800;color=#FF2424;historical=1;rm-received-ts=1704558070852;badges=subscriber/48,bits/25000;emotes=;room-id=62300805;badge-info=subscriber/49;first-msg=0;flags= :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn WAYTOODANK","@client-nonce=d50ff0ab6747ea7b53ad1435feb253f3;flags=;user-type=;tmi-sent-ts=1704558070946;emotes=;display-name=Patixxl;mod=0;id=8e29dbc3-a6b7-40d7-b7e6-d0302c78662c;rm-received-ts=1704558071131;turbo=0;historical=1;returning-chatter=0;badge-info=;first-msg=0;badges=;user-id=51967700;room-id=62300805;color=#FF0000;subscriber=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;flags=;id=5a9fb2c0-4980-4fd5-8725-4dab7b2ef20c;color=#9146FF;badges=moderator/1,subscriber/60,rplace-2023/1;first-msg=0;user-type=mod;subscriber=1;emotes=;historical=1;rm-received-ts=1704558071809;tmi-sent-ts=1704558071581;display-name=Mr0lle;user-id=41157245;room-id=62300805;badge-info=subscriber/67;returning-chatter=0;mod=1 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;badge-info=;color=#8A2BE2;tmi-sent-ts=1704558072023;turbo=0;subscriber=0;display-name=Purple_Geco;first-msg=0;id=e5eb4695-7136-4e14-b49e-7e292f9456af;mod=0;historical=1;badges=no_audio/1;user-id=253596827;rm-received-ts=1704558072207;client-nonce=931ecc7bd7b6e3e67e650415ebddb8c4;flags=;returning-chatter=0;room-id=62300805;user-type= :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emotes=;subscriber=0;turbo=0;flags=;tmi-sent-ts=1704558074219;display-name=Patixxl;first-msg=0;badges=;id=29adcd97-2588-4924-b04e-32e9f0590821;room-id=62300805;mod=0;user-id=51967700;color=#FF0000;client-nonce=4a0c6f2d448f35e008475441ed1c10a9;user-type=;returning-chatter=0;rm-received-ts=1704558074385;badge-info=;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@turbo=0;room-id=62300805;flags=;emotes=;returning-chatter=0;first-msg=0;badge-info=subscriber/5;rm-received-ts=1704558075785;subscriber=1;badges=subscriber/3,no_video/1;color=#FF0000;id=717e2ccd-bf01-4198-a0a4-846463b3d673;display-name=buong1;client-nonce=6ec2660e6b572b546e89431f6db633c0;user-type=;user-id=75144877;historical=1;tmi-sent-ts=1704558075597;mod=0 :buong1!buong1@buong1.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOODONKMAN","@tmi-sent-ts=1704558078901;returning-chatter=0;room-id=62300805;user-id=433352132;display-name=jontEmillian;badge-info=subscriber/38;emotes=;id=f41fe58c-c230-4fad-956e-7e7a83470e66;badges=subscriber/36,twitch-recap-2023/1;rm-received-ts=1704558079077;historical=1;mod=0;flags=;user-type=;first-msg=0;subscriber=1;turbo=0;color=#63BD68 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@user-type=;display-name=Patixxl;color=#FF0000;user-id=51967700;tmi-sent-ts=1704558079301;id=3408f243-0d2c-47d4-8c0f-cc9137a57ae0;mod=0;historical=1;subscriber=0;badges=;client-nonce=77d1e635e796091ea351671916dad357;room-id=62300805;emotes=;turbo=0;rm-received-ts=1704558079500;first-msg=0;badge-info=;flags=;returning-chatter=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;id=a34acc3a-5915-4fe4-a560-501a1c0cce3f;badges=vip/1,subscriber/72,rplace-2023/1;returning-chatter=0;color=#D52AFF;subscriber=1;rm-received-ts=1704558080880;mod=0;user-id=87120320;room-id=62300805;user-type=;display-name=Joshlad;tmi-sent-ts=1704558080665;vip=1;turbo=0;badge-info=subscriber/77;emotes=;historical=1;first-msg=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=;user-id=216144449;room-id=62300805;historical=1;subscriber=0;flags=;rm-received-ts=1704558084195;color=#00FF7F;client-nonce=ce7fde98cf9cdfa0abe9c2099c503d23;display-name=FollowProtoBuddy;mod=0;returning-chatter=0;first-msg=0;badges=turbo/1;emotes=;id=dfc2b883-94dc-475b-a811-7016eb0e3b0a;user-type=;tmi-sent-ts=1704558083997;turbo=1 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn :nimeDance forsen can i dance with you?","@mod=0;color=#DAA520;room-id=62300805;first-msg=0;badges=subscriber/36,no_audio/1;emotes=;returning-chatter=0;flags=;historical=1;id=76a4b633-d826-421b-8116-aa18aa7562e1;user-type=;user-id=85837900;subscriber=1;badge-info=subscriber/37;tmi-sent-ts=1704558089747;rm-received-ts=1704558089924;display-name=DontCagePlebs;client-nonce=5c2bbbed12d726e8b87c09e040711bf4;turbo=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :Alien360 AlienPls","@first-msg=0;emotes=;badges=moderator/1,subscriber/60,rplace-2023/1;returning-chatter=0;user-id=41157245;badge-info=subscriber/67;color=#9146FF;id=b1bfc39e-e2d8-4110-b508-e3613892d091;room-id=62300805;display-name=Mr0lle;mod=1;tmi-sent-ts=1704558089791;user-type=mod;turbo=0;historical=1;subscriber=1;rm-received-ts=1704558089960;flags= :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badge-info=;display-name=Yrmyli;room-id=62300805;user-id=51809775;color=#FF1493;turbo=0;badges=;flags=;id=c6239761-2d38-4e08-93f7-c9ed66199a9a;emotes=;user-type=;returning-chatter=0;historical=1;client-nonce=e9e19adfe26b6058df829d30846b3eb9;rm-received-ts=1704558092866;first-msg=0;subscriber=0;tmi-sent-ts=1704558092695;mod=0 :yrmyli!yrmyli@yrmyli.tmi.twitch.tv PRIVMSG #nymn :NymN what is this game about DOCING","@room-id=62300805;badge-info=subscriber/38;mod=0;historical=1;subscriber=1;returning-chatter=0;tmi-sent-ts=1704558092963;color=#63BD68;user-type=;flags=;first-msg=0;user-id=433352132;id=fe0d3af0-2f2f-46dd-9191-f2a79c1acdaf;badges=subscriber/36,twitch-recap-2023/1;rm-received-ts=1704558093158;display-name=jontEmillian;emotes=;turbo=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@turbo=0;id=bbea25c8-0c21-46ff-ac08-c727ee27ba94;display-name=Purple_Geco;subscriber=0;returning-chatter=0;client-nonce=1bd1af296edd046efba9d68c2c963fd0;user-type=;first-msg=0;flags=;rm-received-ts=1704558093209;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;color=#8A2BE2;badges=no_audio/1;room-id=62300805;tmi-sent-ts=1704558093022;user-id=253596827;historical=1;badge-info= :purple_geco!purple_geco@purple_geco.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty","@tmi-sent-ts=1704558095417;subscriber=1;color=#FF2424;turbo=0;id=71d335f9-06d8-4a1f-a686-26954be8c282;flags=;rm-received-ts=1704558095595;badges=subscriber/48,bits/25000;first-msg=0;emotes=;user-type=;historical=1;mod=0;user-id=159210800;room-id=62300805;returning-chatter=0;client-nonce=41b2ecb48d1d6f49dac62e83e77c31b2;badge-info=subscriber/49;display-name=ME_ME :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :WAYTOODANK 󠀀","@tmi-sent-ts=1704558096173;returning-chatter=0;turbo=0;flags=;emotes=;badges=subscriber/54,chatter-cs-go-2022/1;display-name=h_h410;room-id=62300805;historical=1;user-type=;badge-info=subscriber/54;color=#00FF7F;mod=0;id=b82e6297-8e23-4624-bc41-d147ecdfa11a;subscriber=1;user-id=117088592;rm-received-ts=1704558096362;first-msg=0 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn forseninsane","@historical=1;rm-received-ts=1704558097913;emotes=;room-id=62300805;first-msg=0;user-type=;subscriber=1;id=10d6d300-5d94-4023-aa96-63e4d979afe7;color=#008000;badge-info=subscriber/9;tmi-sent-ts=1704558097719;display-name=Phant0mBlades;mod=0;flags=;returning-chatter=0;turbo=0;badges=subscriber/9,chatter-cs-go-2022/1;user-id=278896263 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :EASIEST DIFFICULTY LULE","@historical=1;subscriber=1;turbo=1;client-nonce=aa81bb0d65cccf15ab8b6d21b88334ac;badges=subscriber/9,turbo/1;user-type=;display-name=Kotzblitz20;mod=0;flags=;rm-received-ts=1704558099116;color=#FFFF00;user-id=40037186;tmi-sent-ts=1704558098935;badge-info=subscriber/9;emotes=emotesv2_b7482780923442c499ae7b4706040695:0-11;emote-only=1;room-id=62300805;id=478cdd66-8ae3-4d18-98c3-cbf74f88b324;first-msg=0;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn forsenInsane","@badge-info=;badges=bits-charity/1;mod=0;id=e7f57f9c-95d3-4989-b67e-33166b520b6a;user-type=;tmi-sent-ts=1704558100913;returning-chatter=0;client-nonce=933ec0b4089df2788a52f6f4592fd705;first-msg=0;emotes=;historical=1;color=#0000FF;display-name=Intel_power;subscriber=0;room-id=62300805;rm-received-ts=1704558101080;flags=;turbo=0;user-id=103665668 :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn forseninsane","@user-type=;badge-info=subscriber/49;historical=1;user-id=159210800;flags=;tmi-sent-ts=1704558101636;mod=0;rm-received-ts=1704558101845;client-nonce=530927fea61f7acb071b230d7ceec398;room-id=62300805;emotes=;turbo=0;color=#FF2424;badges=subscriber/48,bits/25000;id=02df18ce-fe5f-427c-af2b-06c7b8bc0716;returning-chatter=0;first-msg=0;subscriber=1;display-name=ME_ME :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :@Phant0mBlades OkeyL","@room-id=62300805;subscriber=1;first-msg=0;rm-received-ts=1704558102758;display-name=orange_bean;id=fafc3d5b-edb3-402d-809d-146a6110198f;emotes=;returning-chatter=0;mod=0;user-type=;badges=subscriber/48;user-id=29649547;turbo=0;color=#FF7F50;tmi-sent-ts=1704558102580;flags=;historical=1;badge-info=subscriber/53 :orange_bean!orange_bean@orange_bean.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime","@first-msg=0;returning-chatter=0;rm-received-ts=1704558103260;user-type=;subscriber=1;emotes=;badge-info=subscriber/9;room-id=62300805;flags=;mod=0;badges=subscriber/9,turbo/1;color=#FFFF00;turbo=1;id=a3cbf83a-e2af-4daa-8b8b-63feb024c3fa;client-nonce=76d8b591141374cfd23a66eedc6af5b8;historical=1;tmi-sent-ts=1704558103084;display-name=Kotzblitz20;user-id=40037186 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn AlienTechno","@user-id=51967700;mod=0;color=#FF0000;id=df8888b0-05b0-49e4-8a0f-2d2080bbdfcc;subscriber=0;emotes=;first-msg=0;historical=1;returning-chatter=0;rm-received-ts=1704558104943;user-type=;room-id=62300805;badges=;badge-info=;client-nonce=4b8d9fa638d86eb58e33bf553008d257;flags=;turbo=0;display-name=Patixxl;tmi-sent-ts=1704558104749 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-type=;room-id=62300805;turbo=0;first-msg=0;rm-received-ts=1704558105052;subscriber=1;badge-info=subscriber/38;returning-chatter=0;id=9f7462d5-3a41-470c-92d9-da8a68e230e7;display-name=jontEmillian;emotes=;badges=subscriber/36,twitch-recap-2023/1;flags=;user-id=433352132;color=#63BD68;mod=0;tmi-sent-ts=1704558104870;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@first-msg=0;subscriber=1;user-type=;tmi-sent-ts=1704558106145;badges=subscriber/42,bits/100;badge-info=subscriber/45;turbo=0;color=#FFFFFF;room-id=62300805;mod=0;emotes=;historical=1;user-id=38936358;id=3e2e6027-28dd-4d66-b233-06427ee9bbdf;returning-chatter=0;client-nonce=d9f7659de8c660422d19370372c8dcf0;rm-received-ts=1704558106335;display-name=havelsring;flags= :havelsring!havelsring@havelsring.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@display-name=Mr0lle;user-type=mod;subscriber=1;rm-received-ts=1704558106631;room-id=62300805;returning-chatter=0;badge-info=subscriber/67;first-msg=0;user-id=41157245;tmi-sent-ts=1704558106459;historical=1;mod=1;badges=moderator/1,subscriber/60,rplace-2023/1;turbo=0;color=#9146FF;flags=;id=62162402-8ca9-47a8-bbc6-8bd2518ce4ea;emotes= :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@rm-received-ts=1704558106934;badge-info=subscriber/9;subscriber=1;returning-chatter=0;display-name=Phant0mBlades;flags=;historical=1;first-msg=0;user-type=;user-id=278896263;tmi-sent-ts=1704558106755;room-id=62300805;turbo=0;emotes=;id=07c7bc32-48ed-49c9-883e-929f344ff3cb;mod=0;badges=subscriber/9,chatter-cs-go-2022/1;color=#008000 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :@ME_ME dankHug","@subscriber=1;first-msg=0;badge-info=subscriber/37;returning-chatter=0;color=#DAA520;rm-received-ts=1704558108060;user-id=85837900;room-id=62300805;display-name=DontCagePlebs;turbo=0;mod=0;emotes=;id=fa6bcb9b-80c2-4ea3-be8b-212561e1961c;client-nonce=2040f9e5f6aa7f3e50f628eff46089b8;historical=1;user-type=;tmi-sent-ts=1704558107879;badges=subscriber/36,no_audio/1;flags= :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@rm-received-ts=1704558108101;tmi-sent-ts=1704558107914;subscriber=0;user-type=;first-msg=0;emotes=;room-id=62300805;user-id=51967700;badges=;client-nonce=f388628b820df921fdff23d3932c5b3f;mod=0;badge-info=;turbo=0;display-name=Patixxl;id=69b8945f-fee9-45d3-8a83-d2bdede8dbd7;flags=;color=#FF0000;returning-chatter=0;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@display-name=Joshlad;flags=;user-id=87120320;color=#D52AFF;first-msg=0;emotes=;tmi-sent-ts=1704558108067;turbo=0;mod=0;historical=1;rm-received-ts=1704558108240;badge-info=subscriber/77;room-id=62300805;returning-chatter=0;user-type=;id=9fcc43d8-19e9-4ba4-9131-0c447ddc5121;badges=vip/1,subscriber/72,rplace-2023/1;subscriber=1;vip=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@client-nonce=1dd74c3a13c350b2fc76d1588ade7cac;display-name=crazyjuni0r_;historical=1;user-id=222340799;user-type=;id=20ec0428-8f47-4792-ac3d-6d8b99af1b2e;badges=subscriber/6,chatter-cs-go-2022/1;turbo=0;color=#B22222;emote-only=1;first-msg=0;subscriber=1;flags=;rm-received-ts=1704558109005;returning-chatter=0;room-id=62300805;tmi-sent-ts=1704558108791;emotes=emotesv2_d6be8456d996420c81d1fb0791cf9d20:0-8;mod=0;badge-info=subscriber/7 :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn pspGAGAGA","@room-id=62300805;id=6c9d74be-278e-45d1-a02f-f4e0198a0cf7;user-type=;returning-chatter=0;mod=0;flags=;display-name=holy4uck;historical=1;rm-received-ts=1704558109145;emotes=;client-nonce=f6ef7e9c4c0d08e5b5a9e307a6a9dc90;subscriber=0;turbo=0;color=#FF0000;tmi-sent-ts=1704558108993;badges=;user-id=810718356;first-msg=0;badge-info= :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@id=1f8ac249-12f0-4830-8674-83c285f6d691;room-id=62300805;turbo=0;color=#23CE3F;user-id=190828982;badge-info=subscriber/20;mod=0;historical=1;first-msg=0;client-nonce=d58ae9a7bf695bc6cb2410a39473a205;returning-chatter=0;display-name=Near____________;flags=;badges=subscriber/18,twitch-recap-2023/1;tmi-sent-ts=1704558109027;rm-received-ts=1704558109208;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10/emotesv2_2ce848d8d4cb42cbb94ba47b9dd8183e:12-18;user-type=;subscriber=1 :near____________!near____________@near____________.tmi.twitch.tv PRIVMSG #nymn :forsenParty nymnEDM 󠀀","@first-msg=0;badges=subscriber/9,turbo/1;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;returning-chatter=0;id=f2884bb9-aede-420d-8014-6e5b9e8a882f;user-id=40037186;historical=1;turbo=1;badge-info=subscriber/9;subscriber=1;user-type=;display-name=Kotzblitz20;rm-received-ts=1704558110532;room-id=62300805;color=#FFFF00;tmi-sent-ts=1704558110342;flags=;client-nonce=dcb8db1dc43eb2c1ed7eda2eccfbd668 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;id=b672890e-ecce-4bbc-ba7d-106b2e653337;client-nonce=be33080321bb65eb3d501c4257e831bc;historical=1;user-type=;mod=0;rm-received-ts=1704558112028;color=#FF2424;turbo=0;flags=;tmi-sent-ts=1704558111835;subscriber=1;emotes=;badges=subscriber/48,bits/25000;display-name=ME_ME;badge-info=subscriber/49;room-id=62300805;user-id=159210800;returning-chatter=0 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn DOCING","@id=1cd4847c-c6da-4481-8067-d9fbf886b00e;subscriber=0;display-name=KelemvorUber;mod=0;client-nonce=1ff64bcdbc10f1e42446b57629301a59;returning-chatter=0;user-type=;historical=1;turbo=0;tmi-sent-ts=1704558112134;room-id=62300805;user-id=38635616;first-msg=0;badges=twitch-recap-2023/1;flags=;badge-info=;emotes=;rm-received-ts=1704558112311;color=#F7FF00 :kelemvoruber!kelemvoruber@kelemvoruber.tmi.twitch.tv PRIVMSG #nymn OMEGALUOL","@subscriber=1;color=#FFFF00;room-id=62300805;display-name=Kotzblitz20;historical=1;first-msg=0;tmi-sent-ts=1704558112919;flags=;rm-received-ts=1704558113117;id=869622c8-24d3-4045-803f-bab8ec374ac9;badge-info=subscriber/9;turbo=1;user-id=40037186;returning-chatter=0;client-nonce=5d8d0e1100a601806753709196af91ca;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122;user-type=;badges=subscriber/9,turbo/1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@client-nonce=8f154789fffb86cef0845261984a02d3;color=#0000FF;id=4e00f786-c01f-4f84-a9d6-79bb688b9a70;subscriber=1;display-name=SnuggleUncle;mod=0;first-msg=0;turbo=0;room-id=62300805;badges=subscriber/12,twitch-recap-2023/1;user-id=69072013;rm-received-ts=1704558113467;emotes=;tmi-sent-ts=1704558113280;badge-info=subscriber/17;flags=;historical=1;returning-chatter=0;user-type= :snuggleuncle!snuggleuncle@snuggleuncle.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-type=;rm-received-ts=1704558113972;id=fffbdd14-0932-4b3e-b5a8-9feecfca5b00;badge-info=;user-id=92830211;room-id=62300805;client-nonce=4ff9af2ca6ca3ace7916c46b5d76aa0a;mod=0;tmi-sent-ts=1704558113791;returning-chatter=0;emotes=;color=#71A1E6;subscriber=0;turbo=0;historical=1;first-msg=0;badges=glhf-pledge/1;flags=;display-name=soran2202 :soran2202!soran2202@soran2202.tmi.twitch.tv PRIVMSG #nymn :FeelsDankMan 👋 helo","@rm-received-ts=1704558114227;mod=0;id=3a646610-8601-4347-aad0-2e52075a5c72;tmi-sent-ts=1704558114068;emotes=;user-id=51967700;turbo=0;color=#FF0000;display-name=Patixxl;client-nonce=942344bcb85638fd942b3d34d69481f9;flags=;historical=1;returning-chatter=0;first-msg=0;room-id=62300805;user-type=;subscriber=0;badges=;badge-info= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@flags=;subscriber=1;emotes=;turbo=0;color=#63BD68;returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;badge-info=subscriber/38;first-msg=0;mod=0;room-id=62300805;user-id=433352132;user-type=;tmi-sent-ts=1704558114040;id=d8ada0fb-57b5-4f1e-b0cf-c4ba723444ba;historical=1;display-name=jontEmillian;rm-received-ts=1704558114231 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@rm-received-ts=1704558115440;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;subscriber=0;tmi-sent-ts=1704558115231;badges=;room-id=62300805;user-id=151423066;mod=0;user-type=;first-msg=0;id=4042d549-74dd-478f-9e7e-50348ff731f1;badge-info=;returning-chatter=0;turbo=0;flags=;emote-only=1;color=#FF69B4;historical=1;display-name=forsenkkona_ :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badge-info=subscriber/9;mod=0;user-type=;turbo=1;display-name=Kotzblitz20;client-nonce=0612841dc2ae04f4ef6c314874ae4f5c;historical=1;flags=;tmi-sent-ts=1704558115773;rm-received-ts=1704558115968;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;subscriber=1;room-id=62300805;id=beec82dd-3a40-4ddb-a7ea-5e41dfe21a4b;user-id=40037186;first-msg=0;returning-chatter=0;badges=subscriber/9,turbo/1;color=#FFFF00 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;tmi-sent-ts=1704558116560;turbo=0;color=#1E90FF;flags=;returning-chatter=0;badges=;historical=1;id=921877aa-e334-4ac2-afcb-d740bcbc9cb8;subscriber=0;client-nonce=7009d4d14b1369080a2920763da2f62a;display-name=zzlint;badge-info=;room-id=62300805;user-id=29764188;rm-received-ts=1704558116727;emotes=;first-msg=0;mod=0 :zzlint!zzlint@zzlint.tmi.twitch.tv PRIVMSG #nymn :a rave in a sewer","@color=#9146FF;returning-chatter=0;emotes=;mod=1;display-name=Mr0lle;historical=1;flags=;room-id=62300805;user-id=41157245;turbo=0;subscriber=1;first-msg=0;badges=moderator/1,subscriber/60,rplace-2023/1;badge-info=subscriber/67;id=e4c0b050-a28c-40a8-a467-a41a44dc76c6;tmi-sent-ts=1704558116591;user-type=mod;rm-received-ts=1704558116766 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badge-info=;historical=1;tmi-sent-ts=1704558117029;emotes=;first-msg=0;client-nonce=f11d8a2495d2d74f7c7a71653825cb8d;color=#FF0000;room-id=62300805;display-name=Patixxl;badges=;rm-received-ts=1704558117219;turbo=0;user-id=51967700;user-type=;subscriber=0;id=567c72af-0add-4cba-9698-7cedf86bf10f;returning-chatter=0;mod=0;flags= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;client-nonce=4a4f92047f7193581f8a335964b97731;mod=1;first-msg=0;returning-chatter=0;display-name=Storm;user-type=mod;tmi-sent-ts=1704558117901;rm-received-ts=1704558118091;flags=;badge-info=;historical=1;turbo=1;user-id=13107998;badges=moderator/1,turbo/1;id=123d26df-aa30-4e43-b30d-7df96fb54b92;color=#376180;subscriber=0;emotes=90076:6-17 :storm!storm@storm.tmi.twitch.tv PRIVMSG #nymn :Ratge StinkyCheese","@subscriber=1;display-name=Phant0mBlades;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;user-id=278896263;rm-received-ts=1704558118197;turbo=0;user-type=;badges=subscriber/9,chatter-cs-go-2022/1;flags=;badge-info=subscriber/9;first-msg=0;tmi-sent-ts=1704558118021;room-id=62300805;historical=1;mod=0;id=2146a4d9-d9a3-4c11-95ba-198755ec9074;color=#008000;returning-chatter=0 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@user-type=;returning-chatter=0;user-id=216144449;historical=1;display-name=FollowProtoBuddy;color=#00FF7F;id=d14b661e-a9fa-4d23-b911-9b26332c43bc;turbo=1;first-msg=0;client-nonce=6dea2760e96d24918be2fdd4c28f49d0;subscriber=0;badge-info=;room-id=62300805;badges=turbo/1;flags=;emotes=;tmi-sent-ts=1704558118561;rm-received-ts=1704558118750;mod=0 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn nimeDance","@first-msg=0;user-type=;returning-chatter=0;emotes=;room-id=62300805;subscriber=1;display-name=jontEmillian;user-id=433352132;badge-info=subscriber/38;color=#63BD68;id=98d8adef-151e-4ef1-8f40-fa8152f23dac;flags=;mod=0;rm-received-ts=1704558123091;badges=subscriber/36,twitch-recap-2023/1;tmi-sent-ts=1704558122930;turbo=0;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@emotes=;display-name=Kotzblitz20;color=#FFFF00;client-nonce=f873fc68fe513770ce0985f634769824;turbo=1;subscriber=1;first-msg=0;flags=;user-type=;user-id=40037186;returning-chatter=0;tmi-sent-ts=1704558123415;badge-info=subscriber/9;rm-received-ts=1704558123591;id=f8080f7b-972a-4212-87ca-b42c472df804;mod=0;badges=subscriber/9,turbo/1;room-id=62300805;historical=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn ClueLookingAtYou","@first-msg=0;user-id=133862911;reply-parent-msg-id=fffbdd14-0932-4b3e-b5a8-9feecfca5b00;reply-thread-parent-msg-id=fffbdd14-0932-4b3e-b5a8-9feecfca5b00;returning-chatter=0;tmi-sent-ts=1704558128218;user-type=;reply-thread-parent-user-id=92830211;reply-thread-parent-display-name=soran2202;reply-thread-parent-user-login=soran2202;badges=;reply-parent-user-login=soran2202;historical=1;rm-received-ts=1704558128399;id=be3a2cb7-25c0-4ac6-b2b7-475a19ca71ef;color=#FF0000;display-name=123homo;reply-parent-display-name=soran2202;reply-parent-msg-body=FeelsDankMan\\s👋\\shelo;client-nonce=f14fd90ead77c4c5679338e9aaec09cf;emotes=;flags=;turbo=0;subscriber=0;room-id=62300805;mod=0;badge-info=;reply-parent-user-id=92830211 :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn :@soran2202 sup dude how are ya","@user-id=159210800;returning-chatter=0;emotes=;historical=1;first-msg=0;badges=subscriber/48,bits/25000;client-nonce=0717802bff8668a612aa1810edd143ea;color=#FF2424;id=849e739f-ca10-4982-8b70-5bb4c53ff494;badge-info=subscriber/49;subscriber=1;flags=;rm-received-ts=1704558128625;room-id=62300805;user-type=;display-name=ME_ME;mod=0;turbo=0;tmi-sent-ts=1704558128427 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@emotes=;id=6236b23e-cb57-4cb0-adc9-72e737641a88;color=#0000FF;first-msg=0;turbo=0;flags=;client-nonce=d72d284b9e47c73f597cb8dcb527d344;badges=subscriber/12,twitch-recap-2023/1;subscriber=1;badge-info=subscriber/17;historical=1;user-type=;tmi-sent-ts=1704558129219;display-name=SnuggleUncle;returning-chatter=0;rm-received-ts=1704558129405;mod=0;user-id=69072013;room-id=62300805 :snuggleuncle!snuggleuncle@snuggleuncle.tmi.twitch.tv PRIVMSG #nymn ClueLookingAtYou","@subscriber=0;id=456695c7-594f-4a6c-8d8e-c78cb652f5bc;returning-chatter=0;historical=1;tmi-sent-ts=1704558129719;room-id=62300805;flags=;color=#9ACD32;user-id=135853293;badge-info=;emotes=;badges=twitch-recap-2023/1;turbo=0;client-nonce=50c0e3264f5749dfa75ee22bde206fdc;display-name=theKiryu;rm-received-ts=1704558129892;user-type=;first-msg=0;mod=0 :thekiryu!thekiryu@thekiryu.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@returning-chatter=0;turbo=0;flags=;rm-received-ts=1704558129914;user-type=;first-msg=0;historical=1;subscriber=0;emotes=;mod=0;user-id=137332535;tmi-sent-ts=1704558129728;color=#1E90FF;client-nonce=2e7353fc46a525f34fd8fb0cab8e7bac;display-name=BlueAves;badge-info=;badges=;id=f99d8b6a-ae72-46a4-8c65-a92ca670abb0;room-id=62300805 :blueaves!blueaves@blueaves.tmi.twitch.tv PRIVMSG #nymn :nymn im giving you mufflo meat but its not coming","@display-name=DontCagePlebs;first-msg=0;returning-chatter=0;flags=;room-id=62300805;mod=0;historical=1;user-type=;user-id=85837900;badge-info=subscriber/37;subscriber=1;badges=subscriber/36,no_audio/1;turbo=0;emotes=;color=#DAA520;rm-received-ts=1704558131276;id=ac94948c-32e4-4e41-a152-d1afff1e2a62;client-nonce=774787c7e9b0580060eccf9f9fddc647;tmi-sent-ts=1704558131102 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn Listening","@subscriber=1;historical=1;display-name=Kotzblitz20;user-type=;emotes=;mod=0;color=#FFFF00;user-id=40037186;badge-info=subscriber/9;rm-received-ts=1704558131559;tmi-sent-ts=1704558131385;room-id=62300805;flags=;id=3321bb7c-4647-4247-95fd-1de4311400e3;turbo=1;client-nonce=4ce84653605b19585da296e666fc16c1;first-msg=0;badges=subscriber/9,turbo/1;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :ClueLookingAtYou ClueLookingAtYou","@emotes=;user-type=mod;badges=moderator/1,subscriber/60,rplace-2023/1;rm-received-ts=1704558133957;tmi-sent-ts=1704558133729;badge-info=subscriber/67;display-name=Mr0lle;room-id=62300805;id=bf1b6466-f55c-455c-b575-87cb8c5c2c09;first-msg=0;historical=1;turbo=0;user-id=41157245;subscriber=1;mod=1;flags=;color=#9146FF;returning-chatter=0 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn Despair","@emotes=;user-type=;tmi-sent-ts=1704558133959;user-id=46691465;mod=0;rm-received-ts=1704558134180;badges=twitch-recap-2023/1;returning-chatter=0;flags=;subscriber=0;badge-info=;first-msg=0;turbo=0;historical=1;display-name=DeeBeeZenron;id=31ca603d-8630-4208-8231-51a47ec4cf44;color=#9ACD32;room-id=62300805 :deebeezenron!deebeezenron@deebeezenron.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@user-type=;badge-info=subscriber/4;returning-chatter=0;mod=0;emotes=;room-id=62300805;color=#8A2BE2;badges=subscriber/3,no_audio/1;id=dbd0a1b3-b17c-496a-a2c8-d9c2b05b7308;tmi-sent-ts=1704558134055;subscriber=1;turbo=0;historical=1;first-msg=0;display-name=pleasekeepconnor6silly;user-id=137782780;flags=;client-nonce=f0669459051424941fe0945f80d2626b;rm-received-ts=1704558134275 :pleasekeepconnor6silly!pleasekeepconnor6silly@pleasekeepconnor6silly.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@user-id=103592036;subscriber=1;rm-received-ts=1704558135243;first-msg=0;emotes=;tmi-sent-ts=1704558135041;returning-chatter=0;turbo=0;badges=subscriber/54,bits/1000;historical=1;id=d106af6b-bdfe-4050-8a68-4b1529637c64;client-nonce=a786cae81197eb879d978fe02e9c4ddc;mod=0;flags=;badge-info=subscriber/55;display-name=SecretCarrot;color=#00615C;room-id=62300805;user-type= :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn :Okayeg 💢 ❓","@rm-received-ts=1704558136142;id=67a07fca-5f86-4560-96c4-f505812abba9;first-msg=0;subscriber=1;badge-info=subscriber/101;emotes=;mod=1;user-type=mod;color=#FF69B4;flags=;tmi-sent-ts=1704558135943;turbo=0;returning-chatter=0;display-name=botnextdoor;badges=moderator/1,subscriber/72;user-id=97661864;room-id=62300805;historical=1 :botnextdoor!botnextdoor@botnextdoor.tmi.twitch.tv PRIVMSG #nymn :\u0001ACTION Make sure to subscribe to NymN's YouTube channels: Stream channel - youtube.com/nymnion | Music channel - youtube.com/nymnhs | Clips channel - https://www.youtube.com/c/dailydoseofnymnion\u0001","@client-nonce=164bd91e537dc2c2213751e683638c86;mod=0;first-msg=0;room-id=62300805;rm-received-ts=1704558137343;flags=;badge-info=subscriber/9;badges=subscriber/9,turbo/1;user-id=40037186;historical=1;user-type=;color=#FFFF00;emotes=;display-name=Kotzblitz20;subscriber=1;tmi-sent-ts=1704558137149;returning-chatter=0;turbo=1;id=b0972df5-8409-4dff-a353-005426f16a56 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn Listening","@mod=0;badge-info=subscriber/49;turbo=0;tmi-sent-ts=1704558138793;display-name=ME_ME;returning-chatter=0;badges=subscriber/48,bits/25000;first-msg=0;user-type=;subscriber=1;id=78834a53-610c-4e25-b8df-bbd3e872b0b3;historical=1;rm-received-ts=1704558138954;flags=;client-nonce=798e7ce7b1a53595156cd82d71b94492;emotes=;user-id=159210800;room-id=62300805;color=#FF2424 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenUnpleased 󠀀","@turbo=0;user-type=mod;display-name=Mr0lle;badge-info=subscriber/67;rm-received-ts=1704558139453;badges=moderator/1,subscriber/60,rplace-2023/1;returning-chatter=0;emotes=emotesv2_0d9a7f5d38e44c2ca8eb96ab0e3e380a:0-9;first-msg=0;tmi-sent-ts=1704558139264;color=#9146FF;mod=1;historical=1;flags=;id=ebec3d7c-f667-447e-8905-6a16ecb974ca;subscriber=1;room-id=62300805;user-id=41157245;emote-only=1 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn k4yDespair","@mod=0;returning-chatter=0;emotes=;historical=1;user-id=433352132;flags=;id=ae3999cf-116f-4c4a-b925-b3f6865d33cb;turbo=0;color=#63BD68;tmi-sent-ts=1704558143888;rm-received-ts=1704558144062;room-id=62300805;badges=subscriber/36,twitch-recap-2023/1;badge-info=subscriber/38;user-type=;display-name=jontEmillian;subscriber=1;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn Life","@flags=;badge-info=subscriber/9;color=#FFFF00;tmi-sent-ts=1704558146666;user-id=40037186;room-id=62300805;historical=1;first-msg=0;client-nonce=92cd04d209150071f4934ca527be7080;rm-received-ts=1704558146849;badges=subscriber/9,turbo/1;display-name=Kotzblitz20;mod=0;returning-chatter=0;id=674bbe3c-d6b4-4edc-827f-329110fe1f03;subscriber=1;turbo=1;user-type=;emotes= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn OOOO","@badges=subscriber/54,chatter-cs-go-2022/1;user-type=;first-msg=0;display-name=h_h410;returning-chatter=0;badge-info=subscriber/54;emotes=;historical=1;subscriber=1;rm-received-ts=1704558147145;flags=;id=e3d2928d-ef22-4334-9f3a-0232c46c525c;mod=0;turbo=0;user-id=117088592;room-id=62300805;tmi-sent-ts=1704558146971;color=#00FF7F :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn docNOWAY","@tmi-sent-ts=1704558147594;user-type=;client-nonce=d7fb0bba3cd460c0d7f8675e5292e4a5;badge-info=;display-name=ALotOfChickens;historical=1;subscriber=0;id=f28ad525-b97c-45df-8d01-c9be42762cb7;room-id=62300805;rm-received-ts=1704558147812;flags=;color=#10E2E2;first-msg=0;turbo=0;mod=0;badges=twitch-recap-2023/1;emotes=;returning-chatter=0;user-id=167633177 :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn PagMan","@display-name=SadRosh;historical=1;badge-info=;color=#B22222;room-id=62300805;mod=0;tmi-sent-ts=1704558147995;turbo=0;emotes=;user-id=184644555;id=42f7454e-a8f8-4a91-afca-af752bc76328;user-type=;first-msg=0;subscriber=0;rm-received-ts=1704558148178;badges=no_audio/1;returning-chatter=0;flags=;client-nonce=c2e8282f6fcebdddec8042d4dfcca95c :sadrosh!sadrosh@sadrosh.tmi.twitch.tv PRIVMSG #nymn huge","@tmi-sent-ts=1704558148029;room-id=62300805;subscriber=1;flags=;display-name=SecretCarrot;badge-info=subscriber/55;user-id=103592036;returning-chatter=0;client-nonce=af073bd1fd381f926cb180e49bdca822;turbo=0;rm-received-ts=1704558148214;color=#00615C;emotes=;first-msg=0;id=c505db6e-e3b1-45bc-a00f-0aca1c570f1e;mod=0;badges=subscriber/54,bits/1000;historical=1;user-type= :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn docOOOO","@display-name=Phant0mBlades;historical=1;flags=;tmi-sent-ts=1704558148552;returning-chatter=0;user-type=;badge-info=subscriber/9;color=#008000;user-id=278896263;badges=subscriber/9,chatter-cs-go-2022/1;id=a4c82c4d-1871-41b8-8cb3-8c3e1f8b1cf5;turbo=0;emotes=;first-msg=0;rm-received-ts=1704558148748;room-id=62300805;subscriber=1;mod=0 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@emotes=;subscriber=1;historical=1;color=#DAA520;returning-chatter=0;mod=0;user-id=85837900;id=a72c5f44-fbbd-4a6d-9593-2bd8dbe0ccdb;user-type=;client-nonce=b90e7167c7c1bc168ed6673faf28133a;flags=;room-id=62300805;tmi-sent-ts=1704558148576;rm-received-ts=1704558148761;turbo=0;first-msg=0;badge-info=subscriber/37;display-name=DontCagePlebs;badges=subscriber/36,no_audio/1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn OOOO","@flags=;id=d746ad18-260c-4bd1-93d1-2913a9c767ff;user-id=75144877;display-name=buong1;emotes=;client-nonce=bc28711f3e54c6136f069450ec262550;turbo=0;badge-info=subscriber/5;color=#FF0000;mod=0;tmi-sent-ts=1704558149119;rm-received-ts=1704558149297;first-msg=0;historical=1;subscriber=1;badges=subscriber/3,no_video/1;user-type=;room-id=62300805;returning-chatter=0 :buong1!buong1@buong1.tmi.twitch.tv PRIVMSG #nymn OOOO","@turbo=0;subscriber=1;badge-info=subscriber/17;mod=0;user-type=;user-id=69072013;id=b6cdf67a-cd09-45bf-bfe6-ee2fd9fee34d;rm-received-ts=1704558149654;first-msg=0;room-id=62300805;color=#0000FF;display-name=SnuggleUncle;badges=subscriber/12,twitch-recap-2023/1;emotes=;flags=;historical=1;tmi-sent-ts=1704558149411;client-nonce=12dc059fabb70f70176246b246a4e830;returning-chatter=0 :snuggleuncle!snuggleuncle@snuggleuncle.tmi.twitch.tv PRIVMSG #nymn OOOO","@mod=0;subscriber=1;first-msg=0;client-nonce=caeea8326e960d1f46d719c4d883bca9;color=#00FF7F;flags=;id=1c8d6fa8-2873-4a88-b70f-0cbde7f1565c;returning-chatter=0;tmi-sent-ts=1704558150299;user-id=80542722;room-id=62300805;turbo=0;badges=subscriber/0,no_video/1;emotes=;user-type=;rm-received-ts=1704558150499;display-name=jqxlol;historical=1;badge-info=subscriber/2 :jqxlol!jqxlol@jqxlol.tmi.twitch.tv PRIVMSG #nymn :Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ?","@historical=1;subscriber=0;color=#5F9EA0;flags=;user-type=;emotes=;client-nonce=7c4ab09671c546fbbe2da7bdb055cbbd;turbo=0;room-id=62300805;user-id=133344079;badges=no_audio/1;badge-info=;returning-chatter=0;rm-received-ts=1704558150898;display-name=sehtt_;mod=0;tmi-sent-ts=1704558150717;first-msg=0;id=cdc24373-8268-44d6-828c-d530d28bc990 :sehtt_!sehtt_@sehtt_.tmi.twitch.tv PRIVMSG #nymn docNOWAY","@display-name=Duchene;color=#000000;returning-chatter=0;historical=1;id=d33671cc-fd23-4a88-8599-11bb963c92b9;tmi-sent-ts=1704558151096;client-nonce=2745de0d32d3ec4c7475e8a76983c5eb;turbo=0;user-type=;emotes=;badge-info=;badges=;flags=;first-msg=0;subscriber=0;user-id=205837377;mod=0;room-id=62300805;rm-received-ts=1704558151306 :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn OOOO","@emotes=;returning-chatter=0;display-name=mnqn18;historical=1;color=#1E90FF;client-nonce=f6c25ba5948cbdfde77aebb22b3e120b;subscriber=1;badges=subscriber/0,premium/1;tmi-sent-ts=1704558151975;room-id=62300805;badge-info=subscriber/2;user-id=474204887;first-msg=0;id=bbd4d3a6-e5a3-4de5-bc94-5605cab79779;flags=;turbo=0;user-type=;rm-received-ts=1704558152450;mod=0 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn WHAT","@rm-received-ts=1704558152841;tmi-sent-ts=1704558152670;flags=;badge-info=subscriber/49;room-id=62300805;emotes=;color=#FF2424;badges=subscriber/48,bits/25000;client-nonce=2c8d17466ae0eddbaa909c8b5ffa9b65;subscriber=1;turbo=0;returning-chatter=0;display-name=ME_ME;first-msg=0;user-type=;historical=1;user-id=159210800;id=0f1d74ee-9cd6-4351-8617-de6124d8a50d;mod=0 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn Ratge","@display-name=orange_bean;emotes=;tmi-sent-ts=1704558152884;first-msg=0;mod=0;historical=1;badge-info=subscriber/53;rm-received-ts=1704558153074;id=e9be9aee-2002-4b98-8126-ec989b53cfad;user-type=;turbo=0;color=#FF7F50;user-id=29649547;subscriber=1;badges=subscriber/48;flags=;room-id=62300805;returning-chatter=0 :orange_bean!orange_bean@orange_bean.tmi.twitch.tv PRIVMSG #nymn Ratge","@badge-info=subscriber/20;subscriber=1;turbo=0;flags=;color=#1E90FF;first-msg=0;client-nonce=ebf666d318a252d4be0f2d3229c3f4a4;rm-received-ts=1704558153864;mod=0;user-type=;user-id=80236449;display-name=any_plinkers;tmi-sent-ts=1704558153679;emotes=;room-id=62300805;id=c06cb711-fdc6-403d-93bd-ce8d651ebd1b;historical=1;returning-chatter=0;badges=subscriber/18 :any_plinkers!any_plinkers@any_plinkers.tmi.twitch.tv PRIVMSG #nymn Ratge","@flags=;subscriber=0;emotes=;client-nonce=ec0e4680c1d1a19a9250dd1a9195abbf;first-msg=0;color=#25E000;user-id=63372784;badge-info=;historical=1;turbo=0;tmi-sent-ts=1704558153676;badges=bits/100;returning-chatter=0;rm-received-ts=1704558153864;room-id=62300805;user-type=;mod=0;id=9ea5efa5-362c-4807-8489-ed7d921d8d5f;display-name=DM8917 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn Ratge","@user-type=;badge-info=subscriber/9;emotes=;mod=0;historical=1;rm-deleted=1;room-id=62300805;flags=;returning-chatter=0;tmi-sent-ts=1704558153853;turbo=0;user-id=423574282;subscriber=1;first-msg=0;display-name=e7om;rm-received-ts=1704558154050;color=#D2FFD6;badges=subscriber/9,gold-pixel-heart/1;id=7b9dd1f1-4278-4d86-8c60-8cd333e6578f :e7om!e7om@e7om.tmi.twitch.tv PRIVMSG #nymn :⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⡿⠋⠄⠄⠄⠄⠄⠄⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⡿⠋⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣿⣿⡏⢉⡙⢻⡉⢉⡏⢉⡙⢻⣿⣿ ⣿⣿⣿⡿⠁⠄⢀⣰⣶⣾⣿⣿⣿⣿⣦⠄⣿⣿⡇⢠⡀⢿⠇⠸⡇⢨⣥⣾⣿⣿ ⣿⣿⣿⣇⠄⠄⣾⣿⣿⠿⢿⣿⣿⠿⠿⠇⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⡄⠘⣿⣿⣐⣲⣤⣿⣧⣬⣥⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣯⣢⣿⣿⣿⣿⣿⡿⢿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⢹⣿⣿⣿⡿⠊⠤⣈⢻⣿⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⠿⠛⠛⣎⠛⠿⢿⣿⣿⣭⣽⠟⢃⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠟⠉⠄⠄⡀⡀⢻⣧⣀⠄⠄⠉⠉⠉⡀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠂⠸⠫⢀⣄⠄⡻⣿⣷⣄⠄⢀⣼⡇⠄⠄⠈⠻⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠉⢀⣀⢀⢐⣄⠙⠻⠿⠿⠿⠂⠄⠄⠄⠄⠄⠄⠄⠙⢿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠈⠄⠁⠂⠹⣷⡝⢷⣶⣶⣄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠙⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⠄⠜⣿⣦⠻⣿⣿⣷⠄⣴⣠⠄⠄⠄⠄⠄⠄⠈⢿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⢘⣿⣷⡙⣿⣿⡆⣿⣿⡇⠄⠄⡀⠒⠄⠄⠘⢿⣿⣿","@rm-received-ts=1704558154679;tmi-sent-ts=1704558154585;ban-duration=180;room-id=62300805;target-user-id=423574282;historical=1 :tmi.twitch.tv CLEARCHAT #nymn e7om","@rm-received-ts=1704558162700;user-type=;badges=no_audio/1;tmi-sent-ts=1704558156548;room-id=62300805;badge-info=;returning-chatter=0;color=#0000FF;client-nonce=9f4888a1653f847ef531fa2287e42de8;historical=1;subscriber=0;turbo=0;id=9df92182-192c-4839-a3af-b360b9e4a09f;emotes=302972056:0-11;emote-only=1;mod=0;display-name=OfficialScrap;first-msg=0;flags=;user-id=85115603 :officialscrap!officialscrap@officialscrap.tmi.twitch.tv PRIVMSG #nymn ItsHappening","@display-name=Rattge;first-msg=0;user-type=;room-id=62300805;turbo=0;returning-chatter=0;flags=;tmi-sent-ts=1704558156630;historical=1;client-nonce=cf9c9c299e3e86c16b1c803db78a1c6d;emotes=;id=620bb23c-dff0-45b5-9078-5a9cd27845a4;rm-received-ts=1704558162701;color=#FF0000;badge-info=;badges=twitch-recap-2023/1;mod=0;subscriber=0;user-id=91515163 :rattge!rattge@rattge.tmi.twitch.tv PRIVMSG #nymn Ratge","@rm-received-ts=1704558162715;id=ee317454-5c4c-4e88-9096-f9d87e2201b0;badges=subscriber/36;badge-info=subscriber/41;turbo=0;first-msg=0;tmi-sent-ts=1704558157080;historical=1;user-id=154079285;client-nonce=7e2af4294366cda34a0c15394f2749bd;flags=;color=#00FF7F;subscriber=1;user-type=;room-id=62300805;returning-chatter=0;mod=0;display-name=boogkitty;emotes= :boogkitty!boogkitty@boogkitty.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime","@user-id=232078107;emotes=;turbo=0;badges=no_audio/1;client-nonce=11e530e579624107fb09f83af24e080e;room-id=62300805;returning-chatter=0;first-msg=0;mod=0;color=#008000;display-name=BastunGuy1;subscriber=0;historical=1;flags=;user-type=;badge-info=;rm-received-ts=1704558162750;tmi-sent-ts=1704558158049;id=94c1e5e8-0c55-48ff-bfdc-782e8561c923 :bastunguy1!bastunguy1@bastunguy1.tmi.twitch.tv PRIVMSG #nymn forsen","@badges=subscriber/0,no_video/1;badge-info=subscriber/2;user-type=;turbo=0;emotes=;client-nonce=441ce45dc3e104c77442f9a2815ecc9b;room-id=62300805;display-name=jqxlol;id=2152c3b9-0bad-476c-9a15-8b9c3ff1b861;first-msg=0;flags=;color=#00FF7F;user-id=80542722;historical=1;returning-chatter=0;tmi-sent-ts=1704558159488;subscriber=1;mod=0;rm-received-ts=1704558162796 :jqxlol!jqxlol@jqxlol.tmi.twitch.tv PRIVMSG #nymn :Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? 󠀀","@subscriber=1;returning-chatter=0;user-id=32852911;tmi-sent-ts=1704558160717;color=#FAD130;client-nonce=d797aa21c00a05d4d6f58cfd5a32b2af;first-msg=0;id=482c0dcb-3999-4e4d-a548-5dee3f756b85;badges=subscriber/12,sub-gifter/5;emotes=emotesv2_94c411cb0e2b467e8e844a01ab92489b:0-5;rm-received-ts=1704558162829;mod=0;display-name=WhideX;historical=1;turbo=0;emote-only=1;user-type=;badge-info=subscriber/15;flags=;room-id=62300805 :whidex!whidex@whidex.tmi.twitch.tv PRIVMSG #nymn cniSip","@first-msg=0;badge-info=;client-nonce=95be8cd2cd8f7b736f3a35952f562251;tmi-sent-ts=1704558161355;mod=0;id=7cf4ee00-7596-4bc3-923f-617bd9c22418;turbo=0;badges=rplace-2023/1;emotes=;color=#1E90FF;subscriber=0;historical=1;user-id=150759149;rm-received-ts=1704558162848;room-id=62300805;display-name=MxW02;returning-chatter=0;flags=;user-type= :mxw02!mxw02@mxw02.tmi.twitch.tv PRIVMSG #nymn :sounds like hotline miami","@subscriber=0;display-name=Patixxl;flags=;emotes=;first-msg=0;user-id=51967700;color=#FF0000;id=30e1f0d3-9fd8-41aa-9a1a-2499ee2931a9;historical=1;badges=;tmi-sent-ts=1704558162772;user-type=;turbo=0;badge-info=;returning-chatter=0;rm-received-ts=1704558162943;mod=0;client-nonce=69b3529630ea86206338fc0dd227f5c7;room-id=62300805 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@first-msg=0;client-nonce=4bd8911054a768b38ca7186371e31a93;user-id=265935517;emotes=;subscriber=0;id=4fcc0125-b108-4b3f-b2f3-335dced675e9;flags=;badges=;user-type=;tmi-sent-ts=1704558163358;badge-info=;display-name=TankingJo;room-id=62300805;turbo=0;color=#FF4500;historical=1;rm-received-ts=1704558163541;mod=0;returning-chatter=0 :tankingjo!tankingjo@tankingjo.tmi.twitch.tv PRIVMSG #nymn :is this what mitch jones sees every day?","@historical=1;badges=glhf-pledge/1;badge-info=;returning-chatter=0;first-msg=0;turbo=0;rm-deleted=1;id=46d7d7ae-de74-4ea5-84fd-a4f7df3e2aa7;user-type=;rm-received-ts=1704558164389;display-name=Abitbol;user-id=31034458;mod=0;room-id=62300805;emotes=;color=#9ACD32;subscriber=0;tmi-sent-ts=1704558164189;flags= :abitbol!abitbol@abitbol.tmi.twitch.tv PRIVMSG #nymn :I⣿⣿⡇⢀⣤⣤⣤⣤⣤⠹⠟⣩⣭⣤⣬⣍⡻⠁⣤⡄⢤⣤⣬⣉⠻⣿⣿⣿I I⣿⣿⡇⣈⢿⠄⠄⠤⠤⢠⣿⡿⣩⣤⣍⢻⡷⡀⣿⡇⠠⠬⢹⣿⠄⣿⣿⣿I I⣿⣿⡇⣷⣯⠺⠿⠿⠿⢸⣿⡇⣿⣿⣿⢈⣬⡃⣶⡇⣶⣶⣾⡟⢰⣿⣿⣿I I⣿⣿⡇⣿⣿⢸⣿⣿⣿⡌⢷⣶⣌⠛⢩⣼⡿⠁⠽⡇⢰⣦⠐⣉⠈⣿⣿⣿I I⣿⣿⣇⣃⣘⣸⣿⣿⣿⣿⣦⣍⣋⣐⣛⣋⣴⣀⣀⣀⣻⣿⣄⣋⣁⣿⣿⣿I I⣿⣿⡟⢩⣴⣶⢰⣶⣬⠁⣤⣦⣶⣶⣶⣶⢀⣶⣦⠹⣿⢰⣶⡆⣿⣿⣿⣿I I⣿⣿⡇⢺⣿⣐⣒⠘⠛⠂⠈⢻⢐⣒⡒⡒⢘⠊⠻⢇⠹⢨⣿⡇⣿⣿⣿⣿I I⣿⣿⡷⠆⠙⠫⠻⠿⡑⠄⣉⡣⠙⠛⠋⠂⢸⣽⡇⠹⣷⠘⣿⡇⣿⣿⣿⣿I I⣿⣿⡄⢻⡧⢌⠉⣀⠃⠂⠽⠇⠈⠉⡉⣉⢸⣿⡇⣦⠹⣷⡜⠂⣿⣿⣿⣿I I⣿⣿⣿⣦⣈⣀⣀⣀⣠⣆⣀⣀⣀⣈⣀⣀⣀⣀⣀⣿⣧⣀⣈⣀⣿⣿⣿⣿I I⣶⡆⣶⣶⡶⣦⠙⠟⣩⣴⣶⢰⣦⣍⡙⡐⣶⣦⢀⣶⡶⠄⣡⣶⠶⣶⣦⣍I I⣿⡇⣐⡒⣨⡾⠃⢰⣿⢋⣤⣦⡘⡿⠷⢰⠸⢇⡼⣿⢡⠄⣿⣇⣐⠂⠘⢛I I⡿⠁⢛⣛⠻⠶⠄⠸⠍⠸⣿⣿⡇⣰⣻⢰⠇⣾⡌⢀⣾⠷⠌⠙⡓⠛⠿⠤I I⢤⡅⡈⣉⣀⡈⠁⡀⢻⢧⢈⢋⣴⡴⠏⡌⣸⣾⠇⣼⣿⠄⢶⣦⠉⢉⡈⠄I I⣈⣁⣈⣀⣀⣤⣶⣿⣦⣤⣀⣈⣤⣴⣾⣄⣀⣠⣼⣿⣿⣿⣦⣭⣄⣈⣠⣴I","@historical=1;room-id=62300805;tmi-sent-ts=1704558164931;rm-received-ts=1704558165026;target-user-id=31034458;ban-duration=180 :tmi.twitch.tv CLEARCHAT #nymn abitbol","@historical=1;turbo=0;id=dd19eacb-9ede-4b28-840c-74a217845de9;user-id=85837900;user-type=;emotes=;rm-received-ts=1704558165579;first-msg=0;room-id=62300805;badge-info=subscriber/37;tmi-sent-ts=1704558165406;returning-chatter=0;client-nonce=1ca4609ecb55d4fc3e65da684e786d81;flags=;display-name=DontCagePlebs;subscriber=1;color=#DAA520;badges=subscriber/36,no_audio/1;mod=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn kek","@first-msg=0;room-id=62300805;rm-received-ts=1704558166123;turbo=0;flags=0-3:P.3;client-nonce=b2489fba5f0c52e14dcee40213e719ed;returning-chatter=0;color=#DAA520;badge-info=;badges=rplace-2023/1;tmi-sent-ts=1704558165954;user-type=;subscriber=0;id=4988da4c-9718-4633-97db-8dec40ca59c2;emotes=;user-id=501354315;mod=0;historical=1;display-name=ricardo_with_clothes :ricardo_with_clothes!ricardo_with_clothes@ricardo_with_clothes.tmi.twitch.tv PRIVMSG #nymn :lmao hollow knight reference","@id=096fcede-0b5b-404d-b2c1-573b603198c0;room-id=62300805;badge-info=subscriber/9;returning-chatter=0;user-type=;turbo=0;historical=1;mod=0;rm-received-ts=1704558166736;color=#008000;flags=;tmi-sent-ts=1704558166564;emotes=emotesv2_4c39207000564711868f3196cc0a8748:13-19;first-msg=0;subscriber=1;badges=subscriber/9,chatter-cs-go-2022/1;display-name=Phant0mBlades;user-id=278896263 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsen died? PoroSad","@flags=;returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;badge-info=subscriber/38;emotes=;rm-received-ts=1704558166904;subscriber=1;mod=0;user-type=;room-id=62300805;display-name=jontEmillian;first-msg=0;tmi-sent-ts=1704558166726;user-id=433352132;id=feeefdab-e645-4bef-b654-0965836cd0a7;turbo=0;color=#63BD68;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@client-nonce=e45b13d87ed27bc4c65462baa7c5ca7f;tmi-sent-ts=1704558166763;rm-received-ts=1704558166966;color=#008000;flags=;mod=0;subscriber=0;returning-chatter=0;display-name=AikawaCaiman;room-id=62300805;historical=1;id=b66a0f31-c3c1-4a18-b708-3c42b8c4a7bc;user-type=;emotes=;turbo=0;badges=no_audio/1;user-id=157747813;badge-info=;first-msg=0 :aikawacaiman!aikawacaiman@aikawacaiman.tmi.twitch.tv PRIVMSG #nymn :o7 FLY HIGH FORSEN 🕊️","@emotes=;historical=1;client-nonce=cbfe2d9b538627c6ef229b346717e737;display-name=Kotzblitz20;id=a8e59836-a137-4e5d-9a06-36a087cfc339;user-type=;room-id=62300805;badges=subscriber/9,turbo/1;user-id=40037186;color=#FFFF00;first-msg=0;turbo=1;mod=0;tmi-sent-ts=1704558167209;badge-info=subscriber/9;flags=;rm-received-ts=1704558167388;subscriber=1;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@display-name=ME_ME;room-id=62300805;emote-only=1;historical=1;emotes=31097:0-9;rm-received-ts=1704558168193;first-msg=0;tmi-sent-ts=1704558168004;user-type=;mod=0;id=47bad94f-7ae0-496b-b11f-52691218f3c6;color=#FF2424;flags=;client-nonce=9709d35ec3849012a63290dc622cc8fa;badge-info=subscriber/49;subscriber=1;badges=subscriber/48,bits/25000;user-id=159210800;returning-chatter=0;turbo=0 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenBoys","@color=#FF0000;badge-info=;user-type=;subscriber=0;mod=0;flags=;room-id=62300805;rm-received-ts=1704558168824;first-msg=0;historical=1;user-id=51967700;badges=;client-nonce=ac98ffeee20603b03966b3d24a2256b5;returning-chatter=0;id=5643a39b-ec81-4c58-8b80-1dde472f2185;tmi-sent-ts=1704558168655;turbo=0;emotes=;display-name=Patixxl :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenUnpleased 󠀀","@id=0721a398-812e-43cc-8308-729b4aeaaaa5;user-type=;flags=;turbo=0;tmi-sent-ts=1704558170495;subscriber=0;badges=;returning-chatter=0;badge-info=;emotes=;rm-received-ts=1704558170669;user-id=91920489;client-nonce=8b74febcb2643e8b861d5ff5730e37b9;historical=1;room-id=62300805;color=#FF4500;first-msg=0;mod=0;display-name=Franck_Saget :franck_saget!franck_saget@franck_saget.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@user-type=;returning-chatter=0;badge-info=subscriber/38;display-name=jontEmillian;turbo=0;historical=1;user-id=433352132;flags=;room-id=62300805;subscriber=1;mod=0;first-msg=0;tmi-sent-ts=1704558170558;id=b7da7b46-346b-44a0-8eda-f7dc873a2bee;badges=subscriber/36,twitch-recap-2023/1;emotes=;color=#63BD68;rm-received-ts=1704558170719 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@emotes=;badges=bits-charity/1;color=#0000FF;user-id=103665668;user-type=;client-nonce=88d94741ba24eec5c1aea4079c8022bd;subscriber=0;returning-chatter=0;id=bf46e7ae-1d63-4389-81d2-92d09a8ae003;display-name=Intel_power;turbo=0;tmi-sent-ts=1704558171866;mod=0;first-msg=0;room-id=62300805;flags=;historical=1;rm-received-ts=1704558172033;badge-info= :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn Alien360","@user-id=63372784;display-name=DM8917;id=fa29c6b2-bd07-47ef-a4f9-19a0834d20dd;color=#25E000;badge-info=;emotes=;room-id=62300805;historical=1;flags=;rm-received-ts=1704558173058;user-type=;badges=bits/100;client-nonce=800dbb697b7d58c832090bb8d29b0f29;first-msg=0;returning-chatter=0;mod=0;tmi-sent-ts=1704558172836;turbo=0;subscriber=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn monkaE","@badge-info=subscriber/49;color=#FF2424;client-nonce=4d3e3c84a31938c03ed2b39102324c56;display-name=ME_ME;flags=;returning-chatter=0;subscriber=1;mod=0;rm-received-ts=1704558173142;badges=subscriber/48,bits/25000;id=9e63d588-3d69-48c4-9258-001a548aa153;turbo=0;first-msg=0;historical=1;emotes=;room-id=62300805;user-id=159210800;tmi-sent-ts=1704558172929;user-type= :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn PepeS","@flags=;user-type=;client-nonce=0eb503d0f331d9c2697ff76d8ba7e9f2;user-id=85837900;badge-info=subscriber/37;tmi-sent-ts=1704558172999;turbo=0;display-name=DontCagePlebs;first-msg=0;emotes=;returning-chatter=0;color=#DAA520;mod=0;room-id=62300805;subscriber=1;id=addcaaf1-521c-4f78-806d-4d49017974ef;historical=1;badges=subscriber/36,no_audio/1;rm-received-ts=1704558173191 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn monkaS","@badge-info=;first-msg=0;user-type=;turbo=0;room-id=62300805;emotes=;id=b5975931-4724-4a7d-8038-3ea64698792c;flags=0-5:A.6/I.6;returning-chatter=0;tmi-sent-ts=1704558173072;client-nonce=59e49395644e34027bf315f857ee9a2c;display-name=Obiwun;badges=no_audio/1;user-id=46199261;mod=0;historical=1;color=#8A2BE2;subscriber=0;rm-received-ts=1704558173247 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn SCHIZO","@subscriber=1;emotes=;rm-received-ts=1704558173558;user-id=278896263;historical=1;badge-info=subscriber/9;returning-chatter=0;first-msg=0;display-name=Phant0mBlades;color=#008000;mod=0;room-id=62300805;user-type=;turbo=0;id=dd3fca53-4412-4d7f-b545-2d51a6c48520;tmi-sent-ts=1704558173394;badges=subscriber/9,chatter-cs-go-2022/1;flags= :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@first-msg=0;returning-chatter=0;subscriber=1;flags=;color=#1E90FF;id=02ff21d8-3794-4262-a6a6-e5980742ce70;historical=1;rm-received-ts=1704558173772;client-nonce=09c07f927f3b2f8e5dacf743eb846a1b;room-id=62300805;emotes=;display-name=mnqn18;mod=0;turbo=0;badge-info=subscriber/2;user-type=;tmi-sent-ts=1704558173601;user-id=474204887;badges=subscriber/0,premium/1 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@returning-chatter=0;badge-info=;room-id=62300805;color=#5F9EA0;emotes=;mod=0;turbo=0;user-id=133344079;badges=no_audio/1;client-nonce=7f803033a32f858c135d028e3e4638b5;historical=1;rm-received-ts=1704558173915;tmi-sent-ts=1704558173719;flags=;id=9a55b087-901f-4daf-b967-7d6a4243715b;display-name=sehtt_;user-type=;first-msg=0;subscriber=0 :sehtt_!sehtt_@sehtt_.tmi.twitch.tv PRIVMSG #nymn ::","@returning-chatter=0;rm-received-ts=1704558174136;flags=;subscriber=1;id=8829a9b5-b02b-4d0d-9827-0a0671856cf1;mod=1;turbo=0;first-msg=0;user-id=41157245;badge-info=subscriber/67;tmi-sent-ts=1704558173961;emotes=;display-name=Mr0lle;room-id=62300805;badges=moderator/1,subscriber/60,rplace-2023/1;user-type=mod;color=#9146FF;historical=1 :mr0lle!mr0lle@mr0lle.tmi.twitch.tv PRIVMSG #nymn forsenParty","@mod=0;tmi-sent-ts=1704558174127;user-id=22733078;rm-received-ts=1704558174314;room-id=62300805;badge-info=subscriber/2;id=7f1c7958-03b6-489d-b0e8-d89dbc6318df;flags=;subscriber=1;display-name=miniwoffer;emotes=;returning-chatter=0;first-msg=0;color=#FF69B4;user-type=;turbo=0;client-nonce=90e8710a650c223c523c5da211bda469;badges=subscriber/0,bits/1;historical=1 :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn :DOCING What is this game about","@color=#FF0000;first-msg=0;mod=0;emotes=;room-id=62300805;badges=;id=b80d3a07-2ed4-4656-a188-6493834e856c;returning-chatter=0;tmi-sent-ts=1704558174500;subscriber=0;flags=;historical=1;user-type=;display-name=Patixxl;rm-received-ts=1704558174665;badge-info=;user-id=51967700;turbo=0;client-nonce=e8f0fe09de0a2a9595df06c57bd0e013 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@flags=;returning-chatter=0;display-name=Patixxl;rm-received-ts=1704558178268;mod=0;tmi-sent-ts=1704558178103;user-type=;user-id=51967700;subscriber=0;emotes=;id=f572bb10-d704-4c5f-8ac0-19f26f6d4635;turbo=0;historical=1;first-msg=0;client-nonce=e35e4e7941fc0a7e9d1feab0783c30e3;badges=;room-id=62300805;color=#FF0000;badge-info= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenUnpleased 󠀀","@turbo=0;badges=subscriber/36,twitch-recap-2023/1;returning-chatter=0;color=#63BD68;id=050a6072-26cc-4418-913b-c6c17e267e9d;user-type=;badge-info=subscriber/38;tmi-sent-ts=1704558178487;room-id=62300805;rm-received-ts=1704558178675;emotes=;user-id=433352132;mod=0;first-msg=0;subscriber=1;display-name=jontEmillian;flags=;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@emotes=;badge-info=subscriber/2;subscriber=1;room-id=62300805;tmi-sent-ts=1704558178621;display-name=jqxlol;client-nonce=af96c8566b07c63d5e1cd7f0b00e1a70;turbo=0;mod=0;badges=subscriber/0,no_video/1;user-id=80542722;user-type=;historical=1;rm-received-ts=1704558178804;first-msg=0;id=859d8091-2eed-408f-904e-0f53ea223f11;flags=;color=#00FF7F;returning-chatter=0 :jqxlol!jqxlol@jqxlol.tmi.twitch.tv PRIVMSG #nymn :Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ?","@flags=;badges=vip/1,subscriber/72,rplace-2023/1;returning-chatter=0;id=848a18d9-1b18-4236-a781-1c36089749c8;first-msg=0;color=#D52AFF;room-id=62300805;rm-received-ts=1704558178994;badge-info=subscriber/77;display-name=Joshlad;user-type=;historical=1;turbo=0;mod=0;vip=1;user-id=87120320;emotes=;subscriber=1;tmi-sent-ts=1704558178820 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn monkaS","@turbo=0;tmi-sent-ts=1704558179829;display-name=Intel_power;returning-chatter=0;historical=1;mod=0;user-type=;client-nonce=c00a231f8934624eafd79a64c3e97668;rm-received-ts=1704558180008;badge-info=;room-id=62300805;first-msg=0;id=33a78c35-dce0-484c-8be5-9222a2781611;subscriber=0;user-id=103665668;emotes=;flags=;color=#0000FF;badges=bits-charity/1 :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@returning-chatter=0;tmi-sent-ts=1704558180325;first-msg=0;mod=0;user-id=137782780;flags=;badge-info=subscriber/4;client-nonce=0a1b266a425b6cd205248923aa6057e2;historical=1;id=840635bb-3ca0-4636-a1f1-4a6debb37454;rm-received-ts=1704558180499;room-id=62300805;display-name=pleasekeepconnor6silly;user-type=;badges=subscriber/3,no_audio/1;color=#8A2BE2;subscriber=1;turbo=0;emotes= :pleasekeepconnor6silly!pleasekeepconnor6silly@pleasekeepconnor6silly.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@color=#B22222;user-type=;badge-info=subscriber/7;emotes=emotesv2_e16d73fce3b840949d5474dfaca63ffd:12-22;turbo=0;subscriber=1;historical=1;room-id=62300805;mod=0;first-msg=0;rm-received-ts=1704558181805;display-name=crazyjuni0r_;id=4f667a25-6ce9-47e7-8486-511ab1e1aee5;returning-chatter=0;badges=subscriber/6,chatter-cs-go-2022/1;tmi-sent-ts=1704558181619;flags=;user-id=222340799;client-nonce=d536c94bbe04001059053d52e419d299 :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn :!#showemote spacea32HOM","@turbo=0;badge-info=;first-msg=0;historical=1;tmi-sent-ts=1704558182428;display-name=forsenkkona_;rm-received-ts=1704558182599;user-type=;emotes=;id=a02c8cb3-6204-4f6f-90dc-aed0b9a4349c;user-id=151423066;color=#FF69B4;room-id=62300805;mod=0;badges=;returning-chatter=0;subscriber=0;flags= :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@first-msg=0;id=e7096162-fcb9-40c1-9f4e-4ffbcecef179;flags=0-2:P.3;historical=1;mod=0;turbo=0;tmi-sent-ts=1704558182845;room-id=62300805;badge-info=subscriber/15;user-type=;emotes=;returning-chatter=0;subscriber=1;badges=subscriber/12,twitch-recap-2023/1;color=#E2C92B;client-nonce=46af8ee370f349edbde8b61dd36cae62;rm-received-ts=1704558183038;user-id=90319443;display-name=huSiOx :husiox!husiox@husiox.tmi.twitch.tv PRIVMSG #nymn :WTF IS THIS GAME ABOUT docYell","@rm-received-ts=1704558183040;first-msg=0;badge-info=subscriber/17;user-id=69072013;emotes=;room-id=62300805;client-nonce=ef4253731729ef8bf3d7d03540d62e0a;mod=0;badges=subscriber/12,twitch-recap-2023/1;display-name=SnuggleUncle;flags=;turbo=0;subscriber=1;color=#0000FF;id=1194ab99-cf4e-4aa8-a2ec-f9c7978d704c;returning-chatter=0;historical=1;user-type=;tmi-sent-ts=1704558182859 :snuggleuncle!snuggleuncle@snuggleuncle.tmi.twitch.tv PRIVMSG #nymn :don't look behind you monkaOMEGA","@emotes=;first-msg=0;badges=bits/1000;flags=;display-name=HajleSellasje;user-id=45923155;badge-info=;returning-chatter=0;room-id=62300805;id=9c8f325e-739c-477e-a00e-97130ad55bd9;turbo=0;user-type=;tmi-sent-ts=1704558183769;color=#FF0000;subscriber=0;rm-received-ts=1704558183941;historical=1;mod=0 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@user-type=;flags=;historical=1;emotes=;room-id=62300805;mod=0;user-id=216144449;returning-chatter=0;rm-received-ts=1704558184490;tmi-sent-ts=1704558184313;color=#00FF7F;badges=turbo/1;subscriber=0;badge-info=;turbo=1;client-nonce=dc732730e69b18ba1fa63aa486cf17e0;id=ca468ddc-af12-498d-b3f8-438c2245551e;first-msg=0;display-name=FollowProtoBuddy :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn monkaGIGA","@returning-chatter=0;display-name=sehtt_;badges=no_audio/1;client-nonce=537c9c8c12461d79d5fa60d8231ae6b1;turbo=0;id=b2ec4298-a0aa-40d7-88f0-2dcbd278abdd;emotes=;user-type=;historical=1;badge-info=;subscriber=0;mod=0;rm-received-ts=1704558184542;tmi-sent-ts=1704558184317;first-msg=0;room-id=62300805;user-id=133344079;color=#5F9EA0;flags= :sehtt_!sehtt_@sehtt_.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@first-msg=0;subscriber=0;badges=rplace-2023/1;badge-info=;room-id=62300805;user-id=246452436;rm-received-ts=1704558184595;id=eb73f38e-2118-4e36-90dc-6002b20c2855;returning-chatter=0;display-name=kb_h;client-nonce=63249421ede84223b1ba7cccd9605b8b;historical=1;user-type=;tmi-sent-ts=1704558184423;mod=0;color=#1E90FF;emotes=;flags=;turbo=0 :kb_h!kb_h@kb_h.tmi.twitch.tv PRIVMSG #nymn :what is this, liminal space for rats?","@subscriber=0;user-id=38635616;display-name=KelemvorUber;emotes=;tmi-sent-ts=1704558185042;returning-chatter=0;badges=twitch-recap-2023/1;flags=;historical=1;user-type=;mod=0;badge-info=;turbo=0;color=#F7FF00;rm-received-ts=1704558185220;client-nonce=2c80e220e9db19558882bfa42075a04a;room-id=62300805;first-msg=0;id=f12dc210-68d0-4f1d-8106-fe0bb20970f5 :kelemvoruber!kelemvoruber@kelemvoruber.tmi.twitch.tv PRIVMSG #nymn :anyone feeling extra fine","@id=dfdc46c6-3125-4603-b48a-a64a800e4072;room-id=62300805;flags=;subscriber=1;historical=1;rm-received-ts=1704558185421;user-type=;badges=subscriber/36,no_audio/1;user-id=85837900;turbo=0;client-nonce=fd40b10744c17a6487b112fce5b65dcd;badge-info=subscriber/37;display-name=DontCagePlebs;first-msg=0;returning-chatter=0;emotes=;color=#DAA520;tmi-sent-ts=1704558185237;mod=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@color=#008000;user-type=;first-msg=0;badges=subscriber/9,chatter-cs-go-2022/1;historical=1;room-id=62300805;tmi-sent-ts=1704558185971;mod=0;rm-received-ts=1704558186127;emotes=;flags=;returning-chatter=0;user-id=278896263;badge-info=subscriber/9;display-name=Phant0mBlades;id=97bddc9b-c183-4619-92d9-b8ea731a0314;subscriber=1;turbo=0 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenUnpleased 󠀀","@subscriber=0;badges=no_audio/1;user-type=;badge-info=;user-id=431946171;display-name=jonhycrack;emotes=;tmi-sent-ts=1704558186695;historical=1;turbo=0;color=#008000;flags=;id=1bcbc165-734a-49f7-9260-8a2dd3d7ec04;mod=0;rm-received-ts=1704558186873;room-id=62300805;returning-chatter=0;first-msg=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn nym","@room-id=62300805;id=118c7135-35f0-4a4c-9741-d9ebb8810044;historical=1;rm-received-ts=1704558191191;badges=subscriber/36,twitch-recap-2023/1;emotes=;first-msg=0;user-id=433352132;subscriber=1;flags=;tmi-sent-ts=1704558191033;user-type=;mod=0;badge-info=subscriber/38;turbo=0;returning-chatter=0;color=#63BD68;display-name=jontEmillian :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@id=6e0777f7-4242-493d-981c-abd1de6ded9a;display-name=Kotzblitz20;user-id=40037186;returning-chatter=0;tmi-sent-ts=1704558191015;subscriber=1;room-id=62300805;user-type=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138;badge-info=subscriber/9;turbo=1;badges=subscriber/9,turbo/1;rm-received-ts=1704558191212;historical=1;color=#FFFF00;first-msg=0;flags=;mod=0;client-nonce=9756ed05932eb83f60af7d2746fa1b03 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@client-nonce=65473cb037b882cbf5737e4d0f83da06;first-msg=0;returning-chatter=0;turbo=0;id=03ba3439-6fb3-4ad1-a02a-c556eb47944e;user-type=;rm-received-ts=1704558191295;tmi-sent-ts=1704558191115;emotes=;badge-info=;historical=1;user-id=63372784;room-id=62300805;color=#25E000;badges=bits/100;mod=0;display-name=DM8917;flags=;subscriber=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@first-msg=0;turbo=0;subscriber=1;badges=subscriber/24,sub-gifter/5;id=85b5451f-81d8-4fe7-982d-175b3e871a79;client-nonce=6d80f47330297015c90d434aac43e117;user-id=544395745;badge-info=subscriber/29;rm-received-ts=1704558191465;returning-chatter=0;color=#9ACD32;flags=;mod=0;room-id=62300805;display-name=PepePge;user-type=;historical=1;tmi-sent-ts=1704558191289;emotes= :pepepge!pepepge@pepepge.tmi.twitch.tv PRIVMSG #nymn :!#showemote vegan 󠀀","@returning-chatter=0;client-nonce=e68ef3f7a0d67f4dd5532268bb6c2f0d;id=60cae5a1-34ef-4aa7-b652-b2dd41ca46a1;turbo=0;user-type=;rm-received-ts=1704558192415;color=#00FF7F;display-name=jqxlol;badge-info=subscriber/2;subscriber=1;room-id=62300805;first-msg=0;historical=1;mod=0;emotes=;tmi-sent-ts=1704558192239;badges=subscriber/0,no_video/1;user-id=80542722;flags= :jqxlol!jqxlol@jqxlol.tmi.twitch.tv PRIVMSG #nymn :Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? 󠀀","@emotes=;display-name=jontEmillian;rm-received-ts=1704558192720;first-msg=0;id=27451f45-e6bb-44eb-be76-bdc45a8297a8;flags=;historical=1;mod=0;tmi-sent-ts=1704558192549;turbo=0;user-type=;subscriber=1;color=#63BD68;badge-info=subscriber/38;badges=subscriber/36,twitch-recap-2023/1;returning-chatter=0;user-id=433352132;room-id=62300805 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@display-name=Patixxl;badges=;mod=0;color=#FF0000;first-msg=0;tmi-sent-ts=1704558193047;user-id=51967700;turbo=0;client-nonce=307d7bac6173ef7a5d8a2b54d14a571c;returning-chatter=0;emotes=;historical=1;rm-received-ts=1704558193230;user-type=;id=70142434-1817-4745-b8a1-90144782d1b3;flags=;room-id=62300805;badge-info=;subscriber=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;rm-received-ts=1704558193486;id=d06f0674-5ee3-4001-860d-bb899ec2ae31;tmi-sent-ts=1704558193291;vip=1;display-name=Joshlad;historical=1;flags=;badges=vip/1,subscriber/72,rplace-2023/1;user-type=;user-id=87120320;subscriber=1;turbo=0;emotes=;badge-info=subscriber/77;color=#D52AFF;first-msg=0;returning-chatter=0;mod=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;user-type=;mod=0;client-nonce=864b2e02368c629b447719779a0655ed;flags=;historical=1;color=#FFFF00;user-id=40037186;display-name=Kotzblitz20;subscriber=1;rm-received-ts=1704558194226;badges=subscriber/9,turbo/1;turbo=1;id=65e474d7-e552-43bd-a8f1-a026ad84c2f1;room-id=62300805;tmi-sent-ts=1704558194014;first-msg=0;badge-info=subscriber/9;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;returning-chatter=0;badges=;subscriber=0;client-nonce=dbb17564b4ec368b83b92e9c7b80bfaa;badge-info=;mod=0;tmi-sent-ts=1704558194203;first-msg=0;color=#FF0000;room-id=62300805;id=b1ab5d5b-a62b-45af-a493-3e168d0fa240;emotes=;turbo=0;user-id=810718356;flags=;display-name=holy4uck;historical=1;rm-received-ts=1704558194391 :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn forsenParty","@emotes=;rm-received-ts=1704558194545;room-id=62300805;mod=0;color=#DAA520;id=16047008-c158-49b6-a392-203f5fe0013b;user-id=85837900;client-nonce=1da6703a9d1af3ad52eb9c4c3edb391f;badges=subscriber/36,no_audio/1;first-msg=0;returning-chatter=0;display-name=DontCagePlebs;badge-info=subscriber/37;historical=1;tmi-sent-ts=1704558194367;flags=;subscriber=1;user-type=;turbo=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=subscriber/49;badges=subscriber/48,bits/25000;flags=;user-type=;room-id=62300805;turbo=0;subscriber=1;returning-chatter=0;display-name=ME_ME;rm-received-ts=1704558194607;user-id=159210800;first-msg=0;historical=1;client-nonce=f69c8172c28414a3f21ac62c589c7597;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;id=3dddf9bf-2511-4375-8278-895fccc05146;tmi-sent-ts=1704558194423;emote-only=1;color=#FF2424 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenParty","@id=b89eb516-d083-4033-8cc8-454cb1c299e1;flags=;turbo=0;tmi-sent-ts=1704558194613;first-msg=0;returning-chatter=0;mod=0;emotes=;color=#FF0000;badges=;display-name=Patixxl;user-id=51967700;user-type=;subscriber=0;badge-info=;historical=1;client-nonce=9ac0bd656414469f58c799b1934a1bb7;room-id=62300805;rm-received-ts=1704558194778 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@room-id=62300805;user-id=135853293;display-name=theKiryu;user-type=;color=#9ACD32;mod=0;id=2c19bc99-9cc2-42f0-b519-56b023460b16;subscriber=0;flags=;historical=1;first-msg=0;emotes=;badge-info=;returning-chatter=0;tmi-sent-ts=1704558194737;client-nonce=768eac8314920773a77c8851af644705;badges=twitch-recap-2023/1;turbo=0;rm-received-ts=1704558194915 :thekiryu!thekiryu@thekiryu.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@flags=;first-msg=0;historical=1;color=#FF69B4;id=14dfe43b-a22f-4e45-807f-a067cb210f25;user-type=;badges=subscriber/0,bits/1;user-id=22733078;mod=0;badge-info=subscriber/2;rm-received-ts=1704558195317;subscriber=1;returning-chatter=0;tmi-sent-ts=1704558195115;display-name=miniwoffer;emotes=;room-id=62300805;turbo=0;client-nonce=c3726a9e001e7dfe357bc6b8832f4815 :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn monkaS","@user-type=;mod=0;id=24cdf813-af53-49d3-ad27-2a07ffc19545;room-id=62300805;returning-chatter=0;first-msg=0;tmi-sent-ts=1704558195441;badges=subscriber/9,chatter-cs-go-2022/1;emote-only=1;flags=;badge-info=subscriber/9;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;turbo=0;color=#008000;subscriber=1;rm-received-ts=1704558195627;display-name=Phant0mBlades;user-id=278896263;historical=1 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn forsenParty","@historical=1;tmi-sent-ts=1704558195576;mod=0;turbo=0;first-msg=0;user-type=;room-id=62300805;subscriber=1;returning-chatter=0;rm-received-ts=1704558195777;display-name=WhideX;client-nonce=2ce1a6a55fae4feaa11ef65e368fefdf;flags=;color=#FAD130;badges=subscriber/12,sub-gifter/5;emotes=;user-id=32852911;id=b5e04c52-5069-482d-9121-82412c9ff57d;badge-info=subscriber/15 :whidex!whidex@whidex.tmi.twitch.tv PRIVMSG #nymn :forsenboys? more like forsen re tiredboys","@first-msg=0;room-id=62300805;color=#41FF00;badges=subscriber/36;turbo=0;tmi-sent-ts=1704558195686;user-id=92402102;emotes=;historical=1;badge-info=subscriber/41;rm-received-ts=1704558195860;mod=0;id=ddf08051-10af-4e75-8002-e3183c232165;flags=;subscriber=1;display-name=liber7as;returning-chatter=0;client-nonce=cb6f3d498c23949492defe2be8dbbd4d;user-type= :liber7as!liber7as@liber7as.tmi.twitch.tv PRIVMSG #nymn :THIS ??????","@subscriber=0;display-name=SadRosh;returning-chatter=0;historical=1;turbo=0;user-type=;color=#B22222;mod=0;first-msg=0;user-id=184644555;emotes=;badges=no_audio/1;badge-info=;client-nonce=67898821b9e8192ce8af839995ac0b59;room-id=62300805;tmi-sent-ts=1704558195772;rm-received-ts=1704558195968;id=36e3dfc8-61a4-469c-94e0-efbad57f377a;flags= :sadrosh!sadrosh@sadrosh.tmi.twitch.tv PRIVMSG #nymn forsenParty","@emotes=;turbo=0;historical=1;user-type=;display-name=Patixxl;user-id=51967700;rm-received-ts=1704558196337;badges=;badge-info=;client-nonce=1e4383b9f54ff0d37dee5cd7531472e8;tmi-sent-ts=1704558196141;color=#FF0000;room-id=62300805;id=4659b954-fde3-41fc-836a-4c3ce7c7e71a;returning-chatter=0;subscriber=0;first-msg=0;flags=;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=87120320;display-name=Joshlad;historical=1;id=776f0ade-3419-4e60-89b5-fe37674a7bb9;mod=0;first-msg=0;user-type=;color=#D52AFF;subscriber=1;room-id=62300805;vip=1;rm-received-ts=1704558196519;badges=vip/1,subscriber/72,rplace-2023/1;flags=;badge-info=subscriber/77;emotes=;tmi-sent-ts=1704558196338;turbo=0;returning-chatter=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badges=subscriber/36,twitch-recap-2023/1;mod=0;user-id=433352132;room-id=62300805;returning-chatter=0;user-type=;emotes=;display-name=jontEmillian;tmi-sent-ts=1704558196372;historical=1;rm-received-ts=1704558196556;first-msg=0;badge-info=subscriber/38;id=3343f7f5-c68b-4dab-88f1-84f5244affe9;color=#63BD68;turbo=0;subscriber=1;flags= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@color=#000000;emotes=;flags=;mod=0;first-msg=0;turbo=0;user-type=;badge-info=;badges=;returning-chatter=0;id=99e16736-0820-48a3-9b10-04a9f9bca7cb;tmi-sent-ts=1704558196398;display-name=Duchene;rm-received-ts=1704558196575;subscriber=0;client-nonce=3a0e84b88167286bf31f77c97c826a59;historical=1;user-id=205837377;room-id=62300805 :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@rm-received-ts=1704558197134;display-name=buong1;user-id=75144877;color=#FF0000;room-id=62300805;flags=;emotes=;historical=1;mod=0;badge-info=subscriber/5;first-msg=0;returning-chatter=0;turbo=0;client-nonce=7ace2fd39212e9f61b377f68887a7a06;id=771aaf2c-1627-4ebc-aab8-12703b4f5905;user-type=;tmi-sent-ts=1704558196942;subscriber=1;badges=subscriber/3,no_video/1 :buong1!buong1@buong1.tmi.twitch.tv PRIVMSG #nymn forsenParty","@room-id=62300805;badges=subscriber/0,premium/1;rm-received-ts=1704558197216;user-type=;subscriber=1;user-id=474204887;client-nonce=471fd7c0712a2c8c877e4ada58908a34;tmi-sent-ts=1704558197040;badge-info=subscriber/2;display-name=mnqn18;id=f9643840-be50-4cd0-b7cc-816ad3cb7195;turbo=0;returning-chatter=0;first-msg=0;historical=1;emotes=;mod=0;color=#1E90FF;flags= :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime","@returning-chatter=0;emotes=;display-name=tedkaczynski___;room-id=62300805;badges=premium/1;flags=;mod=0;client-nonce=59a1029f8ec4768aa10c721ae74065d2;first-msg=0;id=0f6da476-af72-4312-9309-060489b50a93;color=#DA197A;rm-received-ts=1704558197325;user-type=;historical=1;user-id=199969143;badge-info=;subscriber=0;turbo=0;tmi-sent-ts=1704558197130 :tedkaczynski___!tedkaczynski___@tedkaczynski___.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@rm-received-ts=1704558197611;badges=subscriber/3030,glhf-pledge/1;id=4477366c-cd8a-49b2-82e5-a5924aabf42a;historical=1;first-msg=0;returning-chatter=0;emote-only=1;flags=;display-name=LOZZLY;room-id=62300805;subscriber=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;mod=0;badge-info=subscriber/30;user-id=84185616;tmi-sent-ts=1704558197394;turbo=0;color=#00CCCC;user-type= :lozzly!lozzly@lozzly.tmi.twitch.tv PRIVMSG #nymn forsenParty","@historical=1;display-name=ME_ME;turbo=0;user-type=;room-id=62300805;subscriber=1;color=#FF2424;mod=0;returning-chatter=0;badges=subscriber/48,bits/25000;tmi-sent-ts=1704558197491;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;rm-received-ts=1704558197657;badge-info=subscriber/49;flags=;id=b68d4108-9187-4450-8168-6294dbc22bde;user-id=159210800;first-msg=0;client-nonce=82bf9d47e479c64a89ce0da9628b7ea3 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@badge-info=subscriber/9;user-type=;badges=subscriber/9,chatter-cs-go-2022/1;historical=1;flags=;user-id=278896263;id=4232ed07-2f56-4541-afcb-346014e83b7a;mod=0;tmi-sent-ts=1704558197694;color=#008000;display-name=Phant0mBlades;first-msg=0;room-id=62300805;subscriber=1;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;turbo=0;rm-received-ts=1704558197873 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@returning-chatter=0;subscriber=0;user-id=51967700;turbo=0;id=033989bd-8622-4bdd-98d6-94fbf51ef094;flags=;display-name=Patixxl;color=#FF0000;badge-info=;room-id=62300805;first-msg=0;tmi-sent-ts=1704558197840;historical=1;mod=0;emotes=;badges=;client-nonce=9502f37d34a369a9abcc48e12fbe50eb;user-type=;rm-received-ts=1704558198027 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@emotes=;turbo=0;display-name=DontCagePlebs;badges=subscriber/36,no_audio/1;returning-chatter=0;badge-info=subscriber/37;first-msg=0;client-nonce=ee6467bb1a9b9570f89164245452d04c;color=#DAA520;id=27e9162f-4e01-4c78-b3b1-569f998c036a;user-id=85837900;room-id=62300805;user-type=;tmi-sent-ts=1704558197970;flags=;subscriber=1;historical=1;rm-received-ts=1704558198248;mod=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badges=subscriber/9,chatter-cs-go-2022/1;first-msg=0;user-id=278896263;returning-chatter=0;mod=0;display-name=Phant0mBlades;historical=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;tmi-sent-ts=1704558198855;flags=;subscriber=1;user-type=;badge-info=subscriber/9;turbo=0;id=9ad761e6-d77b-411a-ac8d-de3ce7dc6756;rm-received-ts=1704558199039;room-id=62300805;color=#008000 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@user-id=32852911;flags=;subscriber=1;returning-chatter=0;first-msg=0;tmi-sent-ts=1704558198874;user-type=;historical=1;color=#FAD130;room-id=62300805;emotes=;id=56abb64c-97d3-4e47-819d-9d90244510ec;badges=subscriber/12,sub-gifter/5;display-name=WhideX;client-nonce=cff614e4c68b0ce1fafa42c2a7c9c296;rm-received-ts=1704558199045;turbo=0;mod=0;badge-info=subscriber/15 :whidex!whidex@whidex.tmi.twitch.tv PRIVMSG #nymn :forsenboys? more like forsen re tiredboys 󠀀","@turbo=0;client-nonce=39b2fc175d8bbba13685b5c64a7e84e9;tmi-sent-ts=1704558199554;historical=1;id=6f0098b2-d6e4-476f-9297-8ec51bf10464;badges=;color=#FF0000;first-msg=0;display-name=Patixxl;emotes=;room-id=62300805;flags=;user-id=51967700;mod=0;returning-chatter=0;user-type=;badge-info=;subscriber=0;rm-received-ts=1704558199732 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;turbo=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;room-id=62300805;mod=0;badge-info=;flags=12-15:P.3;rm-received-ts=1704558199945;user-id=29764188;first-msg=0;historical=1;badges=;display-name=zzlint;subscriber=0;tmi-sent-ts=1704558199774;id=973f3cf1-cd6f-4d51-92b9-7f08eddd9dd4;returning-chatter=0;client-nonce=67353085a1089e47a046bb4ff1bbf671;color=#1E90FF :zzlint!zzlint@zzlint.tmi.twitch.tv PRIVMSG #nymn :forsenParty shit","@rm-received-ts=1704558200172;turbo=0;first-msg=0;returning-chatter=0;subscriber=0;badges=;color=#FF0000;mod=0;user-id=810718356;id=3a36a314-0444-4591-b21f-ce1cbb0537a3;badge-info=;display-name=holy4uck;historical=1;room-id=62300805;tmi-sent-ts=1704558200005;flags=;client-nonce=0c1c160767de6663b630dd7c7a63ec9b;emotes=;user-type= :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@first-msg=0;subscriber=1;flags=;display-name=jontEmillian;badge-info=subscriber/38;mod=0;emotes=;returning-chatter=0;tmi-sent-ts=1704558200345;room-id=62300805;turbo=0;user-id=433352132;badges=subscriber/36,twitch-recap-2023/1;historical=1;color=#63BD68;user-type=;rm-received-ts=1704558200535;id=57b00358-5a65-424e-9a1d-3075421ea987 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badge-info=subscriber/29;rm-received-ts=1704558201021;subscriber=1;turbo=0;user-type=;color=#9ACD32;flags=;historical=1;badges=subscriber/24,sub-gifter/5;room-id=62300805;tmi-sent-ts=1704558200836;display-name=PepePge;user-id=544395745;client-nonce=4d66f94dcb573498a4e1a2931cf48c9c;mod=0;first-msg=0;id=42b37fdb-97cf-408d-be10-f9aa2fb80abc;returning-chatter=0;emotes= :pepepge!pepepge@pepepge.tmi.twitch.tv PRIVMSG #nymn :!#showemote vegan","@display-name=Joshlad;color=#D52AFF;subscriber=1;turbo=0;flags=;returning-chatter=0;user-type=;emotes=;badges=vip/1,subscriber/72,rplace-2023/1;badge-info=subscriber/77;id=80383c59-7ebd-41c4-8ad1-142f2276b71e;room-id=62300805;historical=1;user-id=87120320;vip=1;mod=0;first-msg=0;tmi-sent-ts=1704558201151;rm-received-ts=1704558201321 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;color=#008000;user-id=278896263;turbo=0;rm-received-ts=1704558201558;badges=subscriber/9,chatter-cs-go-2022/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;badge-info=subscriber/9;subscriber=1;first-msg=0;tmi-sent-ts=1704558201374;room-id=62300805;user-type=;display-name=Phant0mBlades;mod=0;historical=1;id=a42808d7-7f4d-4a11-b705-0a452c0354e8;flags= :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@flags=;tmi-sent-ts=1704558201591;room-id=62300805;rm-received-ts=1704558201774;emotes=;id=8fdcd2c4-5ae6-48b6-9794-2f08d82b574b;mod=0;first-msg=0;returning-chatter=0;subscriber=0;client-nonce=7b37f78ba5b7ae660ac7458312fa2917;user-type=;badge-info=;user-id=51967700;historical=1;badges=;color=#FF0000;turbo=0;display-name=Patixxl :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@flags=;historical=1;subscriber=1;client-nonce=48ac4d7618c53399e007825e7d8413cd;mod=0;user-type=;id=f60be363-c6e9-435d-8e8c-05f7035e11e7;first-msg=0;returning-chatter=0;user-id=40037186;turbo=1;badges=subscriber/9,turbo/1;color=#FFFF00;display-name=Kotzblitz20;tmi-sent-ts=1704558201971;rm-received-ts=1704558202179;badge-info=subscriber/9;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90;room-id=62300805 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;room-id=62300805;mod=0;emote-only=1;id=b02a2980-b6d8-46ef-a305-2392a1700644;turbo=0;historical=1;badge-info=subscriber/49;rm-received-ts=1704558203272;first-msg=0;client-nonce=14879c16526748e58d2830173560abea;flags=;color=#FF2424;user-id=159210800;badges=subscriber/48,bits/25000;display-name=ME_ME;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;subscriber=1;returning-chatter=0;tmi-sent-ts=1704558203063 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenParty","@color=#FF0000;emotes=;user-id=810718356;subscriber=0;first-msg=0;user-type=;id=b2cf343e-8d80-4171-b5ac-484e1a1b2ca9;display-name=holy4uck;historical=1;rm-received-ts=1704558203551;badges=;returning-chatter=0;turbo=0;flags=;client-nonce=d34ab6874f8e6344b06d4cc2b980b83a;mod=0;tmi-sent-ts=1704558203388;badge-info=;room-id=62300805 :holy4uck!holy4uck@holy4uck.tmi.twitch.tv PRIVMSG #nymn TriKool","@emotes=;id=ec5d5821-4a54-41ea-81e3-a8dea0978666;first-msg=0;subscriber=1;user-type=;color=#FF0000;badges=subscriber/3,no_video/1;user-id=75144877;historical=1;display-name=buong1;badge-info=subscriber/5;tmi-sent-ts=1704558203712;rm-received-ts=1704558203896;room-id=62300805;flags=;client-nonce=ce1ae06dc8b511922aae6b5196416d52;turbo=0;mod=0;returning-chatter=0 :buong1!buong1@buong1.tmi.twitch.tv PRIVMSG #nymn :rat battle","@user-type=;user-id=433352132;first-msg=0;color=#63BD68;historical=1;flags=;room-id=62300805;emotes=;tmi-sent-ts=1704558203749;mod=0;turbo=0;rm-received-ts=1704558203914;subscriber=1;badge-info=subscriber/38;returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;id=5b0e741b-de67-48ba-9385-aceebd5db91d;display-name=jontEmillian :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@display-name=DontCagePlebs;tmi-sent-ts=1704558203857;user-type=;subscriber=1;room-id=62300805;emotes=;color=#DAA520;client-nonce=694fdd9aecda75d281c5e91360af364c;turbo=0;mod=0;user-id=85837900;historical=1;first-msg=0;badges=subscriber/36,no_audio/1;rm-received-ts=1704558204016;badge-info=subscriber/37;id=00121252-2036-475d-a10e-98aa62ec6477;flags=;returning-chatter=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn TriKool","@first-msg=0;rm-received-ts=1704558204055;tmi-sent-ts=1704558203880;flags=;user-type=;client-nonce=5bdfec2a4dacd6e7f3ec6957a9757b9a;badges=;subscriber=0;returning-chatter=0;room-id=62300805;mod=0;turbo=0;historical=1;user-id=137332535;badge-info=;display-name=BlueAves;id=5a56af8b-5f39-4aec-afa4-d252210a5f8e;color=#1E90FF;emotes= :blueaves!blueaves@blueaves.tmi.twitch.tv PRIVMSG #nymn :RAT BATTLE","@flags=;color=#0000FF;room-id=62300805;mod=0;first-msg=0;turbo=0;user-type=;badge-info=;tmi-sent-ts=1704558203945;subscriber=0;user-id=103665668;emotes=;badges=bits-charity/1;historical=1;rm-received-ts=1704558204127;client-nonce=4ad780f4eb3dc6c75d6bfbcd26942b62;id=98bf5a98-4537-4fe4-bc9b-27d71fb96bd1;returning-chatter=0;display-name=Intel_power :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn :RAT BATTLE","@flags=;id=29010001-9bb5-4678-b689-25acec58df6c;client-nonce=2751fd57df2c3e1128b3d2ce1cfb67c8;badge-info=subscriber/9;subscriber=1;user-id=40037186;mod=0;rm-received-ts=1704558204198;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;returning-chatter=0;historical=1;turbo=1;color=#FFFF00;badges=subscriber/9,turbo/1;tmi-sent-ts=1704558204021;first-msg=0;room-id=62300805;display-name=Kotzblitz20;user-type= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@user-type=;display-name=SecretCarrot;color=#00615C;rm-received-ts=1704558204394;id=1c2a81b4-7207-4a7b-9e86-a770d731e00a;tmi-sent-ts=1704558204214;first-msg=0;room-id=62300805;badges=subscriber/54,bits/1000;user-id=103592036;returning-chatter=0;mod=0;historical=1;badge-info=subscriber/55;flags=;turbo=0;emotes=;client-nonce=f5321af192793b3a795055b3e796a3d6;subscriber=1 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn TriKool","@mod=0;historical=1;subscriber=0;tmi-sent-ts=1704558204849;user-type=;display-name=Patixxl;color=#FF0000;badge-info=;returning-chatter=0;rm-received-ts=1704558205028;turbo=0;badges=;emotes=;flags=;room-id=62300805;client-nonce=1c340813bea2243106b8dd2bb1001aed;id=b1ba39c9-3f4b-4810-98f5-127997bc4f2d;first-msg=0;user-id=51967700 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#FF69B4;subscriber=1;tmi-sent-ts=1704558205550;rm-received-ts=1704558205753;user-id=450253959;room-id=62300805;mod=0;historical=1;client-nonce=655006b9d5f9d121563dcb59b8369071;emotes=emotesv2_55b2b64121f44472bc69ed6b7adfedd8:0-6;id=34306fd0-cdca-4de7-99fd-6e7c8f47046f;badge-info=subscriber/29;returning-chatter=0;emote-only=1;user-type=;first-msg=0;display-name=Gayguh;flags=;badges=subscriber/24;turbo=0 :gayguh!gayguh@gayguh.tmi.twitch.tv PRIVMSG #nymn pspRave","@turbo=1;subscriber=1;client-nonce=aa655553b581654e869e10a0abea8b94;color=#FFFF00;badge-info=subscriber/9;user-type=;user-id=40037186;mod=0;historical=1;flags=;rm-received-ts=1704558206165;id=214aa31a-83ff-4eda-b528-0b9bc5875ef8;first-msg=0;room-id=62300805;returning-chatter=0;emotes=;badges=subscriber/9,turbo/1;tmi-sent-ts=1704558205969;display-name=Kotzblitz20 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn TriKool","@badge-info=subscriber/77;subscriber=1;first-msg=0;user-type=;returning-chatter=0;historical=1;mod=0;room-id=62300805;turbo=0;id=ab1af198-16be-48e9-a707-7f4b6ea7eb26;user-id=87120320;badges=vip/1,subscriber/72,rplace-2023/1;flags=;rm-received-ts=1704558206310;display-name=Joshlad;tmi-sent-ts=1704558206149;emotes=;vip=1;color=#D52AFF :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :rat battle Painsge","@badges=subscriber/0,premium/1;turbo=0;client-nonce=6837b30857f233c00c167f4e77a1fccb;historical=1;returning-chatter=0;room-id=62300805;first-msg=0;color=#1E90FF;tmi-sent-ts=1704558207167;id=4355ed15-8401-4fcd-93c1-9e5ccb92d0b4;rm-received-ts=1704558207343;display-name=mnqn18;badge-info=subscriber/2;subscriber=1;user-type=;flags=;mod=0;user-id=474204887;emotes= :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn emiNymN","@rm-received-ts=1704558207703;emotes=;tmi-sent-ts=1704558207544;client-nonce=ae1fe673f6c5b331b258eb305b7d991d;first-msg=0;subscriber=0;historical=1;mod=0;badges=;display-name=Patixxl;badge-info=;turbo=0;user-id=51967700;user-type=;color=#FF0000;room-id=62300805;flags=;returning-chatter=0;id=54230e05-eb91-4498-84a2-53bb05c1d169 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn vegan","@mod=0;badge-info=subscriber/49;client-nonce=b819213c70d584e2c178bc4edcfd00e5;emote-only=1;returning-chatter=0;user-type=;turbo=0;emotes=300799759:0-7;badges=subscriber/48,bits/25000;display-name=ME_ME;flags=;first-msg=0;room-id=62300805;rm-received-ts=1704558207862;color=#FF2424;tmi-sent-ts=1704558207681;user-id=159210800;subscriber=1;historical=1;id=0e9ee8cc-bf2b-46fa-9c5e-5c93a78bb491 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenBB","@subscriber=0;flags=;user-id=45923155;user-type=;id=5aeef6a8-bc05-49f7-8c77-660691fff28f;mod=0;color=#FF0000;room-id=62300805;returning-chatter=0;first-msg=0;historical=1;badges=bits/1000;badge-info=;tmi-sent-ts=1704558208660;turbo=0;display-name=HajleSellasje;rm-received-ts=1704558208835;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@turbo=0;returning-chatter=0;mod=0;subscriber=0;client-nonce=8921b2cd566a3aeebf63745845f67200;emotes=;rm-received-ts=1704558208957;user-id=38635616;tmi-sent-ts=1704558208779;display-name=KelemvorUber;room-id=62300805;id=24611b0c-0ffb-4ac2-887c-870cc0c83bea;badges=twitch-recap-2023/1;color=#F7FF00;badge-info=;user-type=;historical=1;first-msg=0;flags= :kelemvoruber!kelemvoruber@kelemvoruber.tmi.twitch.tv PRIVMSG #nymn NymnPretendingToEnjoyHisCrappyRemix","@rm-received-ts=1704558209545;turbo=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282;id=b9130abd-39bb-4c10-8699-bf0366b4f5a2;badges=subscriber/9,turbo/1;client-nonce=e4b06b826af6611004071c97149c24b8;display-name=Kotzblitz20;first-msg=0;returning-chatter=0;flags=;mod=0;tmi-sent-ts=1704558209353;user-type=;user-id=40037186;room-id=62300805;subscriber=1;badge-info=subscriber/9;historical=1;color=#FFFF00 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;returning-chatter=0;turbo=0;badge-info=subscriber/53;rm-received-ts=1704558210664;user-type=;badges=subscriber/48;mod=0;user-id=29649547;display-name=orange_bean;historical=1;room-id=62300805;flags=;tmi-sent-ts=1704558210489;id=ead36bca-f116-4bb7-a0b1-d66f1dd4dc17;subscriber=1;color=#FF7F50;emotes= :orange_bean!orange_bean@orange_bean.tmi.twitch.tv PRIVMSG #nymn :OOOO bars","@room-id=62300805;flags=;client-nonce=a11f66001ca32adf4b2eb625fda85522;mod=0;subscriber=0;user-id=51967700;rm-received-ts=1704558210701;turbo=0;historical=1;first-msg=0;display-name=Patixxl;badges=;color=#FF0000;tmi-sent-ts=1704558210509;badge-info=;id=ba53ae1e-e83e-44d0-83dd-8b09ee7b1ece;user-type=;returning-chatter=0;emotes= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=1;historical=1;first-msg=0;badge-info=subscriber/38;mod=0;color=#63BD68;flags=;returning-chatter=0;turbo=0;tmi-sent-ts=1704558211269;rm-received-ts=1704558211447;emotes=;user-id=433352132;room-id=62300805;display-name=jontEmillian;user-type=;badges=subscriber/36,twitch-recap-2023/1;id=ef897dcf-182a-41f8-aea2-ce1d7f907712 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@display-name=HajleSellasje;returning-chatter=0;badges=bits/1000;mod=0;first-msg=0;badge-info=;id=db277f99-707b-423d-9127-f97200f8197d;rm-received-ts=1704558211623;user-type=;user-id=45923155;room-id=62300805;subscriber=0;color=#FF0000;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,17-27,33-43,49-59,65-75,81-91,97-107,113-123,129-139,145-155,161-171,177-187,193-203;tmi-sent-ts=1704558211446;flags=;turbo=0;historical=1 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@subscriber=1;user-id=40037186;badge-info=subscriber/9;mod=0;first-msg=0;rm-received-ts=1704558212912;id=48b59f15-0096-4bbb-8ed3-a19a1668081e;user-type=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106;room-id=62300805;returning-chatter=0;display-name=Kotzblitz20;turbo=1;tmi-sent-ts=1704558212728;historical=1;badges=subscriber/9,turbo/1;color=#FFFF00;flags=;client-nonce=840a243e8ad8c7188d703f555b3e17af :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=b1c249bc-1eb4-4335-80d0-d9dc590b4ec7;client-nonce=d4eda18886fe9bf410c60585925c6dd1;mod=0;flags=;display-name=Patixxl;returning-chatter=0;subscriber=0;historical=1;room-id=62300805;tmi-sent-ts=1704558213076;color=#FF0000;badge-info=;user-id=51967700;badges=;rm-received-ts=1704558213252;emotes=;turbo=0;user-type=;first-msg=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@color=#FF2424;emote-only=1;id=012e7ede-15cb-404f-aff2-45771a83a797;user-id=159210800;historical=1;rm-received-ts=1704558213256;display-name=ME_ME;emotes=300116349:9-16/300799759:0-7;badge-info=subscriber/49;first-msg=0;room-id=62300805;turbo=0;client-nonce=7b82233c57207aaf10dd4d93591aa7a1;returning-chatter=0;mod=0;user-type=;tmi-sent-ts=1704558213077;flags=;subscriber=1;badges=subscriber/48,bits/25000 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :forsenBB SingsMic","@display-name=Phant0mBlades;id=2f667fa1-e582-4458-a082-f31857c128d5;flags=;historical=1;badge-info=subscriber/9;user-id=278896263;subscriber=1;room-id=62300805;returning-chatter=0;mod=0;first-msg=0;tmi-sent-ts=1704558213151;color=#008000;rm-received-ts=1704558213323;badges=subscriber/9,chatter-cs-go-2022/1;user-type=;turbo=0;emotes= :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn Pepepains","@vip=1;turbo=0;user-type=;user-id=87120320;emotes=;room-id=62300805;badges=vip/1,subscriber/72,rplace-2023/1;display-name=Joshlad;subscriber=1;rm-received-ts=1704558213468;badge-info=subscriber/77;historical=1;flags=;color=#D52AFF;returning-chatter=0;first-msg=0;mod=0;id=2058533b-f89d-438c-8cf0-cb95005ce63a;tmi-sent-ts=1704558213278 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;turbo=0;mod=0;badges=bits/100;display-name=DM8917;color=#25E000;first-msg=0;user-id=63372784;flags=;client-nonce=a06c8322bf55c186d4d04f0b2de7a0a3;user-type=;rm-received-ts=1704558213645;id=1a6fd806-f61c-431a-8a51-243afaedf518;tmi-sent-ts=1704558213469;emotes=;badge-info=;room-id=62300805;subscriber=0;historical=1 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn :#showemote vegan","@mod=0;color=#9ACD32;rm-received-ts=1704558214015;turbo=0;historical=1;tmi-sent-ts=1704558213812;client-nonce=e58bcd51a8e42742d976af08244a39bc;user-type=;badge-info=subscriber/42;user-id=68478828;badges=subscriber/42,bits/1000;display-name=wheeely;returning-chatter=0;room-id=62300805;emotes=;flags=;first-msg=0;id=a3a63eaa-6238-4927-bf7c-a68b5c967ca3;subscriber=1 :wheeely!wheeely@wheeely.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@historical=1;rm-received-ts=1704558214515;room-id=62300805;badge-info=;user-id=45923155;badges=bits/1000;id=dc0c4b83-5f02-4c8e-9881-803944c90fba;returning-chatter=0;mod=0;display-name=HajleSellasje;color=#FF0000;tmi-sent-ts=1704558214324;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;flags=;first-msg=0;turbo=0;user-type=;subscriber=0 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@flags=;badge-info=;badges=no_audio/1;emotes=;client-nonce=b48636ad5e758949425518c5c5d8b814;turbo=0;subscriber=0;tmi-sent-ts=1704558214722;rm-received-ts=1704558214923;color=#8A2BE2;id=7d7e3fb9-1d8e-4855-996c-46ed82a3e777;returning-chatter=0;mod=0;user-type=;display-name=Obiwun;user-id=46199261;room-id=62300805;first-msg=0;historical=1 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn up","@emotes=;client-nonce=9e79c6f980c645566cb5fb1d7507d05b;badge-info=;flags=;user-type=;tmi-sent-ts=1704558215934;historical=1;room-id=62300805;turbo=0;subscriber=0;id=bbc633cf-25aa-4777-a13d-1464fbc22da4;mod=0;first-msg=0;badges=;rm-received-ts=1704558216153;color=#FF0000;returning-chatter=0;display-name=Patixxl;user-id=51967700 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=c1ac09b9-32ba-4897-b12c-bdbea202f1c7;emotes=;subscriber=1;mod=0;historical=1;tmi-sent-ts=1704558216228;rm-received-ts=1704558216419;client-nonce=0b6cc37409501651fd73b87ae67604a8;color=#9ACD32;display-name=wheeely;flags=;badge-info=subscriber/42;room-id=62300805;user-id=68478828;badges=subscriber/42,bits/1000;returning-chatter=0;user-type=;turbo=0;first-msg=0 :wheeely!wheeely@wheeely.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;client-nonce=17e37293303044ddbf0e197a6a039a28;id=c6cb5b87-457b-47da-ba51-1ee629795f82;display-name=DontCagePlebs;color=#DAA520;user-id=85837900;mod=0;room-id=62300805;user-type=;tmi-sent-ts=1704558216363;subscriber=1;flags=;returning-chatter=0;first-msg=0;emotes=;badges=subscriber/36,no_audio/1;historical=1;badge-info=subscriber/37;rm-received-ts=1704558216543 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn that","@subscriber=0;user-type=;color=#B22222;rm-received-ts=1704558216994;tmi-sent-ts=1704558216810;badges=;user-id=109362100;room-id=62300805;mod=0;returning-chatter=0;id=c55dfe2e-9a73-4417-a2b5-240ea77e0df8;turbo=0;badge-info=;client-nonce=fe08b067d55e940ce004afbb906b158f;display-name=Shinoerah;flags=;first-msg=0;historical=1;emotes= :shinoerah!shinoerah@shinoerah.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122;id=e0aafa0f-5fd8-4110-9368-39ae73a8b107;tmi-sent-ts=1704558218518;user-type=;client-nonce=df7426e2177ecedd715fea773f0e8bd9;turbo=1;room-id=62300805;first-msg=0;badge-info=subscriber/9;user-id=40037186;color=#FFFF00;subscriber=1;display-name=Kotzblitz20;mod=0;returning-chatter=0;rm-received-ts=1704558218703;flags=;historical=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-id=85115603;client-nonce=542f6d96cf7240e1b4c99df8eb7a5fa4;rm-received-ts=1704558218954;badges=no_audio/1;room-id=62300805;display-name=OfficialScrap;first-msg=0;emotes=emotesv2_01092a3cb1324c17b66c443373e12519:0-6;emote-only=1;subscriber=0;historical=1;turbo=0;tmi-sent-ts=1704558218740;flags=;color=#0000FF;id=13905bb2-7625-4b16-9dc5-cac661d00cbc;returning-chatter=0;user-type=;badge-info=;mod=0 :officialscrap!officialscrap@officialscrap.tmi.twitch.tv PRIVMSG #nymn p00sJAM","@emotes=;client-nonce=ff3b03da2760ca14759555471da4e83b;display-name=Patixxl;rm-received-ts=1704558219617;id=6695a2e7-e9ef-48f4-8163-0395e72c026c;flags=;user-id=51967700;room-id=62300805;first-msg=0;turbo=0;subscriber=0;badge-info=;mod=0;color=#FF0000;badges=;historical=1;returning-chatter=0;user-type=;tmi-sent-ts=1704558219452 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@returning-chatter=0;first-msg=0;client-nonce=20c4bc114bc6056a873c29c74d9364cb;flags=;tmi-sent-ts=1704558219440;emotes=;id=b55fd88f-3cd9-4934-abcc-284f0ae544df;historical=1;badge-info=;turbo=0;color=#0000FF;badges=premium/1;room-id=62300805;rm-received-ts=1704558219643;subscriber=0;mod=0;user-id=36237730;display-name=Raztheman;user-type= :raztheman!raztheman@raztheman.tmi.twitch.tv PRIVMSG #nymn <---","@emotes=;subscriber=1;turbo=0;color=#63BD68;badges=subscriber/36,twitch-recap-2023/1;returning-chatter=0;badge-info=subscriber/38;user-id=433352132;id=06df5f35-8762-426e-99dd-b8ba5322597e;tmi-sent-ts=1704558220587;first-msg=0;display-name=jontEmillian;flags=;mod=0;historical=1;room-id=62300805;rm-received-ts=1704558220758;user-type= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn Concerned","@flags=;emotes=;historical=1;id=f4f5376a-1654-4a9e-8167-4b81dc130377;mod=0;badge-info=subscriber/77;returning-chatter=0;vip=1;user-id=87120320;badges=vip/1,subscriber/72,rplace-2023/1;user-type=;turbo=0;subscriber=1;display-name=Joshlad;room-id=62300805;color=#D52AFF;rm-received-ts=1704558221293;first-msg=0;tmi-sent-ts=1704558221139 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn HUH","@mod=0;subscriber=1;emotes=;display-name=SecretCarrot;historical=1;first-msg=0;rm-received-ts=1704558221481;color=#00615C;turbo=0;tmi-sent-ts=1704558221312;badge-info=subscriber/55;returning-chatter=0;user-type=;room-id=62300805;flags=;id=e62b5ca4-2683-4a65-a3d7-28bac3523254;badges=subscriber/54,bits/1000;client-nonce=68bc13d6a87381b4d797b7073a4f2b28;user-id=103592036 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn HUH","@flags=;badge-info=subscriber/29;first-msg=0;returning-chatter=0;mod=0;rm-received-ts=1704558221513;turbo=0;badges=subscriber/24,sub-gifter/5;id=6f2679ef-817b-4fac-8d8d-fde1ea356df0;display-name=PepePge;historical=1;emotes=425618:27-29;room-id=62300805;tmi-sent-ts=1704558221342;subscriber=1;client-nonce=7bf152662cc24fa515fa28944e2f04e6;color=#9ACD32;user-id=544395745;user-type= :pepepge!pepepge@pepepge.tmi.twitch.tv PRIVMSG #nymn :did a poro make this game? LUL","@display-name=Kotzblitz20;rm-received-ts=1704558221569;user-id=40037186;flags=;badges=subscriber/9,turbo/1;returning-chatter=0;id=6db9bd08-e46c-4035-8c3a-8f2d4bac0df9;subscriber=1;tmi-sent-ts=1704558221396;client-nonce=390a766d0665389aedb2dfb702ff5c0b;historical=1;first-msg=0;mod=0;user-type=;color=#FFFF00;badge-info=subscriber/9;turbo=1;room-id=62300805;emotes= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn Concerned","@badges=twitch-recap-2023/1;user-type=;user-id=38635616;rm-received-ts=1704558221931;id=10824ced-3c2f-4a26-86bc-33e7b4eee126;emotes=;historical=1;subscriber=0;returning-chatter=0;client-nonce=8939dcb4340b62660c43232d70e1cc25;mod=0;room-id=62300805;badge-info=;tmi-sent-ts=1704558221760;flags=;first-msg=0;display-name=KelemvorUber;color=#F7FF00;turbo=0 :kelemvoruber!kelemvoruber@kelemvoruber.tmi.twitch.tv PRIVMSG #nymn :always left","@rm-received-ts=1704558222731;display-name=mnqn18;subscriber=1;historical=1;emotes=;mod=0;user-type=;first-msg=0;returning-chatter=0;tmi-sent-ts=1704558222548;badge-info=subscriber/2;id=107f12c8-cd85-4db6-ab52-cf28d0b33093;room-id=62300805;color=#1E90FF;client-nonce=641985ae66ce2fdbfdbf1b1d9865fa98;badges=subscriber/0,premium/1;flags=;user-id=474204887;turbo=0 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn HUH","@user-type=;turbo=0;rm-received-ts=1704558223092;emotes=;room-id=62300805;client-nonce=8243d06b2d8a99883a56516b046b3470;display-name=Patixxl;flags=;tmi-sent-ts=1704558222923;subscriber=0;badge-info=;historical=1;user-id=51967700;id=d45ae9af-27a4-4d28-9d47-4048b9a00784;returning-chatter=0;first-msg=0;color=#FF0000;badges=;mod=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn HUH","@turbo=0;id=236de330-57cf-4386-b758-7b9eecbe6ce2;user-type=;user-id=222340799;returning-chatter=0;emotes=;badges=subscriber/6,chatter-cs-go-2022/1;badge-info=subscriber/7;rm-received-ts=1704558223208;subscriber=1;color=#B22222;tmi-sent-ts=1704558223014;mod=0;room-id=62300805;historical=1;client-nonce=1a4f40efea22d22c3edc1082ed4bbf94;first-msg=0;display-name=crazyjuni0r_;flags= :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn HUH","@mod=0;returning-chatter=0;user-id=103665668;id=16c3ac01-33db-4065-83f3-27582f0f4bbe;user-type=;turbo=0;historical=1;first-msg=0;subscriber=0;badges=bits-charity/1;rm-received-ts=1704558223794;color=#0000FF;flags=;room-id=62300805;display-name=Intel_power;badge-info=;emotes=;client-nonce=6392bd15ea31c67a96244799eb9f730d;tmi-sent-ts=1704558223609 :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn Gayge","@color=#9ACD32;subscriber=0;id=b1f8cdcd-576b-49e5-b5f8-985d3a4efb0f;room-id=62300805;tmi-sent-ts=1704558223852;badges=twitch-recap-2023/1;client-nonce=f12d54f6a6ed9a6027c9345cb4354144;badge-info=;emotes=;flags=;historical=1;first-msg=0;mod=0;display-name=theKiryu;turbo=0;user-id=135853293;rm-received-ts=1704558224024;user-type=;returning-chatter=0 :thekiryu!thekiryu@thekiryu.tmi.twitch.tv PRIVMSG #nymn ????","@turbo=1;badge-info=subscriber/9;room-id=62300805;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58;historical=1;tmi-sent-ts=1704558223958;color=#FFFF00;flags=;client-nonce=a59c8038ada882868f5dccc6f858e9bf;display-name=Kotzblitz20;returning-chatter=0;user-id=40037186;user-type=;mod=0;first-msg=0;id=b751a54d-3844-496c-980f-c3f3d75d9add;rm-received-ts=1704558224152;badges=subscriber/9,turbo/1;subscriber=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#000000;badge-info=;subscriber=0;user-id=205837377;tmi-sent-ts=1704558224324;client-nonce=782c1eb8c40a0704d92a886a6dc3b68a;returning-chatter=0;mod=0;id=d8fd79ea-dee8-41d6-a7ac-14ae49a2ad3a;display-name=Duchene;turbo=0;room-id=62300805;badges=;emotes=;first-msg=0;user-type=;flags=;rm-received-ts=1704558224500;historical=1 :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn HUH'","@rm-received-ts=1704558224690;emotes=;first-msg=0;flags=;user-type=;turbo=0;badge-info=;returning-chatter=0;historical=1;display-name=BastunGuy1;user-id=232078107;subscriber=0;client-nonce=9b54e6ce823c0c29e0a65f1633a57be4;tmi-sent-ts=1704558224485;id=28f5d627-5fbb-4930-ba79-6494c0cdfb45;room-id=62300805;mod=0;badges=no_audio/1;color=#008000 :bastunguy1!bastunguy1@bastunguy1.tmi.twitch.tv PRIVMSG #nymn Corncerned","@returning-chatter=0;id=857f8b17-d7f9-43b8-8f4c-6e95aeaa8306;historical=1;mod=0;badge-info=;user-id=37931493;flags=;badges=no_audio/1;display-name=deever44;first-msg=0;rm-received-ts=1704558225088;subscriber=0;emotes=;client-nonce=a19c41ed0b5acf782bd483c005ce6112;color=;room-id=62300805;turbo=0;tmi-sent-ts=1704558224915;user-type= :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn HUH","@turbo=0;returning-chatter=0;client-nonce=9857c522a35bea622d65cba536f101dd;room-id=62300805;id=db445036-c33d-4679-8168-630820812998;subscriber=0;user-id=133344079;user-type=;flags=;color=#5F9EA0;badges=no_audio/1;emotes=;tmi-sent-ts=1704558224950;rm-received-ts=1704558225138;first-msg=0;mod=0;badge-info=;display-name=sehtt_;historical=1 :sehtt_!sehtt_@sehtt_.tmi.twitch.tv PRIVMSG #nymn Corncerned","@first-msg=0;turbo=0;badges=twitch-recap-2023/1;mod=0;client-nonce=da9edb223c233e806137137acd23c1f7;returning-chatter=0;subscriber=0;color=#10E2E2;historical=1;badge-info=;tmi-sent-ts=1704558225186;flags=;room-id=62300805;rm-received-ts=1704558225363;id=c874099e-b05a-450a-9fa7-dd42342134e9;user-id=167633177;emotes=;display-name=ALotOfChickens;user-type= :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn LULE","@emotes=;color=#008000;user-id=278896263;tmi-sent-ts=1704558226128;first-msg=0;flags=;room-id=62300805;subscriber=1;id=df471785-73ef-4765-80db-7c25ef94cd8b;badge-info=subscriber/9;turbo=0;display-name=Phant0mBlades;returning-chatter=0;user-type=;historical=1;rm-received-ts=1704558226317;badges=subscriber/9,chatter-cs-go-2022/1;mod=0 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn Concerned","@tmi-sent-ts=1704558226528;id=f32c091f-99e7-4b39-9f01-2cdbf546b0e8;user-type=;subscriber=1;emotes=;first-msg=0;rm-received-ts=1704558226715;room-id=62300805;badge-info=subscriber/15;returning-chatter=0;badges=subscriber/12,sub-gifter/5;historical=1;turbo=0;display-name=WhideX;color=#FAD130;client-nonce=6e63b8e40bbbb851c9370f758cad08db;mod=0;flags=;user-id=32852911 :whidex!whidex@whidex.tmi.twitch.tv PRIVMSG #nymn :doctorLeMonkePls where is lemonkeh","@subscriber=1;mod=0;flags=;badge-info=subscriber/9;display-name=Kotzblitz20;historical=1;room-id=62300805;user-type=;rm-received-ts=1704558227058;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138;returning-chatter=0;turbo=1;first-msg=0;client-nonce=97dce3755ff6270dd513a7d97975a2e0;user-id=40037186;id=51b4d0e9-9ade-448a-b797-bc7d3c575cdc;color=#FFFF00;tmi-sent-ts=1704558226865 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;first-msg=0;subscriber=1;id=4d8afb5a-4010-47ab-a78e-fe5626e26023;emotes=;mod=0;color=#DAA520;badges=subscriber/36,no_audio/1;tmi-sent-ts=1704558227324;turbo=0;room-id=62300805;user-id=85837900;flags=;badge-info=subscriber/37;display-name=DontCagePlebs;historical=1;client-nonce=5a47549217db9176ece84064a2702e64;rm-received-ts=1704558227491;user-type= :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn Concerned","@badges=subscriber/36,twitch-recap-2023/1;display-name=jontEmillian;emotes=;badge-info=subscriber/38;user-id=433352132;tmi-sent-ts=1704558227854;flags=;returning-chatter=0;color=#63BD68;mod=0;subscriber=1;room-id=62300805;turbo=0;rm-received-ts=1704558228032;historical=1;user-type=;id=de7d9806-30da-42a8-aaea-0b94639b509e;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@color=#FF0000;badges=;client-nonce=3a1035336e2636f20e9cefbdef867343;flags=;first-msg=0;returning-chatter=0;user-id=51967700;badge-info=;subscriber=0;mod=0;historical=1;rm-received-ts=1704558228200;turbo=0;emotes=;tmi-sent-ts=1704558228013;user-type=;room-id=62300805;display-name=Patixxl;id=ecc6d0e2-d998-4f0c-9eae-dfe539217c2f :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@tmi-sent-ts=1704558228290;user-id=159210800;badges=subscriber/48,bits/25000;mod=0;first-msg=0;turbo=0;user-type=;client-nonce=9f9b7d79143d37e46881fdf211942220;display-name=ME_ME;flags=;id=3349c792-b3e5-4933-b963-26a66336ca63;historical=1;emote-only=1;returning-chatter=0;color=#FF2424;badge-info=subscriber/49;subscriber=1;emotes=300799759:0-7;rm-received-ts=1704558228468;room-id=62300805 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenBB","@flags=;badge-info=subscriber/2;rm-received-ts=1704558229149;subscriber=1;historical=1;mod=0;turbo=0;color=#FF69B4;tmi-sent-ts=1704558228978;user-id=22733078;badges=subscriber/0,bits/1;returning-chatter=0;user-type=;emotes=;id=4106f91f-7d43-48bc-9667-166ea9e6ba77;room-id=62300805;client-nonce=a1f82be67dd23bad82e1442f786c9a52;display-name=miniwoffer;first-msg=0 :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn :Ratge Ratbattle","@subscriber=1;historical=1;first-msg=0;client-nonce=e52bff711d003bd2c9eabfe85a18c268;flags=;user-id=40037186;display-name=Kotzblitz20;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58;badge-info=subscriber/9;tmi-sent-ts=1704558230099;room-id=62300805;id=4ac512df-6f66-477d-bd0f-33cecffeb0d0;user-type=;color=#FFFF00;badges=subscriber/9,turbo/1;turbo=1;mod=0;rm-received-ts=1704558230284 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=subscriber/51;historical=1;room-id=62300805;display-name=melito87;first-msg=0;user-id=35997610;id=597453bf-757c-4e2d-bee9-14c14b721a94;color=#008000;subscriber=1;turbo=0;tmi-sent-ts=1704558230227;client-nonce=edc2bb37d1376ea9547f9670a4da0bfb;user-type=;badges=subscriber/48,twitch-recap-2023/1;flags=;emotes=;rm-received-ts=1704558230408;mod=0;returning-chatter=0 :melito87!melito87@melito87.tmi.twitch.tv PRIVMSG #nymn DonkPls","@badges=;display-name=Patixxl;historical=1;first-msg=0;rm-received-ts=1704558231032;tmi-sent-ts=1704558230856;badge-info=;turbo=0;returning-chatter=0;client-nonce=8f93b3e7e0de063d9cba9dac62267be4;room-id=62300805;subscriber=0;mod=0;user-type=;id=4df42aa9-fe22-4d24-bc1f-d65cea09bd0a;emotes=;user-id=51967700;flags=;color=#FF0000 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-id=159210800;mod=0;room-id=62300805;rm-received-ts=1704558231049;badge-info=subscriber/49;returning-chatter=0;user-type=;emote-only=1;tmi-sent-ts=1704558230870;badges=subscriber/48,bits/25000;id=aff7f8a0-7f0b-4703-9a83-b74f2f1be13d;first-msg=0;color=#FF2424;historical=1;display-name=ME_ME;turbo=0;client-nonce=b9d4ea3cd0ccd2dc9b21de9a1de746ba;flags=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;subscriber=1 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenParty","@flags=;historical=1;rm-received-ts=1704558232565;emotes=;returning-chatter=0;tmi-sent-ts=1704558232372;color=#8A2BE2;client-nonce=e98dc3b209b403537959d35a23ec2d2f;room-id=62300805;first-msg=0;mod=0;user-id=46199261;turbo=0;subscriber=0;user-type=;badges=no_audio/1;id=c302a6ae-f299-4fd6-a298-3e66d72b5d5e;badge-info=;display-name=Obiwun :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn NowWot","@mod=0;emotes=emotesv2_2f9a36844b054423833c817b5f8d4225:0-8;subscriber=0;display-name=zzlint;badges=;client-nonce=4b2d731da157c7e579169b1612e9f200;color=#1E90FF;turbo=0;id=a0d66900-ee81-4c75-8eab-4b4b06208794;room-id=62300805;first-msg=0;historical=1;flags=;user-id=29764188;returning-chatter=0;badge-info=;rm-received-ts=1704558232703;emote-only=1;user-type=;tmi-sent-ts=1704558232530 :zzlint!zzlint@zzlint.tmi.twitch.tv PRIVMSG #nymn forsenPls","@room-id=62300805;emotes=;client-nonce=cafdaa958a96a67bb92177ba6259f4be;tmi-sent-ts=1704558232712;id=8c655aa9-b507-427f-bba6-8b9cefd183cb;rm-received-ts=1704558232879;subscriber=0;user-id=135853293;badges=twitch-recap-2023/1;historical=1;mod=0;color=#9ACD32;user-type=;flags=;returning-chatter=0;turbo=0;badge-info=;first-msg=0;display-name=theKiryu :thekiryu!thekiryu@thekiryu.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,17-27,33-43,49-59,65-75,81-91,97-107,113-123,129-139,145-155,161-171,177-187,193-203;first-msg=0;id=918ad345-8302-409a-810a-f7d3cd990dd5;rm-received-ts=1704558233608;color=#FF0000;user-type=;returning-chatter=0;tmi-sent-ts=1704558233428;room-id=62300805;turbo=0;historical=1;user-id=45923155;badges=bits/1000;subscriber=0;mod=0;badge-info=;display-name=HajleSellasje;flags= :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@id=13de11dc-da21-46d9-b0f7-1f7e6203c6d6;historical=1;user-id=117088592;rm-received-ts=1704558233807;display-name=h_h410;turbo=0;mod=0;emotes=684692:0-6;tmi-sent-ts=1704558233611;first-msg=0;subscriber=1;room-id=62300805;user-type=;color=#00FF7F;returning-chatter=0;badges=subscriber/54,chatter-cs-go-2022/1;badge-info=subscriber/54;flags= :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn :forsenL PianoTime","@room-id=62300805;user-id=40037186;first-msg=0;flags=;subscriber=1;badges=subscriber/9,turbo/1;color=#FFFF00;id=72f74036-db1d-4416-b87d-fbe62ef77606;badge-info=subscriber/9;rm-received-ts=1704558234322;returning-chatter=0;tmi-sent-ts=1704558234153;mod=0;turbo=1;user-type=;historical=1;emotes=;client-nonce=0be525ec8c7b66646edee4cbf595a836;display-name=Kotzblitz20 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn FeelsWeirdMan","@client-nonce=0d54a5dca5773953600a2b07960510c5;badges=subscriber/36,no_audio/1;rm-received-ts=1704558235635;user-id=85837900;flags=;mod=0;turbo=0;display-name=DontCagePlebs;first-msg=0;user-type=;historical=1;badge-info=subscriber/37;color=#DAA520;id=2d491eee-7757-4799-8f17-0fc538735226;returning-chatter=0;subscriber=1;emotes=;room-id=62300805;tmi-sent-ts=1704558235452 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn Listening","@subscriber=0;emotes=;historical=1;user-id=51967700;room-id=62300805;color=#FF0000;mod=0;first-msg=0;turbo=0;badge-info=;user-type=;returning-chatter=0;tmi-sent-ts=1704558235610;badges=;client-nonce=1136cba2a04b2132b956f42a02b2dc8a;display-name=Patixxl;rm-received-ts=1704558235785;flags=;id=6e30133d-fd05-4675-81a0-eb5c9e2258bc :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn Listening","@user-id=35997610;display-name=melito87;tmi-sent-ts=1704558235866;subscriber=1;historical=1;id=511de0a6-72f7-4c89-bb3c-8ce5f2d52790;flags=;badges=subscriber/48,twitch-recap-2023/1;badge-info=subscriber/51;returning-chatter=0;client-nonce=58983d4cef6f60b72541c99b5ef10a5a;mod=0;first-msg=0;rm-received-ts=1704558236097;room-id=62300805;emotes=;turbo=0;color=#008000;user-type= :melito87!melito87@melito87.tmi.twitch.tv PRIVMSG #nymn Listening","@mod=0;returning-chatter=0;user-id=69072013;emotes=;historical=1;turbo=0;badge-info=subscriber/17;first-msg=0;id=a84debfc-6793-4a2c-a19d-f99b5bfecd93;badges=subscriber/12,twitch-recap-2023/1;color=#0000FF;subscriber=1;rm-received-ts=1704558236223;client-nonce=5a31d0f9b31c11e060cbf40608dff124;tmi-sent-ts=1704558236047;room-id=62300805;flags=;display-name=SnuggleUncle;user-type= :snuggleuncle!snuggleuncle@snuggleuncle.tmi.twitch.tv PRIVMSG #nymn doctorLeMonkePls","@rm-received-ts=1704558236406;first-msg=0;flags=;tmi-sent-ts=1704558236231;room-id=62300805;returning-chatter=0;display-name=Kotzblitz20;id=0a60976d-4da7-45a3-98c7-efc6f82bf566;client-nonce=22702fc3ca38b4db24aa635ad2ecb5ab;user-type=;color=#FFFF00;emotes=;turbo=1;historical=1;badge-info=subscriber/9;user-id=40037186;subscriber=1;mod=0;badges=subscriber/9,turbo/1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn FeelsOkayMan","@user-id=87120320;user-type=;turbo=0;badge-info=subscriber/77;vip=1;mod=0;tmi-sent-ts=1704558236236;room-id=62300805;subscriber=1;rm-received-ts=1704558236409;display-name=Joshlad;color=#D52AFF;flags=;returning-chatter=0;emotes=emotesv2_11eff6a54749464c9dfa40570dd356bd:0-7;historical=1;id=b1dd88b1-e757-4f48-be25-f2660e24d9ac;badges=vip/1,subscriber/72,rplace-2023/1;first-msg=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :elisElis PianoTime","@tmi-sent-ts=1704558236400;turbo=0;first-msg=0;rm-received-ts=1704558236586;emotes=;display-name=MaxThurian;color=#00ED2A;client-nonce=819f5873de0312ca4856c51769e03293;user-id=60181947;badges=subscriber/42,twitch-recap-2023/1;historical=1;subscriber=1;user-type=;mod=0;returning-chatter=0;badge-info=subscriber/43;room-id=62300805;id=79445cd0-0e3f-4153-a868-e969a53e95a1;flags= :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn AlienUnpleased","@display-name=boogkitty;user-type=;id=c213a2c3-4308-4122-99cd-b0a6c1b4bc68;tmi-sent-ts=1704558237380;user-id=154079285;subscriber=1;flags=;returning-chatter=0;turbo=0;room-id=62300805;color=#00FF7F;first-msg=0;emotes=;badges=subscriber/36;rm-received-ts=1704558237545;client-nonce=08a1cff4c641292bcc832efcaf00c22a;badge-info=subscriber/41;mod=0;historical=1 :boogkitty!boogkitty@boogkitty.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime 󠀀","@turbo=0;historical=1;badges=bits/1000;returning-chatter=0;mod=0;flags=;emotes=;rm-received-ts=1704558237958;user-id=45923155;tmi-sent-ts=1704558237787;first-msg=0;subscriber=0;id=36029869-7918-4a73-a9a0-937fa8f42e13;user-type=;badge-info=;color=#FF0000;room-id=62300805;display-name=HajleSellasje :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn Listening","@display-name=jontEmillian;first-msg=0;returning-chatter=0;badge-info=subscriber/38;flags=;historical=1;rm-received-ts=1704558238123;subscriber=1;emotes=;user-id=433352132;tmi-sent-ts=1704558237959;badges=subscriber/36,twitch-recap-2023/1;turbo=0;user-type=;mod=0;room-id=62300805;id=d831ed70-cc93-49d5-8b30-265717a30f3f;color=#63BD68 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn Life","@color=#9ACD32;id=eeb40d48-f4ca-4fe1-9dd1-0e3ee5e60001;flags=;turbo=0;emotes=;historical=1;tmi-sent-ts=1704558238507;room-id=62300805;display-name=PepePge;badges=subscriber/24,sub-gifter/5;subscriber=1;badge-info=subscriber/29;user-id=544395745;user-type=;returning-chatter=0;mod=0;client-nonce=86a9b509b1082e35e0fabdd8c74d5132;first-msg=0;rm-received-ts=1704558238680 :pepepge!pepepge@pepepge.tmi.twitch.tv PRIVMSG #nymn doctorLeMonkePls","@client-nonce=5a9c8701326742cedfa460ed9ec83aa0;returning-chatter=0;badge-info=;mod=0;flags=;turbo=0;historical=1;display-name=ALotOfChickens;room-id=62300805;user-id=167633177;first-msg=0;subscriber=0;tmi-sent-ts=1704558238667;rm-received-ts=1704558238859;user-type=;badges=twitch-recap-2023/1;emotes=;color=#10E2E2;id=773ad414-e2cf-4591-ab89-a9d75ebbcea7 :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn :Ratge PianoTime","@turbo=1;rm-received-ts=1704558240268;first-msg=0;historical=1;color=#FFFF00;badges=subscriber/9,turbo/1;mod=0;user-type=;returning-chatter=0;subscriber=1;display-name=Kotzblitz20;id=7d531d06-1573-4319-824c-7cf947a2d8b2;tmi-sent-ts=1704558240087;emotes=;client-nonce=49c72507f6f1d4de73b47e169bef09e1;room-id=62300805;flags=;badge-info=subscriber/9;user-id=40037186 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn Listening","@historical=1;rm-received-ts=1704558240269;badge-info=subscriber/54;id=9fdbaa4b-2d9c-49b7-a8f4-30c99ee3618c;emote-only=1;color=#00FF7F;subscriber=1;badges=subscriber/54,chatter-cs-go-2022/1;returning-chatter=0;mod=0;tmi-sent-ts=1704558240084;turbo=0;first-msg=0;display-name=h_h410;user-id=117088592;room-id=62300805;user-type=;flags=;emotes=31021:0-6 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn forsenW","@subscriber=1;badges=subscriber/24,no_audio/1;emotes=;display-name=Tad0ch;flags=;mod=0;badge-info=subscriber/26;user-type=;turbo=0;first-msg=0;id=714bf216-1b0c-42b4-b626-2ce6c02e9f1a;user-id=110476113;room-id=62300805;tmi-sent-ts=1704558241625;color=#B22222;returning-chatter=0;client-nonce=54523ea77020fcefa6105f70a851baec;historical=1;rm-received-ts=1704558241823 :tad0ch!tad0ch@tad0ch.tmi.twitch.tv PRIVMSG #nymn :nymn? catAsk","@turbo=0;flags=;display-name=deever44;id=79d673ab-b725-4751-97be-ad7b2aa01a20;badge-info=;color=;user-id=37931493;subscriber=0;tmi-sent-ts=1704558241671;historical=1;returning-chatter=0;user-type=;mod=0;rm-received-ts=1704558241837;room-id=62300805;client-nonce=62314f590be588513f3134ddf9a610e2;first-msg=0;emotes=emotesv2_11eff6a54749464c9dfa40570dd356bd:0-7;badges=no_audio/1 :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn :elisElis PianoTime","@flags=;user-id=278896263;rm-received-ts=1704558242068;historical=1;mod=0;badges=subscriber/9,chatter-cs-go-2022/1;emotes=;room-id=62300805;display-name=Phant0mBlades;tmi-sent-ts=1704558241899;first-msg=0;id=4165444c-fa5d-4bc8-9784-25fd51ab4b3e;badge-info=subscriber/9;returning-chatter=0;color=#008000;turbo=0;subscriber=1;user-type= :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :2023 weeb arc 2024 furry arc Aware","@badges=vip/1,subscriber/72,rplace-2023/1;flags=;emotes=emotesv2_11eff6a54749464c9dfa40570dd356bd:0-7;mod=0;color=#D52AFF;room-id=62300805;badge-info=subscriber/77;first-msg=0;user-type=;subscriber=1;user-id=87120320;id=f6513acc-338c-4adb-872b-3cb0a5928b37;historical=1;rm-received-ts=1704558243297;returning-chatter=0;vip=1;display-name=Joshlad;tmi-sent-ts=1704558243121;turbo=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :elisElis PianoTime","@color=#00CC00;user-type=;badges=subscriber/6;returning-chatter=0;first-msg=0;turbo=0;historical=1;client-nonce=9396760182f94c208fd907379b9db206;user-id=35778622;display-name=jollyaustin1;badge-info=subscriber/7;id=8413fb34-d789-4d44-bb31-6fbfaca5085c;emotes=emotesv2_ba11c3b03cfd40f9bfa52851d03e4bdc:0-10,12-22,24-34,36-46;room-id=62300805;emote-only=1;tmi-sent-ts=1704558243173;subscriber=1;mod=0;rm-received-ts=1704558243367;flags= :jollyaustin1!jollyaustin1@jollyaustin1.tmi.twitch.tv PRIVMSG #nymn :jaboodyVibe jaboodyVibe jaboodyVibe jaboodyVibe","@tmi-sent-ts=1704558245601;returning-chatter=0;emote-only=1;historical=1;id=22057025-b7b1-4cfd-98a7-83ec1f80be1f;display-name=jonhycrack;color=#008000;user-type=;mod=0;badge-info=;flags=;emotes=31021:0-6;rm-received-ts=1704558245806;badges=no_audio/1;room-id=62300805;user-id=431946171;subscriber=0;turbo=0;first-msg=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn forsenW","@user-id=40037186;subscriber=1;historical=1;tmi-sent-ts=1704558245763;client-nonce=66b87f5fa7a6b892285522c84d47d7c2;badge-info=subscriber/9;display-name=Kotzblitz20;flags=;mod=0;emotes=;returning-chatter=0;first-msg=0;color=#FFFF00;turbo=1;rm-received-ts=1704558245937;id=6d2d7bc0-7b07-4985-baba-f2769e258981;badges=subscriber/9,turbo/1;room-id=62300805;user-type= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :Listening okay","@room-id=62300805;rm-received-ts=1704558248154;mod=0;user-type=;first-msg=0;emotes=;tmi-sent-ts=1704558247980;turbo=0;subscriber=0;badges=;id=6f0e4b33-9e32-4101-91dd-5f6454735b32;flags=;returning-chatter=0;display-name=123homo;user-id=133862911;badge-info=;color=#FF0000;client-nonce=e87c7a52e31410d47a51a845a33de3c3;historical=1 :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn :I HATE ROBOTS","@historical=1;first-msg=0;badges=subscriber/48,bits/25000;subscriber=1;returning-chatter=0;flags=;user-id=159210800;emotes=;badge-info=subscriber/49;client-nonce=1077e8a58a4d3eccf8d477f206eefc2d;display-name=ME_ME;id=625d3098-2544-4ed8-bbab-03aea8a75bf4;user-type=;rm-received-ts=1704558249138;mod=0;room-id=62300805;color=#FF2424;tmi-sent-ts=1704558248934;turbo=0 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :Ratge PianoTime","@display-name=forsenkkona_;room-id=62300805;emotes=31021:0-6;badge-info=;first-msg=0;user-type=;badges=;emote-only=1;user-id=151423066;subscriber=0;mod=0;color=#FF69B4;turbo=0;tmi-sent-ts=1704558249935;historical=1;flags=;id=61c00d37-d162-417b-a643-9eb235d2fb0b;rm-received-ts=1704558250118;returning-chatter=0 :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn forsenW","@user-type=;rm-received-ts=1704558250972;subscriber=1;user-id=103592036;mod=0;badges=subscriber/54,bits/1000;emotes=300740045:0-7;badge-info=subscriber/55;display-name=SecretCarrot;id=55ac1256-2938-4d27-ac4b-7b6bf01bfcb4;turbo=0;tmi-sent-ts=1704558250799;color=#00615C;returning-chatter=0;room-id=62300805;client-nonce=de42bdc9495986fc2f785cd0a8a68c28;first-msg=0;flags=;historical=1 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn :nymnOkey PianoTime","@historical=1;badge-info=;tmi-sent-ts=1704558251693;emotes=;turbo=0;client-nonce=f39deef850c729323b43d79168b82a54;first-msg=0;returning-chatter=0;display-name=Sailx;rm-received-ts=1704558251879;color=#B22222;mod=0;id=b860bc9b-fba9-4393-81cb-2aa6fcf56ca2;flags=;room-id=62300805;badges=glitchcon2020/1;subscriber=0;user-type=;user-id=40181060 :sailx!sailx@sailx.tmi.twitch.tv PRIVMSG #nymn :im relaxed pal","@room-id=62300805;mod=0;color=#B22222;id=0afec80d-fe09-40b9-b42b-35af5540e3fb;user-type=;user-id=222340799;tmi-sent-ts=1704558251935;client-nonce=4ea2d3683d13ac062e60a28d7ea686e5;display-name=crazyjuni0r_;badges=subscriber/6,chatter-cs-go-2022/1;emotes=;historical=1;badge-info=subscriber/7;first-msg=0;rm-received-ts=1704558252114;subscriber=1;turbo=0;returning-chatter=0;flags= :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn 2024NymN","@user-id=117088592;badge-info=subscriber/54;emotes=;badges=subscriber/54,chatter-cs-go-2022/1;color=#00FF7F;turbo=0;user-type=;tmi-sent-ts=1704558253116;historical=1;returning-chatter=0;room-id=62300805;display-name=h_h410;rm-received-ts=1704558253308;id=80ebc3ad-db2d-4b83-987b-873ed316cd17;flags=;first-msg=0;subscriber=1;mod=0 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn forseninsane","@tmi-sent-ts=1704558253145;room-id=62300805;user-id=85115603;subscriber=0;user-type=;client-nonce=d787e5e2c41c80a096df278f4daa6924;historical=1;emotes=;display-name=OfficialScrap;badges=no_audio/1;color=#0000FF;mod=0;id=a47f89c4-37aa-4101-afac-d14e4d98a341;returning-chatter=0;badge-info=;first-msg=0;turbo=0;flags=;rm-received-ts=1704558253349 :officialscrap!officialscrap@officialscrap.tmi.twitch.tv PRIVMSG #nymn relax","@historical=1;mod=0;turbo=0;display-name=lustforlife777;client-nonce=f68e38a70330c2b734fe06632d72d412;emotes=;badges=;user-id=748810228;first-msg=0;subscriber=0;rm-received-ts=1704558255033;flags=;returning-chatter=0;room-id=62300805;tmi-sent-ts=1704558254848;id=e61a5184-2694-47e7-a936-8603dc420741;user-type=;badge-info=;color= :lustforlife777!lustforlife777@lustforlife777.tmi.twitch.tv PRIVMSG #nymn :PagMan LEAN","@turbo=0;historical=1;user-id=765575336;emotes=;returning-chatter=0;id=c2eb1c85-2443-4705-bce2-48c8695667ed;badges=subscriber/3;user-type=;flags=;first-msg=0;client-nonce=b2123d92cdbbc3dbb343848a96febee5;subscriber=1;display-name=drtumbleweed1;room-id=62300805;badge-info=subscriber/5;tmi-sent-ts=1704558254965;color=#8A2BE2;mod=0;rm-received-ts=1704558255155 :drtumbleweed1!drtumbleweed1@drtumbleweed1.tmi.twitch.tv PRIVMSG #nymn :relax, will ya? @NymN","@historical=1;badge-info=subscriber/49;room-id=62300805;mod=0;display-name=ME_ME;flags=;returning-chatter=0;client-nonce=312a76f280971dd366c6814611645963;emotes=;id=9c25f9b8-40a2-46f0-98f4-ff56f8763503;badges=subscriber/48,bits/25000;rm-received-ts=1704558255595;user-type=;first-msg=0;user-id=159210800;color=#FF2424;tmi-sent-ts=1704558255435;subscriber=1;turbo=0 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :Ratge PianoTime 󠀀","@emotes=emotesv2_7e256eaeb9cf4316bd2f0d7acd8ced78:107-115;returning-chatter=0;turbo=0;room-id=62300805;user-type=mod;user-id=97661864;historical=1;display-name=botnextdoor;badges=moderator/1,subscriber/72;badge-info=subscriber/101;tmi-sent-ts=1704558255942;subscriber=1;flags=;first-msg=0;id=b0d4ce2a-7e94-4322-b3b5-30a0d2fb9fd7;mod=1;color=#FF69B4;rm-received-ts=1704558256116 :botnextdoor!botnextdoor@botnextdoor.tmi.twitch.tv PRIVMSG #nymn :\u0001ACTION Make sure you follow NymN on Twitter to stay up-to-date on stream information: https://twitter.com/nymnion nymnBenis\u0001","@client-nonce=d8e626a3a2ff2d951b37ac92e6e32b9e;color=#8A2BE2;user-type=;emotes=;display-name=Obiwun;badges=no_audio/1;historical=1;badge-info=;turbo=0;rm-received-ts=1704558256176;room-id=62300805;tmi-sent-ts=1704558255984;subscriber=0;first-msg=0;id=e19e930e-f7b2-4006-a8bd-f8c7f79f6916;flags=;user-id=46199261;returning-chatter=0;mod=0 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn :LEAN PagMan","@room-id=62300805;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;turbo=0;returning-chatter=0;tmi-sent-ts=1704558256029;first-msg=0;historical=1;rm-received-ts=1704558256209;mod=0;subscriber=0;badges=no_audio/1;badge-info=;display-name=jonhycrack;flags=;id=35c6e928-e8fe-4a5a-ad20-c28eaa69d327;user-id=431946171;user-type=;color=#008000;emote-only=1 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn forsenPossessed","@flags=;rm-received-ts=1704558256213;returning-chatter=0;tmi-sent-ts=1704558256035;id=166acfe3-8671-43d7-9236-082e25060cdc;historical=1;turbo=1;first-msg=0;client-nonce=505dd03bd2b4cacb1198c51b66f43813;color=#FFFF00;badges=subscriber/9,turbo/1;display-name=Kotzblitz20;subscriber=1;mod=0;room-id=62300805;user-type=;badge-info=subscriber/9;emotes=;user-id=40037186 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@returning-chatter=0;rm-received-ts=1704558256716;flags=;mod=0;badge-info=subscriber/38;user-id=433352132;user-type=;color=#63BD68;id=fa3c37e7-ba76-4657-b8c2-f3829cb79c7b;emotes=;badges=subscriber/36,twitch-recap-2023/1;room-id=62300805;tmi-sent-ts=1704558256539;subscriber=1;historical=1;display-name=jontEmillian;turbo=0;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :Life PianoTime","@color=#FF2424;tmi-sent-ts=1704558258865;historical=1;returning-chatter=0;user-type=;mod=0;badges=subscriber/48,bits/25000;user-id=159210800;badge-info=subscriber/49;id=2ca5796d-28d8-4500-9920-acd936161661;subscriber=1;display-name=ME_ME;flags=;first-msg=0;client-nonce=9cfacb4faf8fabb2bde2cb5a262b13c4;emotes=;turbo=0;room-id=62300805;rm-received-ts=1704558259043 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :Based hating guy","@badges=no_audio/1;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;first-msg=0;user-id=431946171;color=#008000;turbo=0;flags=;tmi-sent-ts=1704558258928;returning-chatter=0;id=696784a5-d335-4d95-87a3-ad2a502cb60c;subscriber=0;display-name=jonhycrack;user-type=;historical=1;rm-received-ts=1704558259103;room-id=62300805;mod=0;badge-info= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed PianoTime","@rm-received-ts=1704558259408;first-msg=0;user-type=;id=82d32b72-6088-447f-bcd3-c6e5a77c3b24;mod=0;turbo=0;subscriber=0;badges=no_audio/1;emotes=;tmi-sent-ts=1704558259226;user-id=37931493;historical=1;badge-info=;client-nonce=ae3f42e962f06331b994b07ea4e94119;color=;returning-chatter=0;room-id=62300805;flags=;display-name=deever44 :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn Listening","@user-type=;historical=1;subscriber=1;rm-received-ts=1704558260833;emotes=emotesv2_11eff6a54749464c9dfa40570dd356bd:0-7;turbo=0;id=492551a1-439f-46d5-a85c-ea22287b9ef6;user-id=87120320;mod=0;room-id=62300805;vip=1;color=#D52AFF;first-msg=0;returning-chatter=0;tmi-sent-ts=1704558260669;badges=vip/1,subscriber/72,rplace-2023/1;display-name=Joshlad;badge-info=subscriber/77;flags= :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :elisElis PianoTime","@display-name=jamboz77;client-nonce=3ba324b439234a5c1ad64808bc74beb2;emotes=;historical=1;tmi-sent-ts=1704558261265;flags=;user-type=;badge-info=subscriber/2;color=#008000;subscriber=1;rm-received-ts=1704558261439;returning-chatter=0;badges=subscriber/0;mod=0;id=a2162bac-fce5-4c04-b935-b8ffb660ecb7;turbo=0;first-msg=0;user-id=899238522;room-id=62300805 :jamboz77!jamboz77@jamboz77.tmi.twitch.tv PRIVMSG #nymn xddShrug","@color=#008000;mod=0;id=9ab70b41-b87f-4c9d-988a-7d9cf3d6eadb;badges=no_audio/1;subscriber=0;badge-info=;historical=1;tmi-sent-ts=1704558261453;user-id=431946171;display-name=jonhycrack;turbo=0;returning-chatter=0;room-id=62300805;first-msg=0;flags=;rm-received-ts=1704558261626;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;user-type= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed PianoTime","@id=59018444-0a6a-4ea5-9d7d-096837e4e708;mod=0;first-msg=0;display-name=forsenkkona_;returning-chatter=0;historical=1;user-type=;subscriber=0;color=#FF69B4;room-id=62300805;flags=;emotes=;badge-info=;tmi-sent-ts=1704558261646;turbo=0;rm-received-ts=1704558261818;badges=;user-id=151423066 :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn pepoJS","@badge-info=;rm-received-ts=1704558263279;mod=0;first-msg=0;flags=;client-nonce=9b504719660efeecfebbcf06661d74bd;badges=no_audio/1;user-id=232078107;tmi-sent-ts=1704558263094;id=aa12b892-419c-4680-8d93-a3e2edda95f8;returning-chatter=0;color=#008000;subscriber=0;turbo=0;user-type=;historical=1;display-name=BastunGuy1;emotes=;room-id=62300805 :bastunguy1!bastunguy1@bastunguy1.tmi.twitch.tv PRIVMSG #nymn :sippin on waka","@color=#DAA520;room-id=62300805;user-id=85837900;client-nonce=9b7bdfc126d2bb8a4988d8035e9e7e03;subscriber=1;flags=;emotes=;badges=subscriber/36,no_audio/1;display-name=DontCagePlebs;tmi-sent-ts=1704558264032;rm-received-ts=1704558264208;badge-info=subscriber/37;first-msg=0;user-type=;returning-chatter=0;mod=0;turbo=0;id=c9bb57a8-c98e-429e-8daf-866e9d4c164c;historical=1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :Listening zimmer","@badge-info=;subscriber=0;tmi-sent-ts=1704558264302;client-nonce=c9d4cc765aa9681bba45b5d51fb36c4e;turbo=0;room-id=62300805;first-msg=0;id=d522ca94-8f25-49ac-9679-e443172b320b;badges=bits/100;color=#25E000;historical=1;display-name=DM8917;mod=0;emotes=;user-id=63372784;returning-chatter=0;user-type=;flags=;rm-received-ts=1704558264492 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@badges=subscriber/9,turbo/1;flags=;color=#FFFF00;user-type=;emotes=;display-name=Kotzblitz20;client-nonce=1a055b2faf02af4d702030a0ad287a1a;first-msg=0;turbo=1;rm-received-ts=1704558264547;room-id=62300805;tmi-sent-ts=1704558264381;returning-chatter=0;user-id=40037186;id=b9bfb05e-9691-40f1-be2b-62fc440aabd1;historical=1;mod=0;badge-info=subscriber/9;subscriber=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn notListening","@color=#1E90FF;rm-received-ts=1704558264981;badge-info=subscriber/2;display-name=mnqn18;badges=subscriber/0,premium/1;client-nonce=81ae041d66adf9c10dcac477ae532431;mod=0;first-msg=0;subscriber=1;room-id=62300805;user-type=;user-id=474204887;flags=;id=8aa6d737-feff-4dd7-b21f-c7f7f9ef94a4;emotes=;turbo=0;returning-chatter=0;tmi-sent-ts=1704558264812;historical=1 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn silly","@id=2cb48d7c-b30b-4364-8fc0-7a6f67136f02;badges=subscriber/6;badge-info=subscriber/7;subscriber=1;color=#00CC00;user-id=35778622;tmi-sent-ts=1704558264864;emotes=emotesv2_ba11c3b03cfd40f9bfa52851d03e4bdc:0-10,12-22,24-34;mod=0;room-id=62300805;turbo=0;flags=;user-type=;display-name=jollyaustin1;rm-received-ts=1704558265068;historical=1;returning-chatter=0;first-msg=0;emote-only=1;client-nonce=118f3f4b68f0f321aae13058a5921707 :jollyaustin1!jollyaustin1@jollyaustin1.tmi.twitch.tv PRIVMSG #nymn :jaboodyVibe jaboodyVibe jaboodyVibe","@color=#FF2424;mod=0;first-msg=0;subscriber=1;historical=1;user-type=;flags=;display-name=ME_ME;user-id=159210800;returning-chatter=0;tmi-sent-ts=1704558264950;turbo=0;badges=subscriber/48,bits/25000;room-id=62300805;rm-received-ts=1704558265145;emotes=;badge-info=subscriber/49;id=69a75a54-418c-4463-8a33-6aa0c8826ee3;client-nonce=eaedd8b040f1725f1d57db4a42f4ced4 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :Ratge PianoTime","@display-name=jonhycrack;room-id=62300805;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;flags=;historical=1;user-id=431946171;badges=no_audio/1;first-msg=0;id=4fd64510-d895-4c5f-a520-dfd6accb81a8;returning-chatter=0;badge-info=;rm-received-ts=1704558265826;color=#008000;tmi-sent-ts=1704558265645;subscriber=0;user-type=;turbo=0;mod=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed PianoTime","@subscriber=0;emotes=;id=415e8239-28f4-405b-9172-91f7dae52e4b;rm-received-ts=1704558268847;tmi-sent-ts=1704558268657;client-nonce=c64bf406f2c9f7a129caacc57fd40711;first-msg=0;badge-info=;returning-chatter=0;display-name=DrBenDover69;flags=;user-type=;mod=0;historical=1;turbo=0;color=#9ACD32;room-id=62300805;user-id=218692219;badges=no_video/1 :drbendover69!drbendover69@drbendover69.tmi.twitch.tv PRIVMSG #nymn :drink sludge","@color=;display-name=deever44;subscriber=0;tmi-sent-ts=1704558269826;client-nonce=8c647aa0cf4a340ecc86bd41f6643b6c;historical=1;badge-info=;id=8076f2be-7dfa-409d-913d-778f4bf418be;rm-received-ts=1704558269993;badges=no_audio/1;returning-chatter=0;emotes=;flags=;turbo=0;room-id=62300805;user-type=;first-msg=0;mod=0;user-id=37931493 :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn :i like this","@room-id=62300805;first-msg=0;client-nonce=c747aae941b9dc403961f7c0e1e650dc;id=c84da173-1c03-4e80-b280-d0e4c5a6040b;turbo=0;mod=0;color=#1E90FF;user-type=;display-name=kb_h;user-id=246452436;badges=rplace-2023/1;flags=;subscriber=0;historical=1;rm-received-ts=1704558271513;badge-info=;tmi-sent-ts=1704558271320;returning-chatter=0;emotes= :kb_h!kb_h@kb_h.tmi.twitch.tv PRIVMSG #nymn :the ratrooms","@color=#008000;room-id=62300805;turbo=0;first-msg=0;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;returning-chatter=0;tmi-sent-ts=1704558271505;badge-info=;rm-received-ts=1704558271674;user-id=431946171;historical=1;id=b0ede228-f358-43a3-b7c1-1bf80bb4ffc4;subscriber=0;flags=;mod=0;user-type=;badges=no_audio/1;display-name=jonhycrack :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed PianoTime","@tmi-sent-ts=1704558273963;badge-info=subscriber/47;turbo=0;historical=1;room-id=62300805;flags=;rm-received-ts=1704558274152;user-type=;color=#FF0000;display-name=Mawsonator;returning-chatter=0;mod=0;user-id=92529125;first-msg=0;id=4cd50bb8-a43e-4363-9f47-90a94ede725a;subscriber=1;badges=subscriber/42,twitch-recap-2023/1;client-nonce=e451c38f001915e49f1cf9806b45b0eb;emotes= :mawsonator!mawsonator@mawsonator.tmi.twitch.tv PRIVMSG #nymn :Ratge PianoTime","@returning-chatter=0;user-id=133862911;display-name=123homo;color=#FF0000;turbo=0;flags=;subscriber=0;rm-received-ts=1704558276750;user-type=;first-msg=0;tmi-sent-ts=1704558276576;room-id=62300805;badges=;historical=1;id=1eae5b00-fcdd-4d7e-9d10-82e9fe4712bc;badge-info=;client-nonce=2ec844049390515d583d6f2299bbd115;mod=0;emotes= :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn :I LIKE PIANO","@badges=;room-id=62300805;tmi-sent-ts=1704558277987;color=#FF0000;user-type=;client-nonce=e06360c705be1508bfd4b4a595fa25b7;emotes=;display-name=Patixxl;first-msg=0;returning-chatter=0;turbo=0;historical=1;flags=;badge-info=;subscriber=0;mod=0;user-id=51967700;id=cf56cfb0-672d-4b4c-a99e-5444af66e6e2;rm-received-ts=1704558278155 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@id=13aca2bd-2714-418f-b44e-c04367755dfa;tmi-sent-ts=1704558278196;user-type=;turbo=1;historical=1;room-id=62300805;display-name=Kotzblitz20;badges=subscriber/9,turbo/1;user-id=40037186;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122;color=#FFFF00;first-msg=0;subscriber=1;badge-info=subscriber/9;mod=0;flags=;returning-chatter=0;rm-received-ts=1704558278382;client-nonce=33769cc0cdaa95a18fe15be8e752dd77 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;display-name=jonhycrack;color=#008000;rm-received-ts=1704558278657;subscriber=0;id=52e2d672-5d66-4b3a-bff5-4a2867b00170;badges=no_audio/1;user-type=;historical=1;tmi-sent-ts=1704558278485;returning-chatter=0;room-id=62300805;mod=0;first-msg=0;flags=;badge-info=;user-id=431946171;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed PianoTime","@first-msg=0;tmi-sent-ts=1704558278749;returning-chatter=0;turbo=0;badge-info=subscriber/38;id=6f0d1001-ef3e-4f31-90d4-1618cf782082;color=#63BD68;user-type=;room-id=62300805;mod=0;historical=1;emotes=;subscriber=1;badges=subscriber/36,twitch-recap-2023/1;flags=;user-id=433352132;display-name=jontEmillian;rm-received-ts=1704558278919 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@mod=0;badges=subscriber/54,chatter-cs-go-2022/1;rm-received-ts=1704558279245;historical=1;turbo=0;color=#00FF7F;badge-info=subscriber/54;flags=;first-msg=0;tmi-sent-ts=1704558279038;user-type=;display-name=h_h410;user-id=117088592;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14;id=1e5270df-c8ae-4140-96fa-e5106fa0af6e;subscriber=1;returning-chatter=0;room-id=62300805 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed ♻","@flags=;badges=subscriber/0,no_audio/1;id=b66fe4d5-d546-437b-b514-72025a8e0443;mod=0;emotes=;tmi-sent-ts=1704558279121;color=#008000;subscriber=1;historical=1;first-msg=0;turbo=0;display-name=Lohatrons420;returning-chatter=0;rm-received-ts=1704558279290;user-type=;room-id=62300805;badge-info=subscriber/1;user-id=87509154 :lohatrons420!lohatrons420@lohatrons420.tmi.twitch.tv PRIVMSG #nymn :turn extension off nymn","@rm-received-ts=1704558280355;user-type=;room-id=62300805;tmi-sent-ts=1704558280174;flags=;emotes=;client-nonce=13a8b885fe4916a7637107826e811d5e;mod=0;turbo=0;first-msg=0;display-name=Patixxl;id=47781997-ef86-4641-a672-f00f38c92cd7;returning-chatter=0;subscriber=0;badges=;badge-info=;user-id=51967700;color=#FF0000;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;turbo=0;room-id=62300805;historical=1;flags=;rm-received-ts=1704558281463;user-id=85837900;first-msg=0;emotes=;subscriber=1;badges=subscriber/36,no_audio/1;tmi-sent-ts=1704558281272;color=#DAA520;id=09157acb-1435-4b95-8f36-330f880ca2bb;display-name=DontCagePlebs;client-nonce=fd61537ee54fe0cea08a9fe404338d32;badge-info=subscriber/37;returning-chatter=0;mod=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;historical=1;returning-chatter=0;color=#9ACD32;flags=;emotes=;subscriber=0;first-msg=0;badges=twitch-recap-2023/1;user-type=;mod=0;user-id=135853293;id=5e7da21e-828d-44c5-9f0b-031531482989;client-nonce=16c0076df46b23825855f1dcd93e73a1;tmi-sent-ts=1704558281708;rm-received-ts=1704558281881;room-id=62300805;badge-info=;display-name=theKiryu :thekiryu!thekiryu@thekiryu.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@color=#008000;subscriber=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;rm-received-ts=1704558281978;mod=0;first-msg=0;returning-chatter=0;flags=;historical=1;turbo=0;badge-info=subscriber/9;id=fec03a38-d33e-4308-abbb-3b53459bf6c2;user-type=;badges=subscriber/9,chatter-cs-go-2022/1;user-id=278896263;room-id=62300805;tmi-sent-ts=1704558281768;display-name=Phant0mBlades :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@color=#FFFF00;client-nonce=0fb3374bae9f0e7b2817b2270494e3d1;flags=;user-id=40037186;badge-info=subscriber/9;first-msg=0;user-type=;turbo=1;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282;subscriber=1;display-name=Kotzblitz20;rm-received-ts=1704558282047;room-id=62300805;mod=0;returning-chatter=0;id=32e3766f-19a3-4a0c-8fb3-94590af0f090;tmi-sent-ts=1704558281856;historical=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@rm-received-ts=1704558282378;subscriber=1;client-nonce=a0c462e4a6da03f15a2a4d5268ac73c5;user-type=;display-name=SnuggleUncle;returning-chatter=0;room-id=62300805;emotes=;id=73778c4c-5457-4e7f-b51f-2c3c32bdeb26;mod=0;badges=subscriber/12,twitch-recap-2023/1;turbo=0;badge-info=subscriber/17;historical=1;tmi-sent-ts=1704558282189;color=#0000FF;first-msg=0;user-id=69072013;flags= :snuggleuncle!snuggleuncle@snuggleuncle.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@display-name=Joshlad;color=#D52AFF;vip=1;historical=1;tmi-sent-ts=1704558282715;flags=;emotes=;room-id=62300805;badges=vip/1,subscriber/72,rplace-2023/1;rm-received-ts=1704558282884;subscriber=1;badge-info=subscriber/77;user-id=87120320;user-type=;mod=0;id=4af9860d-97c1-467d-bf6f-eae593c2c08b;turbo=0;first-msg=0;returning-chatter=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@client-nonce=e2de0364c31b61506a35482bd829cfc0;emotes=;first-msg=0;color=#FF0000;returning-chatter=0;id=d51d2f38-9c2e-4ce3-b3c9-adf4241b6a20;flags=;badge-info=;rm-received-ts=1704558283246;subscriber=0;display-name=Patixxl;tmi-sent-ts=1704558283079;user-type=;mod=0;room-id=62300805;badges=;turbo=0;user-id=51967700;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@display-name=jontEmillian;emotes=;turbo=0;color=#63BD68;tmi-sent-ts=1704558283447;flags=;subscriber=1;user-type=;badges=subscriber/36,twitch-recap-2023/1;rm-received-ts=1704558283609;historical=1;mod=0;returning-chatter=0;id=dca30460-3647-4650-ba94-2e6354489306;first-msg=0;room-id=62300805;user-id=433352132;badge-info=subscriber/38 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@flags=;mod=0;badges=bits/1000;historical=1;rm-received-ts=1704558283899;room-id=62300805;color=#FF0000;badge-info=;display-name=HajleSellasje;turbo=0;first-msg=0;emotes=;subscriber=0;user-id=45923155;tmi-sent-ts=1704558283736;returning-chatter=0;id=b7a2aa46-8c0b-45db-823e-d49bf3bc64cb;user-type= :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :Listening 󠀀","@subscriber=0;user-id=59060199;tmi-sent-ts=1704558284051;color=;turbo=0;badges=;badge-info=;flags=;mod=0;room-id=62300805;user-type=;returning-chatter=0;emotes=;first-msg=0;display-name=bomberman2442;historical=1;client-nonce=eef2e2a42797ae0f7a0e7d2dcc297ed2;rm-received-ts=1704558284210;id=a58a9c53-f5bd-413c-9751-c16ecb29ce82 :bomberman2442!bomberman2442@bomberman2442.tmi.twitch.tv PRIVMSG #nymn :actually good game PagMan","@returning-chatter=0;user-type=;turbo=0;tmi-sent-ts=1704558284650;user-id=205837377;rm-received-ts=1704558284825;badge-info=;display-name=Duchene;mod=0;first-msg=0;subscriber=0;historical=1;emotes=;room-id=62300805;color=#000000;flags=;badges=;id=8e3d59ff-16b1-4463-b60f-e1b8d521b4ae :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn :\u0001ACTION forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀\u0001","@flags=;subscriber=0;user-type=;badge-info=;historical=1;mod=0;returning-chatter=0;client-nonce=7de6532d5935f8414e0a06afe816e2c8;user-id=51967700;first-msg=0;display-name=Patixxl;room-id=62300805;badges=;rm-received-ts=1704558285130;emotes=;turbo=0;tmi-sent-ts=1704558284931;color=#FF0000;id=e27682df-de09-4927-a66e-aee2785075a8 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#00ED2A;tmi-sent-ts=1704558285027;mod=0;badges=subscriber/42,twitch-recap-2023/1;historical=1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170;room-id=62300805;user-type=;turbo=0;rm-received-ts=1704558285221;flags=;subscriber=1;client-nonce=8890033439d094fcc1d6136a276a9560;badge-info=subscriber/43;user-id=60181947;returning-chatter=0;display-name=MaxThurian;first-msg=0;id=77c0ef77-c1c1-4cc4-a786-79ffcb8a18b9 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@returning-chatter=0;subscriber=0;user-type=;turbo=0;badges=bits/1000;historical=1;room-id=62300805;first-msg=0;id=c827a22a-8887-4f16-a2d5-1e3a1a76d05b;tmi-sent-ts=1704558285410;color=#FF0000;mod=0;badge-info=;flags=;display-name=HajleSellasje;user-id=45923155;rm-received-ts=1704558285604;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@client-nonce=a67ad87903178ff9050c8c4ce9cd5f3e;mod=0;user-id=40037186;rm-received-ts=1704558286507;display-name=Kotzblitz20;color=#FFFF00;room-id=62300805;subscriber=1;emotes=;badges=subscriber/9,turbo/1;id=1bea2b0b-38cb-4f31-b10b-7ff89973ae4c;first-msg=0;turbo=1;badge-info=subscriber/9;historical=1;tmi-sent-ts=1704558286332;returning-chatter=0;user-type=;flags= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@subscriber=1;room-id=62300805;badges=subscriber/36,no_audio/1;badge-info=subscriber/37;id=ccdf49c5-dcdf-44f2-91bc-f86785e63a2e;emotes=;turbo=0;tmi-sent-ts=1704558287890;client-nonce=b7aa180359040f4f00c1a3945156a076;returning-chatter=0;user-type=;flags=;display-name=DontCagePlebs;first-msg=0;historical=1;mod=0;rm-received-ts=1704558288062;user-id=85837900;color=#DAA520 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn Aware","@room-id=62300805;id=f50c0981-789a-4edd-822a-9886c0e567b3;historical=1;user-id=45923155;user-type=;rm-received-ts=1704558288700;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,17-27,33-43,49-59,65-75,81-91,97-107,113-123,129-139,145-155,161-171,177-187,193-203;color=#FF0000;subscriber=0;badges=bits/1000;display-name=HajleSellasje;first-msg=0;returning-chatter=0;turbo=0;flags=;badge-info=;tmi-sent-ts=1704558288530;mod=0 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badges=subscriber/42,twitch-recap-2023/1;subscriber=1;mod=0;tmi-sent-ts=1704558289794;flags=;rm-received-ts=1704558289966;room-id=62300805;badge-info=subscriber/47;id=29741864-f340-4824-8029-b97c2699289f;returning-chatter=0;color=#FF0000;display-name=Mawsonator;user-id=92529125;turbo=0;first-msg=0;emotes=;user-type=;historical=1;client-nonce=0c5978125e8f24205eec7dff4d1f1c84 :mawsonator!mawsonator@mawsonator.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@first-msg=0;badges=;client-nonce=0422710ffd837f0a5408bee46adc954a;subscriber=0;user-type=;room-id=62300805;tmi-sent-ts=1704558289821;badge-info=;id=f7282e2a-c521-4bab-9f24-70d0565e4d67;mod=0;turbo=0;display-name=Patixxl;rm-received-ts=1704558289986;emotes=;color=#FF0000;historical=1;flags=;returning-chatter=0;user-id=51967700 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@badges=;rm-received-ts=1704558290289;color=#FF69B4;emotes=;mod=0;user-type=;display-name=ehtia;returning-chatter=0;id=2078944f-9387-41f9-832e-a15a3bc39ea6;user-id=163155934;historical=1;client-nonce=4e9fbaf8ca8779175041f6474649cfd6;turbo=0;flags=;subscriber=0;badge-info=;first-msg=0;tmi-sent-ts=1704558290112;room-id=62300805 :ehtia!ehtia@ehtia.tmi.twitch.tv PRIVMSG #nymn Stare","@mod=0;subscriber=0;emotes=;tmi-sent-ts=1704558291192;badges=no_audio/1;display-name=sehtt_;client-nonce=6eeb17fc92819d809e121c4816a5723e;room-id=62300805;first-msg=0;historical=1;user-id=133344079;badge-info=;turbo=0;flags=;user-type=;id=d26cc055-2795-465f-9add-6e5058813cf0;returning-chatter=0;color=#5F9EA0;rm-received-ts=1704558291381 :sehtt_!sehtt_@sehtt_.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@badge-info=subscriber/49;color=#FF2424;returning-chatter=0;subscriber=1;user-type=;mod=0;rm-received-ts=1704558291521;historical=1;first-msg=0;id=eeebe9e4-aedb-41a5-bcb4-2334f747c9df;room-id=62300805;emotes=;badges=subscriber/48,bits/25000;turbo=0;user-id=159210800;display-name=ME_ME;client-nonce=5ebb68c2d7b278d5765d93b450ba3a49;flags=;tmi-sent-ts=1704558291357 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@user-id=46199261;emotes=;badge-info=;first-msg=0;mod=0;turbo=0;display-name=Obiwun;tmi-sent-ts=1704558291347;user-type=;flags=;client-nonce=1d75c3b3086ff8f715f69160a3dbba75;id=b503aaff-abbe-4ea1-9759-7cf589a19f47;returning-chatter=0;room-id=62300805;subscriber=0;color=#8A2BE2;historical=1;rm-received-ts=1704558291526;badges=no_audio/1 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn monkaGIGA","@display-name=h_h410;rm-received-ts=1704558291721;turbo=0;first-msg=0;badges=subscriber/54,chatter-cs-go-2022/1;user-type=;historical=1;tmi-sent-ts=1704558291548;emotes=;id=8e8f9090-b67e-430d-87cc-c52d429a0a82;user-id=117088592;subscriber=1;badge-info=subscriber/54;room-id=62300805;returning-chatter=0;flags=;color=#00FF7F;mod=0 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn :FeelsAmazingMan PianoTime","@display-name=yikeyikers;first-msg=0;subscriber=0;user-type=;emotes=;badge-info=;returning-chatter=0;tmi-sent-ts=1704558292111;user-id=275131292;id=3aaf0bf0-f043-402c-9285-981ba10c7b39;turbo=0;badges=;client-nonce=13557a4951404e7c0f3793035eef17bb;mod=0;historical=1;room-id=62300805;color=#008000;rm-received-ts=1704558292327;flags= :yikeyikers!yikeyikers@yikeyikers.tmi.twitch.tv PRIVMSG #nymn Aware","@user-type=;mod=0;emotes=;user-id=87120320;color=#D52AFF;display-name=Joshlad;turbo=0;badges=vip/1,subscriber/72,rplace-2023/1;badge-info=subscriber/77;room-id=62300805;returning-chatter=0;tmi-sent-ts=1704558292430;vip=1;id=888f1d29-69bf-4c22-b76f-0f53cdc51281;flags=;rm-received-ts=1704558292602;first-msg=0;subscriber=1;historical=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn monkaS","@subscriber=0;display-name=jonhycrack;flags=;tmi-sent-ts=1704558292872;badges=no_audio/1;first-msg=0;emotes=;rm-received-ts=1704558293040;id=ffe8e1e7-5729-4018-a1c4-85b5278bb831;user-id=431946171;historical=1;returning-chatter=0;badge-info=;color=#008000;room-id=62300805;user-type=;mod=0;turbo=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@room-id=62300805;display-name=Patixxl;user-id=51967700;emotes=;tmi-sent-ts=1704558293378;historical=1;rm-received-ts=1704558293553;id=b6efde1c-3a26-4342-92f2-a43f48afa5b6;user-type=;badges=;returning-chatter=0;mod=0;flags=;subscriber=0;first-msg=0;turbo=0;client-nonce=c04b18ddf9106f07ec5f3378ace63b44;badge-info=;color=#FF0000 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@id=c1e5eedb-58d0-4495-8434-027ed58006db;first-msg=0;display-name=MaxThurian;returning-chatter=0;user-id=60181947;room-id=62300805;user-type=;badges=subscriber/42,twitch-recap-2023/1;badge-info=subscriber/43;color=#00ED2A;mod=0;turbo=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170;subscriber=1;flags=;rm-received-ts=1704558293566;tmi-sent-ts=1704558293381;client-nonce=e01c7d240e90975fd7bd49315703c5bb;historical=1 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-id=40037186;subscriber=1;client-nonce=7761274e4fe5a05de70701b933463c5e;tmi-sent-ts=1704558293455;emotes=;badge-info=subscriber/9;mod=0;flags=;historical=1;id=917bd6fc-e240-4af7-a590-32b343771f0e;color=#FFFF00;badges=subscriber/9,turbo/1;returning-chatter=0;display-name=Kotzblitz20;turbo=1;rm-received-ts=1704558293629;room-id=62300805;first-msg=0;user-type= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn Aware","@room-id=62300805;returning-chatter=0;client-nonce=efa8e9cab5adc49607cbd0aa21c19930;badges=subscriber/6,twitch-recap-2023/1;first-msg=0;color=#84FFD5;display-name=cyan_tide;turbo=0;user-id=904684680;user-type=;rm-received-ts=1704558294607;tmi-sent-ts=1704558294421;id=56560072-f0a0-4ed7-aa19-7999a34a2842;mod=0;subscriber=1;badge-info=subscriber/7;emotes=;flags=;historical=1 :cyan_tide!cyan_tide@cyan_tide.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@color=#63BD68;rm-received-ts=1704558295039;display-name=jontEmillian;user-type=;turbo=0;mod=0;id=0efe97f2-3963-4ce7-8936-6e6c5f641f06;first-msg=0;user-id=433352132;subscriber=1;tmi-sent-ts=1704558294860;flags=;returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;room-id=62300805;emotes=;badge-info=subscriber/38;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn Stare","@id=55ee9502-a3b5-4cf2-96e2-42e2fd0efeed;returning-chatter=0;rm-received-ts=1704558295665;mod=0;turbo=0;badge-info=;room-id=62300805;user-type=;flags=;emotes=;display-name=jonhycrack;badges=no_audio/1;subscriber=0;tmi-sent-ts=1704558295489;color=#008000;user-id=431946171;historical=1;first-msg=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA 󠀀","@historical=1;badges=subscriber/48,bits/25000;client-nonce=a91a93171c48c1e447f56655da6ea2c2;first-msg=0;display-name=ME_ME;id=92476813-f193-42a9-af09-bd069bb17c99;mod=0;tmi-sent-ts=1704558296225;user-id=159210800;emotes=;color=#FF2424;rm-received-ts=1704558296417;turbo=0;returning-chatter=0;room-id=62300805;user-type=;flags=;subscriber=1;badge-info=subscriber/49 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn Stare","@turbo=0;subscriber=0;tmi-sent-ts=1704558296714;badge-info=;user-type=;id=d17ef9b3-b88a-445f-8641-9012f4da88be;mod=0;flags=;user-id=998960046;badges=;color=;historical=1;client-nonce=21d36f0dbad51fe1629b14e90031b3b0;emotes=;returning-chatter=0;first-msg=0;rm-received-ts=1704558296891;display-name=jross1812;room-id=62300805 :jross1812!jross1812@jross1812.tmi.twitch.tv PRIVMSG #nymn Aware","@room-id=62300805;first-msg=0;color=#DAA520;id=6d673376-2c0d-4e1b-9c31-7475785effd5;user-type=;mod=0;badge-info=subscriber/37;rm-received-ts=1704558297057;historical=1;emotes=;flags=;returning-chatter=0;display-name=DontCagePlebs;user-id=85837900;tmi-sent-ts=1704558296881;turbo=0;badges=subscriber/36,no_audio/1;subscriber=1;client-nonce=a9fdf132331119198b31828bf73b3cfa :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn Stare","@id=8eb422de-c2ed-437d-b020-60d6b91aac0a;mod=0;display-name=Empathos;turbo=0;badges=twitch-recap-2023/1;rm-received-ts=1704558297057;tmi-sent-ts=1704558296861;room-id=62300805;emotes=;client-nonce=db21bfdd170b6b9f6bc5dac6b04fcc8e;subscriber=0;user-type=;historical=1;color=#8A2BE2;first-msg=0;badge-info=;user-id=31363069;returning-chatter=0;flags= :empathos!empathos@empathos.tmi.twitch.tv PRIVMSG #nymn NOOO","@subscriber=1;returning-chatter=0;rm-received-ts=1704558297988;display-name=Kotzblitz20;room-id=62300805;first-msg=0;client-nonce=c94e87a98d83dc6ff369fbe7a9c2a44b;badge-info=subscriber/9;tmi-sent-ts=1704558297816;user-id=40037186;flags=;turbo=1;id=13000419-899c-44e7-b0df-856204c088af;emotes=;historical=1;user-type=;color=#FFFF00;mod=0;badges=subscriber/9,turbo/1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@color=#FF2424;historical=1;user-id=159210800;emotes=;display-name=ME_ME;mod=0;badges=subscriber/48,bits/25000;room-id=62300805;client-nonce=bf2fecdca610aa30899f6af7aea85b08;rm-received-ts=1704558297993;user-type=;id=b84cc141-95a6-484a-8e0f-02687032dad0;returning-chatter=0;tmi-sent-ts=1704558297826;badge-info=subscriber/49;turbo=0;flags=;first-msg=0;subscriber=1 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@returning-chatter=0;turbo=0;id=1eae99a1-2811-45b3-a139-e38cc5e24c13;mod=0;historical=1;user-id=63372784;user-type=;tmi-sent-ts=1704558298206;badges=bits/100;subscriber=0;emotes=;display-name=DM8917;rm-received-ts=1704558298383;client-nonce=7fcb46bb8e5520ced438c68ac9095fbc;room-id=62300805;badge-info=;flags=;first-msg=0;color=#25E000 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn :IVEGONEPASTHEPOINTOFINSANITY 󠀀","@subscriber=1;id=3e4e2c27-b273-4ff2-b08f-3ed646bbd656;emotes=;flags=;historical=1;turbo=0;room-id=62300805;user-type=;color=#1E90FF;display-name=mnqn18;returning-chatter=0;user-id=474204887;badges=subscriber/0,premium/1;tmi-sent-ts=1704558298344;client-nonce=445cba1e1156526dbd0c1c0c2040a492;badge-info=subscriber/2;first-msg=0;rm-received-ts=1704558298516;mod=0 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@mod=0;returning-chatter=0;emotes=;room-id=62300805;user-type=;tmi-sent-ts=1704558298525;flags=;display-name=jontEmillian;badges=subscriber/36,twitch-recap-2023/1;rm-received-ts=1704558298691;id=05a914b9-5bc5-4ddd-a3a5-089e8dd6b0dd;first-msg=0;color=#63BD68;user-id=433352132;turbo=0;badge-info=subscriber/38;subscriber=1;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn ClueLookingAtYou","@emotes=;id=3bd0a24d-c75a-4c0a-b877-84beea8a12b5;display-name=Intel_power;room-id=62300805;tmi-sent-ts=1704558298936;color=#0000FF;badges=bits-charity/1;turbo=0;rm-received-ts=1704558299115;mod=0;user-id=103665668;subscriber=0;returning-chatter=0;badge-info=;historical=1;client-nonce=507cdafd820a3e86ba03357f6b6e82bb;first-msg=0;flags=;user-type= :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn Stare","@returning-chatter=0;room-id=62300805;rm-received-ts=1704558300570;color=#FF0000;flags=;mod=0;tmi-sent-ts=1704558300403;badges=;historical=1;turbo=0;display-name=Patixxl;badge-info=;client-nonce=82869a08183f7970ca50c81111420a97;user-id=51967700;emotes=;user-type=;subscriber=0;first-msg=0;id=85e15786-c65d-47aa-a4ab-ba47627d0ebc :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@returning-chatter=0;display-name=DontCagePlebs;mod=0;color=#DAA520;badge-info=subscriber/37;historical=1;rm-received-ts=1704558300805;emotes=;turbo=0;user-type=;user-id=85837900;client-nonce=69f422140f84b5e367a6aed2bba6548f;badges=subscriber/36,no_audio/1;tmi-sent-ts=1704558300634;subscriber=1;flags=;id=0cdb6d1f-fe0a-4227-87de-1c78218a0a31;first-msg=0;room-id=62300805 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@emotes=;subscriber=1;tmi-sent-ts=1704558300698;room-id=62300805;badges=subscriber/42,twitch-recap-2023/1;rm-received-ts=1704558300883;historical=1;mod=0;display-name=MaxThurian;color=#00ED2A;turbo=0;id=fa7e4ae8-bf54-49c8-af4f-87a65a06467d;client-nonce=435ff1d3b746bfeddeac3a8a28d8c0bc;returning-chatter=0;user-id=60181947;first-msg=0;flags=;badge-info=subscriber/43;user-type= :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA ForsenLookingAtYou","@client-nonce=a1a49fc74c1945e1fc38de3f21d4204b;user-type=;subscriber=1;id=055d85b4-8e13-486d-8535-e8ff6daecdba;room-id=62300805;tmi-sent-ts=1704558300749;flags=;badge-info=subscriber/9;emotes=;historical=1;turbo=1;mod=0;rm-received-ts=1704558300930;badges=subscriber/9,turbo/1;display-name=Kotzblitz20;color=#FFFF00;returning-chatter=0;first-msg=0;user-id=40037186 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA monkaOMEGA monkaOMEGA monkaOMEGA monkaOMEGA","@display-name=deever44;first-msg=0;turbo=0;color=;tmi-sent-ts=1704558301450;id=e35d6b0a-79e5-49e9-8dfc-299342b5b1c2;rm-received-ts=1704558301636;user-type=;user-id=37931493;badges=no_audio/1;historical=1;subscriber=0;returning-chatter=0;flags=;room-id=62300805;mod=0;emotes=;client-nonce=6f19b16eee728b787d4b2fe66e8e2274;badge-info= :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn Stare","@rm-received-ts=1704558301861;display-name=123homo;badges=;returning-chatter=0;user-type=;mod=0;historical=1;badge-info=;color=#FF0000;id=71b0752b-770e-465d-9157-10577decf8c1;subscriber=0;room-id=62300805;flags=;tmi-sent-ts=1704558301692;user-id=133862911;client-nonce=86a6d1d82507591a80e3ff7285e2ac01;turbo=0;first-msg=0;emotes= :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn :I LOVE APOLLO","@room-id=62300805;tmi-sent-ts=1704558301748;flags=;turbo=0;first-msg=0;returning-chatter=0;badge-info=;user-type=;badges=;emotes=115234:0-7;mod=0;user-id=275131292;id=285f3ad9-caff-40b6-8c34-a3cf34865749;subscriber=0;historical=1;rm-received-ts=1704558301957;client-nonce=e1828eee7a36bd14d38db1e79ad9fd4a;display-name=yikeyikers;color=#008000 :yikeyikers!yikeyikers@yikeyikers.tmi.twitch.tv PRIVMSG #nymn :BatChest AI","@emotes=;turbo=0;client-nonce=959fe0ca83a36a5b38b020ab9cf32958;color=#FF2424;first-msg=0;mod=0;badges=subscriber/48,bits/25000;historical=1;id=b17c2c76-a71f-4c28-a9d0-f2338434d1cb;room-id=62300805;user-id=159210800;flags=;tmi-sent-ts=1704558302194;returning-chatter=0;user-type=;subscriber=1;rm-received-ts=1704558302354;display-name=ME_ME;badge-info=subscriber/49 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :IVEGONEPASTHEPOINTOFINSANITY 󠀀","@id=37c73d2c-c840-4879-b070-f10819de7320;mod=0;display-name=jontEmillian;tmi-sent-ts=1704558302391;flags=;badge-info=subscriber/38;emotes=;user-type=;subscriber=1;badges=subscriber/36,twitch-recap-2023/1;user-id=433352132;first-msg=0;room-id=62300805;color=#63BD68;rm-received-ts=1704558302545;turbo=0;historical=1;returning-chatter=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :ClueLookingAtYou 󠀀","@client-nonce=6615fab12a3cbea682b6ef20b13e72b7;historical=1;turbo=0;rm-received-ts=1704558302651;returning-chatter=0;badges=twitch-recap-2023/1;color=#10E2E2;room-id=62300805;flags=;display-name=ALotOfChickens;tmi-sent-ts=1704558302464;id=c5ca4fbb-fd43-4a78-b3eb-65831de43a75;badge-info=;user-type=;first-msg=0;mod=0;emotes=;subscriber=0;user-id=167633177 :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn :pepeLaugh TeaTime","@emote-only=1;badge-info=subscriber/77;user-id=87120320;tmi-sent-ts=1704558302616;id=78c456ac-d05d-4d70-8c24-c5074bc703d1;flags=;historical=1;emotes=emotesv2_662cb46b3efe41b4ada2d6560fd06cac:0-17;first-msg=0;user-type=;rm-received-ts=1704558302788;badges=vip/1,subscriber/72,rplace-2023/1;returning-chatter=0;mod=0;display-name=Joshlad;turbo=0;subscriber=1;vip=1;room-id=62300805;color=#D52AFF :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn suntgiLookingAtYou","@mod=0;color=#8A2BE2;returning-chatter=0;tmi-sent-ts=1704558302753;badges=no_audio/1;room-id=62300805;user-type=;client-nonce=ea47ae3d799e07d8d2e09be2cebf8627;display-name=Obiwun;emotes=;turbo=0;historical=1;id=812a5797-9f6f-41c1-842d-94085d4dc6b1;flags=;first-msg=0;subscriber=0;badge-info=;rm-received-ts=1704558302932;user-id=46199261 :obiwun!obiwun@obiwun.tmi.twitch.tv PRIVMSG #nymn ForsenLookingAtYou","@emotes=;room-id=62300805;display-name=jonhycrack;historical=1;first-msg=0;color=#008000;flags=;rm-received-ts=1704558303454;user-id=431946171;tmi-sent-ts=1704558303273;badge-info=;returning-chatter=0;turbo=0;id=efd2f0d9-e5d3-4029-b79b-62851010b08a;badges=no_audio/1;subscriber=0;mod=0;user-type= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA BEHIND THE GREEN CURTAIN","@id=1a6308fa-c20e-431b-95ed-6e68a178c8c1;room-id=62300805;rm-received-ts=1704558303622;tmi-sent-ts=1704558303150;color=#00FF7F;client-nonce=68c270ed92b9ca46390e580bf13b7b36;user-id=83365099;emotes=;subscriber=0;historical=1;first-msg=0;returning-chatter=0;flags=0-12:P.3;turbo=0;badge-info=;badges=glitchcon2020/1;display-name=OmniValor;user-type=;mod=0 :omnivalor!omnivalor@omnivalor.tmi.twitch.tv PRIVMSG #nymn wtfffffffffff","@rm-received-ts=1704558303980;subscriber=1;id=1272038c-5fb9-47e9-a12e-259d030aa9c9;user-type=;display-name=Kotzblitz20;color=#FFFF00;user-id=40037186;first-msg=0;flags=;historical=1;mod=0;client-nonce=6e7880bb87efd70ace3c0200cf46475f;emotes=;badges=subscriber/9,turbo/1;turbo=1;returning-chatter=0;tmi-sent-ts=1704558303803;badge-info=subscriber/9;room-id=62300805 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@flags=;turbo=0;returning-chatter=0;user-id=431946171;tmi-sent-ts=1704558304774;badges=no_audio/1;color=#008000;badge-info=;subscriber=0;first-msg=0;mod=0;user-type=;id=aac65138-2265-42cc-800e-9e27ae53171b;display-name=jonhycrack;room-id=62300805;rm-received-ts=1704558304934;emotes=;historical=1 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA BEHIND THE GREEN CURTAIN","@color=#FF0000;turbo=0;user-id=133862911;client-nonce=3afd83cd50d36060a9387aee58e137b4;mod=0;rm-received-ts=1704558304965;emotes=;badge-info=;historical=1;room-id=62300805;subscriber=0;returning-chatter=0;flags=;badges=;id=5a3f2b89-056e-4114-995d-8570aee1dee3;user-type=;display-name=123homo;first-msg=0;tmi-sent-ts=1704558304798 :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn :I HATE AI","@badge-info=subscriber/41;client-nonce=6252e0206b6474e8baf80fbcfd933f44;turbo=0;id=873d1ef6-617d-47ad-b657-3883259ce8f6;user-type=;first-msg=0;user-id=154079285;rm-received-ts=1704558305162;tmi-sent-ts=1704558304973;historical=1;room-id=62300805;emotes=;flags=;badges=subscriber/36;returning-chatter=0;mod=0;color=#00FF7F;subscriber=1;display-name=boogkitty :boogkitty!boogkitty@boogkitty.tmi.twitch.tv PRIVMSG #nymn :yes its randomised Nymn","@id=e4991da4-a52d-49cd-a82a-e20b84751423;historical=1;badges=;room-id=62300805;turbo=0;client-nonce=b9b1635fef4548fe66149bdeeb7f5c26;display-name=Patixxl;user-type=;subscriber=0;color=#FF0000;rm-received-ts=1704558305341;first-msg=0;tmi-sent-ts=1704558305183;mod=0;user-id=51967700;badge-info=;returning-chatter=0;flags=;emotes= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :IVEGONEPASTHEPOINTOFINSANITY 󠀀","@turbo=0;mod=0;subscriber=1;rm-received-ts=1704558305350;badges=subscriber/9,chatter-cs-go-2022/1;display-name=Phant0mBlades;tmi-sent-ts=1704558305175;first-msg=0;color=#008000;id=efb0af5d-3b59-49ec-b3cf-dce5c7386214;returning-chatter=0;room-id=62300805;emotes=;flags=;historical=1;user-id=278896263;badge-info=subscriber/9;user-type= :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@rm-received-ts=1704558306205;display-name=jontEmillian;tmi-sent-ts=1704558306034;flags=;subscriber=1;returning-chatter=0;mod=0;badges=subscriber/36,twitch-recap-2023/1;first-msg=0;turbo=0;room-id=62300805;emotes=;user-type=;historical=1;badge-info=subscriber/38;color=#63BD68;id=bde0c098-922c-455d-a23a-bddc8b82b2eb;user-id=433352132 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn ClueLookingAtYou","@id=f9ae2b7d-29ce-415a-8c39-9e2b1ec71b93;room-id=62300805;badges=no_audio/1;color=#008000;emotes=;display-name=jonhycrack;flags=;first-msg=0;mod=0;rm-received-ts=1704558306589;returning-chatter=0;user-type=;historical=1;user-id=431946171;turbo=0;badge-info=;subscriber=0;tmi-sent-ts=1704558306408 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA BEHIND THE GREEN CURTAIN","@badges=subscriber/9,turbo/1;flags=;user-type=;badge-info=subscriber/9;rm-received-ts=1704558307542;subscriber=1;mod=0;turbo=1;room-id=62300805;returning-chatter=0;display-name=Kotzblitz20;id=7b3f8887-f3f0-4d28-b6ef-0254600d81e8;first-msg=0;tmi-sent-ts=1704558307350;historical=1;color=#FFFF00;emotes=;client-nonce=29567dd99ba1a611f2a3ae29636df9bf;user-id=40037186 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :IVEGONEPASTHEPOINTOFINSANITY IVEGONEPASTHEPOINTOFINSANITY IVEGONEPASTHEPOINTOFINSANITY IVEGONEPASTHEPOINTOFINSANITY IVEGONEPASTHEPOINTOFINSANITY","@user-id=222340799;turbo=0;badge-info=subscriber/7;rm-received-ts=1704558308109;room-id=62300805;color=#B22222;first-msg=0;tmi-sent-ts=1704558307914;flags=;emotes=emotesv2_e16d73fce3b840949d5474dfaca63ffd:12-22;subscriber=1;id=11c9190b-9a7b-4374-a5cb-16570adbca42;display-name=crazyjuni0r_;client-nonce=e68bbaedc41ee337e322b1a65d572745;user-type=;badges=subscriber/6,chatter-cs-go-2022/1;returning-chatter=0;mod=0;historical=1 :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn :!#showemote spacea32HOM","@mod=0;badges=subscriber/42,twitch-recap-2023/1;returning-chatter=0;first-msg=0;flags=;turbo=0;subscriber=1;display-name=MaxThurian;client-nonce=5db1e38800091cd2bc5c0bc0b9ef418e;user-type=;emotes=;badge-info=subscriber/43;room-id=62300805;id=0241e30f-9407-4e93-a93a-d908c3dbccf1;user-id=60181947;rm-received-ts=1704558308214;historical=1;color=#00ED2A;tmi-sent-ts=1704558308025 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA ForsenLookingAtYou 󠀀","@subscriber=1;historical=1;color=#8A2BE2;tmi-sent-ts=1704558308187;mod=0;returning-chatter=0;room-id=62300805;user-id=137782780;first-msg=0;user-type=;id=0f1f844f-8f10-4b8a-b4f5-af4645f28c06;turbo=0;badge-info=subscriber/4;flags=;client-nonce=97081ba776de2e36895e70769e3622e8;badges=subscriber/3,no_audio/1;rm-received-ts=1704558308541;emotes=;display-name=pleasekeepconnor6silly :pleasekeepconnor6silly!pleasekeepconnor6silly@pleasekeepconnor6silly.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@badge-info=;room-id=62300805;user-type=;color=#008000;badges=no_audio/1;first-msg=0;emotes=;turbo=0;tmi-sent-ts=1704558308427;historical=1;mod=0;rm-received-ts=1704558308612;flags=;returning-chatter=0;id=9863f240-b6c6-414e-9a17-a47cc0905324;subscriber=0;display-name=jonhycrack;user-id=431946171 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA BEHIND THE GREEN CURTAIN","@turbo=0;id=e6929219-ce61-4295-bb6a-88b70ae4fa32;room-id=62300805;client-nonce=f1ba29c814860616774514a82ec5fa67;emotes=;returning-chatter=0;subscriber=1;badge-info=subscriber/49;mod=0;tmi-sent-ts=1704558309446;rm-received-ts=1704558309629;badges=subscriber/48,bits/25000;historical=1;display-name=ME_ME;color=#FF2424;flags=;user-id=159210800;first-msg=0;user-type= :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@display-name=Patixxl;mod=0;user-id=51967700;flags=;color=#FF0000;id=deaff6db-6df2-40a6-9a48-8e88bcdbc9ec;subscriber=0;returning-chatter=0;client-nonce=4f22dd1d098b7ba2775f156496b3808f;user-type=;tmi-sent-ts=1704558309939;turbo=0;badges=;historical=1;first-msg=0;emotes=;rm-received-ts=1704558310113;room-id=62300805;badge-info= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@user-id=28317294;display-name=ImDaxify;vip=1;flags=;badges=vip/1,subscriber/36,twitch-recap-2023/1;room-id=62300805;turbo=0;first-msg=0;returning-chatter=0;client-nonce=4c706ed3fe3fef6bd3dabce0f62a4d74;tmi-sent-ts=1704558310694;mod=0;badge-info=subscriber/36;user-type=;id=cd749df0-6847-4cb9-b64a-c347746661a5;color=#1F8FFF;emotes=;rm-received-ts=1704558310867;historical=1;subscriber=1 :imdaxify!imdaxify@imdaxify.tmi.twitch.tv PRIVMSG #nymn :ai generated game? @NymN","@room-id=62300805;first-msg=0;color=#63BD68;historical=1;rm-received-ts=1704558310930;tmi-sent-ts=1704558310766;emotes=;flags=;mod=0;badge-info=subscriber/38;user-id=433352132;badges=subscriber/36,twitch-recap-2023/1;turbo=0;id=8668e86b-2962-4524-b130-63b1b36e30c8;subscriber=1;display-name=jontEmillian;user-type=;returning-chatter=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :ClueLookingAtYou 󠀀","@emotes=;badge-info=;badges=bits/100;subscriber=0;rm-received-ts=1704558312125;historical=1;user-id=63372784;user-type=;color=#25E000;room-id=62300805;id=c3857edd-89b0-4f4a-a829-73c3ce64ca40;client-nonce=cd54cf76802ccc9de6f676191e8a7261;display-name=DM8917;flags=;returning-chatter=0;tmi-sent-ts=1704558311940;first-msg=0;turbo=0;mod=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn :IVEGONEPASTHEPOINTOFINSANITY IVEGONEPASTHEPOINTOFINSANITY","@tmi-sent-ts=1704558314190;room-id=62300805;display-name=Dankarop;mod=0;subscriber=0;color=#00FF7F;rm-received-ts=1704558314391;returning-chatter=0;user-type=;flags=5-7:P.3;emotes=;badge-info=;turbo=0;first-msg=0;id=eb7f11b7-dfd4-454d-a496-2ee41c2d84fb;client-nonce=18dcf3f8e3e26de01694b5b946569400;user-id=467106798;badges=rplace-2023/1;historical=1 :dankarop!dankarop@dankarop.tmi.twitch.tv PRIVMSG #nymn :monk ass BillyApprove","@display-name=miniwoffer;id=96a0d440-6225-4348-8533-09fc5bae3462;tmi-sent-ts=1704558314381;first-msg=0;user-type=;mod=0;color=#FF69B4;room-id=62300805;turbo=0;user-id=22733078;client-nonce=1ef4e4cf50663e8a47750ab6f0cf7e87;badges=subscriber/0,bits/1;badge-info=subscriber/2;subscriber=1;returning-chatter=0;emotes=;flags=;rm-received-ts=1704558314554;historical=1 :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn monkaS","@room-id=62300805;badges=subscriber/9,turbo/1;badge-info=subscriber/9;user-type=;display-name=Kotzblitz20;emotes=;historical=1;returning-chatter=0;flags=;client-nonce=5a8238db251becbbc27ef83899d9dafa;color=#FFFF00;user-id=40037186;rm-received-ts=1704558315337;turbo=1;subscriber=1;mod=0;tmi-sent-ts=1704558315167;first-msg=0;id=89b072ac-212a-4a7c-a4be-468b64ddd10f :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@color=#00FF7F;badge-info=;display-name=Dankarop;subscriber=0;first-msg=0;client-nonce=21261092ada5f0db291f91b83cc30025;id=9026a45e-a7fc-428d-b01e-7b9ee280fd38;flags=5-7:P.3;returning-chatter=0;emotes=;turbo=0;tmi-sent-ts=1704558315236;mod=0;badges=rplace-2023/1;user-id=467106798;historical=1;rm-received-ts=1704558315402;room-id=62300805;user-type= :dankarop!dankarop@dankarop.tmi.twitch.tv PRIVMSG #nymn :monk ass BillyApprove 󠀀","@user-type=;emotes=;badge-info=;returning-chatter=0;id=dbf58bc9-85c1-428b-b498-f9600e16c88a;mod=0;client-nonce=2054333e43b9720c33e2db1edc90bac3;display-name=Bonfiredes;first-msg=0;color=#A81C00;subscriber=0;flags=;badges=gold-pixel-heart/1;rm-received-ts=1704558315436;tmi-sent-ts=1704558315264;room-id=62300805;user-id=44312943;turbo=0;historical=1 :bonfiredes!bonfiredes@bonfiredes.tmi.twitch.tv PRIVMSG #nymn yes","@tmi-sent-ts=1704558315706;emotes=;color=#D2691E;id=0cec7379-5b38-409c-8eaf-9df82c4bd924;mod=0;display-name=Binfz;flags=;user-id=116793607;rm-received-ts=1704558315890;client-nonce=6ec93cc31adcde7767b6f6de2b07c556;badges=;turbo=0;returning-chatter=0;subscriber=0;room-id=62300805;badge-info=;first-msg=0;user-type=;historical=1 :binfz!binfz@binfz.tmi.twitch.tv PRIVMSG #nymn :AI GENERATED GAME LULW","@id=b3a2e2c4-8b37-4c85-9a35-dca479b2e963;badge-info=;rm-received-ts=1704558316431;user-id=467106798;color=#00FF7F;badges=rplace-2023/1;subscriber=0;returning-chatter=0;emotes=;mod=0;flags=5-7:P.3;room-id=62300805;historical=1;display-name=Dankarop;tmi-sent-ts=1704558316234;client-nonce=3b93fcb34e3f672c9d3dcca9f6dce9a9;first-msg=0;turbo=0;user-type= :dankarop!dankarop@dankarop.tmi.twitch.tv PRIVMSG #nymn :monk ass BillyApprove","@client-nonce=027f4619e7ccf6e01c79e3f3379ff3ae;badges=;id=335c87cd-3018-4be3-af9c-f04bc324d9dd;returning-chatter=0;display-name=Patixxl;turbo=0;emotes=;historical=1;subscriber=0;color=#FF0000;first-msg=0;flags=;user-type=;room-id=62300805;tmi-sent-ts=1704558316436;mod=0;badge-info=;rm-received-ts=1704558316592;user-id=51967700 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :IVEGONEPASTHEPOINTOFINSANITY 󠀀","@color=#008000;badges=twitch-recap-2023/1;emotes=;historical=1;first-msg=0;user-id=11654373;rm-received-ts=1704558316905;id=8f916376-5f46-422e-a1a6-c1c3617fe961;flags=;room-id=62300805;badge-info=;display-name=bovabova;tmi-sent-ts=1704558316719;returning-chatter=0;mod=0;client-nonce=60b377c83abc5cfd38c9005242b3989c;subscriber=0;user-type=;turbo=0 :bovabova!bovabova@bovabova.tmi.twitch.tv PRIVMSG #nymn :i love you nymn","@historical=1;badges=subscriber/9,turbo/1;returning-chatter=0;flags=;display-name=Kotzblitz20;turbo=1;rm-received-ts=1704558317627;user-type=;user-id=40037186;id=7199d335-d925-4278-8762-8486683ce9e4;mod=0;badge-info=subscriber/9;tmi-sent-ts=1704558317433;subscriber=1;first-msg=0;client-nonce=c7e654f8ff49f0f308c88b12d3130c4d;room-id=62300805;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74;color=#FFFF00 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;client-nonce=8efb2c564cdfd666b8b80b4d7cdb1dc1;id=6746ff61-6e78-4aac-8164-2f7ec4426a94;rm-received-ts=1704558317705;subscriber=0;mod=0;first-msg=0;badges=no_audio/1;emotes=;user-type=;returning-chatter=0;display-name=deever44;tmi-sent-ts=1704558317538;user-id=37931493;color=;badge-info=;room-id=62300805;historical=1;flags= :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn yes","@room-id=62300805;returning-chatter=0;client-nonce=158ac9e8ca2a0a794338b2039c346637;tmi-sent-ts=1704558318322;color=#FF0000;emotes=;rm-received-ts=1704558318499;display-name=Patixxl;flags=;user-id=51967700;mod=0;user-type=;turbo=0;badges=;id=97e1ea95-2145-47f1-935c-ce8797d9902f;subscriber=0;badge-info=;historical=1;first-msg=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=;turbo=0;client-nonce=8ecd8512e8860b6af6ae2a0058b51a7b;mod=0;color=#FF0000;returning-chatter=0;first-msg=0;historical=1;id=f3fe105f-d6b4-43d5-8859-a61b2fe090dc;user-type=;subscriber=0;tmi-sent-ts=1704558319513;flags=;room-id=62300805;user-id=86965943;emotes=;badges=premium/1;display-name=voyu1337;rm-received-ts=1704558319718 :voyu1337!voyu1337@voyu1337.tmi.twitch.tv PRIVMSG #nymn :WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK","@first-msg=0;id=3ba84985-6097-4429-8b78-6af4b09afdbb;color=#D52AFF;rm-received-ts=1704558319749;room-id=62300805;historical=1;flags=;emotes=;subscriber=1;mod=0;turbo=0;tmi-sent-ts=1704558319556;user-type=;badges=vip/1,subscriber/72,rplace-2023/1;display-name=Joshlad;vip=1;user-id=87120320;returning-chatter=0;badge-info=subscriber/77 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;client-nonce=eb6645ae1c43a2aeb59278d81bfb2f85;user-id=64153307;badge-info=;id=24278ced-396d-48d0-8fbb-273c8e46d4f3;turbo=0;room-id=62300805;display-name=ZormiK;tmi-sent-ts=1704558320714;first-msg=0;rm-received-ts=1704558320914;emotes=;subscriber=0;mod=0;user-type=;badges=glitchcon2020/1;returning-chatter=0;color=#FF4500;historical=1 :zormik!zormik@zormik.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@tmi-sent-ts=1704558320918;badge-info=subscriber/43;flags=;first-msg=0;user-id=60181947;returning-chatter=0;rm-received-ts=1704558321090;emotes=;mod=0;display-name=MaxThurian;badges=subscriber/42,twitch-recap-2023/1;client-nonce=28215166443431512c53d815f3f47c3f;user-type=;id=7fcaf95b-1797-4ac5-9e98-333fe2b442f7;historical=1;room-id=62300805;color=#00ED2A;subscriber=1;turbo=0 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn WAYTOODANK","@tmi-sent-ts=1704558321101;mod=0;flags=;first-msg=0;display-name=DontCagePlebs;room-id=62300805;user-id=85837900;badge-info=subscriber/37;emotes=;client-nonce=05b0e54fdec0c1c4c039cf3256fd2161;badges=subscriber/36,no_audio/1;id=970e2afe-ee81-404f-ad4d-0455dede5194;historical=1;returning-chatter=0;turbo=0;subscriber=1;rm-received-ts=1704558321269;user-type=;color=#DAA520 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn WAYTOOBUH","@badges=subscriber/36,twitch-recap-2023/1;badge-info=subscriber/38;room-id=62300805;user-type=;rm-received-ts=1704558321374;first-msg=0;id=79cc43b9-c7ef-4959-8610-e6cb2d6af6f4;user-id=433352132;color=#63BD68;historical=1;flags=;turbo=0;display-name=jontEmillian;subscriber=1;mod=0;tmi-sent-ts=1704558321184;returning-chatter=0;emotes= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@room-id=62300805;mod=0;badge-info=subscriber/9;user-id=40037186;subscriber=1;id=82d847df-5e55-45ae-9b25-7b6618171ad9;client-nonce=1be7581e194abde88d6fc68763c6ed4d;first-msg=0;badges=subscriber/9,turbo/1;tmi-sent-ts=1704558321206;user-type=;rm-received-ts=1704558321387;flags=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106;display-name=Kotzblitz20;returning-chatter=0;color=#FFFF00;historical=1;turbo=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;rm-received-ts=1704558322012;client-nonce=f5d6339f25a02b9a89daa738ce35b072;tmi-sent-ts=1704558321836;badge-info=;mod=0;emotes=;returning-chatter=0;turbo=0;user-type=;display-name=Patixxl;color=#FF0000;subscriber=0;room-id=62300805;user-id=51967700;id=12fd4a70-a7bc-465a-a484-72243726ea6e;historical=1;badges=;first-msg=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@returning-chatter=0;historical=1;display-name=123homo;reply-thread-parent-msg-id=cd749df0-6847-4cb9-b64a-c347746661a5;client-nonce=87d704664a3b935d00dfb5bffeb50ef8;reply-thread-parent-user-id=28317294;emotes=;user-type=;badges=;subscriber=0;reply-parent-user-id=28317294;badge-info=;reply-parent-display-name=ImDaxify;color=#FF0000;reply-parent-user-login=imdaxify;room-id=62300805;user-id=133862911;reply-parent-msg-id=cd749df0-6847-4cb9-b64a-c347746661a5;reply-parent-msg-body=ai\\sgenerated\\sgame?\\s@NymN;id=7d198fbe-6da9-47a4-8f5a-2a43e1c4f7fc;turbo=0;mod=0;reply-thread-parent-display-name=ImDaxify;first-msg=0;reply-thread-parent-user-login=imdaxify;flags=;rm-received-ts=1704558322863;tmi-sent-ts=1704558322694 :123homo!123homo@123homo.tmi.twitch.tv PRIVMSG #nymn :@ImDaxify NOOOOOOOOOOO","@id=7dac020c-1d98-42fa-839c-6c4bae3247d2;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;flags=;returning-chatter=0;badge-info=subscriber/9;badges=subscriber/9,chatter-cs-go-2022/1;display-name=Phant0mBlades;user-type=;subscriber=1;room-id=62300805;mod=0;user-id=278896263;historical=1;rm-received-ts=1704558323183;tmi-sent-ts=1704558323001;color=#008000;turbo=0;first-msg=0 :phant0mblades!phant0mblades@phant0mblades.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@display-name=Kotzblitz20;user-type=;turbo=1;flags=;returning-chatter=0;tmi-sent-ts=1704558323184;badges=subscriber/9,turbo/1;mod=0;color=#FFFF00;id=ebfcef5f-564b-4afd-9bd5-71b7c9251919;subscriber=1;user-id=40037186;historical=1;rm-received-ts=1704558323349;badge-info=subscriber/9;client-nonce=fb892838f7b2355f2647bb94a0817bc7;room-id=62300805;first-msg=0;emotes= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn WAYTOOBUH","@rm-received-ts=1704558323841;user-id=167633177;emotes=;mod=0;id=56025526-3beb-4adc-9d45-3bba873464fb;first-msg=0;subscriber=0;user-type=;color=#10E2E2;flags=;badges=twitch-recap-2023/1;client-nonce=58142ebdd499fec4f4ae021e9c58cca7;display-name=ALotOfChickens;room-id=62300805;tmi-sent-ts=1704558323647;returning-chatter=0;turbo=0;badge-info=;historical=1 :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn WAYTOODANK","@color=#00ED2A;badge-info=subscriber/43;user-type=;rm-received-ts=1704558323906;id=4e54fdf3-4432-439d-91f2-3676b73cdcb0;flags=;first-msg=0;emotes=;tmi-sent-ts=1704558323736;returning-chatter=0;turbo=0;display-name=MaxThurian;room-id=62300805;mod=0;client-nonce=d876f58bfb9c200c314771e02fd63525;subscriber=1;user-id=60181947;historical=1;badges=subscriber/42,twitch-recap-2023/1 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :WAYTOODANK my head","@tmi-sent-ts=1704558323769;id=dc863ee7-9f5a-41c5-8a91-5da56b75187e;user-id=433352132;emotes=;badge-info=subscriber/38;subscriber=1;color=#63BD68;returning-chatter=0;display-name=jontEmillian;turbo=0;rm-received-ts=1704558323949;first-msg=0;flags=;room-id=62300805;user-type=;mod=0;badges=subscriber/36,twitch-recap-2023/1;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@badges=;display-name=forsenkkona_;returning-chatter=0;rm-received-ts=1704558324219;mod=0;user-id=151423066;flags=;badge-info=;tmi-sent-ts=1704558324036;turbo=0;historical=1;room-id=62300805;id=b1af35e4-7614-4b5c-bf16-d8383f53ee64;subscriber=0;emotes=;color=#FF69B4;user-type=;first-msg=0 :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn AlienPls","@room-id=62300805;rm-received-ts=1704558324589;flags=;badge-info=;user-type=;emotes=;historical=1;user-id=163349624;id=edf97de2-7804-4e21-8055-8b7074d6299f;turbo=0;display-name=IllidanSF;tmi-sent-ts=1704558324388;mod=0;badges=no_video/1;first-msg=0;color=#00FF7F;returning-chatter=0;subscriber=0 :illidansf!illidansf@illidansf.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;color=#008000;client-nonce=c6a93a2d077a401bfcc0a1fa13bc7580;rm-received-ts=1704558324709;tmi-sent-ts=1704558324533;display-name=AikawaCaiman;first-msg=0;subscriber=0;emotes=;id=b7bbeb17-bad6-4b2b-84a5-a342092933cf;badge-info=;badges=no_audio/1;returning-chatter=0;turbo=0;flags=;mod=0;historical=1;user-id=157747813;user-type= :aikawacaiman!aikawacaiman@aikawacaiman.tmi.twitch.tv PRIVMSG #nymn WAYTOODANK","@id=6ff2fcf2-22a7-4ed8-8847-f21270e9a516;tmi-sent-ts=1704558324999;mod=0;emotes=;badge-info=;turbo=0;flags=;subscriber=0;first-msg=0;user-id=51967700;color=#FF0000;historical=1;user-type=;room-id=62300805;returning-chatter=0;client-nonce=ab3623b9ce719f9a99e4dde80add843d;display-name=Patixxl;badges=;rm-received-ts=1704558325178 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@tmi-sent-ts=1704558325455;badges=subscriber/0,bits/1;rm-received-ts=1704558325628;room-id=62300805;badge-info=subscriber/2;color=#FF69B4;user-id=22733078;display-name=miniwoffer;mod=0;emotes=;turbo=0;id=df51ecee-0194-4574-94f2-c62cc72f7382;first-msg=0;flags=;subscriber=1;historical=1;client-nonce=ce97fa5118e62ea6d35f4c01cfb629c8;returning-chatter=0;user-type= :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn :monkaS RUN","@id=4781f1ef-00ff-4e16-af2f-91620969570c;color=#FFFF00;mod=0;badges=subscriber/9,turbo/1;display-name=Kotzblitz20;client-nonce=e18e193d7de9217664758c7c389f63f6;returning-chatter=0;badge-info=subscriber/9;tmi-sent-ts=1704558325486;rm-received-ts=1704558325700;first-msg=0;subscriber=1;user-id=40037186;user-type=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154;historical=1;turbo=1;room-id=62300805;flags= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@historical=1;display-name=Joshlad;color=#D52AFF;badges=vip/1,subscriber/72,rplace-2023/1;subscriber=1;emotes=;returning-chatter=0;vip=1;badge-info=subscriber/77;tmi-sent-ts=1704558325613;first-msg=0;turbo=0;room-id=62300805;user-type=;rm-received-ts=1704558325787;id=0038971c-4ade-4266-9a8f-40170237375a;flags=;mod=0;user-id=87120320 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;rm-received-ts=1704558326207;room-id=62300805;id=05abf77d-7568-41b4-815b-2650c38ed1ed;turbo=0;historical=1;first-msg=0;user-id=36237730;emotes=;display-name=Raztheman;mod=0;color=#0000FF;badge-info=;client-nonce=e2c7aca43bd133717017f167193ddb5a;subscriber=0;returning-chatter=0;user-type=;tmi-sent-ts=1704558326027;badges=premium/1 :raztheman!raztheman@raztheman.tmi.twitch.tv PRIVMSG #nymn :@ImDaxify this was before AI","@historical=1;flags=;first-msg=0;rm-received-ts=1704558326768;display-name=jontEmillian;color=#63BD68;emotes=;id=67ec380b-2a79-4c4e-8fae-f75549560804;user-type=;badges=subscriber/36,twitch-recap-2023/1;mod=0;turbo=0;user-id=433352132;badge-info=subscriber/38;tmi-sent-ts=1704558326594;returning-chatter=0;subscriber=1;room-id=62300805 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@rm-received-ts=1704558326993;user-id=85837900;mod=0;emotes=;id=53b0a831-68e3-4e86-ba22-e58eed4a67a6;user-type=;flags=;first-msg=0;color=#DAA520;turbo=0;subscriber=1;historical=1;returning-chatter=0;room-id=62300805;client-nonce=f0bae9acabec8a9e7e64c53104396faa;badge-info=subscriber/37;tmi-sent-ts=1704558326822;display-name=DontCagePlebs;badges=subscriber/36,no_audio/1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@tmi-sent-ts=1704558327034;historical=1;flags=;badge-info=;user-type=;turbo=0;emotes=;user-id=431946171;mod=0;id=05ec2df1-5748-4d05-a4b9-37f3f8e9b0b7;first-msg=0;display-name=jonhycrack;color=#008000;room-id=62300805;badges=no_audio/1;subscriber=0;returning-chatter=0;rm-received-ts=1704558327202 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@first-msg=0;user-id=179965969;subscriber=0;display-name=Dhreago;flags=;room-id=62300805;badge-info=;returning-chatter=0;emotes=;user-type=;color=#1E90FF;tmi-sent-ts=1704558327227;mod=0;id=2e5b7044-5b17-489f-a592-2468d9786e74;client-nonce=8ba5592e4e63f92f781610a42125f573;turbo=0;badges=;rm-received-ts=1704558327412;historical=1 :dhreago!dhreago@dhreago.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@historical=1;badge-info=;first-msg=0;client-nonce=b7a36956bcd166dc622c3148685df8d6;subscriber=0;user-id=998960046;tmi-sent-ts=1704558327520;room-id=62300805;color=;rm-received-ts=1704558327690;display-name=jross1812;flags=;turbo=0;mod=0;returning-chatter=0;emotes=;id=14f1b73e-3620-4aba-97d1-9bb384a95b05;badges=;user-type= :jross1812!jross1812@jross1812.tmi.twitch.tv PRIVMSG #nymn docPls","@room-id=62300805;user-type=;badges=;id=b7757500-61d5-4001-8ba7-d7d7f0f9b825;returning-chatter=0;flags=;rm-received-ts=1704558328019;mod=0;badge-info=;historical=1;client-nonce=8d8e78bd5573ce8b1f59a81103bde050;emotes=;user-id=51967700;display-name=Patixxl;turbo=0;color=#FF0000;tmi-sent-ts=1704558327844;first-msg=0;subscriber=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@rm-received-ts=1704558328563;tmi-sent-ts=1704558328406;id=a512d728-5fa5-4648-ae93-d2861056290a;badge-info=;returning-chatter=0;display-name=jonhycrack;subscriber=0;emotes=;color=#008000;user-type=;user-id=431946171;mod=0;flags=;badges=no_audio/1;historical=1;turbo=0;room-id=62300805;first-msg=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOOGOOD 󠀀","@tmi-sent-ts=1704558328665;user-id=157747813;turbo=0;user-type=;returning-chatter=0;client-nonce=25c4ddac4292049bf19080daf414878a;flags=16-19:P.6;id=fd1b5054-c8cc-42b9-b173-75ff3a05d5fe;rm-received-ts=1704558328836;subscriber=0;emotes=;room-id=62300805;color=#008000;badges=no_audio/1;historical=1;first-msg=0;mod=0;display-name=AikawaCaiman;badge-info= :aikawacaiman!aikawacaiman@aikawacaiman.tmi.twitch.tv PRIVMSG #nymn :WAYTOODANK HOLY FUCK","@tmi-sent-ts=1704558329150;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266;id=db3854e4-1d2f-4701-a791-a757fce273f1;first-msg=0;display-name=Kotzblitz20;returning-chatter=0;rm-received-ts=1704558329336;flags=;turbo=1;mod=0;historical=1;user-type=;room-id=62300805;color=#FFFF00;badges=subscriber/9,turbo/1;badge-info=subscriber/9;subscriber=1;user-id=40037186;client-nonce=08ee21f48a8801d7a40050f6c54dd7b6 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@historical=1;flags=;id=6ed41fe1-a803-4c51-a4e6-10d7fc68a46d;tmi-sent-ts=1704558329195;room-id=62300805;client-nonce=38080a601ba7c58cc3289bc61f44f40c;rm-received-ts=1704558329358;mod=0;user-id=474204887;emotes=;subscriber=1;badges=subscriber/0,premium/1;user-type=;returning-chatter=0;first-msg=0;display-name=mnqn18;color=#1E90FF;badge-info=subscriber/2;turbo=0 :mnqn18!mnqn18@mnqn18.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@turbo=0;subscriber=0;badge-info=;first-msg=0;historical=1;rm-received-ts=1704558330327;display-name=jonhycrack;badges=no_audio/1;room-id=62300805;id=37ddbe79-8f91-4487-afaf-c53015c8e761;flags=;user-id=431946171;mod=0;tmi-sent-ts=1704558330159;returning-chatter=0;color=#008000;user-type=;emotes= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@first-msg=0;room-id=62300805;subscriber=0;returning-chatter=0;user-type=;emotes=;flags=;color=#D2691E;badge-info=;mod=0;historical=1;rm-received-ts=1704558330379;user-id=38870532;turbo=0;tmi-sent-ts=1704558330217;display-name=Nopem8;badges=;id=37bf2d07-ee0c-4939-b1de-9605c77b8b00;client-nonce=83b9c6c63fc2cd78a7e2e2bd661e8d6e :nopem8!nopem8@nopem8.tmi.twitch.tv PRIVMSG #nymn :docPls ?","@mod=0;room-id=62300805;user-type=;tmi-sent-ts=1704558330830;color=#00FF7F;subscriber=0;flags=;badges=rplace-2023/1;display-name=Dankarop;historical=1;turbo=0;emotes=;id=0aaeb648-dd41-480f-903e-f4926d8f455d;client-nonce=2b26b1e2e466f12060390faeeeb399a6;first-msg=0;returning-chatter=0;rm-received-ts=1704558331026;user-id=467106798;badge-info= :dankarop!dankarop@dankarop.tmi.twitch.tv PRIVMSG #nymn forsenParty","@turbo=0;user-type=;returning-chatter=0;first-msg=0;badge-info=;user-id=37931493;display-name=deever44;subscriber=0;id=f00cc3b1-db07-4248-822e-3ed5daa3f2dc;mod=0;color=;flags=;client-nonce=76ce7498ac62f01d523af3744d0962de;emotes=;room-id=62300805;tmi-sent-ts=1704558331567;badges=no_audio/1;historical=1;rm-received-ts=1704558331754 :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badge-info=subscriber/38;user-type=;turbo=0;emotes=;first-msg=0;color=#63BD68;id=7ea7ac00-50c7-436c-9935-d818900df7dd;user-id=433352132;returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;mod=0;tmi-sent-ts=1704558332090;historical=1;room-id=62300805;rm-received-ts=1704558332267;subscriber=1;display-name=jontEmillian;flags= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty ACID","@rm-received-ts=1704558332628;user-type=;user-id=117033693;first-msg=0;badges=subscriber/3;mod=0;emotes=;historical=1;turbo=0;badge-info=subscriber/3;client-nonce=aecd4490775960d52f208338a036f354;flags=;room-id=62300805;tmi-sent-ts=1704558332440;returning-chatter=0;id=55632e15-3f0d-41ad-9cb4-cfc96365604c;display-name=NSAPartyVan;subscriber=1;color=#1E90FF :nsapartyvan!nsapartyvan@nsapartyvan.tmi.twitch.tv PRIVMSG #nymn WalterVibe","@rm-received-ts=1704558332904;user-type=;turbo=0;tmi-sent-ts=1704558332713;color=#FF2424;client-nonce=6fe3c74a3c08e3e2354716c6a0ad0b80;first-msg=0;emotes=;room-id=62300805;subscriber=1;badge-info=subscriber/49;badges=subscriber/48,bits/25000;historical=1;id=119f53e1-f41c-41fb-9db8-de06b238da2c;display-name=ME_ME;mod=0;returning-chatter=0;flags=;user-id=159210800 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn WAYTOODANK","@badges=subscriber/9,turbo/1;historical=1;room-id=62300805;mod=0;flags=;user-type=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74;turbo=1;client-nonce=235b75636b5f8876bf06445c3d4c4040;first-msg=0;id=3f4ad233-6c34-42eb-a443-3200a5520f6b;rm-received-ts=1704558332936;subscriber=1;returning-chatter=0;badge-info=subscriber/9;display-name=Kotzblitz20;tmi-sent-ts=1704558332757;color=#FFFF00;user-id=40037186 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@display-name=Mawsonator;client-nonce=35dadbb6ecdc5c4222fc342eb82a4863;first-msg=0;badge-info=subscriber/47;id=9997af8f-cefd-46fc-a3c6-6c4ffa371775;rm-received-ts=1704558333303;color=#FF0000;user-type=;room-id=62300805;mod=0;flags=;user-id=92529125;badges=subscriber/42,twitch-recap-2023/1;subscriber=1;emotes=;historical=1;tmi-sent-ts=1704558333127;turbo=0;returning-chatter=0 :mawsonator!mawsonator@mawsonator.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@room-id=62300805;rm-received-ts=1704558333647;badge-info=;color=#FF0000;client-nonce=d3607f59caba20c53029dcf1c4da8cab;returning-chatter=0;first-msg=0;user-id=86965943;user-type=;display-name=voyu1337;subscriber=0;id=5a67d1c7-12d0-4fc7-a4b8-4ee5f038259a;tmi-sent-ts=1704558333471;turbo=0;mod=0;flags=;historical=1;emotes=;badges=premium/1 :voyu1337!voyu1337@voyu1337.tmi.twitch.tv PRIVMSG #nymn :WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK WAYTOODANK","@returning-chatter=0;room-id=62300805;user-type=;display-name=Joshlad;tmi-sent-ts=1704558333947;mod=0;badge-info=subscriber/77;historical=1;emotes=;first-msg=0;badges=vip/1,subscriber/72,rplace-2023/1;subscriber=1;color=#D52AFF;rm-received-ts=1704558334135;user-id=87120320;turbo=0;id=5a86c4fb-2d4a-4ae4-8b07-942382ebb38b;flags=;vip=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@tmi-sent-ts=1704558334578;user-id=431946171;rm-received-ts=1704558334748;badges=no_audio/1;historical=1;room-id=62300805;color=#008000;subscriber=0;badge-info=;first-msg=0;emotes=;turbo=0;user-type=;flags=;mod=0;returning-chatter=0;id=6319543e-fb08-4748-87fe-db77cf300e15;display-name=jonhycrack :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOOGOOD EDM","@badge-info=subscriber/38;subscriber=1;user-type=;color=#63BD68;flags=;room-id=62300805;badges=subscriber/36,twitch-recap-2023/1;id=3b276ed7-329b-49d0-800a-9a13a90e43a5;tmi-sent-ts=1704558334882;turbo=0;user-id=433352132;mod=0;display-name=jontEmillian;historical=1;returning-chatter=0;rm-received-ts=1704558335066;emotes=;first-msg=0 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@rm-received-ts=1704558335346;user-id=29764188;room-id=62300805;client-nonce=145a8db4e58956c0ef5c22e32797af55;turbo=0;tmi-sent-ts=1704558335174;user-type=;emotes=30134:16-19;first-msg=0;id=e148a3ed-c424-41a5-a549-badb8bf13a5c;display-name=zzlint;flags=;badge-info=;returning-chatter=0;color=#1E90FF;subscriber=0;historical=1;badges=;mod=0 :zzlint!zzlint@zzlint.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOOGOOD Mau5","@badge-info=;room-id=62300805;turbo=0;client-nonce=99b07f2e056e82ab76823ddb19329e84;first-msg=0;badges=bits/100;flags=;id=985500ad-15cf-44a7-8343-ce2c5a392e6c;returning-chatter=0;emotes=;tmi-sent-ts=1704558335289;rm-received-ts=1704558335472;display-name=DM8917;user-type=;user-id=63372784;historical=1;subscriber=0;color=#25E000;mod=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn docJAMMER","@id=5770bafc-fb5c-49fb-9330-67b7c50f6471;badges=no_audio/1;turbo=0;emotes=;subscriber=0;historical=1;display-name=jonhycrack;room-id=62300805;mod=0;tmi-sent-ts=1704558336496;user-id=431946171;color=#008000;first-msg=0;rm-received-ts=1704558336666;user-type=;returning-chatter=0;flags=;badge-info= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOOGOOD EDM","@display-name=Patixxl;returning-chatter=0;subscriber=0;tmi-sent-ts=1704558336633;historical=1;rm-received-ts=1704558336826;badge-info=;user-id=51967700;room-id=62300805;turbo=0;color=#FF0000;client-nonce=2de04a9fc39a1c420602361333c29548;user-type=;flags=;id=d040509a-cf15-49cd-94b4-e779a0899ba6;mod=0;badges=;emotes=;first-msg=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOODONKMAN","@subscriber=1;user-type=;turbo=1;returning-chatter=0;user-id=40037186;client-nonce=6544d1f489cff2e0662da3e92f86a1ab;color=#FFFF00;mod=0;rm-received-ts=1704558336970;id=9054e699-2b56-4949-a852-07ad3688493b;emotes=;tmi-sent-ts=1704558336796;room-id=62300805;historical=1;flags=;badges=subscriber/9,turbo/1;display-name=Kotzblitz20;badge-info=subscriber/9;first-msg=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@emotes=;subscriber=1;mod=0;room-id=62300805;first-msg=0;rm-received-ts=1704558337489;id=1c22b735-dfe2-4915-92b6-b60fa347eacb;client-nonce=1b62955d200aac3c87cf87e90281a5b4;badge-info=subscriber/42;badges=subscriber/42,bits/1000;color=#9ACD32;turbo=0;display-name=wheeely;flags=;user-type=;user-id=68478828;tmi-sent-ts=1704558337312;returning-chatter=0;historical=1 :wheeely!wheeely@wheeely.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-type=;client-nonce=73b0314c295afe026f6f1e4627e77261;returning-chatter=0;flags=;user-id=85837900;turbo=0;tmi-sent-ts=1704558337339;historical=1;emotes=28:0-12;rm-received-ts=1704558337529;id=6cd76476-0425-4ebc-893a-ec12dd78d05a;badge-info=subscriber/37;room-id=62300805;display-name=DontCagePlebs;mod=0;subscriber=1;color=#DAA520;first-msg=0;badges=subscriber/36,no_audio/1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :MrDestructoid Clap","@emotes=;user-id=465513111;tmi-sent-ts=1704558337347;first-msg=0;subscriber=0;client-nonce=3f36dd46a3aa44625cd2c8b3b171d2f5;historical=1;id=ba3ab534-3e8c-4c92-a051-2c1d57f79ccf;rm-received-ts=1704558337537;mod=0;room-id=62300805;badge-info=;badges=;flags=;returning-chatter=0;color=;turbo=0;user-type=;display-name=mongushmengi :mongushmengi!mongushmengi@mongushmengi.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@display-name=liber7as;historical=1;turbo=0;tmi-sent-ts=1704558337987;returning-chatter=0;user-type=;badge-info=subscriber/41;user-id=92402102;subscriber=1;badges=subscriber/36;id=0daf3441-461e-4aaf-bf20-845faaf3bd4b;emotes=;room-id=62300805;client-nonce=b7b620ab04a5dd464871e5e256567180;color=#41FF00;mod=0;rm-received-ts=1704558338167;first-msg=0;flags= :liber7as!liber7as@liber7as.tmi.twitch.tv PRIVMSG #nymn :i think its just going to get weirder and weirder with each tunnel","@display-name=NSAPartyVan;badges=subscriber/3;first-msg=0;emotes=;historical=1;room-id=62300805;returning-chatter=0;tmi-sent-ts=1704558338599;color=#1E90FF;client-nonce=bbe7dba570c8812e9a1a68fb86c897ee;badge-info=subscriber/3;id=82c478fa-8ef3-4ddf-9970-48ba219fbda3;subscriber=1;user-type=;flags=;turbo=0;user-id=117033693;mod=0;rm-received-ts=1704558338754 :nsapartyvan!nsapartyvan@nsapartyvan.tmi.twitch.tv PRIVMSG #nymn :WalterVibe 󠀀","@flags=;returning-chatter=0;display-name=Kotzblitz20;historical=1;badge-info=subscriber/9;rm-received-ts=1704558339764;user-id=40037186;room-id=62300805;color=#FFFF00;user-type=;client-nonce=a28fab8314c98e5a8df581d6ec7c672b;tmi-sent-ts=1704558339587;id=caafdac3-0710-48b5-9801-0257c7d28b45;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42;first-msg=0;subscriber=1;badges=subscriber/9,turbo/1;turbo=1;mod=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;display-name=jonhycrack;flags=;room-id=62300805;turbo=0;subscriber=0;tmi-sent-ts=1704558340475;rm-received-ts=1704558340649;color=#008000;badges=no_audio/1;returning-chatter=0;badge-info=;emotes=;mod=0;user-type=;id=0a26d8e4-a61e-4c97-81ce-d921f90f1835;historical=1;user-id=431946171 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOOGOOD EDM","@flags=;historical=1;user-type=;subscriber=0;returning-chatter=0;user-id=151423066;room-id=62300805;color=#FF69B4;badge-info=;emotes=;first-msg=0;tmi-sent-ts=1704558340966;display-name=forsenkkona_;mod=0;badges=;id=fff8cab9-47aa-4390-97f2-696d8ca070b3;rm-received-ts=1704558341126;turbo=0 :forsenkkona_!forsenkkona_@forsenkkona_.tmi.twitch.tv PRIVMSG #nymn :AlienPls 󠀀","@flags=;turbo=0;mod=0;user-id=433352132;emotes=;user-type=;id=78a3ad68-fc6d-4d65-8b0d-399f1f181e40;first-msg=0;returning-chatter=0;room-id=62300805;display-name=jontEmillian;subscriber=1;color=#63BD68;badge-info=subscriber/38;badges=subscriber/36,twitch-recap-2023/1;historical=1;rm-received-ts=1704558341267;tmi-sent-ts=1704558341100 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@returning-chatter=0;badge-info=;tmi-sent-ts=1704558342827;id=19e4769d-0334-45a9-b0ee-3175545595f2;historical=1;color=#25E000;user-type=;subscriber=0;client-nonce=9f65c668a1cdac9bfefcf1246309ad22;display-name=DM8917;emotes=;rm-received-ts=1704558343005;turbo=0;room-id=62300805;user-id=63372784;flags=;mod=0;first-msg=0;badges=bits/100 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOODONKMAN","@flags=;mod=0;historical=1;room-id=62300805;first-msg=0;tmi-sent-ts=1704558343772;user-type=;turbo=0;emotes=;subscriber=0;returning-chatter=0;client-nonce=db722ef1be2cc9b0a57747dbdac4894e;badge-info=;display-name=Patixxl;badges=;color=#FF0000;user-id=51967700;id=63e9a403-7b0a-46c3-9db5-0d50246e9cd2;rm-received-ts=1704558343936 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOOGOOD","@badge-info=;id=673d33e0-fd71-4d4a-922d-5a6ff4015423;first-msg=0;user-type=;color=;display-name=jross1812;mod=0;flags=;historical=1;subscriber=0;client-nonce=773129302dba08d500aad5b30b76d74b;user-id=998960046;turbo=0;tmi-sent-ts=1704558343809;rm-received-ts=1704558343983;returning-chatter=0;emotes=;badges=;room-id=62300805 :jross1812!jross1812@jross1812.tmi.twitch.tv PRIVMSG #nymn :docPls docPls","@subscriber=1;room-id=62300805;returning-chatter=0;color=#FF69B4;rm-received-ts=1704558346671;badges=subscriber/0,bits/1;tmi-sent-ts=1704558346474;client-nonce=9660eb07d06b1617c16fa1f4a3f6f172;user-type=;badge-info=subscriber/2;first-msg=0;flags=;mod=0;user-id=22733078;display-name=miniwoffer;historical=1;id=3e3b48d5-5f79-42b9-b0cd-47390746da94;emotes=;turbo=0 :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOODONKMAN","@returning-chatter=0;turbo=0;historical=1;mod=0;badges=premium/1;subscriber=0;first-msg=0;display-name=sikonic;room-id=62300805;flags=;tmi-sent-ts=1704558346461;client-nonce=5bbeec575baf8c4a4a545943cdd62009;rm-received-ts=1704558346681;badge-info=;user-id=147025589;emotes=;user-type=;color=#FFBB3C;id=9efed424-7285-4b90-91ec-cc68b1fad32c :sikonic!sikonic@sikonic.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty Ratge RaveTime EDM forsenParty","@subscriber=0;user-type=;badge-info=;tmi-sent-ts=1704558347073;rm-received-ts=1704558347238;turbo=0;user-id=837777908;color=#8A2BE2;room-id=62300805;badges=;first-msg=0;flags=;client-nonce=d9080b34d5ad87f041c5f1111560a4e6;mod=0;display-name=16oct22;id=f04d1db1-0c07-476e-9da3-c9d6a68958b3;returning-chatter=0;historical=1;emotes= :16oct22!16oct22@16oct22.tmi.twitch.tv PRIVMSG #nymn :This is like a Cruelty squad demo, not bad, and it is randomized it says","@first-msg=0;subscriber=0;room-id=62300805;display-name=Intel_power;id=e2f46fee-0f38-4f2d-973c-5180e9867de0;mod=0;user-type=;flags=;rm-received-ts=1704558347410;returning-chatter=0;color=#0000FF;client-nonce=752a3a1e24b43462fd355cdbaa4ff968;historical=1;turbo=0;emotes=;tmi-sent-ts=1704558347239;badge-info=;user-id=103665668;badges=bits-charity/1 :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@flags=;returning-chatter=0;first-msg=0;rm-received-ts=1704558347868;display-name=ME_ME;room-id=62300805;id=3bdb5102-f8eb-4a95-b70c-e9b9699b11f2;subscriber=1;emotes=;mod=0;turbo=0;user-type=;user-id=159210800;tmi-sent-ts=1704558347706;historical=1;badges=subscriber/48,bits/25000;client-nonce=507a6b36bc9a20bfd8e20a320cbceb9f;badge-info=subscriber/49;color=#FF2424 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn :WAYTOODANK 󠀀","@user-id=40037186;first-msg=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58;returning-chatter=0;rm-received-ts=1704558349387;id=6275aaa3-7679-462d-bdbe-3fd2c6360ca5;mod=0;historical=1;turbo=1;badge-info=subscriber/9;user-type=;room-id=62300805;tmi-sent-ts=1704558349190;badges=subscriber/9,turbo/1;client-nonce=57f18d039babda4daed6f1f6026ca5cd;flags=;display-name=Kotzblitz20;color=#FFFF00;subscriber=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;mod=0;badges=subscriber/36,twitch-recap-2023/1;first-msg=0;emotes=;flags=;turbo=0;historical=1;rm-received-ts=1704558350549;color=#63BD68;user-type=;display-name=jontEmillian;tmi-sent-ts=1704558350356;subscriber=1;user-id=433352132;id=fa74d11d-1bbe-4483-ad5d-284a3ca9cf59;returning-chatter=0;badge-info=subscriber/38 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@flags=;badges=subscriber/9,turbo/1;id=9b469c92-eb93-4068-a720-715787559163;rm-received-ts=1704558351700;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;first-msg=0;historical=1;room-id=62300805;returning-chatter=0;user-type=;color=#FFFF00;mod=0;user-id=40037186;badge-info=subscriber/9;display-name=Kotzblitz20;tmi-sent-ts=1704558351145;subscriber=1;turbo=1;client-nonce=7738a42f34f0f19da554581d6fad7e9f :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@display-name=Patixxl;badges=;mod=0;emotes=;user-type=;turbo=0;badge-info=;id=21f0cc2d-1008-4601-9908-0a538d8b4dc0;user-id=51967700;client-nonce=389ea1585c09bf5563d7f60bd8bfa20b;rm-received-ts=1704558352627;flags=;returning-chatter=0;tmi-sent-ts=1704558352439;room-id=62300805;subscriber=0;first-msg=0;color=#FF0000;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=subscriber/38;first-msg=0;room-id=62300805;turbo=0;tmi-sent-ts=1704558352479;color=#63BD68;subscriber=1;flags=;historical=1;rm-received-ts=1704558352645;returning-chatter=0;display-name=jontEmillian;mod=0;badges=subscriber/36,twitch-recap-2023/1;user-id=433352132;emotes=;id=edde475e-d2d9-4ed2-91a0-976ef81ba4ae;user-type= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@tmi-sent-ts=1704558352643;first-msg=0;returning-chatter=0;mod=0;id=5d3db884-ec47-4938-85fb-1dbc976a9c62;color=#D52AFF;display-name=Joshlad;turbo=0;emotes=;room-id=62300805;vip=1;user-id=87120320;subscriber=1;user-type=;flags=;rm-received-ts=1704558352816;historical=1;badge-info=subscriber/77;badges=vip/1,subscriber/72,rplace-2023/1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badge-info=subscriber/11;user-type=;color=#008000;id=d3f9d69e-808e-44d3-8df7-411addd9a878;mod=0;rm-received-ts=1704558353609;returning-chatter=0;subscriber=1;room-id=62300805;historical=1;client-nonce=7dcab2448d1d7b3b5b475c452c7b7d4e;emotes=;first-msg=0;turbo=0;user-id=135571016;flags=;display-name=terning;tmi-sent-ts=1704558353433;badges=subscriber/9,rplace-2023/1 :terning!terning@terning.tmi.twitch.tv PRIVMSG #nymn IVEGONEPASTHEPOINTOFINSANITY","@subscriber=0;flags=;mod=0;first-msg=0;turbo=0;rm-received-ts=1704558353926;user-type=;returning-chatter=0;id=dbd8d668-b130-4099-9a5e-40ddee5c53ba;user-id=38870532;tmi-sent-ts=1704558353761;badges=;client-nonce=a668c12be8fa44b736b11a28fdf4f05e;color=#D2691E;room-id=62300805;badge-info=;display-name=Nopem8;emotes=;historical=1 :nopem8!nopem8@nopem8.tmi.twitch.tv PRIVMSG #nymn docPls","@emotes=;subscriber=1;room-id=62300805;returning-chatter=0;display-name=MaxThurian;historical=1;badges=subscriber/42,twitch-recap-2023/1;mod=0;badge-info=subscriber/43;client-nonce=87da96cd27dd086fa057683379735851;color=#00ED2A;flags=;id=840187ff-3a78-4235-8520-1d1541a52ba4;rm-received-ts=1704558354069;turbo=0;user-id=60181947;user-type=;first-msg=0;tmi-sent-ts=1704558353902 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA ForsenLookingAtYou","@user-type=;emotes=;turbo=0;historical=1;rm-received-ts=1704558354670;client-nonce=652c893de89a9822d3b007485ccb2939;mod=0;display-name=Patixxl;flags=;room-id=62300805;returning-chatter=0;badges=;user-id=51967700;tmi-sent-ts=1704558354490;id=bda06fa4-c856-438d-ac20-b0b2cd255944;first-msg=0;subscriber=0;color=#FF0000;badge-info= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138;user-type=;badge-info=subscriber/9;id=e3af92dc-6ad6-4f14-bcf7-9f41880497e3;turbo=1;subscriber=1;rm-received-ts=1704558355115;room-id=62300805;color=#FFFF00;tmi-sent-ts=1704558354918;user-id=40037186;mod=0;badges=subscriber/9,turbo/1;historical=1;client-nonce=0493eecde5af5299cc960d66fa5dcb44;display-name=Kotzblitz20;first-msg=0;returning-chatter=0;flags= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@display-name=ME_ME;flags=;user-id=159210800;room-id=62300805;historical=1;subscriber=1;badges=subscriber/48,bits/25000;emote-only=1;rm-received-ts=1704558355254;mod=0;tmi-sent-ts=1704558355052;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;color=#FF2424;first-msg=0;returning-chatter=0;turbo=0;id=4c5ff458-0634-4b36-bf71-c7fd0d7f4772;badge-info=subscriber/49;user-type=;client-nonce=db77bb11b4dc7d5e87c27c23670f080a :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenParty","@subscriber=1;room-id=62300805;turbo=0;user-type=;flags=;color=#63BD68;user-id=433352132;badge-info=subscriber/38;rm-received-ts=1704558355789;returning-chatter=0;mod=0;display-name=jontEmillian;emotes=;tmi-sent-ts=1704558355600;badges=subscriber/36,twitch-recap-2023/1;first-msg=0;id=5db9223f-0cab-4d40-9c47-d10c0f0ad437;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@first-msg=0;user-id=103665668;emotes=;flags=;badges=bits-charity/1;id=f520782b-2c65-457c-bb21-f668f7c5227f;rm-received-ts=1704558355994;turbo=0;client-nonce=9ecee36f432861d92e849da044f52d50;room-id=62300805;user-type=;mod=0;display-name=Intel_power;historical=1;subscriber=0;badge-info=;tmi-sent-ts=1704558355826;returning-chatter=0;color=#0000FF :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn :RAT RAVE","@emotes=;mod=0;returning-chatter=0;badges=;user-type=;rm-received-ts=1704558356803;display-name=Patixxl;badge-info=;first-msg=0;subscriber=0;tmi-sent-ts=1704558356626;turbo=0;user-id=51967700;flags=;room-id=62300805;historical=1;id=2200ea1f-d969-4d21-835e-07be22d4ede8;color=#FF0000;client-nonce=94ae4306657275d452d9ebea26c97bfd :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;badges=subscriber/54,bits/1000;tmi-sent-ts=1704558356619;user-type=;room-id=62300805;client-nonce=88d2ba33098cb57b9056a88f99b815eb;first-msg=0;returning-chatter=0;badge-info=subscriber/55;id=813f93d8-731c-463f-ab81-e52cf52109f9;subscriber=1;user-id=103592036;historical=1;turbo=0;color=#00615C;display-name=SecretCarrot;rm-received-ts=1704558356812;emotes=;mod=0 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn AlienDance","@room-id=62300805;badges=bits/100;badge-info=;client-nonce=7ac19d53974bf5449b9230cc2269e0ea;id=9d89000a-edab-4b19-a016-1daa18871134;color=#25E000;subscriber=0;mod=0;historical=1;user-id=63372784;display-name=DM8917;rm-received-ts=1704558357172;tmi-sent-ts=1704558356991;first-msg=0;emotes=;flags=;returning-chatter=0;turbo=0;user-type= :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN","@client-nonce=9735f1c95a4d0b7c409aaeae2bddeec6;historical=1;tmi-sent-ts=1704558358704;badges=subscriber/48,bits/25000;badge-info=subscriber/49;emote-only=1;display-name=ME_ME;emotes=555555560:0-1;mod=0;flags=;returning-chatter=0;id=5c74b507-da41-4ffc-8ddd-7c321549f54f;rm-received-ts=1704558358889;user-type=;turbo=0;first-msg=0;user-id=159210800;color=#FF2424;subscriber=1;room-id=62300805 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn ::D","@returning-chatter=0;badges=;id=6ea7e5f1-eb19-41b6-905a-cbc270e8fdcb;subscriber=0;tmi-sent-ts=1704558359045;room-id=62300805;flags=;first-msg=0;badge-info=;color=#000000;turbo=0;rm-received-ts=1704558359228;mod=0;user-id=205837377;historical=1;user-type=;display-name=Duchene;emotes= :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn :\u0001ACTION forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀\u0001","@client-nonce=ee76af044adf14fb9c9577c831f4e81f;flags=;badges=subscriber/42,twitch-recap-2023/1;display-name=MaxThurian;badge-info=subscriber/43;mod=0;user-id=60181947;rm-received-ts=1704558359607;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170;color=#00ED2A;room-id=62300805;historical=1;user-type=;id=6cfb94e0-008a-4f52-857e-35f380e0382d;turbo=0;returning-chatter=0;subscriber=1;first-msg=0;tmi-sent-ts=1704558359422 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;badge-info=;tmi-sent-ts=1704558359614;rm-received-ts=1704558359787;flags=;mod=0;historical=1;emotes=;client-nonce=0f7efa76c59c2b3533efc70f4c053222;color=#FF0000;subscriber=0;user-id=51967700;first-msg=0;id=173d2f93-6008-412f-b91a-da3557f36e62;returning-chatter=0;turbo=0;display-name=Patixxl;badges=;room-id=62300805 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202;historical=1;badges=bits/1000;subscriber=0;tmi-sent-ts=1704558359649;user-id=45923155;returning-chatter=0;color=#FF0000;id=082e71d0-d252-4906-aa04-423d1f34267a;user-type=;room-id=62300805;flags=;badge-info=;first-msg=0;rm-received-ts=1704558359840;turbo=0;display-name=HajleSellasje;mod=0 :hajlesellasje!hajlesellasje@hajlesellasje.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badges=;user-type=;turbo=0;badge-info=;display-name=AAAAUUUUGGGHHHHH;id=fe86f573-63ee-4de5-9104-2c1e8eb348a0;tmi-sent-ts=1704558360129;client-nonce=5c2cb15b0fca07ae322374d60ccce7d5;subscriber=0;user-id=809558302;first-msg=1;emotes=;room-id=62300805;historical=1;color=;rm-received-ts=1704558360358;returning-chatter=0;mod=0;flags= :aaaauuuuggghhhhh!aaaauuuuggghhhhh@aaaauuuuggghhhhh.tmi.twitch.tv PRIVMSG #nymn forsenParty","@returning-chatter=0;badges=subscriber/36,twitch-recap-2023/1;subscriber=1;rm-received-ts=1704558360386;room-id=62300805;user-id=433352132;flags=;turbo=0;color=#63BD68;mod=0;display-name=jontEmillian;emotes=555555560:0-1;first-msg=0;id=72ac6810-3aba-4aa4-af35-f62deba5baa3;historical=1;badge-info=subscriber/38;user-type=;emote-only=1;tmi-sent-ts=1704558360209 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn ::D","@flags=;display-name=ThinMartin;historical=1;returning-chatter=0;rm-received-ts=1704558361281;badges=;tmi-sent-ts=1704558361098;id=ca6717f6-0fbe-4b4d-963b-ddf0ad2bfe14;client-nonce=bb27367ad56e4ba11ca5a27e289bec26;mod=0;turbo=0;subscriber=0;badge-info=;color=#1E90FF;user-type=;emotes=;user-id=108762203;first-msg=0;room-id=62300805 :thinmartin!thinmartin@thinmartin.tmi.twitch.tv PRIVMSG #nymn :what is this game about nymn","@flags=;badge-info=;mod=0;emotes=496:0-1;user-id=37931493;display-name=deever44;badges=no_audio/1;rm-received-ts=1704558361823;subscriber=0;room-id=62300805;user-type=;tmi-sent-ts=1704558361630;first-msg=0;client-nonce=ef857cbe124955a59b99ce514bc0d25f;historical=1;emote-only=1;turbo=0;id=53b18995-698f-45b2-849c-9b0e2fb8a0a0;returning-chatter=0;color= :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn ::D","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170;tmi-sent-ts=1704558362519;id=c8da497d-ef13-4098-b37a-85901d5183df;room-id=62300805;turbo=0;badge-info=subscriber/43;display-name=MaxThurian;user-id=60181947;client-nonce=45392a88fa1b4e9476cac75e0661f80c;badges=subscriber/42,twitch-recap-2023/1;rm-received-ts=1704558362716;flags=;returning-chatter=0;mod=0;historical=1;first-msg=0;color=#00ED2A;subscriber=1;user-type= :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-type=;historical=1;display-name=Kotzblitz20;id=9ca12e75-ea6e-45be-89b0-7fab952a4a87;color=#FFFF00;client-nonce=dde3afea3ba6219ca78a92cdab8cdf11;turbo=1;user-id=40037186;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186;rm-received-ts=1704558363192;first-msg=0;room-id=62300805;flags=;tmi-sent-ts=1704558362999;badges=subscriber/9,turbo/1;subscriber=1;mod=0;returning-chatter=0;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=0;returning-chatter=0;user-type=;first-msg=0;emotes=;id=c419a2fa-87db-4dce-98ca-5659c66847aa;tmi-sent-ts=1704558363045;turbo=0;user-id=431946171;historical=1;room-id=62300805;flags=;rm-received-ts=1704558363211;mod=0;badges=no_audio/1;display-name=jonhycrack;color=#008000;badge-info= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn Alien360","@first-msg=0;display-name=zzlint;historical=1;id=a668efb4-d9e9-4632-8b5e-8c819ebfe694;returning-chatter=0;flags=;rm-received-ts=1704558364202;client-nonce=f2ea00474929e05b4febde034eb82119;subscriber=0;tmi-sent-ts=1704558364036;user-type=;emotes=emotesv2_2f9a36844b054423833c817b5f8d4225:0-8;badges=;room-id=62300805;emote-only=1;mod=0;turbo=0;user-id=29764188;color=#1E90FF;badge-info= :zzlint!zzlint@zzlint.tmi.twitch.tv PRIVMSG #nymn forsenPls","@tmi-sent-ts=1704558364433;historical=1;badges=;subscriber=0;mod=0;color=#FF0000;user-id=51967700;turbo=0;client-nonce=4f36558386640aa28dc3fb324a5c7d12;id=370efb92-8a63-4135-8f70-de229f6addf1;emotes=;room-id=62300805;display-name=Patixxl;rm-received-ts=1704558364612;user-type=;first-msg=0;flags=;badge-info=;returning-chatter=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;flags=;color=#63BD68;display-name=jontEmillian;badges=subscriber/36,twitch-recap-2023/1;user-type=;tmi-sent-ts=1704558364524;returning-chatter=0;mod=0;id=05140eaf-529a-4cc6-8097-e1e2eba0d739;turbo=0;badge-info=subscriber/38;historical=1;emotes=;subscriber=1;user-id=433352132;room-id=62300805;rm-received-ts=1704558364697 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn FIRSTTIMECHATTER","@user-type=;vip=1;emotes=;room-id=62300805;historical=1;badge-info=subscriber/77;display-name=Joshlad;rm-received-ts=1704558364899;id=d73a3660-035e-404c-a047-60afb03ee8c2;badges=vip/1,subscriber/72,rplace-2023/1;first-msg=0;returning-chatter=0;flags=;user-id=87120320;color=#D52AFF;tmi-sent-ts=1704558364721;mod=0;turbo=0;subscriber=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emotes=;subscriber=0;badge-info=;badges=;historical=1;turbo=0;color=;client-nonce=b7c80c98a61be823fc9e6cd52b318343;user-id=465513111;tmi-sent-ts=1704558365024;returning-chatter=0;rm-received-ts=1704558365199;id=21fe77af-bcad-434e-87f3-812734423be6;user-type=;mod=0;display-name=mongushmengi;first-msg=0;room-id=62300805;flags= :mongushmengi!mongushmengi@mongushmengi.tmi.twitch.tv PRIVMSG #nymn FeelsDankMan","@id=488b9a38-0cd6-4481-943c-9144ff6b8482;emotes=;returning-chatter=0;tmi-sent-ts=1704558365024;user-id=85837900;client-nonce=8f0a4d1d73ca7f5551cddd44436c98a4;badges=subscriber/36,no_audio/1;user-type=;flags=;badge-info=subscriber/37;historical=1;subscriber=1;display-name=DontCagePlebs;rm-received-ts=1704558365220;turbo=0;color=#DAA520;room-id=62300805;mod=0;first-msg=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emote-only=1;badge-info=subscriber/49;tmi-sent-ts=1704558365156;returning-chatter=0;badges=subscriber/48,bits/25000;color=#FF2424;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;id=dea75169-4282-4ec8-a099-8a6b8ec94427;rm-received-ts=1704558365338;flags=;subscriber=1;user-type=;first-msg=0;client-nonce=1468b44e56ba92be4eb5125f843d10bb;historical=1;user-id=159210800;display-name=ME_ME;mod=0;turbo=0;room-id=62300805 :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn forsenParty","@historical=1;color=#FFFF00;id=344fbcfb-f4ae-467a-84a8-f68fb6af1d30;user-id=40037186;room-id=62300805;subscriber=1;badges=subscriber/9,turbo/1;display-name=Kotzblitz20;turbo=1;rm-received-ts=1704558365404;first-msg=0;returning-chatter=0;flags=;mod=0;client-nonce=025122561a6c8c77c64df491835a1a04;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282,288-298;tmi-sent-ts=1704558365217;user-type=;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=17f02747-5338-411c-9f3c-f0643132cc76;color=#FF69B4;client-nonce=f674b35954ca7cdcb11aae5195bf02dd;subscriber=1;user-type=;turbo=0;rm-received-ts=1704558366319;user-id=22733078;room-id=62300805;display-name=miniwoffer;emotes=;badge-info=subscriber/2;flags=;mod=0;badges=subscriber/0,bits/1;returning-chatter=0;tmi-sent-ts=1704558366132;first-msg=0;historical=1 :miniwoffer!miniwoffer@miniwoffer.tmi.twitch.tv PRIVMSG #nymn FIRSTTIMECHATTER","@mod=0;returning-chatter=0;emotes=;historical=1;id=fa4e238d-9b22-47b2-863c-50b143f4b416;rm-received-ts=1704558366976;turbo=0;room-id=62300805;color=#00615C;flags=;tmi-sent-ts=1704558366792;badges=subscriber/54,bits/1000;user-type=;first-msg=0;user-id=103592036;badge-info=subscriber/55;display-name=SecretCarrot;subscriber=1;client-nonce=29bd60cf782ead864fb0eb764467ebf6 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn forsenParty","@badges=;display-name=Patixxl;room-id=62300805;mod=0;historical=1;subscriber=0;badge-info=;id=1420090c-2493-4455-a424-b874c4d54279;user-id=51967700;flags=;client-nonce=36839e4e863025c3f264deb28326a77c;first-msg=0;emotes=;tmi-sent-ts=1704558367404;user-type=;rm-received-ts=1704558367576;turbo=0;returning-chatter=0;color=#FF0000 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@badge-info=;user-id=63372784;color=#25E000;id=c755d6d9-0016-477d-9ad2-d7d6a3d77e56;flags=;tmi-sent-ts=1704558367479;display-name=DM8917;user-type=;room-id=62300805;badges=bits/100;emotes=;turbo=0;historical=1;rm-received-ts=1704558367663;returning-chatter=0;first-msg=0;client-nonce=ccd1fce4d89e59cd82fae61dcf7e487b;mod=0;subscriber=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOODONKMAN","@id=fb6cc2ee-909e-44bf-ac74-a04ceb10b6f6;returning-chatter=0;user-id=433352132;badges=subscriber/36,twitch-recap-2023/1;tmi-sent-ts=1704558367804;user-type=;room-id=62300805;turbo=0;color=#63BD68;emotes=;badge-info=subscriber/38;mod=0;rm-received-ts=1704558367969;historical=1;first-msg=0;flags=;display-name=jontEmillian;subscriber=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@rm-received-ts=1704558368112;display-name=kfms6741;id=ddaa5215-8178-48fd-bf60-1bb6f939c31d;first-msg=0;returning-chatter=0;badges=subscriber/6,premium/1;user-type=;emote-only=1;room-id=62300805;emotes=emotesv2_10304fc8867a4d3586aadf2c409b153a:0-14,16-30,32-46,48-62,64-78,80-94,96-110,112-126,128-142,144-158,160-174;flags=;badge-info=subscriber/8;tmi-sent-ts=1704558367884;historical=1;turbo=0;client-nonce=d32814a0b0477dc5fe6d6b912302bfe2;color=#DAA520;subscriber=1;user-id=42838116;mod=0 :kfms6741!kfms6741@kfms6741.tmi.twitch.tv PRIVMSG #nymn :forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed forsenPossessed","@mod=0;room-id=62300805;subscriber=0;rm-received-ts=1704558368229;id=cd0ea73c-5e47-4a58-a399-5ee7c96d0ec4;user-type=;first-msg=0;badges=no_audio/1;tmi-sent-ts=1704558368035;color=#008000;emotes=;returning-chatter=0;badge-info=;user-id=431946171;historical=1;display-name=jonhycrack;turbo=0;flags= :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn :Alien360 󠀀","@rm-received-ts=1704558369438;badges=subscriber/9,turbo/1;user-id=40037186;turbo=1;tmi-sent-ts=1704558369248;flags=;room-id=62300805;historical=1;client-nonce=2fab6158ec3ac51d21432a431b93c00f;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;first-msg=0;returning-chatter=0;id=a7ba8c7e-1b69-429b-89b5-af1835e9b97a;color=#FFFF00;display-name=Kotzblitz20;user-type=;subscriber=1;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@flags=;color=#DAA520;display-name=DontCagePlebs;turbo=0;mod=0;user-type=;tmi-sent-ts=1704558369505;subscriber=1;client-nonce=9469406a90ad5f280f79b88f30dadc5c;user-id=85837900;returning-chatter=0;badges=subscriber/36,no_audio/1;room-id=62300805;historical=1;emotes=;rm-received-ts=1704558369684;id=ae6c744b-3d3d-478a-bcc0-96fccdc7d1da;badge-info=subscriber/37;first-msg=0 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM WUB WUB","@tmi-sent-ts=1704558370015;color=;rm-received-ts=1704558370199;flags=;user-id=37931493;subscriber=0;first-msg=0;client-nonce=db7fff6489ffbc786da8deb99837aa3b;badges=no_audio/1;display-name=deever44;turbo=0;returning-chatter=0;emotes=;mod=0;historical=1;badge-info=;id=5ed675ed-23f7-4984-93a6-b7831b0c02f2;room-id=62300805;user-type= :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn forsenParty","@flags=;returning-chatter=0;mod=0;turbo=0;color=#00FF7F;id=dc6782e6-a5a2-4c7b-83c5-2daaf93037c0;user-id=154079285;user-type=;tmi-sent-ts=1704558370116;display-name=boogkitty;emotes=;client-nonce=4fcde071cb0f334963a95e5ae5a0422c;badges=subscriber/36;badge-info=subscriber/41;room-id=62300805;rm-received-ts=1704558370283;historical=1;first-msg=0;subscriber=1 :boogkitty!boogkitty@boogkitty.tmi.twitch.tv PRIVMSG #nymn :Ratge RaveTime","@tmi-sent-ts=1704558370903;turbo=0;client-nonce=247098a52c2dac2cc3e94cb17a55006c;id=d3460fc8-92b6-4405-817e-45653c3a59c4;mod=0;badges=;historical=1;rm-received-ts=1704558371071;badge-info=;returning-chatter=0;flags=;user-type=;room-id=62300805;color=#000000;display-name=Duchene;emotes=;first-msg=0;subscriber=0;user-id=205837377 :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@user-id=51967700;room-id=62300805;first-msg=0;display-name=Patixxl;client-nonce=39441701b67f5ac0a09884eefba06273;user-type=;emotes=;tmi-sent-ts=1704558370970;mod=0;rm-received-ts=1704558371141;turbo=0;returning-chatter=0;flags=;color=#FF0000;historical=1;subscriber=0;badge-info=;id=322e2ee6-9c56-49e6-b0b4-a605138e1011;badges= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@emotes=;badges=;subscriber=0;id=01568b13-f48a-4b51-a289-627211cd5eed;color=;user-id=809558302;room-id=62300805;turbo=0;user-type=;tmi-sent-ts=1704558371094;first-msg=0;returning-chatter=0;flags=;mod=0;display-name=AAAAUUUUGGGHHHHH;rm-received-ts=1704558371274;badge-info=;client-nonce=5c5c027d67228165d9ddb8a1ea194e02;historical=1 :aaaauuuuggghhhhh!aaaauuuuggghhhhh@aaaauuuuggghhhhh.tmi.twitch.tv PRIVMSG #nymn forsendiscosnake","@room-id=62300805;emotes=;user-type=;display-name=jontEmillian;returning-chatter=0;id=84c71c54-04a3-4d2c-89aa-b32c69ac6133;tmi-sent-ts=1704558371190;first-msg=0;user-id=433352132;turbo=0;subscriber=1;badges=subscriber/36,twitch-recap-2023/1;badge-info=subscriber/38;color=#63BD68;historical=1;rm-received-ts=1704558371350;mod=0;flags= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@mod=0;color=#008000;user-id=431946171;historical=1;turbo=0;tmi-sent-ts=1704558371652;badges=no_audio/1;first-msg=0;badge-info=;user-type=;emotes=;room-id=62300805;flags=;rm-received-ts=1704558371829;id=d72ba1a2-c593-4ef4-af0f-55d074c2990f;display-name=jonhycrack;returning-chatter=0;subscriber=0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn Alien360","@room-id=62300805;user-id=103592036;tmi-sent-ts=1704558371794;historical=1;badges=subscriber/54,bits/1000;rm-received-ts=1704558371962;user-type=;color=#00615C;emotes=;first-msg=0;id=ddc5934a-ae34-4abc-b08c-9726af73ed53;turbo=0;badge-info=subscriber/55;client-nonce=0bf6d12caa30061e488b645225be2356;flags=;mod=0;display-name=SecretCarrot;returning-chatter=0;subscriber=1 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn :forsenParty okay","@id=3bbc0eaa-6b33-475b-8aba-11f6469c1ad1;subscriber=0;client-nonce=db2ffd62e8563204b733a15c9e9fa6d2;room-id=62300805;badge-info=;historical=1;first-msg=0;rm-received-ts=1704558372604;user-id=809558302;turbo=0;color=;tmi-sent-ts=1704558372448;user-type=;display-name=AAAAUUUUGGGHHHHH;flags=;badges=;mod=0;returning-chatter=0;emotes= :aaaauuuuggghhhhh!aaaauuuuggghhhhh@aaaauuuuggghhhhh.tmi.twitch.tv PRIVMSG #nymn :forsendiscosnake 󠀀","@emotes=;id=708911c5-ec5b-4852-942f-a7d0716f7274;flags=;first-msg=0;mod=0;badge-info=;turbo=0;user-id=51967700;room-id=62300805;user-type=;returning-chatter=0;client-nonce=409a64806b507f65db54694a4147e44e;rm-received-ts=1704558373326;subscriber=0;tmi-sent-ts=1704558373153;historical=1;display-name=Patixxl;badges=;color=#FF0000 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@vip=1;display-name=ImDaxify;subscriber=1;room-id=62300805;turbo=0;emotes=;color=#1F8FFF;client-nonce=199b15033cf2c3564c8ebf0c60066bb4;mod=0;flags=0-3:P.0;id=45d383d0-d9e3-4b47-88d7-406e2e5ce616;badges=vip/1,subscriber/36,twitch-recap-2023/1;user-id=28317294;badge-info=subscriber/36;tmi-sent-ts=1704558373446;returning-chatter=0;historical=1;user-type=;rm-received-ts=1704558373635;first-msg=0 :imdaxify!imdaxify@imdaxify.tmi.twitch.tv PRIVMSG #nymn :damn rats are having raves in the sewers? forsenParty","@turbo=0;flags=;mod=0;historical=1;badges=;user-type=;room-id=62300805;client-nonce=0643afa2de7aea8ebe1a38b0f6e07990;badge-info=;emotes=;rm-received-ts=1704558375049;user-id=38870532;first-msg=0;subscriber=0;id=49ec6e5d-6421-4d96-ba87-e980658fced2;returning-chatter=0;tmi-sent-ts=1704558374871;color=#D2691E;display-name=Nopem8 :nopem8!nopem8@nopem8.tmi.twitch.tv PRIVMSG #nymn :forsenParty sewer rave","@tmi-sent-ts=1704558375413;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;returning-chatter=0;display-name=Kotzblitz20;subscriber=1;flags=;turbo=1;client-nonce=977a320ab81516105211894b8feba859;first-msg=0;rm-received-ts=1704558375586;color=#FFFF00;mod=0;badges=subscriber/9,turbo/1;user-type=;emote-only=1;historical=1;user-id=40037186;room-id=62300805;badge-info=subscriber/9;id=aaa3b65e-cfaf-415f-926c-1d18efbb73e5 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn forsenParty","@client-nonce=693d50d27c1b9a409aef6f90199f1e59;subscriber=1;returning-chatter=0;rm-received-ts=1704558376011;room-id=62300805;color=#00615C;tmi-sent-ts=1704558375845;first-msg=0;user-type=;emotes=;turbo=0;badge-info=subscriber/55;display-name=SecretCarrot;badges=subscriber/54,bits/1000;user-id=103592036;flags=;id=afa046c8-a267-4c18-bd74-96272de0930f;mod=0;historical=1 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@turbo=0;mod=0;emotes=;subscriber=1;display-name=DontCagePlebs;rm-received-ts=1704558376248;badges=subscriber/36,no_audio/1;tmi-sent-ts=1704558376069;flags=;color=#DAA520;id=316b3840-627d-4b8d-ab34-150e3c1d9220;badge-info=subscriber/37;user-id=85837900;first-msg=0;room-id=62300805;user-type=;client-nonce=01dd8d81dc08f242212957ed986b3ad4;returning-chatter=0;historical=1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn !","@flags=;rm-received-ts=1704558376981;tmi-sent-ts=1704558376822;badges=vip/1,subscriber/72,rplace-2023/1;display-name=Joshlad;historical=1;mod=0;user-type=;subscriber=1;user-id=87120320;emotes=;vip=1;id=6a192486-acf9-418b-8178-ddb5fef6bd9a;color=#D52AFF;turbo=0;room-id=62300805;first-msg=0;badge-info=subscriber/77;returning-chatter=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn !","@first-msg=0;rm-received-ts=1704558377310;emotes=;display-name=Patixxl;user-id=51967700;mod=0;tmi-sent-ts=1704558377136;color=#FF0000;subscriber=0;user-type=;historical=1;id=959f1371-ce15-4aa8-a0ea-ee938f8825e3;badge-info=;flags=;turbo=0;room-id=62300805;badges=;client-nonce=e5cae5d7c13f5836a688e58c6eb5cce3;returning-chatter=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn forsenUnpleased","@rm-received-ts=1704558377798;tmi-sent-ts=1704558377630;user-id=29649547;color=#FF7F50;emotes=;room-id=62300805;badge-info=subscriber/53;mod=0;historical=1;returning-chatter=0;first-msg=0;user-type=;display-name=orange_bean;badges=subscriber/48;subscriber=1;id=fe19a439-fe4c-4ea5-a8c6-b9ac4842803e;flags=;turbo=0 :orange_bean!orange_bean@orange_bean.tmi.twitch.tv PRIVMSG #nymn SirO","@id=3c39c45c-6368-439d-b903-c94b90b669c5;badge-info=subscriber/37;historical=1;user-id=85837900;returning-chatter=0;user-type=;first-msg=0;client-nonce=0e7c769903d362944364dd51c6b3de1a;rm-received-ts=1704558379370;emotes=;badges=subscriber/36,no_audio/1;room-id=62300805;mod=0;display-name=DontCagePlebs;tmi-sent-ts=1704558379200;turbo=0;color=#DAA520;flags=;subscriber=1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn ❗","@user-type=;color=#FF69B4;room-id=62300805;rm-received-ts=1704558380831;emotes=;subscriber=0;id=10e1772b-89ee-49ac-be18-4cee9267194d;turbo=0;first-msg=0;badge-info=;returning-chatter=0;badges=;mod=0;flags=;tmi-sent-ts=1704558380608;historical=1;display-name=ehtia;user-id=163155934;client-nonce=fb8f9e92b80977381fb46802fa727a73 :ehtia!ehtia@ehtia.tmi.twitch.tv PRIVMSG #nymn :PagMan quests","@room-id=62300805;subscriber=1;user-type=;mod=0;display-name=Kotzblitz20;client-nonce=cb07969de0f4c182a16accf1433a095f;badge-info=subscriber/9;color=#FFFF00;flags=;id=e4471904-9412-4312-a066-fdcd478754cf;first-msg=0;user-id=40037186;returning-chatter=0;turbo=1;emotes=;tmi-sent-ts=1704558380995;badges=subscriber/9,turbo/1;historical=1;rm-received-ts=1704558381200 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn AlienSilly","@turbo=0;user-id=63372784;emotes=;rm-received-ts=1704558381262;tmi-sent-ts=1704558381095;display-name=DM8917;flags=;first-msg=0;color=#25E000;badge-info=;historical=1;user-type=;client-nonce=9c19bcb5fc67194644456dfcb342aab5;subscriber=0;mod=0;id=c1361ed8-acea-4688-86bb-904fb3090956;badges=bits/100;room-id=62300805;returning-chatter=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn docJAMMER","@flags=;mod=0;returning-chatter=0;user-id=431946171;tmi-sent-ts=1704558381594;badges=no_audio/1;room-id=62300805;rm-received-ts=1704558381765;first-msg=0;turbo=0;subscriber=0;badge-info=;display-name=jonhycrack;historical=1;color=#008000;emotes=;user-type=;id=02be0145-6e23-4f62-bd6a-2810bbf548d0 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn AlienUnpleased","@tmi-sent-ts=1704558381879;room-id=62300805;historical=1;display-name=jontEmillian;first-msg=0;badges=subscriber/36,twitch-recap-2023/1;badge-info=subscriber/38;flags=;color=#63BD68;turbo=0;subscriber=1;user-id=433352132;id=bd423c09-7ee8-452f-b642-c12f5fe83bff;mod=0;emotes=;rm-received-ts=1704558382052;returning-chatter=0;user-type= :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn TooMuchWork","@subscriber=1;user-id=80542722;badge-info=subscriber/2;returning-chatter=0;user-type=;badges=subscriber/0,no_video/1;mod=0;historical=1;room-id=62300805;color=#00FF7F;display-name=jqxlol;flags=;emotes=;first-msg=0;tmi-sent-ts=1704558383507;client-nonce=ac69ce3c4618bd5b7e256dcda914ec69;rm-received-ts=1704558383692;turbo=0;id=410f2a70-2c0c-4441-814b-24ac9bf8d27f :jqxlol!jqxlol@jqxlol.tmi.twitch.tv PRIVMSG #nymn :Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ? Okayeg 💢 MUZIKA ?","@user-type=;tmi-sent-ts=1704558384584;client-nonce=e87238020cdce65f7c6aff3abb3672f2;first-msg=0;rm-received-ts=1704558384776;returning-chatter=0;display-name=Kotzblitz20;user-id=40037186;emote-only=1;room-id=62300805;turbo=1;badge-info=subscriber/9;historical=1;color=#FFFF00;badges=subscriber/9,turbo/1;emotes=emotesv2_2f9a36844b054423833c817b5f8d4225:0-8;id=335b5b8c-5a03-4872-974a-0ddcb0952544;mod=0;subscriber=1;flags= :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn forsenPls","@turbo=0;color=#0000FF;rm-received-ts=1704558389403;emotes=1:20-21;badge-info=;room-id=62300805;first-msg=0;mod=0;flags=;tmi-sent-ts=1704558389190;user-type=;display-name=OfficialScrap;returning-chatter=0;user-id=85115603;subscriber=0;historical=1;badges=no_audio/1;client-nonce=e68344caf9846b4f5938496312f47ba0;id=f9cee817-2a5e-4443-8702-3be26c7af6e7 :officialscrap!officialscrap@officialscrap.tmi.twitch.tv PRIVMSG #nymn :stockholm simulator :)","@tmi-sent-ts=1704558390211;id=ef67b2bd-3b45-4608-b830-1a919fdfd8cb;first-msg=0;emotes=;turbo=0;badges=;badge-info=;user-id=465513111;color=;returning-chatter=0;display-name=mongushmengi;historical=1;user-type=;rm-received-ts=1704558390397;client-nonce=dd400c92f0ab3b3de975bd3481033bd0;mod=0;subscriber=0;flags=;room-id=62300805 :mongushmengi!mongushmengi@mongushmengi.tmi.twitch.tv PRIVMSG #nymn AlienPls","@room-id=62300805;subscriber=0;badge-info=;rm-received-ts=1704558397501;mod=0;tmi-sent-ts=1704558397328;first-msg=0;badges=no_audio/1;user-id=431946171;returning-chatter=0;turbo=0;user-type=;emotes=;color=#008000;historical=1;flags=;id=1c217b6a-f1da-4cb1-a209-5aa0bd364a8a;display-name=jonhycrack :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn Pepege","@id=f3ea2bda-0697-4cba-9163-732d04c2f707;client-nonce=774059e4111bb61a6cecc4f73ea521fd;tmi-sent-ts=1704558398455;subscriber=0;returning-chatter=0;display-name=deever44;historical=1;badge-info=;rm-received-ts=1704558398627;mod=0;room-id=62300805;user-type=;color=;emotes=;badges=no_audio/1;turbo=0;user-id=37931493;flags=;first-msg=0 :deever44!deever44@deever44.tmi.twitch.tv PRIVMSG #nymn whoooooos","@rm-received-ts=1704558398738;user-type=;turbo=1;badge-info=subscriber/9;mod=0;returning-chatter=0;emotes=;id=e9161872-1113-4b84-b6ba-d55a782df44d;user-id=40037186;client-nonce=51fade5e48119229d8db957bdb09628e;first-msg=0;tmi-sent-ts=1704558398564;display-name=Kotzblitz20;badges=subscriber/9,turbo/1;subscriber=1;room-id=62300805;historical=1;flags=;color=#FFFF00 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn AlienJam","@subscriber=1;display-name=SecretCarrot;historical=1;color=#00615C;user-id=103592036;mod=0;emotes=;badges=subscriber/54,bits/1000;turbo=0;client-nonce=4ff419686be13ef2f858704f2eaa62bd;id=839aee9d-ba6a-4cdc-9e5a-8d59772e26e0;flags=;rm-received-ts=1704558401080;first-msg=0;user-type=;tmi-sent-ts=1704558400912;returning-chatter=0;badge-info=subscriber/55;room-id=62300805 :secretcarrot!secretcarrot@secretcarrot.tmi.twitch.tv PRIVMSG #nymn Sludge","@badges=subscriber/36,no_audio/1;mod=0;id=7a791d78-b2ec-4b8c-b982-c3640f2bad71;display-name=DontCagePlebs;user-type=;flags=;room-id=62300805;first-msg=0;client-nonce=c039c0e152302e9ace32c39848d7fa97;emotes=;rm-received-ts=1704558402227;tmi-sent-ts=1704558402057;badge-info=subscriber/37;turbo=0;user-id=85837900;color=#DAA520;historical=1;returning-chatter=0;subscriber=1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn Sludge","@turbo=0;id=d32c6052-2379-485a-8df9-192ccfdbbbcf;mod=0;returning-chatter=0;first-msg=0;flags=;subscriber=0;color=#008000;badges=glhf-pledge/1;display-name=elareldan;emotes=;user-type=;historical=1;room-id=62300805;tmi-sent-ts=1704558405669;user-id=551027178;badge-info=;rm-received-ts=1704558405857 :elareldan!elareldan@elareldan.tmi.twitch.tv PRIVMSG #nymn Sludge","@user-id=63372784;tmi-sent-ts=1704558405944;display-name=DM8917;room-id=62300805;client-nonce=6a9189de1aea2f00a96e12abd80d23ae;user-type=;flags=;emotes=;badge-info=;color=#25E000;id=e67fcc8f-706a-4ef6-8961-233253d17b5f;historical=1;mod=0;badges=bits/100;turbo=0;subscriber=0;rm-received-ts=1704558406134;returning-chatter=0;first-msg=0 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn Sludge","@flags=;room-id=62300805;display-name=crunch_sack;rm-received-ts=1704558406247;id=6e127af0-fc9e-46df-a062-2b05473edd68;badges=;user-id=108648666;first-msg=0;subscriber=0;badge-info=;turbo=0;tmi-sent-ts=1704558406091;mod=0;user-type=;emotes=;color=#7AF4FA;returning-chatter=0;historical=1 :crunch_sack!crunch_sack@crunch_sack.tmi.twitch.tv PRIVMSG #nymn :Sludge 󠀀","@first-msg=0;display-name=Kotzblitz20;returning-chatter=0;badges=subscriber/9,turbo/1;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10;subscriber=1;flags=;tmi-sent-ts=1704558410701;historical=1;turbo=1;user-id=40037186;room-id=62300805;rm-received-ts=1704558410880;client-nonce=3cbaf6ea472d69fdec6095e2923eca46;badge-info=subscriber/9;user-type=;color=#FFFF00;id=15b0b643-edce-4507-bdda-174c6fd288af :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM","@display-name=Joshlad;emotes=;returning-chatter=0;mod=0;subscriber=1;vip=1;first-msg=0;color=#D52AFF;badge-info=subscriber/77;flags=;rm-received-ts=1704558411416;tmi-sent-ts=1704558411233;user-id=87120320;id=0752c857-93c1-4c58-b99a-9b960016c92b;user-type=;historical=1;badges=vip/1,subscriber/72,rplace-2023/1;room-id=62300805;turbo=0 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@color=#00ED2A;turbo=0;room-id=62300805;flags=;user-type=;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170;rm-received-ts=1704558412297;id=730a63cf-c99b-4b8d-a74c-e9e4cb38be82;badge-info=subscriber/43;mod=0;user-id=60181947;tmi-sent-ts=1704558412097;client-nonce=0386c8d1fb756dd175acfce21445bd66;first-msg=0;badges=subscriber/42,twitch-recap-2023/1;display-name=MaxThurian;subscriber=1;returning-chatter=0;historical=1 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=1;flags=;color=#63BD68;tmi-sent-ts=1704558412718;emotes=;badges=subscriber/36,twitch-recap-2023/1;id=a3da0e60-8900-45bd-a7b2-4adfab4b89a9;returning-chatter=0;first-msg=0;display-name=jontEmillian;user-id=433352132;historical=1;mod=0;room-id=62300805;user-type=;badge-info=subscriber/38;turbo=0;rm-received-ts=1704558412896 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn forsenParty","@user-id=85837900;rm-received-ts=1704558413016;emotes=;badges=subscriber/36,no_audio/1;first-msg=0;user-type=;mod=0;flags=;color=#DAA520;badge-info=subscriber/37;display-name=DontCagePlebs;id=3ea2910e-b86b-4ca0-995f-572ef2cbef26;room-id=62300805;client-nonce=fd866f5cee610eb23d566141735eb289;tmi-sent-ts=1704558412815;turbo=0;returning-chatter=0;historical=1;subscriber=1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :frooit fly Okayeg","@user-id=51967700;display-name=Patixxl;mod=0;flags=;first-msg=0;room-id=62300805;returning-chatter=0;id=3b5fa772-aa5e-4ed9-ba09-f2f420ee5a68;turbo=0;emotes=;badges=;rm-received-ts=1704558413521;badge-info=;client-nonce=091e8189c0aae8021a40333b1153a69a;user-type=;historical=1;tmi-sent-ts=1704558413331;subscriber=0;color=#FF0000 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;mod=0;color=#FFFF00;badge-info=subscriber/9;tmi-sent-ts=1704558414933;user-id=40037186;display-name=Kotzblitz20;subscriber=1;badges=subscriber/9,turbo/1;id=767aa364-c94b-4f12-ad6f-689978277329;flags=;turbo=1;user-type=;historical=1;first-msg=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186;client-nonce=d0b2bbcc39604d7657b7a3001723269f;rm-received-ts=1704558415116;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@id=e67887b3-a289-4993-8073-949d97b95772;first-msg=0;rm-received-ts=1704558415629;tmi-sent-ts=1704558415459;color=#FF0000;room-id=62300805;emotes=;returning-chatter=0;turbo=0;flags=;badges=;user-id=51967700;mod=0;client-nonce=530fd0a7fdc8ffdf0ccabb12a47143dd;subscriber=0;historical=1;display-name=Patixxl;user-type=;badge-info= :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@first-msg=0;color=#0000FF;mod=0;badges=bits-charity/1;user-type=;id=e9430551-8ab6-47c6-a06d-f3dc497ce053;flags=;turbo=0;tmi-sent-ts=1704558416737;badge-info=;historical=1;display-name=Intel_power;rm-received-ts=1704558416928;client-nonce=d16bda697071b21393ca96858bbdb314;subscriber=0;user-id=103665668;emotes=;room-id=62300805;returning-chatter=0 :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn RaveTime","@first-msg=0;rm-received-ts=1704558417101;historical=1;user-type=;room-id=62300805;returning-chatter=0;user-id=85837900;badge-info=subscriber/37;id=23b87d89-9310-42ea-ab76-1846fca913fc;color=#DAA520;mod=0;display-name=DontCagePlebs;emotes=;tmi-sent-ts=1704558416910;client-nonce=2ea6d32107a02bcb8ac136fbece3f214;turbo=0;flags=;badges=subscriber/36,no_audio/1;subscriber=1 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@user-type=;turbo=0;historical=1;subscriber=1;rm-received-ts=1704558417121;display-name=e7om;user-id=423574282;color=#D2FFD6;first-msg=0;badges=subscriber/9,gold-pixel-heart/1;emotes=;id=64072860-b15c-49a2-9d4a-ad7ad48b33a1;tmi-sent-ts=1704558416904;badge-info=subscriber/9;returning-chatter=0;mod=0;room-id=62300805;flags= :e7om!e7om@e7om.tmi.twitch.tv PRIVMSG #nymn WaterIceSaltAyy","@id=57f13cad-a098-4a24-9689-7cef1b3644c1;mod=0;color=#000000;rm-received-ts=1704558417351;badge-info=;returning-chatter=0;user-type=;badges=;first-msg=0;tmi-sent-ts=1704558417177;emotes=;display-name=Duchene;historical=1;flags=;room-id=62300805;turbo=0;subscriber=0;user-id=205837377 :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn :\u0001ACTION forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀\u0001","@user-id=51967700;flags=;tmi-sent-ts=1704558417249;historical=1;subscriber=0;id=9157f2fc-42df-48fa-87e6-9cdb3ebc1886;client-nonce=ca72fb80f4137e77af26078fcd88e6c2;emotes=;badge-info=;user-type=;display-name=Patixxl;room-id=62300805;rm-received-ts=1704558417426;color=#FF0000;badges=;first-msg=0;turbo=0;mod=0;returning-chatter=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@badges=vip/1,subscriber/72,rplace-2023/1;badge-info=subscriber/77;historical=1;user-id=87120320;tmi-sent-ts=1704558418358;room-id=62300805;returning-chatter=0;turbo=0;emotes=;color=#D52AFF;id=f5acf77f-3c03-4fe2-9778-82eff8b09f4e;user-type=;rm-received-ts=1704558418539;first-msg=0;vip=1;mod=0;flags=;display-name=Joshlad;subscriber=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=0;returning-chatter=0;first-msg=0;turbo=0;room-id=62300805;historical=1;display-name=steviehem;badge-info=;user-type=;user-id=52076774;client-nonce=9e9135359bc631f279e71ec996e6c9e5;color=#FF4500;tmi-sent-ts=1704558418533;rm-received-ts=1704558418737;flags=;mod=0;emotes=;id=3786d624-2b8f-4524-8add-4a0d3947e1a8;badges=premium/1 :steviehem!steviehem@steviehem.tmi.twitch.tv PRIVMSG #nymn :bros sippin on that lean","@badges=;flags=;subscriber=0;id=de0dd8cb-404e-4b31-b6b7-0946e7c9c237;first-msg=0;turbo=0;user-id=51967700;badge-info=;rm-received-ts=1704558419340;tmi-sent-ts=1704558419159;mod=0;emotes=;client-nonce=7ea293fe9bf65c6f77e2214e65646e47;user-type=;color=#FF0000;room-id=62300805;display-name=Patixxl;historical=1;returning-chatter=0 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@color=#B22222;client-nonce=c7390d07e8a633322be7bc360e39d2f7;returning-chatter=0;display-name=crazyjuni0r_;mod=0;room-id=62300805;subscriber=1;rm-received-ts=1704558419366;turbo=0;historical=1;id=b2b871a9-92dc-4695-9eff-8e6489a260d4;user-id=222340799;emotes=;first-msg=0;tmi-sent-ts=1704558419165;flags=;badges=subscriber/6,chatter-cs-go-2022/1;badge-info=subscriber/7;user-type= :crazyjuni0r_!crazyjuni0r_@crazyjuni0r_.tmi.twitch.tv PRIVMSG #nymn :!#showemote Hypnime","@badges=subscriber/36,twitch-recap-2023/1;returning-chatter=0;color=#63BD68;tmi-sent-ts=1704558419663;room-id=62300805;emotes=;flags=;rm-received-ts=1704558419862;user-type=;id=b8a16ee8-b7b1-4aef-9d95-824fb77ccde3;mod=0;turbo=0;display-name=jontEmillian;badge-info=subscriber/38;subscriber=1;user-id=433352132;first-msg=0;historical=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :forsenParty 󠀀","@tmi-sent-ts=1704558422722;badges=subscriber/9,turbo/1;id=1cf97cdf-766e-4b94-8bb9-cb48e31f8c6d;historical=1;rm-received-ts=1704558422911;badge-info=subscriber/9;subscriber=1;room-id=62300805;display-name=Kotzblitz20;mod=0;color=#FFFF00;client-nonce=750e7553dd45b005b130474c0cc6e63f;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;turbo=1;user-id=40037186;first-msg=0;user-type=;flags=;returning-chatter=0 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@room-id=62300805;flags=;first-msg=0;mod=0;user-type=;display-name=h_h410;badges=subscriber/54,chatter-cs-go-2022/1;badge-info=subscriber/54;color=#00FF7F;subscriber=1;id=283e35d7-01c8-419c-9cb7-cd265391d480;tmi-sent-ts=1704558423061;historical=1;rm-received-ts=1704558423252;turbo=0;returning-chatter=0;emotes=;user-id=117088592 :h_h410!h_h410@h_h410.tmi.twitch.tv PRIVMSG #nymn RoyaltE","@badge-info=subscriber/43;flags=;color=#00ED2A;user-id=60181947;turbo=0;rm-received-ts=1704558423958;emotes=;mod=0;user-type=;badges=subscriber/42,twitch-recap-2023/1;historical=1;returning-chatter=0;first-msg=0;display-name=MaxThurian;room-id=62300805;subscriber=1;id=717da12d-1d6e-4821-aa3e-e5033d9e6e18;tmi-sent-ts=1704558423766;client-nonce=0c82f8884db705e2667f1273bdc8d6c1 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@returning-chatter=0;display-name=Joshlad;subscriber=1;turbo=0;badges=vip/1,subscriber/72,rplace-2023/1;tmi-sent-ts=1704558424024;color=#D52AFF;first-msg=0;user-type=;id=a8049204-1665-4931-863f-c8cac534fe3a;mod=0;user-id=87120320;emotes=;historical=1;rm-received-ts=1704558424178;flags=;room-id=62300805;badge-info=subscriber/77;vip=1 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@flags=;turbo=1;user-id=40037186;tmi-sent-ts=1704558424859;historical=1;id=c4b8b5dd-6354-4953-a8cf-287df5e609e1;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282;rm-received-ts=1704558425068;mod=0;color=#FFFF00;returning-chatter=0;user-type=;first-msg=0;subscriber=1;room-id=62300805;display-name=Kotzblitz20;client-nonce=0f8a56f507129632f3ed2648089bedb3;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@turbo=0;historical=1;rm-received-ts=1704558425119;color=#00FF7F;badges=subscriber/36;display-name=boogkitty;client-nonce=25791c4eecb885bfaff45840ed83bfe6;flags=;tmi-sent-ts=1704558424954;first-msg=0;id=9c8bd5b7-317d-48fb-8380-fff872c6a6bb;returning-chatter=0;badge-info=subscriber/41;user-type=;room-id=62300805;mod=0;emotes=;user-id=154079285;subscriber=1 :boogkitty!boogkitty@boogkitty.tmi.twitch.tv PRIVMSG #nymn :steam page says there is Twitch Integration for this Nymn but it doesnt work atm","@client-nonce=8d6066f0ce273965822e8b5c66473755;returning-chatter=0;id=b1f04f97-02e1-4c3f-8c30-ba230361cf0e;flags=;display-name=Intel_power;emotes=;subscriber=0;first-msg=0;badges=bits-charity/1;rm-received-ts=1704558425157;historical=1;user-type=;badge-info=;room-id=62300805;mod=0;tmi-sent-ts=1704558424988;turbo=0;user-id=103665668;color=#0000FF :intel_power!intel_power@intel_power.tmi.twitch.tv PRIVMSG #nymn :RAT KING","@returning-chatter=0;flags=;user-id=63372784;room-id=62300805;subscriber=0;turbo=0;color=#25E000;tmi-sent-ts=1704558425088;display-name=DM8917;client-nonce=694e988f6340df19650c339a586933aa;badges=bits/100;first-msg=0;historical=1;id=bf26a00e-9057-4719-9d3b-718631c3c325;rm-received-ts=1704558425268;mod=0;user-type=;badge-info=;emotes= :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn FEELSWAYTOODONKMAN","@client-nonce=7bf94ad9a226eb86418e4822ebada0ce;emotes=;color=#DAA520;user-type=;flags=;badges=subscriber/36,no_audio/1;turbo=0;rm-received-ts=1704558425924;badge-info=subscriber/37;room-id=62300805;display-name=DontCagePlebs;first-msg=0;subscriber=1;mod=0;historical=1;id=b6a9fd22-acf0-4cbe-9b48-c85b0569be58;tmi-sent-ts=1704558425740;returning-chatter=0;user-id=85837900 :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn headBang","@mod=0;flags=;emotes=;badges=;room-id=62300805;rm-received-ts=1704558426204;badge-info=;user-type=;first-msg=0;historical=1;color=#FF0000;tmi-sent-ts=1704558426032;id=f84c9dfd-9b46-42e7-9992-eedc8fad5d05;subscriber=0;user-id=51967700;turbo=0;returning-chatter=0;client-nonce=fdf40be9235f4c0a490b546e4e23f896;display-name=Patixxl :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn OOOO","@historical=1;room-id=62300805;subscriber=1;user-type=;flags=;id=ca6f57b8-8fce-46ad-b165-fe518b0df503;returning-chatter=0;emotes=;first-msg=0;mod=0;turbo=0;user-id=433352132;display-name=jontEmillian;color=#63BD68;tmi-sent-ts=1704558426038;badges=subscriber/36,twitch-recap-2023/1;rm-received-ts=1704558426212;badge-info=subscriber/38 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn headBang","@subscriber=0;flags=;client-nonce=6d590cea6f146a932d6fbf694f0c5428;returning-chatter=0;id=b9e2e91b-f558-44f3-8092-7d6d928b44c7;historical=1;user-id=167633177;badge-info=;tmi-sent-ts=1704558426532;display-name=ALotOfChickens;room-id=62300805;user-type=;mod=0;emotes=;first-msg=0;turbo=0;badges=twitch-recap-2023/1;rm-received-ts=1704558426712;color=#10E2E2 :alotofchickens!alotofchickens@alotofchickens.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@emotes=;color=#FF2424;subscriber=1;client-nonce=5ea1a329a2599f327bc1aa8aa75590bf;badges=subscriber/48,bits/25000;tmi-sent-ts=1704558427278;room-id=62300805;returning-chatter=0;user-id=159210800;badge-info=subscriber/49;id=f16ec133-6105-4561-b782-c0f6a1e64b2a;historical=1;first-msg=0;turbo=0;rm-received-ts=1704558427465;mod=0;display-name=ME_ME;flags=;user-type= :me_me!me_me@me_me.tmi.twitch.tv PRIVMSG #nymn Ratge","@id=bab41921-40de-47a1-9cc5-bb1dfaa62bc1;tmi-sent-ts=1704558427583;client-nonce=599cc4f7fca1b4f668c3821c171f0b4c;returning-chatter=0;badges=;color=#000000;mod=0;emotes=;subscriber=0;first-msg=0;display-name=Duchene;rm-received-ts=1704558427760;user-type=;user-id=205837377;turbo=0;badge-info=;room-id=62300805;flags=;historical=1 :duchene!duchene@duchene.tmi.twitch.tv PRIVMSG #nymn monkaOMEGA","@subscriber=1;rm-received-ts=1704558427952;color=#00ED2A;client-nonce=d485bfb69a1dd0bb845ca96cf59eb90d;id=2db108d3-886e-4b9e-bb30-41a6a62591cb;badges=subscriber/42,twitch-recap-2023/1;tmi-sent-ts=1704558427776;user-type=;first-msg=0;mod=0;room-id=62300805;emotes=;user-id=60181947;badge-info=subscriber/43;flags=;display-name=MaxThurian;historical=1;turbo=0;returning-chatter=0 :maxthurian!maxthurian@maxthurian.tmi.twitch.tv PRIVMSG #nymn :monkaOMEGA he makes all the rules","@client-nonce=25f894511b804503b35a7d20b9ff8ada;id=bcbba0b7-c32c-479a-83d1-3cf7cbc740d3;user-id=38870532;flags=;badge-info=;historical=1;first-msg=0;rm-received-ts=1704558428105;emotes=;room-id=62300805;mod=0;subscriber=0;display-name=Nopem8;color=#D2691E;tmi-sent-ts=1704558427920;user-type=;badges=;turbo=0;returning-chatter=0 :nopem8!nopem8@nopem8.tmi.twitch.tv PRIVMSG #nymn docPls","@rm-received-ts=1704558428531;id=e6610cff-7d97-439e-91fe-ba654cfc5e7e;user-type=;historical=1;first-msg=0;tmi-sent-ts=1704558428330;room-id=62300805;flags=;client-nonce=7159cd630ec3d2b7f46c37813cfb9796;subscriber=1;badge-info=subscriber/9;returning-chatter=0;user-id=40037186;mod=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282,288-298;display-name=Kotzblitz20;color=#FFFF00;turbo=1;badges=subscriber/9,turbo/1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@first-msg=0;subscriber=0;user-type=;turbo=0;color=#25E000;client-nonce=83a6399c68ba947d0ca5a56dfae7342d;returning-chatter=0;room-id=62300805;display-name=DM8917;user-id=63372784;historical=1;id=c9caafce-0fb9-4b56-b350-f43a5ff42df3;badges=bits/100;mod=0;badge-info=;emotes=;tmi-sent-ts=1704558428905;rm-received-ts=1704558429109;flags= :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN","@color=#63BD68;rm-received-ts=1704558429428;tmi-sent-ts=1704558429273;historical=1;room-id=62300805;user-id=433352132;id=4408b7f2-56f8-4c9b-b84a-4f8f3b48e5cf;display-name=jontEmillian;turbo=0;subscriber=1;first-msg=0;flags=;emotes=;mod=0;user-type=;returning-chatter=0;badge-info=subscriber/38;badges=subscriber/36,twitch-recap-2023/1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn :headBang 󠀀","@client-nonce=cdf08621359979e40b3e353536fca118;badge-info=;historical=1;turbo=0;user-id=51967700;emotes=;flags=;color=#FF0000;id=ffdda8e5-670c-4b56-93f5-3cee6b61453e;badges=;returning-chatter=0;display-name=Patixxl;first-msg=0;rm-received-ts=1704558429444;subscriber=0;mod=0;room-id=62300805;user-type=;tmi-sent-ts=1704558429258 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=0;user-id=163155934;color=#FF69B4;tmi-sent-ts=1704558429961;badges=;rm-received-ts=1704558430134;room-id=62300805;badge-info=;id=87a2822c-09de-4d40-88b5-be0ca8882a4f;first-msg=0;mod=0;flags=;historical=1;turbo=0;user-type=;returning-chatter=0;display-name=ehtia;emotes=;client-nonce=011296f5ccb7a4442058a42bae7747f4 :ehtia!ehtia@ehtia.tmi.twitch.tv PRIVMSG #nymn headBang","@emotes=;display-name=FollowProtoBuddy;flags=;first-msg=0;mod=0;room-id=62300805;color=#00FF7F;user-id=216144449;historical=1;returning-chatter=0;user-type=;client-nonce=7a50bed26a926634e3620e6b8b89c7d9;turbo=1;subscriber=0;rm-received-ts=1704558430351;tmi-sent-ts=1704558430181;id=0e92ae94-5c5c-4cfc-bbb8-d6c5f6a6b297;badge-info=;badges=turbo/1 :followprotobuddy!followprotobuddy@followprotobuddy.tmi.twitch.tv PRIVMSG #nymn OOOO","@emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234;user-id=40037186;flags=;id=868b4a17-3b32-49b3-8fa1-4cf6efab8618;color=#FFFF00;turbo=1;display-name=Kotzblitz20;badges=subscriber/9,turbo/1;rm-received-ts=1704558430684;room-id=62300805;user-type=;returning-chatter=0;tmi-sent-ts=1704558430496;historical=1;first-msg=0;client-nonce=1cbfa1119a40d6a5481a1e4372c758ab;subscriber=1;mod=0;badge-info=subscriber/9 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@tmi-sent-ts=1704558430617;badges=vip/1,subscriber/72,rplace-2023/1;mod=0;returning-chatter=0;turbo=0;flags=;badge-info=subscriber/77;rm-received-ts=1704558430784;user-id=87120320;user-type=;color=#D52AFF;subscriber=1;historical=1;display-name=Joshlad;first-msg=0;room-id=62300805;emote-only=1;id=c8aacf6d-264b-4065-a83a-ba0f68ed9209;vip=1;emotes=emotesv2_67cfc3d84f244644a6891e57215cf79d:0-9 :joshlad!joshlad@joshlad.tmi.twitch.tv PRIVMSG #nymn elisRockin","@room-id=62300805;user-type=;tmi-sent-ts=1704558431457;badges=;flags=;rm-received-ts=1704558431646;returning-chatter=0;emotes=;id=fd58d35b-edbb-4287-b480-d52db56def72;badge-info=;turbo=0;user-id=51967700;client-nonce=1b8470946467f785a8d16b37a55cac8f;mod=0;first-msg=0;display-name=Patixxl;subscriber=0;color=#FF0000;historical=1 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@mod=0;id=c4e6d6b1-4d2f-4d69-8425-204abea6eb53;historical=1;first-msg=0;badges=subscriber/36,twitch-recap-2023/1;rm-received-ts=1704558432481;display-name=jontEmillian;user-id=433352132;flags=;room-id=62300805;user-type=;returning-chatter=0;color=#63BD68;turbo=0;emotes=;badge-info=subscriber/38;tmi-sent-ts=1704558432316;subscriber=1 :jontemillian!jontemillian@jontemillian.tmi.twitch.tv PRIVMSG #nymn headBang","@rm-received-ts=1704558432998;room-id=62300805;mod=0;first-msg=0;badges=subscriber/9,turbo/1;tmi-sent-ts=1704558432809;badge-info=subscriber/9;id=de0bf00b-2011-4c03-af83-370e869be277;returning-chatter=0;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218,224-234,240-250,256-266,272-282,288-298,304-314,320-330,336-346,352-362,368-378,384-394,400-410;subscriber=1;display-name=Kotzblitz20;client-nonce=0b6bc2649923d92d3f7e79473756eee6;user-id=40037186;color=#FFFF00;historical=1;flags=;user-type=;turbo=1 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@mod=0;room-id=62300805;client-nonce=68a7ec8a6f39e9ea183b44b0108f56a5;first-msg=0;returning-chatter=0;badge-info=;tmi-sent-ts=1704558433270;user-type=;emotes=;user-id=51967700;subscriber=0;id=f7c44bff-c544-4fae-8b0b-03125067cd06;display-name=Patixxl;badges=;turbo=0;flags=;historical=1;color=#FF0000;rm-received-ts=1704558433442 :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM","@subscriber=0;emotes=;mod=0;badges=bits/100;first-msg=0;color=#25E000;tmi-sent-ts=1704558433256;flags=;rm-received-ts=1704558433450;turbo=0;user-type=;historical=1;user-id=63372784;display-name=DM8917;id=3506ebf3-398b-4eb7-96aa-5824f2a86e69;returning-chatter=0;badge-info=;client-nonce=e1048a1ee30b40a1d9c5b8b03287d003;room-id=62300805 :dm8917!dm8917@dm8917.tmi.twitch.tv PRIVMSG #nymn :FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN FEELSWAYTOODONKMAN","@returning-chatter=0;id=aedfc38a-f3cb-463d-9257-c0e298a6909b;turbo=0;first-msg=0;rm-received-ts=1704558434507;badge-info=subscriber/37;emotes=;room-id=62300805;mod=0;subscriber=1;badges=subscriber/36,no_audio/1;client-nonce=d92bb36df137745dc27c5d1425c9a4d5;display-name=DontCagePlebs;color=#DAA520;user-type=;user-id=85837900;tmi-sent-ts=1704558434329;historical=1;flags= :dontcageplebs!dontcageplebs@dontcageplebs.tmi.twitch.tv PRIVMSG #nymn :headBang headBang","@badges=subscriber/36;subscriber=1;id=7fdb6244-f85e-46e4-9293-58427784ced3;badge-info=subscriber/41;user-type=;flags=;mod=0;historical=1;tmi-sent-ts=1704558434394;first-msg=0;client-nonce=a90f7915863d1c2b8c5be9cf56736a7c;turbo=0;color=#41FF00;user-id=92402102;room-id=62300805;rm-received-ts=1704558434576;display-name=liber7as;emotes=;returning-chatter=0 :liber7as!liber7as@liber7as.tmi.twitch.tv PRIVMSG #nymn :Ratge peepoKing","@emotes=;user-id=51967700;rm-received-ts=1704558434996;tmi-sent-ts=1704558434830;badges=;display-name=Patixxl;badge-info=;flags=;subscriber=0;id=c5a4344b-9096-4829-ae60-c3e75638ee44;mod=0;first-msg=0;returning-chatter=0;historical=1;user-type=;turbo=0;color=#FF0000;room-id=62300805;client-nonce=30ed60fd117908450d4c9e52d44010df :patixxl!patixxl@patixxl.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM 󠀀","@mod=0;badges=no_audio/1;emotes=;display-name=jonhycrack;color=#008000;flags=;first-msg=0;user-type=;subscriber=0;badge-info=;id=c377622f-ca90-4801-9042-6083e32bc05a;tmi-sent-ts=1704558435079;returning-chatter=0;room-id=62300805;user-id=431946171;rm-received-ts=1704558435281;turbo=0;historical=1 :jonhycrack!jonhycrack@jonhycrack.tmi.twitch.tv PRIVMSG #nymn docCum","@user-type=;first-msg=0;badge-info=subscriber/9;mod=0;historical=1;rm-received-ts=1704558435444;display-name=Kotzblitz20;turbo=1;badges=subscriber/9,turbo/1;emotes=emotesv2_ee52e9d2f1344320bad5e93dd2a6e570:0-10,16-26,32-42,48-58,64-74,80-90,96-106,112-122,128-138,144-154,160-170,176-186,192-202,208-218;color=#FFFF00;user-id=40037186;returning-chatter=0;client-nonce=d3b1db3cb35a737476cecb87aa26bfaf;subscriber=1;id=c7f2229e-b8e2-4790-9c0a-5cadd649814a;flags=;tmi-sent-ts=1704558435255;room-id=62300805 :kotzblitz20!kotzblitz20@kotzblitz20.tmi.twitch.tv PRIVMSG #nymn :forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM forsenParty EDM"],"error":null,"error_code":null} \ No newline at end of file diff --git a/benchmarks/resources/seventvemotes-nymn.json b/benchmarks/resources/seventvemotes-nymn.json new file mode 100644 index 00000000000..0b61afbf025 --- /dev/null +++ b/benchmarks/resources/seventvemotes-nymn.json @@ -0,0 +1 @@ +{"id":"62300805","platform":"TWITCH","username":"nymn","display_name":"NymN","linked_at":1622031401000,"emote_capacity":42069,"emote_set_id":null,"emote_set":{"id":"63b02874ad025a672cb4969f","name":"Tabula rasa","flags":0,"tags":[],"immutable":false,"privileged":false,"emotes":[{"id":"60ae9a57ac03cad60771b2d8","name":"PagMan","flags":0,"timestamp":1672506026185,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae9a57ac03cad60771b2d8","name":"PagMan","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60cbf0275348df16a156b329","username":"chubbss_","display_name":"Chubbss_","avatar_url":"//cdn.7tv.app/user/60cbf0275348df16a156b329/av_65024752bf154a9913688c69/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae9a57ac03cad60771b2d8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1334,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":970,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2318,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2472,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4072,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3620,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4915,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5104,"format":"WEBP"}]}}},{"id":"60b4fd124eb0019aa6ed4ec7","name":"POGPLANT","flags":0,"timestamp":1672506026442,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b4fd124eb0019aa6ed4ec7","name":"POGPLANT","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"609fccd64c18609a1dba6c2c","username":"askhp","display_name":"askhp","avatar_url":"//cdn.7tv.app/user/609fccd64c18609a1dba6c2c/av_64a196dc0cd4eb14e55ee103/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b4fd124eb0019aa6ed4ec7","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1178,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1513,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2896,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2920,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5048,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4407,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5931,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6258,"format":"WEBP"}]}}},{"id":"60a1de4aac2bcb20efc751fb","name":"pokiDance","flags":0,"timestamp":1672506026675,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60a1de4aac2bcb20efc751fb","name":"pokiDance","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60a1dde6ac2bcb20efc7445e","username":"fiakes","display_name":"fiakes","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60a1de4aac2bcb20efc751fb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":45,"size":29265,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":45,"size":44820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":45,"size":64860,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":45,"size":101074,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":45,"size":108125,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":45,"size":167886,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":45,"size":147428,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":45,"size":179198,"format":"WEBP"}]}}},{"id":"6162d21ef7b7a929341244dd","name":"nymn123","flags":0,"timestamp":1672506026912,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6162d21ef7b7a929341244dd","name":"nymn123","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6162d21ef7b7a929341244dd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":87,"height":32,"frame_count":1,"size":2531,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":87,"height":32,"frame_count":1,"size":2570,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":174,"height":64,"frame_count":1,"size":5423,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":174,"height":64,"frame_count":1,"size":6426,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":261,"height":96,"frame_count":1,"size":8100,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":261,"height":96,"frame_count":1,"size":10920,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":348,"height":128,"frame_count":1,"size":11234,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":348,"height":128,"frame_count":1,"size":16142,"format":"WEBP"}]}}},{"id":"60b5917d22b0373436c28ac0","name":"Cat","flags":0,"timestamp":1672506027143,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b5917d22b0373436c28ac0","name":"Cat","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b1d205e67fbb9dd75bfb6c","username":"frocasso","display_name":"frocasso","avatar_url":"//cdn.7tv.app/pp/60b1d205e67fbb9dd75bfb6c/80ff55dac0e944469e435c85fd3806fc","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b5917d22b0373436c28ac0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":39063,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":99640,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":112648,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":234300,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":195257,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":383216,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":284925,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":281196,"format":"WEBP"}]}}},{"id":"605305868c870a000de38b6f","name":"LULE","flags":0,"timestamp":1672593293030,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"605305868c870a000de38b6f","name":"LULE","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042160396832ffa786fbd8a","username":"noctum2k","display_name":"noctum2k","avatar_url":"//cdn.7tv.app/pp/6042160396832ffa786fbd8a/b6787ae92e55401aa651d208be7563e5","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/605305868c870a000de38b6f","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":912,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1206,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2535,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2466,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3921,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4338,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6856,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5731,"format":"AVIF"}]}}},{"id":"6145e8b10969108b671957ec","name":"Aware","flags":0,"timestamp":1672593293292,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6145e8b10969108b671957ec","name":"Aware","flags":0,"tags":["clueless"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603bb6a596832ffa78e7b27b","username":"megakill3","display_name":"MegaKill3","avatar_url":"//cdn.7tv.app/pp/603bb6a596832ffa78e7b27b/add3acfec2fb4256816e944d79b94a0c","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6145e8b10969108b671957ec","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":61,"size":13635,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":61,"size":71976,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":61,"size":29174,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":61,"size":164190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":61,"size":44686,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":61,"size":268170,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":61,"size":66758,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":61,"size":355046,"format":"WEBP"}]}}},{"id":"6154ecd36251d7e000db18a0","name":"Clueless","flags":0,"timestamp":1672593293523,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6154ecd36251d7e000db18a0","name":"Clueless","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6154ecd36251d7e000db18a0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":1,"size":1206,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":1,"size":1138,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":1,"size":2072,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":1,"size":2480,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":1,"size":3177,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":1,"size":3818,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":1,"size":4182,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":1,"size":5372,"format":"WEBP"}]}}},{"id":"60af0116a564afa26e3a7e86","name":"FloppaJAM","flags":0,"timestamp":1672593653882,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60af0116a564afa26e3a7e86","name":"FloppaJAM","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603ccf8b96832ffa78f80acd","username":"ook_3d","display_name":"ook_3D","avatar_url":"//cdn.7tv.app/pp/603ccf8b96832ffa78f80acd/fd41b01caad0452e96d247c7009580ab","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af0116a564afa26e3a7e86","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4812,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":7454,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":7899,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":16102,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":12234,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":23620,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":17564,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":26176,"format":"WEBP"}]}}},{"id":"603cd0152c7b4500143b46db","name":"DOCING","flags":0,"timestamp":1672672534103,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cd0152c7b4500143b46db","name":"DOCING","flags":0,"tags":["docing","docpls","forsencd","doc"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60a7c5c1ad0a5c63ef23bf5d","username":"was1max","display_name":"was1max","avatar_url":"//cdn.7tv.app/pp/60a7c5c1ad0a5c63ef23bf5d/72a8daff0f3744c0aa5184eb424ac711","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cd0152c7b4500143b46db","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":101,"size":38657,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":101,"size":81300,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":101,"size":82711,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":101,"size":189874,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":101,"size":136307,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":101,"size":308808,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":101,"size":420589,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":101,"size":542906,"format":"WEBP"}]}}},{"id":"60e8677677b18d5dd3800410","name":"AlienPls","flags":0,"timestamp":1672672534388,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60e8677677b18d5dd3800410","name":"AlienPls","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae4b445d3fdae583d20e9a","username":"ethantp","display_name":"ethantp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/617473d7-7627-4bd6-befa-a2ff489d8daa-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e8677677b18d5dd3800410","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":256,"size":142250,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":256,"size":203518,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":256,"size":329152,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":256,"size":494008,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":256,"size":571690,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":256,"size":880470,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":256,"size":855284,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":256,"size":1159480,"format":"WEBP"}]}}},{"id":"603caa69faf3a00014dff0b1","name":"Okayeg","flags":0,"timestamp":1672672847172,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"603caa69faf3a00014dff0b1","name":"Okayeg","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603caa3396832ffa78c1aa0d","username":"no_title24","display_name":"no_title24","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8bc0c15d-f2a5-457f-9c52-04bcbda0b806-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603caa69faf3a00014dff0b1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1571,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1138,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3043,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2664,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4419,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4338,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6242,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6204,"format":"WEBP"}]}}},{"id":"60ae546c9986a00349ea35d5","name":"NymN","flags":0,"timestamp":1672702606816,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60ae546c9986a00349ea35d5","name":"NymN","flags":1,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3c29b2ecb015051f8f9a","username":"nymn","display_name":"NymN","avatar_url":"//cdn.7tv.app/pp/60ae3c29b2ecb015051f8f9a/71f269555aeb44c29100cae8aa59b56b","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae546c9986a00349ea35d5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1102,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":742,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1931,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1780,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2802,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2872,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4383,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4306,"format":"WEBP"}]}}},{"id":"6042089e77137b000de9e669","name":"OMEGALUL","flags":0,"timestamp":1672758964245,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6042089e77137b000de9e669","name":"OMEGALUL","flags":0,"tags":["xdddd"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6042089e77137b000de9e669","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1293,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1046,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2437,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2652,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3734,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4586,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5702,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7340,"format":"WEBP"}]}}},{"id":"6186d2e94ea2f24e50099f35","name":"sadE","flags":0,"timestamp":1672758964495,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6186d2e94ea2f24e50099f35","name":"sadE","flags":0,"tags":["forsen","sad","sadge","lule"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"host":{"url":"//cdn.7tv.app/emote/6186d2e94ea2f24e50099f35","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":972,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":708,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1823,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1836,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2584,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3018,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3493,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4506,"format":"WEBP"}]}}},{"id":"6329beb61c85cd937753ec61","name":"TimeToNime","flags":0,"timestamp":1672759315382,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6329beb61c85cd937753ec61","name":"Nidea","flags":0,"tags":["idea","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60421e5596832ffa787cfa62","username":"sauha_","display_name":"Sauha_","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/75305d54-c7cc-40d1-bb9c-91fbe85943c7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6329beb61c85cd937753ec61","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":18,"size":6337,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":14,"size":7828,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":18,"size":9931,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":18,"size":16092,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":18,"size":14116,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":18,"size":26006,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":18,"size":19668,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":18,"size":34324,"format":"WEBP"}]}}},{"id":"60aeff0411a994a4acdd36b6","name":"docnotL","flags":0,"timestamp":1672845413213,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aeff0411a994a4acdd36b6","name":"docnotL","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae653c9627f9aff4f5ccd1","username":"xoo_6119","display_name":"xoo_6119","avatar_url":"//cdn.7tv.app/user/60ae653c9627f9aff4f5ccd1/av_63ca0eccdedb49b24383ae5c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeff0411a994a4acdd36b6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":60,"size":22137,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":60,"size":41474,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":60,"size":44532,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":60,"size":79840,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":60,"size":70270,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":60,"size":123148,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":60,"size":94376,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":60,"size":137488,"format":"WEBP"}]}}},{"id":"60ae997698f4291470d407a8","name":"MODS","flags":0,"timestamp":1672845413728,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae997698f4291470d407a8","name":"MODS","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae997698f4291470d407a8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":156,"size":28522,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":156,"size":117268,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":156,"size":64943,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":156,"size":243580,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":156,"size":114327,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":156,"size":377968,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":156,"size":194242,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":156,"size":415022,"format":"WEBP"}]}}},{"id":"63072162942ffb69e13d703f","name":"pepeW","flags":0,"timestamp":1672860000593,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"63072162942ffb69e13d703f","name":"pepeW","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b04a7fad7fb4b50bd3a982","username":"brian6932","display_name":"brian6932","avatar_url":"//cdn.7tv.app/user/60b04a7fad7fb4b50bd3a982/av_64f8035f8bef730969094d7a/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63072162942ffb69e13d703f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":181,"size":16593,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":177,"size":47362,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":181,"size":28488,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":181,"size":155684,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":181,"size":46691,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":181,"size":247212,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":181,"size":73886,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":181,"size":344758,"format":"WEBP"}]}}},{"id":"60e787dd375879d78fc6b25e","name":"SNIFFA","flags":0,"timestamp":1672922706116,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60e787dd375879d78fc6b25e","name":"SNIFFA","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e787dd375879d78fc6b25e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":139,"size":16776,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":139,"size":137452,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":139,"size":42320,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":139,"size":299940,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":139,"size":84421,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":139,"size":495752,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":139,"size":175672,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":139,"size":591066,"format":"WEBP"}]}}},{"id":"618fd73e17e4d50afc0d4e3f","name":"docPls","flags":0,"timestamp":1672931580819,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"618fd73e17e4d50afc0d4e3f","name":"docPls","flags":0,"tags":["doc","drdisrespect","doctordance"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60edbe6890b3667a8a3cfb66","username":"flushedjulian","display_name":"flushedjulian","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ede97175-eb3e-4656-967d-1fc0da391dac-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/618fd73e17e4d50afc0d4e3f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":126,"size":61366,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":126,"size":102754,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":126,"size":138229,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":126,"size":235070,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":126,"size":231065,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":126,"size":400984,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":126,"size":335953,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":126,"size":491700,"format":"WEBP"}]}}},{"id":"603cbda573d7a5001441f9d5","name":"flushE","flags":0,"timestamp":1672931818368,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cbda573d7a5001441f9d5","name":"flushE","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603cb87696832ffa78d57767","username":"obscurelambda","display_name":"obscurelambda","avatar_url":"//cdn.7tv.app/user/603cb87696832ffa78d57767/av_647a68a9804ca09ebf23e890/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cbda573d7a5001441f9d5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1335,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":906,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2528,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2276,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3593,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3574,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4834,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5360,"format":"WEBP"}]}}},{"id":"60fd57134653f5d6c1b10d86","name":"Nime","flags":0,"timestamp":1672931818641,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60fd57134653f5d6c1b10d86","name":"Nime","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60fd57134653f5d6c1b10d86","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1153,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":802,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1953,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1920,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3068,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3480,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4554,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5248,"format":"WEBP"}]}}},{"id":"603cb3b4c20d020014423c44","name":"pepeLaugh","flags":0,"timestamp":1673018783171,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"603cb3b4c20d020014423c44","name":"pepeLaugh","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb3b4c20d020014423c44","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1400,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1076,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2786,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2688,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4311,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4546,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6117,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6698,"format":"WEBP"}]}}},{"id":"616edc115ff09767de29919b","name":"dankHug","flags":0,"timestamp":1673018789436,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"616edc115ff09767de29919b","name":"dankHug","flags":0,"tags":["dank","hug"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae7643b351b8d1c09294b9","username":"snz_____","display_name":"snz_____","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e441ab99-5ab9-4a54-8967-31efa3fd5e96-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/616edc115ff09767de29919b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1309,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1078,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2377,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2638,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3791,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4482,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5563,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6626,"format":"WEBP"}]}}},{"id":"603cb56bc20d020014423c60","name":"FeelsDonkMan","flags":0,"timestamp":1673020529104,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"603cb56bc20d020014423c60","name":"FeelsDonkMan","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"647e1211804ca09ebf249957","username":"aisenyeeecs","display_name":"AisenYeeeCS","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6039818f-d868-48ec-ab02-b79125eeb894-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb56bc20d020014423c60","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1570,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1038,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3210,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2636,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5008,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4414,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7271,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6684,"format":"WEBP"}]}}},{"id":"60ae4cd10e3547763479fc83","name":"Lamonting","flags":0,"timestamp":1673104655687,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae4cd10e3547763479fc83","name":"Lamonting","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae4b445d3fdae583d20e9a","username":"ethantp","display_name":"ethantp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/617473d7-7627-4bd6-befa-a2ff489d8daa-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae4cd10e3547763479fc83","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":118,"size":17571,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":118,"size":77488,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":118,"size":68298,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":118,"size":187318,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":118,"size":139079,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":118,"size":319382,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":118,"size":224792,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":118,"size":229782,"format":"WEBP"}]}}},{"id":"62f9cabd00630d5b2acd66f0","name":"DinkDonk","flags":0,"timestamp":1673104655945,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62f9cabd00630d5b2acd66f0","name":"DinkDonk","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603c7fca96832ffa788a5f14","username":"hyruverse","display_name":"hyruverse","avatar_url":"//cdn.7tv.app/pp/603c7fca96832ffa788a5f14/2ed3bd237882444ebccf38ae918e8df6","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62f9cabd00630d5b2acd66f0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":2986,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1960,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":4566,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":4152,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":6451,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":6996,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":8261,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":9868,"format":"WEBP"}]}}},{"id":"60ad8c93c7188f3be2332566","name":"NOIDONTTHINKSO","flags":0,"timestamp":1673104656191,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ad8c93c7188f3be2332566","name":"NOIDONTTHINKSO","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ad8c93c7188f3be2332566","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":124,"size":18896,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":124,"size":91376,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":124,"size":42469,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":124,"size":197032,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":124,"size":92230,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":124,"size":316230,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":124,"size":161369,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":124,"size":336336,"format":"WEBP"}]}}},{"id":"6063d9f8f4dc10001426b946","name":"GoodTake","flags":0,"timestamp":1673158455736,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6063d9f8f4dc10001426b946","name":"GoodTake","flags":0,"tags":["muted","boring","volume"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603c624796832ffa78678fd7","username":"juanjunki_6969","display_name":"JuanJunki_6969","avatar_url":"//cdn.7tv.app/user/603c624796832ffa78678fd7/av_64df2a212356a20967ed54d9/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6063d9f8f4dc10001426b946","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":93,"height":32,"frame_count":81,"size":12106,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":93,"height":32,"frame_count":81,"size":50910,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":186,"height":64,"frame_count":81,"size":24839,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":186,"height":64,"frame_count":81,"size":187294,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":279,"height":96,"frame_count":81,"size":47468,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":279,"height":96,"frame_count":81,"size":400330,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":372,"height":128,"frame_count":81,"size":234651,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":372,"height":128,"frame_count":81,"size":1003370,"format":"WEBP"}]}}},{"id":"60e857ca401af27eed2f6a4e","name":"Wokege","flags":0,"timestamp":1673190125994,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60e857ca401af27eed2f6a4e","name":"Wokege","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e857ca401af27eed2f6a4e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":1579,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":1280,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":3186,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":3254,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":4969,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":5682,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":6693,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":7990,"format":"WEBP"}]}}},{"id":"619fb59915b3ff4a5bb7a90a","name":"AlienUnpleased","flags":0,"timestamp":1673190126255,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"619fb59915b3ff4a5bb7a90a","name":"AlienUnpleased","flags":0,"tags":["alien","alienpls","aliens","jam","pls"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"610320e899197254381e176a","username":"pallaslol","display_name":"pallaslol","avatar_url":"//cdn.7tv.app/user/610320e899197254381e176a/av_648247a3b4ddf6f60d18b83c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619fb59915b3ff4a5bb7a90a","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":792,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1178,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1958,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1966,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3368,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2898,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5022,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4112,"format":"AVIF"}]}}},{"id":"61d679c83d52bb5c33c4f9a6","name":"Buhh","flags":0,"timestamp":1673191602063,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"61d679c83d52bb5c33c4f9a6","name":"Buhh","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"613f0d960969108b6718ab36","username":"suspiciousmonkey","display_name":"SUSpiciouSmonkeY","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ae6fdabf-8049-47d9-86d6-0ec9eabf9a26-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61d679c83d52bb5c33c4f9a6","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":936,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1291,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2139,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1968,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3308,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3021,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3864,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4710,"format":"WEBP"}]}}},{"id":"62af82f5454b0130fba333ba","name":"ok","flags":0,"timestamp":1673206059533,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62af82f5454b0130fba333ba","name":"ok","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae759bdf5735e04acb69d9","username":"hotbear1110","display_name":"HotBear1110","avatar_url":"//cdn.7tv.app/pp/60ae759bdf5735e04acb69d9/80e2b49378c14dc6914fde8cb72fa673","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62af82f5454b0130fba333ba","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":856,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1105,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2130,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2096,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3054,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3478,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5214,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4334,"format":"AVIF"}]}}},{"id":"605304ed8c870a000de38b6d","name":"Sadeg","flags":0,"timestamp":1673276592474,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"605304ed8c870a000de38b6d","name":"Sadeg","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042160396832ffa786fbd8a","username":"noctum2k","display_name":"noctum2k","avatar_url":"//cdn.7tv.app/pp/6042160396832ffa786fbd8a/b6787ae92e55401aa651d208be7563e5","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/605304ed8c870a000de38b6d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1496,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1094,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3153,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2818,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4839,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4692,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7317,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6820,"format":"WEBP"}]}}},{"id":"60ae3853b2ecb015050540d2","name":"WTFFF","flags":0,"timestamp":1673276592999,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae3853b2ecb015050540d2","name":"WTFF","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae3853b2ecb015050540d2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":847,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":598,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1371,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1398,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2128,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1852,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2513,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":2138,"format":"WEBP"}]}}},{"id":"61a7bac1e9684edbbc37d009","name":"eShrug","flags":0,"timestamp":1673295807076,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61a7bac1e9684edbbc37d009","name":"eShrug","flags":0,"tags":["forsen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aebf0ae90f445e43b37fe5","username":"prog0ldfish","display_name":"ProG0ldfish","avatar_url":"//cdn.7tv.app/user/60aebf0ae90f445e43b37fe5/av_6353ef0d7f642dee0e30c1f4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61a7bac1e9684edbbc37d009","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":55,"height":32,"frame_count":1,"size":1069,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":55,"height":32,"frame_count":1,"size":1004,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":110,"height":64,"frame_count":1,"size":1787,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":110,"height":64,"frame_count":1,"size":2172,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":165,"height":96,"frame_count":1,"size":2799,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":165,"height":96,"frame_count":1,"size":3654,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":220,"height":128,"frame_count":1,"size":3622,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":220,"height":128,"frame_count":1,"size":5202,"format":"WEBP"}]}}},{"id":"603ccedf2c7b4500143b46d7","name":"forsenCD","flags":0,"timestamp":1673362992445,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603ccedf2c7b4500143b46d7","name":"forsenCD","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603ccea596832ffa78f6ad89","username":"hmoodybins","display_name":"HMOODYBINS","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5de62438-58d6-4cd1-840c-7690cf22e4f8-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603ccedf2c7b4500143b46d7","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":838,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1148,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2180,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2305,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3375,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3596,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4655,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5570,"format":"WEBP"}]}}},{"id":"603c89cbbb69c00014bed23e","name":"ZULUL","flags":0,"timestamp":1673362992934,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603c89cbbb69c00014bed23e","name":"ZULUL","flags":0,"lifecycle":3,"state":["NO_PERSONAL"],"listed":false,"animated":false,"owner":{"id":"000000000000000000000000","username":"","display_name":"","style":{}},"host":{"url":"//cdn.7tv.app/emote/603c89cbbb69c00014bed23e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":27,"height":32,"frame_count":1,"size":1073,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":27,"height":32,"frame_count":1,"size":886,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":54,"height":64,"frame_count":1,"size":1898,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":54,"height":64,"frame_count":1,"size":2064,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":81,"height":96,"frame_count":1,"size":2875,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":81,"height":96,"frame_count":1,"size":3422,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":108,"height":128,"frame_count":1,"size":3916,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":108,"height":128,"frame_count":1,"size":5098,"format":"WEBP"}]}}},{"id":"61630205c1ff9a17cc396522","name":"Sadge","flags":0,"timestamp":1673363399204,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61630205c1ff9a17cc396522","name":"Sadge","flags":0,"tags":["sad"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61562bfd6251d7e000db34c8","username":"mavii","display_name":"Mavii","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/64746afb-68cf-4dfb-98f7-9d4589bfb18b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61630205c1ff9a17cc396522","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1284,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":952,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2475,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2406,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3787,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4156,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5426,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6624,"format":"WEBP"}]}}},{"id":"63438a743d1bc89e0ff9e400","name":"peepoChat","flags":0,"timestamp":1673365629446,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63438a743d1bc89e0ff9e400","name":"peepoChat","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63438a743d1bc89e0ff9e400","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":3044,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1546,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":3708,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":5589,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":6386,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":8212,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":11058,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":9258,"format":"WEBP"}]}}},{"id":"61fe824dd771ca5bf0379bb2","name":"Excel","flags":0,"timestamp":1673446825105,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61fe824dd771ca5bf0379bb2","name":"Excel","flags":0,"tags":["excel","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61fe824dd771ca5bf0379bb2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":993,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":696,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1436,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1356,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1978,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2662,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2391,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3408,"format":"WEBP"}]}}},{"id":"6053abc29d9e96000d24503d","name":"amongE","flags":0,"timestamp":1673449422562,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6053abc29d9e96000d24503d","name":"amongE","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"608bd85b99dcd148faf02644","username":"tehargi_","display_name":"tehargi_","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/294c98b5-e34d-42cd-a8f0-140b72fba9b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6053abc29d9e96000d24503d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1382,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1096,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2718,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2508,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3902,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4338,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5187,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5954,"format":"WEBP"}]}}},{"id":"6042091777137b000de9e66b","name":"monkaOMEGA","flags":0,"timestamp":1673449422786,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6042091777137b000de9e66b","name":"monkaOMEGA","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6042091777137b000de9e66b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1224,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1066,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2189,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2334,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3289,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3862,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4548,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5322,"format":"WEBP"}]}}},{"id":"60afc290ebfcf7562ee8bab5","name":"Pepege","flags":0,"timestamp":1673449457426,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60afc290ebfcf7562ee8bab5","name":"Pepege","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60afb513a3648f409a305485","username":"mistrzu__","display_name":"mistrzu__","avatar_url":"//cdn.7tv.app/pp/60afb513a3648f409a305485/efa75abba8be42929a6178b381804a6a","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afc290ebfcf7562ee8bab5","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":956,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1361,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2644,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2368,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4070,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4086,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5497,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5532,"format":"WEBP"}]}}},{"id":"6351b23d321db49a66a2429f","name":"MEMONEY","flags":0,"timestamp":1673535852634,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6351b23d321db49a66a2429f","name":"MEMONEY","flags":0,"tags":["nime","lime","spongebob","krabs","money","scam"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6351b23d321db49a66a2429f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":74,"height":32,"frame_count":1,"size":3110,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":74,"height":32,"frame_count":1,"size":3742,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":148,"height":64,"frame_count":1,"size":6771,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":148,"height":64,"frame_count":1,"size":10444,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":222,"height":96,"frame_count":1,"size":11017,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":222,"height":96,"frame_count":1,"size":19618,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":296,"height":128,"frame_count":1,"size":14982,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":296,"height":128,"frame_count":1,"size":30874,"format":"WEBP"}]}}},{"id":"6329da94345c8855a28db877","name":"AINTNOWAY","flags":0,"timestamp":1673535852914,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6329da94345c8855a28db877","name":"AINTNOWAY","flags":0,"tags":["naw","deadass","skull","nah"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60d37adc205cf63c7eef6871","username":"eazylemnsqeezy","display_name":"eazylemnsqeezy","avatar_url":"//cdn.7tv.app/user/60d37adc205cf63c7eef6871/av_64200b330ef35e7ab89f2db4/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6329da94345c8855a28db877","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":39,"height":32,"frame_count":200,"size":32648,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":39,"height":32,"frame_count":200,"size":53182,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":78,"height":64,"frame_count":200,"size":355465,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":78,"height":64,"frame_count":200,"size":133396,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":117,"height":96,"frame_count":200,"size":1278863,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":117,"height":96,"frame_count":200,"size":377592,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":156,"height":128,"frame_count":200,"size":3226360,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":156,"height":128,"frame_count":200,"size":1273268,"format":"WEBP"}]}}},{"id":"62f80d745a8981e4c792ca1c","name":"RAGEY","flags":0,"timestamp":1673542285985,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"62f80d745a8981e4c792ca1c","name":"RAGEY","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60e8c8686d77836414234d0d","username":"pattiiiiiiii","display_name":"PATTIIIIIIII","avatar_url":"//cdn.7tv.app/user/60e8c8686d77836414234d0d/av_64b096dbb1a061d079b25a75/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62f80d745a8981e4c792ca1c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":38,"size":14018,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":38,"size":25518,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":38,"size":28460,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":38,"size":51096,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":38,"size":48701,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":38,"size":79202,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":38,"size":62604,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":38,"size":73942,"format":"WEBP"}]}}},{"id":"62c02c2cc2b63d1e2f3d8782","name":"hmmMeeting","flags":0,"timestamp":1673622252419,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62c02c2cc2b63d1e2f3d8782","name":"hmmMeeting","flags":0,"tags":["reunion","group","hmm","discussion","talk","council"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61541c3c20eaf897465ad48b","username":"andreimonty","display_name":"AndreiMonty","avatar_url":"//cdn.7tv.app/user/61541c3c20eaf897465ad48b/av_6458e293d3b4256e12d830a9/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62c02c2cc2b63d1e2f3d8782","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":79,"height":32,"frame_count":29,"size":10921,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":79,"height":32,"frame_count":29,"size":51390,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":158,"height":64,"frame_count":29,"size":28695,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":158,"height":64,"frame_count":29,"size":133652,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":237,"height":96,"frame_count":29,"size":56672,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":237,"height":96,"frame_count":29,"size":224218,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":316,"height":128,"frame_count":29,"size":113458,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":316,"height":128,"frame_count":29,"size":345852,"format":"WEBP"}]}}},{"id":"60ae958e229664e8667aea38","name":"GIGACHAD","flags":0,"timestamp":1673622252685,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae958e229664e8667aea38","name":"GIGACHAD","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae264eaee2aa55389c4164","username":"beutelino","display_name":"Beutelino","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1dfb4e5c-3a50-4e41-9f79-887157c18f36-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae958e229664e8667aea38","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":198,"size":19845,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":198,"size":99794,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":198,"size":61265,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":198,"size":252100,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":198,"size":137527,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":198,"size":414348,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":198,"size":263741,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":198,"size":308490,"format":"WEBP"}]}}},{"id":"6305c255d4b348f08e833c90","name":"GymN","flags":0,"timestamp":1673630581712,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6305c255d4b348f08e833c90","name":"GymN","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6305c255d4b348f08e833c90","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":68,"height":32,"frame_count":1,"size":2480,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":68,"height":32,"frame_count":1,"size":3752,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":136,"height":64,"frame_count":1,"size":5167,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":136,"height":64,"frame_count":1,"size":10610,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":204,"height":96,"frame_count":1,"size":7617,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":204,"height":96,"frame_count":1,"size":20062,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":272,"height":128,"frame_count":1,"size":10343,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":272,"height":128,"frame_count":1,"size":30922,"format":"WEBP"}]}}},{"id":"63695fc399efe5867cd0d4a5","name":"monkaE","flags":0,"timestamp":1673635463409,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"63695fc399efe5867cd0d4a5","name":"monkaE","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63695fc399efe5867cd0d4a5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":8,"size":3905,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":8,"size":3650,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":8,"size":6754,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":8,"size":8000,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":8,"size":9843,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":8,"size":11620,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":8,"size":12931,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":8,"size":16320,"format":"WEBP"}]}}},{"id":"61c71adaef5a587a07458f83","name":"Ogre","flags":0,"timestamp":1673636145247,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61c71adaef5a587a07458f83","name":"Ogre","flags":0,"tags":["erobb221","ugly"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61c7196612987d64d6ae7fc6","username":"quaxi13","display_name":"Quaxi13","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/39135e01-6d49-40ad-afe5-2973e6176848-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61c71adaef5a587a07458f83","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":53,"height":32,"frame_count":1,"size":1523,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":53,"height":32,"frame_count":1,"size":1370,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":106,"height":64,"frame_count":1,"size":2919,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":106,"height":64,"frame_count":1,"size":3268,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":159,"height":96,"frame_count":1,"size":4489,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":159,"height":96,"frame_count":1,"size":5584,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":212,"height":128,"frame_count":1,"size":6591,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":212,"height":128,"frame_count":1,"size":8572,"format":"WEBP"}]}}},{"id":"618a368217e4d50afc0cb2a8","name":"forsenWiggle","flags":0,"timestamp":1673636704456,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"618a368217e4d50afc0cb2a8","name":"forsenWiggle","flags":0,"tags":["forsen","wiggle","zulul","cute","scuffed","lidl"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61661e03ce26ef6063881c94","username":"sidetrvcked","display_name":"sidetrvcked","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3dc090a3-28b7-4707-b087-8641e5755201-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/618a368217e4d50afc0cb2a8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":12,"size":8398,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":12,"size":13368,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":12,"size":17785,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":12,"size":34300,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":12,"size":33488,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":12,"size":58862,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":12,"size":49403,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":12,"size":72840,"format":"WEBP"}]}}},{"id":"6218ad877cc2d4e1953802e9","name":"Listening","flags":0,"timestamp":1673708657421,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6218ad877cc2d4e1953802e9","name":"Listening","flags":0,"tags":["patrickbateman","americanpsycho","christianbale","lolw","music"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b132f3bdea6753986d0208","username":"alastorkunn","display_name":"alastorkunn","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c7d7cd45-054f-4dfd-ba8d-12a3347030d8-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6218ad877cc2d4e1953802e9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":56,"height":32,"frame_count":162,"size":35541,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":56,"height":32,"frame_count":162,"size":160110,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":112,"height":64,"frame_count":162,"size":125123,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":112,"height":64,"frame_count":162,"size":403360,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":168,"height":96,"frame_count":162,"size":274685,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":168,"height":96,"frame_count":162,"size":721338,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":224,"height":128,"frame_count":162,"size":529864,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":224,"height":128,"frame_count":162,"size":921242,"format":"WEBP"}]}}},{"id":"60e8573f3c5b87437a3bac1f","name":"Bedge","flags":0,"timestamp":1673708657711,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60e8573f3c5b87437a3bac1f","name":"Bedge","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e8573f3c5b87437a3bac1f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":1546,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":1260,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":3125,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":3242,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":4760,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":5438,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":6393,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":7762,"format":"WEBP"}]}}},{"id":"63c1e2d7182ccc7de666c18b","name":"FeelsNymNge","flags":0,"timestamp":1673712270114,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c1e2d7182ccc7de666c18b","name":"FeelsNymNge","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c1e2d7182ccc7de666c18b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":42,"height":32,"frame_count":13,"size":12034,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":42,"height":32,"frame_count":13,"size":12778,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":84,"height":64,"frame_count":13,"size":26572,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":84,"height":64,"frame_count":13,"size":29200,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":126,"height":96,"frame_count":13,"size":40648,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":126,"height":96,"frame_count":13,"size":44452,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":168,"height":128,"frame_count":13,"size":60273,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":168,"height":128,"frame_count":13,"size":60834,"format":"WEBP"}]}}},{"id":"603caea243b9e100141caf4f","name":"TrollDespair","flags":0,"timestamp":1673795060685,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603caea243b9e100141caf4f","name":"TrollDespair","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603cae2496832ffa78c758cd","username":"swyfty_","display_name":"swyfty_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/93a53aa0-4f02-4834-82e5-602f39d14ddc-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603caea243b9e100141caf4f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":943,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":716,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1615,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1706,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2364,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2516,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3084,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3702,"format":"WEBP"}]}}},{"id":"610435e3fb49175e996f016b","name":"apolloJAM","flags":0,"timestamp":1673795060939,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"610435e3fb49175e996f016b","name":"apolloJAM","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610435e3fb49175e996f016b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":216,"size":66008,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":216,"size":180824,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":216,"size":188821,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":216,"size":429512,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":216,"size":349160,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":216,"size":747160,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":216,"size":583714,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":216,"size":854150,"format":"WEBP"}]}}},{"id":"603cbdbc73d7a5001441f9d7","name":"Copesen","flags":0,"timestamp":1673795184010,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"603cbdbc73d7a5001441f9d7","name":"Copesen","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603cb87696832ffa78d57767","username":"obscurelambda","display_name":"obscurelambda","avatar_url":"//cdn.7tv.app/user/603cb87696832ffa78d57767/av_647a68a9804ca09ebf23e890/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cbdbc73d7a5001441f9d7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1332,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1146,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2970,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2694,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5148,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4106,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5850,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7446,"format":"WEBP"}]}}},{"id":"61485c0e1eb7078240526fd9","name":"PirateJam","flags":0,"timestamp":1673802627267,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"61485c0e1eb7078240526fd9","name":"PirateJam","flags":0,"tags":["pirate","jam","piratejam"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60e2f07aa940e09428233bf8","username":"largaas_","display_name":"Largaas_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/eb90faf6-01da-427c-b869-8e4dffef997f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61485c0e1eb7078240526fd9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":3752,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":4198,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":6582,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":9414,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":15896,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":10048,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":14136,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":19124,"format":"WEBP"}]}}},{"id":"60ae6c1386fc40d488e1ebf9","name":"BRUH","flags":0,"timestamp":1673881461362,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae6c1386fc40d488e1ebf9","name":"BRUH","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"6068a623452cea46853f9bba","username":"telvann","display_name":"telvann","avatar_url":"//cdn.7tv.app/pp/6068a623452cea46853f9bba/c1926e040ac0414c949438242dd77d4a","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae6c1386fc40d488e1ebf9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1383,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1100,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2761,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2786,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4118,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4834,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5415,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5364,"format":"WEBP"}]}}},{"id":"60b6e6d45d373afbd69c2d45","name":"HEWILLNEVER","flags":0,"timestamp":1673881515389,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b6e6d45d373afbd69c2d45","name":"HEWILLNEVER","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60a2ed47ac2bcb20ef076038","username":"bandy_j","display_name":"bandy_j","avatar_url":"//cdn.7tv.app/user/60a2ed47ac2bcb20ef076038/av_63d1b23e9db7d93a1a304fba/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b6e6d45d373afbd69c2d45","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":2591,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1430,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":4077,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":3424,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":5670,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":5404,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":7488,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":6690,"format":"WEBP"}]}}},{"id":"621e1cdfb027edd02c8b6f09","name":"forsenPossessed","flags":0,"timestamp":1673967880711,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"621e1cdfb027edd02c8b6f09","name":"forsenPossessed","flags":0,"tags":["forsen","insane","asylum","possessed"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613f3cbd7b14fdf700b8ba35","username":"gyoubu_masataka_oniwaaaaa","display_name":"gyoubu_masataka_oniwaaaaa","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8f4d5ea6-6b18-4670-badd-b22dc463f21d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621e1cdfb027edd02c8b6f09","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":51,"size":22614,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":51,"size":32478,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":51,"size":42154,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":51,"size":67724,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":51,"size":66767,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":51,"size":110434,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":51,"size":89593,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":51,"size":130390,"format":"WEBP"}]}}},{"id":"60afcde452a13d1adba73d29","name":"FeelsLagMan","flags":0,"timestamp":1673971753978,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60afcde452a13d1adba73d29","name":"FeelsLagMan","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae6d202a346b32e302acdb","username":"defyyy_","display_name":"Defyyy_","avatar_url":"//cdn.7tv.app/pp/60ae6d202a346b32e302acdb/9bd147d3e37a4700961c86ab8a07958a","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afcde452a13d1adba73d29","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":179,"size":50937,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":179,"size":159250,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":179,"size":133032,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":179,"size":370112,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":179,"size":237184,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":179,"size":631060,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":179,"size":408068,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":179,"size":723108,"format":"WEBP"}]}}},{"id":"603cb2dbc20d020014423c3c","name":"MEGALUL","flags":0,"timestamp":1674054310926,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cb2dbc20d020014423c3c","name":"MEGALUL","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6394d00e986cf8ade3a7b82e","username":"ydyote","display_name":"ydyote","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/b6617b5f-a688-4f2c-bee2-6f7fbc01b917-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb2dbc20d020014423c3c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":1613,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":1218,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":3014,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":3210,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":5256,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":4938,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":6714,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":7740,"format":"WEBP"}]}}},{"id":"61a1368ce9684edbbc36f4ad","name":"SOCCER","flags":0,"timestamp":1674054311179,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61a1368ce9684edbbc36f4ad","name":"SOCCER","flags":0,"tags":["nmyn","soccer","football","ball"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61a1368ce9684edbbc36f4ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1205,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1058,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2542,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2884,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3943,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4948,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5762,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7534,"format":"WEBP"}]}}},{"id":"60e7328e484ebd628b556b3e","name":"Okayge","flags":0,"timestamp":1674132897811,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60e7328e484ebd628b556b3e","name":"Okayge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b2a8d9be695c536f66179e","username":"venceslavsquare","display_name":"VenceslavSquare","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9bce07d2-d3f9-4977-8e17-028ede768a35-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e7328e484ebd628b556b3e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1320,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":966,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2480,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2398,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3774,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4174,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5227,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6236,"format":"WEBP"}]}}},{"id":"629fa7bb2b24f7ba48b6e6c4","name":"WHAT","flags":0,"timestamp":1674140710609,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"629fa7bb2b24f7ba48b6e6c4","name":"WHAT","flags":0,"tags":["what","emotiguy","shocked","shock"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"612d2ba3cffebf58c0aa8bfb","username":"kalorxd","display_name":"kalorxd","avatar_url":"//cdn.7tv.app/pp/612d2ba3cffebf58c0aa8bfb/68960057576f4b72a9e3d943819eaaf1","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/629fa7bb2b24f7ba48b6e6c4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":47,"height":32,"frame_count":55,"size":13622,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":47,"height":32,"frame_count":55,"size":80062,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":94,"height":64,"frame_count":55,"size":31727,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":94,"height":64,"frame_count":55,"size":189190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":141,"height":96,"frame_count":55,"size":54806,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":141,"height":96,"frame_count":55,"size":334118,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":188,"height":128,"frame_count":55,"size":86488,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":188,"height":128,"frame_count":55,"size":505904,"format":"WEBP"}]}}},{"id":"60aea8c73c27a8b79cc0c510","name":"YABBECANYOUBRINGMESOMEFOODPLEASE","flags":0,"timestamp":1674140710876,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aea8c73c27a8b79cc0c510","name":"YABBECANYOUBRINGMESOMEFOODPLEASE","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aea8c73c27a8b79cc0c510","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":23,"size":15698,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":23,"size":28668,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":23,"size":34031,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":23,"size":63172,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":23,"size":62526,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":23,"size":103594,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":23,"size":94302,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":23,"size":115934,"format":"WEBP"}]}}},{"id":"603cb2d7c20d020014423c3b","name":"MegaLUL","flags":0,"timestamp":1674140711208,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cb2d7c20d020014423c3b","name":"MegaLUL","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603cb1c696832ffa78cc3bc2","username":"clyvere","display_name":"clyverE","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3ff40972-0188-4cfc-adbf-8db119d7cf2a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb2d7c20d020014423c3b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1678,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1252,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3870,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5891,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5646,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8784,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8298,"format":"WEBP"}]}}},{"id":"60fdd36e5ab6dc5bc4b1a7f8","name":"walterSmile","flags":0,"timestamp":1674144470885,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60fdd36e5ab6dc5bc4b1a7f8","name":"walterSmile","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b5481a2551fd3c8c239622","username":"isabelcoolaf","display_name":"isabelcoolaf","avatar_url":"//cdn.7tv.app/user/60b5481a2551fd3c8c239622/av_639243dbd1b0228466bcefae/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60fdd36e5ab6dc5bc4b1a7f8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1286,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":1128,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":2330,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":2846,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":3389,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":4682,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":4635,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":6592,"format":"WEBP"}]}}},{"id":"62ac06d1604faf8634221574","name":"PotFaint","flags":0,"timestamp":1674226756142,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62ac06d1604faf8634221574","name":"PotFaint","flags":0,"tags":["potfriend","pot","eldenring","faint","bruhfaint","brorbesvimer"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b132f3bdea6753986d0208","username":"alastorkunn","display_name":"alastorkunn","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c7d7cd45-054f-4dfd-ba8d-12a3347030d8-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62ac06d1604faf8634221574","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":5465,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":5440,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":9378,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":11288,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":13909,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":18838,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":18305,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":25010,"format":"WEBP"}]}}},{"id":"637ce74f58f8ed425904bd51","name":"lookUp","flags":0,"timestamp":1674227244378,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"637ce74f58f8ed425904bd51","name":"lookUp","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60afa8c899923bbe7f6e5a33","username":"trippycolour","display_name":"TrippyColour","avatar_url":"//cdn.7tv.app/user/60afa8c899923bbe7f6e5a33/av_6592e20b64e6ee62744a436c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/637ce74f58f8ed425904bd51","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1323,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1212,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2423,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2710,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3494,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4426,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4576,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6454,"format":"WEBP"}]}}},{"id":"635206405029098c3d6e1c6c","name":"TriKool","flags":0,"timestamp":1674227271735,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"635206405029098c3d6e1c6c","name":"TriKool","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603c7fca96832ffa788a5f14","username":"hyruverse","display_name":"hyruverse","avatar_url":"//cdn.7tv.app/pp/603c7fca96832ffa788a5f14/2ed3bd237882444ebccf38ae918e8df6","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/635206405029098c3d6e1c6c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":14,"size":5955,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":14,"size":9694,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":14,"size":17278,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":14,"size":8895,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":14,"size":24940,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":14,"size":12899,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":14,"size":16801,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":14,"size":32434,"format":"WEBP"}]}}},{"id":"61a7c0e5e9684edbbc37d13a","name":"forsenGa","flags":0,"timestamp":1674227475843,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"61a7c0e5e9684edbbc37d13a","name":"forsenGa","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aebf0ae90f445e43b37fe5","username":"prog0ldfish","display_name":"ProG0ldfish","avatar_url":"//cdn.7tv.app/user/60aebf0ae90f445e43b37fe5/av_6353ef0d7f642dee0e30c1f4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61a7c0e5e9684edbbc37d13a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":28,"height":32,"frame_count":1,"size":1036,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":28,"height":32,"frame_count":1,"size":704,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":56,"height":64,"frame_count":1,"size":1588,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":56,"height":64,"frame_count":1,"size":1600,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":84,"height":96,"frame_count":1,"size":2342,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":84,"height":96,"frame_count":1,"size":2602,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":112,"height":128,"frame_count":1,"size":3038,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":112,"height":128,"frame_count":1,"size":3570,"format":"WEBP"}]}}},{"id":"60f11a7d7affbddfe71361ed","name":"forsenLaughingAtYou","flags":0,"timestamp":1674313510772,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60f11a7d7affbddfe71361ed","name":"forsenLaughingAtYou","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aeaf804b1ea4526d77e0f9","username":"wdeweisheim","display_name":"WDEWEISHEIM","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/55de63ea-64ab-48b4-89e2-1864ce194b88-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f11a7d7affbddfe71361ed","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":72,"size":22427,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":72,"size":50854,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":72,"size":46130,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":72,"size":109892,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":72,"size":186096,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":72,"size":83298,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":72,"size":216466,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":72,"size":133543,"format":"AVIF"}]}}},{"id":"60ae89c64b1ea4526d9244b5","name":"ABDULpls","flags":0,"timestamp":1674313511041,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae89c64b1ea4526d9244b5","name":"ABDULpls","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6330b76d8c374fc092ced2be","username":"abdulhd","display_name":"AbdulHD","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ceb3037e-d440-46c0-9f6e-6278000a7383-profile_image-70x70.png","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae89c64b1ea4526d9244b5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":7784,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":9624,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":15891,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":21610,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":35388,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":25019,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":34382,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":36342,"format":"WEBP"}]}}},{"id":"603ea168284626000d068881","name":"KKrikey","flags":0,"timestamp":1674326030726,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"603ea168284626000d068881","name":"KKrikey","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"603cac0896832ffa78c463e1","username":"rupusen","display_name":"rupusen","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/998f01ae-def8-11e9-b95c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603ea168284626000d068881","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1437,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1134,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3038,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2922,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5300,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4733,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7864,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7037,"format":"AVIF"}]}}},{"id":"6108e194569a3002abab0223","name":"forsenLevel","flags":0,"timestamp":1674399940611,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6108e194569a3002abab0223","name":"forsenLevel","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6118deac50795b4ba00cee36","username":"seloxyyz","display_name":"seloxyyz","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6108e194569a3002abab0223","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1139,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":870,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1892,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1858,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2743,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2984,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3414,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4116,"format":"WEBP"}]}}},{"id":"603cb9fd73d7a5001441f9b4","name":"Pepepains","flags":0,"timestamp":1674399940881,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cb9fd73d7a5001441f9b4","name":"Pepepains","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb9fd73d7a5001441f9b4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1274,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1110,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2460,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2620,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3780,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4456,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5114,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6542,"format":"WEBP"}]}}},{"id":"60ae31deaee2aa5538d2971c","name":"ppHop","flags":0,"timestamp":1674399941176,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae31deaee2aa5538d2971c","name":"ppHop","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae31deaee2aa5538d2971c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":39,"size":7809,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":39,"size":11774,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":39,"size":9466,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":39,"size":19834,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":39,"size":14131,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":39,"size":28090,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":39,"size":16044,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":39,"size":28138,"format":"WEBP"}]}}},{"id":"60b0e3cb7500a64f7c0ba32d","name":"peepoPizza","flags":0,"timestamp":1674477087372,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b0e3cb7500a64f7c0ba32d","name":"peepoPizza","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b0ca28b254a5e16b9e2db4","username":"khorsow","display_name":"khorsow","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/557100ee-5aa2-4b00-960c-8e734d471c5c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b0e3cb7500a64f7c0ba32d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":3509,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":4680,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":5808,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":10354,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":8747,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":16606,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":12610,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":18720,"format":"WEBP"}]}}},{"id":"61ebede31a1b2a6e7324d897","name":"Life","flags":0,"timestamp":1674486348937,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61ebede31a1b2a6e7324d897","name":"Life","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61ebede31a1b2a6e7324d897","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1131,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":686,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2311,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1888,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3629,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3400,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5306,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5834,"format":"WEBP"}]}}},{"id":"62ce92d97025c7defe8c9fd2","name":"DankL","flags":0,"timestamp":1674486349486,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62ce92d97025c7defe8c9fd2","name":"DankL","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"611d40d4e8715718f9917602","username":"the_balla_koala","display_name":"The_Balla_Koala","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/the_balla_koala-profile_image-1ffb68433553158f-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62ce92d97025c7defe8c9fd2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":138,"size":24919,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":138,"size":128898,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":138,"size":46671,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":138,"size":291590,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":138,"size":74908,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":138,"size":467016,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":138,"size":110255,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":138,"size":630368,"format":"WEBP"}]}}},{"id":"6169fc05c52da56cd4908d79","name":"weirdPaper","flags":0,"timestamp":1674572779250,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6169fc05c52da56cd4908d79","name":"weirdPaper","flags":0,"tags":["weird","paper","monka","pepe","pepega","weirdchamp"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61465bf80969108b671966fd","username":"eiti3","display_name":"Eiti3","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9c658e7a-a6b0-4efc-be37-c67816639f15-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6169fc05c52da56cd4908d79","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":1432,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1122,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":2948,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":2986,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":4468,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":5270,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":6148,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":8192,"format":"WEBP"}]}}},{"id":"60ae50320e35477634a5b5a0","name":"PauseMan","flags":0,"timestamp":1674572779522,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae50320e35477634a5b5a0","name":"PauseMan","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae5fb65d6c7fc0fa5b1e2c","username":"leekroom","display_name":"leekroom","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fb847501-0636-4928-a577-3a7ea501b87c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae50320e35477634a5b5a0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1174,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":870,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1980,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1914,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2954,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3158,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3624,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3758,"format":"WEBP"}]}}},{"id":"638359acbf3af4e79c91c521","name":"HUH","flags":0,"timestamp":1674659178792,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"638359acbf3af4e79c91c521","name":"apolloHUH","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/638359acbf3af4e79c91c521","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":1954,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1242,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":5744,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":2240,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":3314,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":10866,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":4497,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":17574,"format":"WEBP"}]}}},{"id":"60b248477e6072867ba50758","name":"UHM","flags":0,"timestamp":1674659179061,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b248477e6072867ba50758","name":"Uhm","flags":0,"tags":["uhm","awkward","huh","bruh"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60a7c5c1ad0a5c63ef23bf5d","username":"was1max","display_name":"was1max","avatar_url":"//cdn.7tv.app/pp/60a7c5c1ad0a5c63ef23bf5d/72a8daff0f3744c0aa5184eb424ac711","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b248477e6072867ba50758","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":128,"size":23451,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":128,"size":85412,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":128,"size":46658,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":128,"size":171628,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":128,"size":90571,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":128,"size":263398,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":128,"size":129145,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":128,"size":286200,"format":"WEBP"}]}}},{"id":"60420e7677137b000de9e677","name":"PepeS","flags":0,"timestamp":1674659179352,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60420e7677137b000de9e677","name":"pepeS","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60420e7677137b000de9e677","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4882,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":9210,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":6684,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":14982,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":9059,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":21934,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":9078,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":22910,"format":"WEBP"}]}}},{"id":"60a7c6d74d83ca509fc737a2","name":"docArrive","flags":0,"timestamp":1674745579169,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60a7c6d74d83ca509fc737a2","name":"docArrive","flags":0,"tags":["docing","docarrive","docleave","doc"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60a7c5c1ad0a5c63ef23bf5d","username":"was1max","display_name":"was1max","avatar_url":"//cdn.7tv.app/pp/60a7c5c1ad0a5c63ef23bf5d/72a8daff0f3744c0aa5184eb424ac711","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60a7c6d74d83ca509fc737a2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":153,"size":51855,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":153,"size":112968,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":153,"size":139033,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":153,"size":257016,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":153,"size":224032,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":153,"size":417376,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":153,"size":335565,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":153,"size":456610,"format":"WEBP"}]}}},{"id":"62da8d42c9fe72853564b4f8","name":"DIESOFCRINGE","flags":0,"timestamp":1674745579443,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62da8d42c9fe72853564b4f8","name":"DIESOFCRINGE","flags":0,"tags":["meow","cat","diesofcringe","kitten"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62da8d42c9fe72853564b4f8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":55,"height":32,"frame_count":120,"size":44573,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":55,"height":32,"frame_count":120,"size":145042,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":110,"height":64,"frame_count":120,"size":99668,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":110,"height":64,"frame_count":120,"size":313876,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":165,"height":96,"frame_count":120,"size":160871,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":165,"height":96,"frame_count":120,"size":493794,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":220,"height":128,"frame_count":120,"size":229565,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":220,"height":128,"frame_count":120,"size":692496,"format":"WEBP"}]}}},{"id":"60af206912d77014919c5ba6","name":"Gondola2","flags":0,"timestamp":1674747679380,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60af206912d77014919c5ba6","name":"gondola2","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60aebde86cfcffe15fea447c","username":"s4tisfaction_","display_name":"s4tisfaction_","avatar_url":"//cdn.7tv.app/pp/60aebde86cfcffe15fea447c/f42ff54ebf7a47f5912b7aaa39af7287","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af206912d77014919c5ba6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":36,"size":12110,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":36,"size":23506,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":36,"size":24315,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":36,"size":49578,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":36,"size":36969,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":36,"size":77974,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":36,"size":56414,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":36,"size":90862,"format":"WEBP"}]}}},{"id":"60a551839485e7cf2f683cd2","name":"DonkPls","flags":0,"timestamp":1674832009007,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60a551839485e7cf2f683cd2","name":"DonkPls","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"609e2e159aa3ab64eb6a5129","username":"bigmeg_","display_name":"Bigmeg_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/70ffe2a1-8246-4328-9fde-4e5463d63616-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60a551839485e7cf2f683cd2","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":10,"size":10976,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":10,"size":10504,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":10,"size":20927,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":10,"size":24554,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":10,"size":40340,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":10,"size":34399,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":10,"size":46778,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":10,"size":55060,"format":"WEBP"}]}}},{"id":"6230f826fe73af690d656eac","name":"Plotge","flags":0,"timestamp":1674832009265,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6230f826fe73af690d656eac","name":"Plotge","flags":0,"tags":["gambage","suskage","sus","plotting","plot"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"614944771eb7078240528ea8","username":"esuesuesuuu","display_name":"EsuEsuEsuuu","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/67daa8bb-e4a8-4cbe-8c36-4fb4b43b9728-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6230f826fe73af690d656eac","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":7214,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4834,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":16332,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":8867,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":15138,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":26800,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":24553,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":30886,"format":"WEBP"}]}}},{"id":"619209bc17e4d50afc0d9619","name":"HandsUp","flags":0,"timestamp":1674834140721,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"619209bc17e4d50afc0d9619","name":"HandsUp","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aebf0ae90f445e43b37fe5","username":"prog0ldfish","display_name":"ProG0ldfish","avatar_url":"//cdn.7tv.app/user/60aebf0ae90f445e43b37fe5/av_6353ef0d7f642dee0e30c1f4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619209bc17e4d50afc0d9619","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":38,"height":32,"frame_count":1,"size":1496,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":38,"height":32,"frame_count":1,"size":1216,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":76,"height":64,"frame_count":1,"size":3178,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":76,"height":64,"frame_count":1,"size":2920,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":114,"height":96,"frame_count":1,"size":5016,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":114,"height":96,"frame_count":1,"size":5324,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":152,"height":128,"frame_count":1,"size":7122,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":152,"height":128,"frame_count":1,"size":8520,"format":"WEBP"}]}}},{"id":"61808bf4c632476d20d0c7c0","name":"bruhSit","flags":0,"timestamp":1674843292115,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61808bf4c632476d20d0c7c0","name":"BRUHSIT","flags":0,"tags":["sit","bruh"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60f1507dd8c44ac3f3f511c8","username":"igor_mec","display_name":"Igor_mec","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/60e7d159-c116-4ce8-b245-2456b1a66554-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61808bf4c632476d20d0c7c0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1472,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1148,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2813,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2686,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4309,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4552,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5642,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6524,"format":"WEBP"}]}}},{"id":"6133b422d6b0df560a6525b2","name":"TakingNotes","flags":1,"timestamp":1674918408777,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6133b422d6b0df560a6525b2","name":"TakingNotes","flags":256,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6133b422d6b0df560a6525b2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":3863,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":6504,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":5813,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":13114,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":9337,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":20634,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":12915,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":22472,"format":"WEBP"}]}}},{"id":"638767f24cc489ef45239272","name":"peepoShy","flags":0,"timestamp":1674918409164,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"638767f24cc489ef45239272","name":"peepoShy","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/638767f24cc489ef45239272","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":4347,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":1906,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":6219,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":28146,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":8577,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":52762,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":10997,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":74752,"format":"WEBP"}]}}},{"id":"615fc15e03c9e8ba70eb7d61","name":"GachiPls","flags":0,"timestamp":1674918409425,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"615fc15e03c9e8ba70eb7d61","name":"GachiPls","flags":0,"tags":["gachi","kazuya","gachipls"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60afe7defd9839f62d6c87ea","username":"onyxclockwork","display_name":"onyxclockwork","avatar_url":"//cdn.7tv.app/pp/60afe7defd9839f62d6c87ea/cf5aa2f1219646fabb37425c0eeefd96","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/615fc15e03c9e8ba70eb7d61","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":30,"size":18603,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":30,"size":28226,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":30,"size":41377,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":30,"size":67690,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":30,"size":70686,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":30,"size":117788,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":30,"size":104365,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":30,"size":151586,"format":"WEBP"}]}}},{"id":"62dbd0fa0a430aad0143c1f4","name":"Vacatime","flags":0,"timestamp":1674936736757,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"62dbd0fa0a430aad0143c1f4","name":"Vacatime","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62dbd0fa0a430aad0143c1f4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1484,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1072,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3090,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2654,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4618,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4482,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6681,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6812,"format":"WEBP"}]}}},{"id":"6192d816d34608492cc36ef6","name":"Smadging","flags":0,"timestamp":1675004839676,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6192d816d34608492cc36ef6","name":"Smadging","flags":0,"tags":["smadge","madge","cry","chatting"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b69a59fb0dd24047d466cc","username":"wokaa","display_name":"woKaa","avatar_url":"//cdn.7tv.app/pp/60b69a59fb0dd24047d466cc/88c94384e4404d329a68cf5f54245a0c","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6192d816d34608492cc36ef6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4103,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":5362,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":7561,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":12872,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":11870,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":20282,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":20109,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":24066,"format":"WEBP"}]}}},{"id":"60bc8bb7824feec0de8f94cc","name":"stopbeingMean","flags":0,"timestamp":1675004842346,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60bc8bb7824feec0de8f94cc","name":"stopbeingMean","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b64822f48dd943d523a741","username":"bubbles_2","display_name":"bubbles_2","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38be6581-853f-4c3c-ae3a-efd27f6101e5-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bc8bb7824feec0de8f94cc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1286,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":894,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2334,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2122,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3486,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3448,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4400,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3926,"format":"WEBP"}]}}},{"id":"61e2c53f095be332e3475ad4","name":"EatTheMinus","flags":0,"timestamp":1675004843753,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61e2c53f095be332e3475ad4","name":"EatTheMinus","flags":0,"tags":["nymn","apollo","minus","yabbe"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e2c53f095be332e3475ad4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":47,"height":32,"frame_count":75,"size":22588,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":47,"height":32,"frame_count":75,"size":63402,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":94,"height":64,"frame_count":75,"size":56548,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":94,"height":64,"frame_count":75,"size":143854,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":141,"height":96,"frame_count":75,"size":101350,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":141,"height":96,"frame_count":75,"size":230886,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":188,"height":128,"frame_count":75,"size":155320,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":188,"height":128,"frame_count":75,"size":166116,"format":"WEBP"}]}}},{"id":"60ae84eb4b1ea4526d5bc117","name":"4WeirdBusiness","flags":0,"timestamp":1675031853799,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60ae84eb4b1ea4526d5bc117","name":"4WeirdBusiness","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"63c2e401219a2920cb344dd7","username":"cnys_","display_name":"Cnys_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7bcc5b32-99e9-41ab-a8af-e03781a6f20c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae84eb4b1ea4526d5bc117","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1260,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":1148,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":2331,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":2742,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":3453,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":4516,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":4363,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":6178,"format":"WEBP"}]}}},{"id":"60af9bd160e24df01a93bcdd","name":"ILUVU","flags":0,"timestamp":1675091238922,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60af9bd160e24df01a93bcdd","name":"ILUVU","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60a536d1ac08622846bced71","username":"marcfryd_0","display_name":"marcfryd_0","avatar_url":"//cdn.7tv.app/user/60a536d1ac08622846bced71/av_63537c8f28e6aaaea2bb599e/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","631ef5ea03e9beb96f849a7e","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af9bd160e24df01a93bcdd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":5379,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":10808,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":10437,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":22896,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":16722,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":36670,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":24566,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":39676,"format":"WEBP"}]}}},{"id":"619002c1b1eb03daac7d997d","name":"PoroHappy","flags":0,"timestamp":1675091239188,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"619002c1b1eb03daac7d997d","name":"PoroHappy","flags":0,"tags":["poro","porosad","league","lol"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60879d10fcf1f9923f6e1573","username":"somso2e","display_name":"Somso2e","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7291e0ba-abe4-4928-9951-6becee40fb61-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619002c1b1eb03daac7d997d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1374,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1264,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3030,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3518,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5069,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6276,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7650,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":9780,"format":"WEBP"}]}}},{"id":"60db66aa9a9fbb6acd8351c1","name":"4Love","flags":0,"timestamp":1675101761339,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60db66aa9a9fbb6acd8351c1","name":"4Love","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b1b50be67fbb9dd7195bba","username":"zomballr","display_name":"zomballr","avatar_url":"//cdn.7tv.app/user/60b1b50be67fbb9dd7195bba/av_637eac05d85f3c9574d6e959/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60db66aa9a9fbb6acd8351c1","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1160,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1421,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2595,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2656,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3720,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4308,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6100,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4946,"format":"AVIF"}]}}},{"id":"60714545dcae02001b44e527","name":"MaN","flags":0,"timestamp":1675177668772,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60714545dcae02001b44e527","name":"MaN","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6071266ebde0639989dc5150","username":"quinndt","display_name":"QuinnDT","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8dd6c04f-804b-4abe-a1a7-d040b45f1cc0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60714545dcae02001b44e527","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":26,"height":32,"frame_count":1,"size":1014,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":26,"height":32,"frame_count":1,"size":1276,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":52,"height":64,"frame_count":1,"size":2514,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":52,"height":64,"frame_count":1,"size":2632,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":78,"height":96,"frame_count":1,"size":4818,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":78,"height":96,"frame_count":1,"size":4132,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":104,"height":128,"frame_count":1,"size":6337,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":104,"height":128,"frame_count":1,"size":7444,"format":"WEBP"}]}}},{"id":"603cb8be73d7a5001441f9ad","name":"arnoldHalt","flags":0,"timestamp":1675177669080,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cb8be73d7a5001441f9ad","name":"arnoldHalt","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603ca71d96832ffa78bd7e2c","username":"grakanutyun","display_name":"grakanutyun","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/dbdc9198-def8-11e9-8681-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb8be73d7a5001441f9ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1421,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1218,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3010,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3072,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4752,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5424,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6974,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7920,"format":"WEBP"}]}}},{"id":"615321b443b2d9da0d32d157","name":"DankWave","flags":0,"timestamp":1675177669372,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"615321b443b2d9da0d32d157","name":"dankWave","flags":0,"tags":["feelsdankman","waving","cute"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae2af4aee2aa5538ab2144","username":"sunred_","display_name":"SunRed_","avatar_url":"//cdn.7tv.app/pp/60ae2af4aee2aa5538ab2144/acc28924022046e3b790ccaf4c7b4c53","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/615321b443b2d9da0d32d157","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4264,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":4854,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":6899,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":8902,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":11049,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":14898,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":14943,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":15610,"format":"WEBP"}]}}},{"id":"60cfa860ca263e7ca4de398a","name":"AREYOUAGIRL","flags":0,"timestamp":1675199071943,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60cfa860ca263e7ca4de398a","name":"AREYOUAGIRL","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603c7fca96832ffa788a5f14","username":"hyruverse","display_name":"hyruverse","avatar_url":"//cdn.7tv.app/pp/603c7fca96832ffa788a5f14/2ed3bd237882444ebccf38ae918e8df6","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60cfa860ca263e7ca4de398a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":56,"size":19554,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":56,"size":41322,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":56,"size":43570,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":56,"size":85620,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":56,"size":76004,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":56,"size":135318,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":56,"size":110786,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":56,"size":117804,"format":"WEBP"}]}}},{"id":"60aea4074b1ea4526d3c97a9","name":"BOOBA","flags":0,"timestamp":1675264098810,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aea4074b1ea4526d3c97a9","name":"BOOBA","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae99954b1ea4526d8ac75b","username":"evilmessy","display_name":"evilmessy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/aaec06da-90ff-46e4-9dfd-ec57221cd405-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aea4074b1ea4526d3c97a9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":55,"size":16291,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":55,"size":51756,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":55,"size":35408,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":55,"size":107490,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":55,"size":61335,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":55,"size":172450,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":55,"size":101262,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":55,"size":195802,"format":"WEBP"}]}}},{"id":"62293ad3b027edd02c8c02ca","name":"kek","flags":0,"timestamp":1675264099131,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62293ad3b027edd02c8c02ca","name":"kek","flags":0,"tags":["kekw","tyler1","league","clm","erobbsbrother","lol"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60bf6023a396a2421e53c937","username":"aanglosaxon","display_name":"aanglosaxon","avatar_url":"//cdn.7tv.app/user/60bf6023a396a2421e53c937/av_6530ad1f1ef5bf2c0c17f934/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62293ad3b027edd02c8c02ca","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":948,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":662,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1650,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1646,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2420,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2652,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3479,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4212,"format":"WEBP"}]}}},{"id":"60ae4c0d5d3fdae583dd938b","name":"Swagging","flags":0,"timestamp":1675264099447,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae4c0d5d3fdae583dd938b","name":"Swagging","flags":0,"tags":["fifteen","ellie","swag","tlou","emoney"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae4b445d3fdae583d20e9a","username":"ethantp","display_name":"ethantp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/617473d7-7627-4bd6-befa-a2ff489d8daa-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae4c0d5d3fdae583dd938b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":232,"size":48943,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":232,"size":169688,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":232,"size":202273,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":232,"size":423008,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":232,"size":380776,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":232,"size":715950,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":232,"size":598520,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":232,"size":549490,"format":"WEBP"}]}}},{"id":"61a91b7315b3ff4a5bb8e72b","name":"Cooking","flags":0,"timestamp":1675276842755,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61a91b7315b3ff4a5bb8e72b","name":"Cooking","flags":0,"tags":["chatting","forsen"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60b0faaa8fb21a01bc3c0385","username":"enzo_supercraftz","display_name":"Enzo_SuperCraftZ","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61a91b7315b3ff4a5bb8e72b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4858,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":5854,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":9437,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":13746,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":15511,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":22592,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":22254,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":27510,"format":"WEBP"}]}}},{"id":"634379257361e04bb26bdb49","name":"coupleofidiots","flags":0,"timestamp":1675284258531,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"634379257361e04bb26bdb49","name":"coupleofidiots","flags":0,"tags":["yabbe","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60c92a0b043eea6bc36384fe","username":"mrbarnabass","display_name":"MrBarnabass","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c9130163-31c0-4836-8894-c189b312526a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/634379257361e04bb26bdb49","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":74,"height":32,"frame_count":1,"size":1807,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":74,"height":32,"frame_count":1,"size":3682,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":148,"height":64,"frame_count":1,"size":3828,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":148,"height":64,"frame_count":1,"size":11828,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":222,"height":96,"frame_count":1,"size":6416,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":222,"height":96,"frame_count":1,"size":24334,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":296,"height":128,"frame_count":1,"size":9085,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":296,"height":128,"frame_count":1,"size":41298,"format":"WEBP"}]}}},{"id":"60fb428d5b7deb3de031df64","name":"AlienLag","flags":0,"timestamp":1675350515194,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60fb428d5b7deb3de031df64","name":"AlienLag","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60dfc16ddd6a810dd453c336","username":"jhonxto","display_name":"jhonxto","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d4531555-a4e7-40ae-9afb-77fb66081418-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60fb428d5b7deb3de031df64","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":326,"size":41028,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":326,"size":253526,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":326,"size":89644,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":326,"size":619134,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":326,"size":156174,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":326,"size":1113734,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":326,"size":279551,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":326,"size":1396534,"format":"WEBP"}]}}},{"id":"609ee21a326f0aaa859f534f","name":"peepoS","flags":0,"timestamp":1675350515916,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"609ee21a326f0aaa859f534f","name":"peepoS","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"609ede7b4c18609a1d94c5ae","username":"yoim5th","display_name":"yoim5th","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/81c11127-ffa9-4b47-a5b0-7e602a998aae-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/609ee21a326f0aaa859f534f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":3352,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":3062,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":5548,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":6222,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":8074,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":9792,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":11556,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":11130,"format":"WEBP"}]}}},{"id":"61360e15b7ef1a05d0a2109c","name":"Love0","flags":1,"timestamp":1675350635011,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61360e15b7ef1a05d0a2109c","name":"Love","flags":256,"tags":["xqcl","heart"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603c624796832ffa78678fd7","username":"juanjunki_6969","display_name":"JuanJunki_6969","avatar_url":"//cdn.7tv.app/user/603c624796832ffa78678fd7/av_64df2a212356a20967ed54d9/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61360e15b7ef1a05d0a2109c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":827,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":528,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1058,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1238,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":1622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1502,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":1919,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":2202,"format":"WEBP"}]}}},{"id":"63c1df8f6c05867c0ce8ace0","name":"vibE","flags":0,"timestamp":1675435783919,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c1df8f6c05867c0ce8ace0","name":"vibE","flags":0,"tags":["vibe","moon","forsen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c1df8f6c05867c0ce8ace0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":34,"size":16373,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":34,"size":18906,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":34,"size":31977,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":34,"size":36908,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":34,"size":50006,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":34,"size":58078,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":34,"size":66579,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":34,"size":68742,"format":"WEBP"}]}}},{"id":"603cc3c62c7b4500143b46c5","name":"hackerCD","flags":0,"timestamp":1675436915491,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cc3c62c7b4500143b46c5","name":"hackerCD","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603cb87696832ffa78d57767","username":"obscurelambda","display_name":"obscurelambda","avatar_url":"//cdn.7tv.app/user/603cb87696832ffa78d57767/av_647a68a9804ca09ebf23e890/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cc3c62c7b4500143b46c5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":102,"size":48058,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":102,"size":99406,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":102,"size":124244,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":102,"size":231364,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":102,"size":205680,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":102,"size":387734,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":102,"size":448616,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":102,"size":559896,"format":"WEBP"}]}}},{"id":"610ff4353f3e99ddb4628023","name":"donkDriving","flags":0,"timestamp":1675439678346,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"610ff4353f3e99ddb4628023","name":"DonkDriving","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60aebf0ae90f445e43b37fe5","username":"prog0ldfish","display_name":"ProG0ldfish","avatar_url":"//cdn.7tv.app/user/60aebf0ae90f445e43b37fe5/av_6353ef0d7f642dee0e30c1f4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610ff4353f3e99ddb4628023","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":3412,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":2248,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":5410,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":4916,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":7803,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":8056,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":10429,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":9960,"format":"WEBP"}]}}},{"id":"627e843e49607a2d9d9b7589","name":"RightNow","flags":1,"timestamp":1675523345571,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"627e843e49607a2d9d9b7589","name":"RightNow","flags":256,"tags":["rightnow","time"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"622a515dcb65b2eb65c03e9d","username":"oldschoolling","display_name":"OldSchoolling","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/2ac9c0a4-850e-4d8c-a5d6-fd555e1e230f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/627e843e49607a2d9d9b7589","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":2,"size":2637,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":2,"size":1480,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":2,"size":2820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":2,"size":3532,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":2,"size":4334,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":2,"size":4648,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":2,"size":5391,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":2,"size":5256,"format":"WEBP"}]}}},{"id":"62a27a7fb12d7075e259f113","name":"Concerned","flags":0,"timestamp":1675523345836,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62a27a7fb12d7075e259f113","name":"Concerned","flags":0,"tags":["clueless","aware"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b0faaa8fb21a01bc3c0385","username":"enzo_supercraftz","display_name":"Enzo_SuperCraftZ","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62a27a7fb12d7075e259f113","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1225,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1116,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2242,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2596,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4470,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3608,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5018,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6826,"format":"WEBP"}]}}},{"id":"60ae99233c27a8b79c7fcb73","name":"Madge","flags":0,"timestamp":1675523408005,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60ae99233c27a8b79c7fcb73","name":"Madge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae8f064b1ea4526dd12b2e","username":"elpicos","display_name":"ElPicos","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/15e7e7f7-ae01-47b5-b892-52d46ad203c2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae99233c27a8b79c7fcb73","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1333,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":960,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2613,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2418,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3876,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5158,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5446,"format":"WEBP"}]}}},{"id":"611687c2446a415801b1b55c","name":"XiJinNymN","flags":0,"timestamp":1675546373163,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"611687c2446a415801b1b55c","name":"XiJinNymN","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3c29b2ecb015051f8f9a","username":"nymn","display_name":"NymN","avatar_url":"//cdn.7tv.app/pp/60ae3c29b2ecb015051f8f9a/71f269555aeb44c29100cae8aa59b56b","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/611687c2446a415801b1b55c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1146,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":808,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2079,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1904,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2918,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2936,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4086,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4548,"format":"WEBP"}]}}},{"id":"62388548271ae0e02d721924","name":"based0","flags":1,"timestamp":1675609745299,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62388548271ae0e02d721924","name":"based0","flags":256,"tags":["forsenbased","ezz"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62388548271ae0e02d721924","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":93,"size":20012,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":93,"size":45368,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":93,"size":36664,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":93,"size":89190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":93,"size":65514,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":93,"size":142074,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":93,"size":89242,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":93,"size":127104,"format":"WEBP"}]}}},{"id":"631210ee113e0e8575d2d130","name":"SoCute","flags":0,"timestamp":1675609745576,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"631210ee113e0e8575d2d130","name":"SoCute","flags":0,"tags":["peepohappy","socute","peepoblush"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60af6d382c36aae19e1bfbd2","username":"ashsii","display_name":"ashsii","avatar_url":"//cdn.7tv.app/user/60af6d382c36aae19e1bfbd2/av_6397c58e9b8dbe094e96d2bf/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","60b3f1ea886e63449c5263b1","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/631210ee113e0e8575d2d130","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":38,"height":32,"frame_count":12,"size":10451,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":38,"height":32,"frame_count":12,"size":11428,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":76,"height":64,"frame_count":12,"size":20669,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":76,"height":64,"frame_count":12,"size":24038,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":114,"height":96,"frame_count":12,"size":30994,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":114,"height":96,"frame_count":12,"size":37484,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":152,"height":128,"frame_count":12,"size":42079,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":152,"height":128,"frame_count":12,"size":51726,"format":"WEBP"}]}}},{"id":"60ae6b4486fc40d488d0b324","name":"AlienGathering","flags":0,"timestamp":1675609745858,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae6b4486fc40d488d0b324","name":"AlienGathering","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae49350e35477634486602","username":"justrogan","display_name":"JustRogan","avatar_url":"//cdn.7tv.app/pp/60ae49350e35477634486602/88d6e3c4265f4be0a452c812c146da50","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae6b4486fc40d488d0b324","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":20,"frame_count":300,"size":684902,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":20,"frame_count":300,"size":835760,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":40,"frame_count":300,"size":1535437,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":40,"frame_count":300,"size":2102086,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":60,"frame_count":300,"size":2451790,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":60,"frame_count":300,"size":3654026,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":80,"frame_count":300,"size":3052819,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":80,"frame_count":300,"size":4897114,"format":"WEBP"}]}}},{"id":"62f9c8cf00630d5b2acd66d1","name":"peepoTalk","flags":0,"timestamp":1675620584046,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"62f9c8cf00630d5b2acd66d1","name":"peepoTalk","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603c7fca96832ffa788a5f14","username":"hyruverse","display_name":"hyruverse","avatar_url":"//cdn.7tv.app/pp/603c7fca96832ffa788a5f14/2ed3bd237882444ebccf38ae918e8df6","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62f9c8cf00630d5b2acd66d1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":3981,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":4150,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":5510,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":7752,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":7501,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":11656,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":9864,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":15742,"format":"WEBP"}]}}},{"id":"6237279d73f35ccbda40a64e","name":"MeowwartsSchool","flags":0,"timestamp":1675690682297,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6237279d73f35ccbda40a64e","name":"MeowwartsSchool","flags":0,"tags":["hogwartz","kitten","harrypotter"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61599595dc267a3441816d24","username":"little_meowisek_xd","display_name":"little_meowisek_xd","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3db44c26-6d62-45ba-a3f6-7d8bd8a588ea-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6237279d73f35ccbda40a64e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":131,"size":33109,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":131,"size":109850,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":131,"size":111918,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":131,"size":284426,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":131,"size":215251,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":131,"size":477274,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":131,"size":397595,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":131,"size":413102,"format":"WEBP"}]}}},{"id":"61ffb7775d4f1907669e8159","name":"ge0","flags":1,"timestamp":1675696175521,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61ffb7775d4f1907669e8159","name":"ge0","flags":256,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61ffb7775d4f1907669e8159","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":908,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1062,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2086,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1944,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2961,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3548,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3705,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4880,"format":"WEBP"}]}}},{"id":"60b2cce90616dd6156d14fc0","name":"Latege","flags":0,"timestamp":1675696175811,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b2cce90616dd6156d14fc0","name":"Latege","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b1122ec924cff187e0ab90","username":"airoh_","display_name":"Airoh_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6159e77c-24e6-45b4-ac30-78449df5da44-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b2cce90616dd6156d14fc0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":11,"size":5258,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":11,"size":10236,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":11,"size":9463,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":11,"size":23732,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":11,"size":17115,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":11,"size":40690,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":11,"size":27557,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":11,"size":46294,"format":"WEBP"}]}}},{"id":"6373c12922efe4715fbc7e7c","name":"notListening","flags":0,"timestamp":1675782575536,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6373c12922efe4715fbc7e7c","name":"notListening","flags":0,"tags":["bateman","americanpsycho"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6373c12922efe4715fbc7e7c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":56,"height":32,"frame_count":162,"size":28838,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":56,"height":32,"frame_count":162,"size":103992,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":112,"height":64,"frame_count":162,"size":55396,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":112,"height":64,"frame_count":162,"size":240098,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":168,"height":96,"frame_count":162,"size":77302,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":168,"height":96,"frame_count":162,"size":374686,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":224,"height":128,"frame_count":162,"size":171422,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":224,"height":128,"frame_count":162,"size":565368,"format":"WEBP"}]}}},{"id":"60c6799b6184f8c1da5ed61f","name":"GAMING","flags":0,"timestamp":1675782575883,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60c6799b6184f8c1da5ed61f","name":"GAMING","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae5e109986a003497d2ea1","username":"grooot2","display_name":"Grooot2","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9c6a0eb2-8446-4259-8f04-e76de1277bfe-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60c6799b6184f8c1da5ed61f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":93595,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":158076,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":265934,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":385530,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":454481,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":656358,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":696275,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":728232,"format":"WEBP"}]}}},{"id":"61b8a304112f39cb68afc749","name":"areyoudonenymn","flags":0,"timestamp":1675782576175,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61b8a304112f39cb68afc749","name":"areyoudonenymn","flags":0,"tags":["nymn","apollo"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3cb1b2ecb0150521fa1f","username":"waterboiledpizza","display_name":"WaterBoiledPizza","avatar_url":"//cdn.7tv.app/user/60ae3cb1b2ecb0150521fa1f/av_652806843e9323c51e05082e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61b8a304112f39cb68afc749","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":56,"size":10931,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":56,"size":39536,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":56,"size":29841,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":56,"size":94258,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":56,"size":60470,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":56,"size":157072,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":56,"size":105658,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":56,"size":116146,"format":"WEBP"}]}}},{"id":"60afa6b412f90fadd60a7d9b","name":"peepoPog","flags":0,"timestamp":1675800079480,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60afa6b412f90fadd60a7d9b","name":"peepoPog","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60af971d12f90fadd6aa9ff8","username":"viscoito","display_name":"Viscoito","avatar_url":"//cdn.7tv.app/user/60af971d12f90fadd6aa9ff8/av_651d964e32b1db5b90eead40/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afa6b412f90fadd60a7d9b","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":902,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1207,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1944,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2078,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2931,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3484,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4186,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3644,"format":"AVIF"}]}}},{"id":"60420a8b77137b000de9e66e","name":"gachiHYPER","flags":0,"timestamp":1675869005474,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60420a8b77137b000de9e66e","name":"gachiHYPER","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60420a8b77137b000de9e66e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":60,"size":20764,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":60,"size":59696,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":60,"size":55912,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":60,"size":130846,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":60,"size":93655,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":60,"size":221462,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":60,"size":206156,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":60,"size":342528,"format":"WEBP"}]}}},{"id":"624f4c9e6bb22d4119fc81bc","name":"BREH","flags":0,"timestamp":1675869005762,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"624f4c9e6bb22d4119fc81bc","name":"BREH","flags":0,"tags":["breh","zoomer","bussin","bruh"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60af7b83a564afa26e9fd0eb","username":"happinson","display_name":"Happinson","avatar_url":"//cdn.7tv.app/user/60af7b83a564afa26e9fd0eb/av_657c054b604852811f0a6390/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/624f4c9e6bb22d4119fc81bc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1186,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":934,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2382,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2438,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3877,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4290,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6024,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7024,"format":"WEBP"}]}}},{"id":"60e1df02dac155e36624afaa","name":"Hmm","flags":0,"timestamp":1675869253176,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60e1df02dac155e36624afaa","name":"Hmm","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aebf0ae90f445e43b37fe5","username":"prog0ldfish","display_name":"ProG0ldfish","avatar_url":"//cdn.7tv.app/user/60aebf0ae90f445e43b37fe5/av_6353ef0d7f642dee0e30c1f4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e1df02dac155e36624afaa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1000,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":800,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1884,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1982,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2713,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3120,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3738,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4468,"format":"WEBP"}]}}},{"id":"60aec23d5174a619db1851ef","name":"3Heading","flags":0,"timestamp":1675955435285,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aec23d5174a619db1851ef","name":"3Heading","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aea31d229664e866695e3f","username":"psyclonetm","display_name":"PsycloneTM","avatar_url":"//cdn.7tv.app/pp/60aea31d229664e866695e3f/1a1bfe41c0144d46b0561b4cd9ae3c05","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aec23d5174a619db1851ef","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":113,"size":58435,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":113,"size":107386,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":113,"size":145460,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":113,"size":259440,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":113,"size":247714,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":113,"size":429106,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":113,"size":363568,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":113,"size":464842,"format":"WEBP"}]}}},{"id":"60ae4bc55d3fdae583d93f34","name":"NOPERS","flags":0,"timestamp":1675955435571,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae4bc55d3fdae583d93f34","name":"NOPERS","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae4b445d3fdae583d20e9a","username":"ethantp","display_name":"ethantp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/617473d7-7627-4bd6-befa-a2ff489d8daa-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae4bc55d3fdae583d93f34","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":32,"size":13211,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":32,"size":29904,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":32,"size":27646,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":32,"size":65946,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":32,"size":43112,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":32,"size":104246,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":32,"size":69624,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":32,"size":113414,"format":"WEBP"}]}}},{"id":"6042076f77137b000de9e666","name":"TRUEING","flags":0,"timestamp":1675955435843,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6042076f77137b000de9e666","name":"TRUEING","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b69d21f28060ef90d00ee2","username":"xenev","display_name":"xenev","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3ca44a21-d878-4ad1-9c58-820987264ac8-profile_image-70x70.png","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6042076f77137b000de9e666","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":99,"size":19116,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":99,"size":58728,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":99,"size":34731,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":99,"size":130106,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":99,"size":56414,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":99,"size":214518,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":99,"size":128876,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":99,"size":427014,"format":"WEBP"}]}}},{"id":"60ae4f175d3fdae583148348","name":"headBang","flags":0,"timestamp":1675966425810,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60ae4f175d3fdae583148348","name":"headBang","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6044f2cc86a556e0b0210e40","username":"hewooo","display_name":"hewooo","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/953ea757-de1f-43c8-b8ea-d6bac5e3233b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae4f175d3fdae583148348","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":7,"size":5842,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":7,"size":6392,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":7,"size":10537,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":7,"size":13350,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":7,"size":16365,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":7,"size":21556,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":7,"size":21131,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":7,"size":23496,"format":"WEBP"}]}}},{"id":"60ae839dea50f43c9ea4893d","name":"ThinkingAboutPoopernoodle","flags":0,"timestamp":1676041835960,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae839dea50f43c9ea4893d","name":"FeelsWowMan","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b561cc04283ab952bfd4e0","username":"on_a_stack","display_name":"On_a_stack","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1ed36b65-71c8-4eb2-a6a7-83ad2bb7566a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae839dea50f43c9ea4893d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1580,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":1174,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":3142,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":2998,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":4778,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":5096,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":6540,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":6948,"format":"WEBP"}]}}},{"id":"61932682b1eb03daac7df6aa","name":"docJAMMER","flags":0,"timestamp":1676041836247,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61932682b1eb03daac7df6aa","name":"docJAMMER","flags":0,"tags":["doc","jam"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6193177e17e4d50afc0db403","username":"samlr__","display_name":"SAMlR__","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ebd01158-8672-48fa-9ddd-a17a97521785-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61932682b1eb03daac7df6aa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":262,"size":164262,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":262,"size":250380,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":262,"size":400113,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":262,"size":580502,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":262,"size":662344,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":262,"size":984124,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":262,"size":960087,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":262,"size":1162954,"format":"WEBP"}]}}},{"id":"60bb0a06c2415f99b5f6ceb6","name":"OMEGALUOL","flags":0,"timestamp":1676128235275,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60bb0a06c2415f99b5f6ceb6","name":"OMEGALOOL","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b97c7626c484211d05b9d4","username":"porocutioner","display_name":"poroCUTIonEr","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/07282f8f-7bf4-4dcb-836f-edacd1eac9db-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bb0a06c2415f99b5f6ceb6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":5114,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":5036,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":10698,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":9163,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":13850,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":17200,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":18513,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":18172,"format":"WEBP"}]}}},{"id":"60ae4bb30e35477634610fda","name":"NODDERS","flags":0,"timestamp":1676128235688,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae4bb30e35477634610fda","name":"NODDERS","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae4b445d3fdae583d20e9a","username":"ethantp","display_name":"ethantp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/617473d7-7627-4bd6-befa-a2ff489d8daa-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae4bb30e35477634610fda","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":7970,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":14500,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":14370,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":32714,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":23591,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":52130,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":36896,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":54426,"format":"WEBP"}]}}},{"id":"63a9c3bbe2080789035383e7","name":"nymnStairs","flags":0,"timestamp":1676128236100,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63a9c3bbe2080789035383e7","name":"nymnStairs","flags":0,"tags":["nymn","stairs"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63a9c3bbe2080789035383e7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":66,"size":9840,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":66,"size":15408,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":66,"size":16814,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":66,"size":48010,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":66,"size":29076,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":66,"size":79368,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":66,"size":55735,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":66,"size":112894,"format":"WEBP"}]}}},{"id":"60ba145c31abfff37bd0d280","name":"Ratge","flags":0,"timestamp":1676128317596,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ba145c31abfff37bd0d280","name":"Ratge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae7907e52a54a8e3a2c668","username":"benjaminyvr","display_name":"benjaminyvr","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1a9471ce-122f-4ba4-963c-0e4260ad8c3c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ba145c31abfff37bd0d280","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":41,"height":32,"frame_count":1,"size":1636,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":41,"height":32,"frame_count":1,"size":1394,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":82,"height":64,"frame_count":1,"size":3294,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":82,"height":64,"frame_count":1,"size":3500,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":123,"height":96,"frame_count":1,"size":4887,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":123,"height":96,"frame_count":1,"size":5878,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":164,"height":128,"frame_count":1,"size":6491,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":164,"height":128,"frame_count":1,"size":7172,"format":"WEBP"}]}}},{"id":"60ae745fdc23eca68e4e0a3d","name":"SoyScream","flags":0,"timestamp":1676128518504,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ae745fdc23eca68e4e0a3d","name":"SoyScream","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae461c9986a003493358f3","username":"gaib_","display_name":"gaib_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ce7e4c60-a008-4e7e-8972-e905ffc54e71-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae745fdc23eca68e4e0a3d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":67,"size":27659,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":67,"size":66578,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":67,"size":64988,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":67,"size":149896,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":67,"size":116110,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":67,"size":243386,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":67,"size":172135,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":67,"size":255152,"format":"WEBP"}]}}},{"id":"60ae387cb2ecb0150505e235","name":"Tssk","flags":0,"timestamp":1676214665655,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae387cb2ecb0150505e235","name":"Tssk","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae387cb2ecb0150505e235","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":24,"size":13338,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":24,"size":20310,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":24,"size":25616,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":24,"size":40498,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":24,"size":39352,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":24,"size":63386,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":24,"size":53192,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":24,"size":72508,"format":"WEBP"}]}}},{"id":"63c55a52a5f1a56aa0e6ddd1","name":"BymN","flags":0,"timestamp":1676214665932,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63c55a52a5f1a56aa0e6ddd1","name":"BymN","flags":0,"tags":["mrbean","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae233e259ac5a73eafe07c","username":"fratroisk","display_name":"fratroisk","avatar_url":"//cdn.7tv.app/user/60ae233e259ac5a73eafe07c/av_6558d64ef95c3b0191d19b06/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c55a52a5f1a56aa0e6ddd1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":1,"size":1186,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":1,"size":1748,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":1,"size":2227,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":1,"size":5350,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":1,"size":3428,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":1,"size":10524,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":1,"size":4726,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":1,"size":17028,"format":"WEBP"}]}}},{"id":"613bf9aebe977eb5b436c816","name":"Modge","flags":0,"timestamp":1676214666230,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"613bf9aebe977eb5b436c816","name":"Modge","flags":0,"tags":["mods","modge","perma","sadge"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60aea31d229664e866695e3f","username":"psyclonetm","display_name":"PsycloneTM","avatar_url":"//cdn.7tv.app/pp/60aea31d229664e866695e3f/1a1bfe41c0144d46b0561b4cd9ae3c05","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613bf9aebe977eb5b436c816","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":13,"size":7292,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":13,"size":12780,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":13,"size":16181,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":13,"size":31620,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":13,"size":27429,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":13,"size":54910,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":13,"size":45672,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":13,"size":70426,"format":"WEBP"}]}}},{"id":"60af12e17e8706b572e5c326","name":"doctorWTF","flags":0,"timestamp":1676301065360,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60af12e17e8706b572e5c326","name":"doctorWTF","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60aeb19256f54d7a40627c3a","username":"nukro","display_name":"nukro","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/b91bdf41-79d9-4472-b606-5a2f2e9cca5d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af12e17e8706b572e5c326","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1320,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1504,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3298,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3087,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4762,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5740,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6659,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6928,"format":"WEBP"}]}}},{"id":"60faf6f74653f5d6c1c65a04","name":"donkiBonk","flags":0,"timestamp":1676301065809,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60faf6f74653f5d6c1c65a04","name":"donkiBonk","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f0577e48cde2fcc3e6eb12","username":"bopens1_reformed","display_name":"bopens1_reformed","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/75305d54-c7cc-40d1-bb9c-91fbe85943c7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60faf6f74653f5d6c1c65a04","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":4310,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":3934,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":7247,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":8590,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":11471,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":14876,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":16122,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":19280,"format":"WEBP"}]}}},{"id":"60af5b5135c50a77928212f3","name":"Sank","flags":0,"timestamp":1676301066116,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60af5b5135c50a77928212f3","name":"Sank","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae2a79259ac5a73ec9cd07","username":"aifanny","display_name":"Aifanny","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/410d4bae-a02b-464b-b250-678eb5e42ae4-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af5b5135c50a77928212f3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":10642,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":25,"size":20846,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":22232,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":41618,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":37747,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":63586,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":52689,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":70818,"format":"WEBP"}]}}},{"id":"63d937ccf74db58df4e60f87","name":"Confused","flags":0,"timestamp":1676387465316,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63d937ccf74db58df4e60f87","name":"Confused","flags":0,"tags":["what","pepe","peepo","cat","huh","clueless"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63d937ccf74db58df4e60f87","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":28,"height":32,"frame_count":189,"size":37712,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":28,"height":32,"frame_count":189,"size":84564,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":56,"height":64,"frame_count":189,"size":75554,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":56,"height":64,"frame_count":189,"size":157476,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":84,"height":96,"frame_count":189,"size":125790,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":84,"height":96,"frame_count":189,"size":246262,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":112,"height":128,"frame_count":189,"size":172713,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":112,"height":128,"frame_count":189,"size":308832,"format":"WEBP"}]}}},{"id":"60aec2196cfcffe15f4e4f93","name":"Prayge","flags":0,"timestamp":1676387465617,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aec2196cfcffe15f4e4f93","name":"Prayge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ac7c354ef7db1ec1e9b730","username":"neowav","display_name":"Neowav","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bf4df7d8-9342-48b4-90b6-30abb7d8dd40-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aec2196cfcffe15f4e4f93","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":936,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1289,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2443,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2278,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3650,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3896,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4926,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4944,"format":"WEBP"}]}}},{"id":"6325e45f55eabea21b003802","name":"Munchin","flags":0,"timestamp":1676387465891,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6325e45f55eabea21b003802","name":"Munchin","flags":0,"tags":["eating","rat","reaction"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61ec247ecc9507d24fd4a789","username":"gloft_0001","display_name":"Gloft_0001","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6325e45f55eabea21b003802","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":146,"size":35490,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":146,"size":118072,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":146,"size":86092,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":146,"size":239722,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":146,"size":156700,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":146,"size":338400,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":146,"size":251198,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":146,"size":443722,"format":"WEBP"}]}}},{"id":"63ebd1953eab12f5199c044a","name":"TAUNTED","flags":0,"timestamp":1676399071109,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"63ebd1953eab12f5199c044a","name":"TAUNTED","flags":0,"tags":["taunted","pepw","nymn","nymning","rage","taunt"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae759bdf5735e04acb69d9","username":"hotbear1110","display_name":"HotBear1110","avatar_url":"//cdn.7tv.app/pp/60ae759bdf5735e04acb69d9/80e2b49378c14dc6914fde8cb72fa673","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ebd1953eab12f5199c044a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":44,"height":32,"frame_count":90,"size":49479,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":44,"height":32,"frame_count":90,"size":77648,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":88,"height":64,"frame_count":90,"size":116204,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":88,"height":64,"frame_count":90,"size":182910,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":132,"height":96,"frame_count":90,"size":220668,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":132,"height":96,"frame_count":90,"size":286830,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":176,"height":128,"frame_count":90,"size":342018,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":176,"height":128,"frame_count":90,"size":370696,"format":"WEBP"}]}}},{"id":"63ebe1fc0cb254f7266f4af3","name":"NYMNING","flags":0,"timestamp":1676403380800,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"63ebe1fc0cb254f7266f4af3","name":"NYMNING","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ebe1fc0cb254f7266f4af3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":108,"size":44245,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":108,"size":69852,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":108,"size":110660,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":108,"size":143814,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":108,"size":260860,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":108,"size":222198,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":108,"size":546368,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":108,"size":328630,"format":"WEBP"}]}}},{"id":"619fffbbffa9aba101bb1bfc","name":"Looking","flags":0,"timestamp":1676473895278,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"619fffbbffa9aba101bb1bfc","name":"Looking","flags":0,"tags":["looking","sussy"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"611fc949e6a24615da9d21cf","username":"krewlex","display_name":"Krewlex","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/85d97663-2fa4-45b8-832a-6c2be6102a3c-profile_image-70x70.png","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619fffbbffa9aba101bb1bfc","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":702,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1108,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1914,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1790,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3136,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2954,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3929,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4736,"format":"WEBP"}]}}},{"id":"60ba5e80671673093a6274e1","name":"BBoomer","flags":0,"timestamp":1676473895596,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ba5e80671673093a6274e1","name":"BBoomer","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ba57b2cc31c8eade405cb4","username":"mflashr","display_name":"mFLASHr","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/2c2cd23c-c609-4589-b3b2-1e3525028a8d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ba5e80671673093a6274e1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":13,"size":8221,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":13,"size":12958,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":13,"size":18180,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":13,"size":32188,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":13,"size":30620,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":13,"size":56416,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":13,"size":46569,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":13,"size":67558,"format":"WEBP"}]}}},{"id":"60aeb2da5174a619db6cd0e7","name":"Gladge","flags":0,"timestamp":1676473895986,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aeb2da5174a619db6cd0e7","name":"Gladge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae9207ac03cad607d3980f","username":"onkel_jodok","display_name":"onkel_jodok","avatar_url":"//cdn.7tv.app/pp/60ae9207ac03cad607d3980f/a7bb23d0f0ad46bb8105768c642ce6a1","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeb2da5174a619db6cd0e7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1285,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":924,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2373,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2286,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3678,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3868,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4910,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5002,"format":"WEBP"}]}}},{"id":"63b70f6dc57736fec02f900c","name":"Donkborne","flags":0,"timestamp":1676560303152,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63b70f6dc57736fec02f900c","name":"Donkborne","flags":0,"tags":["feelsdonkman","bloodborne","souls","donk"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ff054ffbd646ea3b221dc9","username":"tunari__","display_name":"Tunari__","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bc530a7a-e04d-4765-a662-bb3efde482e2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b70f6dc57736fec02f900c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":1,"size":1727,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":1,"size":2532,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":1,"size":3913,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":1,"size":7758,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":1,"size":6692,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":1,"size":15524,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":1,"size":9680,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":1,"size":25298,"format":"WEBP"}]}}},{"id":"63eb71630cb254f7266f4044","name":"nymnBaited","flags":0,"timestamp":1676560317305,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63eb71630cb254f7266f4044","name":"nymnBaited","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63eb71630cb254f7266f4044","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":89,"size":20696,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":89,"size":37588,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":89,"size":39416,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":89,"size":67078,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":89,"size":62007,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":89,"size":97784,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":89,"size":156689,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":89,"size":131684,"format":"WEBP"}]}}},{"id":"60e4b66b73d5b443db31ed3b","name":"Deadlole","flags":0,"timestamp":1676560317611,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60e4b66b73d5b443db31ed3b","name":"Deadlole","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae653c9627f9aff4f5ccd1","username":"xoo_6119","display_name":"xoo_6119","avatar_url":"//cdn.7tv.app/user/60ae653c9627f9aff4f5ccd1/av_63ca0eccdedb49b24383ae5c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e4b66b73d5b443db31ed3b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":19,"size":4588,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":4432,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":19,"size":6294,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":18,"size":9408,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":19,"size":9410,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":18,"size":13844,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":19,"size":15059,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":18,"size":15260,"format":"WEBP"}]}}},{"id":"6349783bf614dc272b1f940b","name":"pl","flags":0,"timestamp":1676560317919,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6349783bf614dc272b1f940b","name":"pl","flags":0,"tags":["gurom","poland","okay","polska","nymn"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6349783bf614dc272b1f940b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1092,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1136,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1977,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2754,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2917,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4950,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3702,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7476,"format":"WEBP"}]}}},{"id":"60ccd826197108c5ca4c1169","name":"gekPls","flags":0,"timestamp":1676636843061,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60ccd826197108c5ca4c1169","name":"gekPls","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60a536d1ac08622846bced71","username":"marcfryd_0","display_name":"marcfryd_0","avatar_url":"//cdn.7tv.app/user/60a536d1ac08622846bced71/av_63537c8f28e6aaaea2bb599e/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","631ef5ea03e9beb96f849a7e","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ccd826197108c5ca4c1169","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":59,"size":27265,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":59,"size":45904,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":59,"size":54915,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":59,"size":92588,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":59,"size":87190,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":59,"size":151898,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":59,"size":114552,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":59,"size":165418,"format":"WEBP"}]}}},{"id":"631bb95ad47e611c913d93ce","name":"WalterVibe","flags":0,"timestamp":1676646717106,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"631bb95ad47e611c913d93ce","name":"WalterVibe","flags":0,"tags":["walter","breakingbad","saulgoodman","saul","jesse","meth"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"610345da41ab14baee7e299e","username":"tmh616","display_name":"TMH616","avatar_url":"//cdn.7tv.app/user/610345da41ab14baee7e299e/av_63cc40ee3d2332c1835aeda7/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/631bb95ad47e611c913d93ce","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":213,"size":145393,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":206,"size":135950,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":213,"size":344126,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":210,"size":331248,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":213,"size":559375,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":211,"size":536122,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":213,"size":758722,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":212,"size":750804,"format":"WEBP"}]}}},{"id":"63333a09078a9df7a28c58c2","name":"peepoChatbutpeepoisnotchatting","flags":0,"timestamp":1676646717697,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63333a09078a9df7a28c58c2","name":"peepoChatbutpeepoisnotchatting","flags":0,"tags":["chat","halloween","keyboard","chatting","peepo","not"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6054fb35b4d31e459f7cde73","username":"21mtd","display_name":"21mtd","avatar_url":"//cdn.7tv.app/user/6054fb35b4d31e459f7cde73/av_657c1e5fa8b03226340b14d7/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63333a09078a9df7a28c58c2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1384,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1876,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2943,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5600,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4541,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11098,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6456,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":17702,"format":"WEBP"}]}}},{"id":"6040a8bccf6746000db10348","name":"pepeJAM","flags":0,"timestamp":1676652635802,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6040a8bccf6746000db10348","name":"pepeJAM","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"618899d04ea2f24e5009cccc","username":"shakothewacko","display_name":"ShakoTheWacko","avatar_url":"//cdn.7tv.app/pp/618899d04ea2f24e5009cccc/ef6ac364e53d4690b86b127f1df74bc1","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6040a8bccf6746000db10348","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":4,"size":4047,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":4,"size":4526,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":4,"size":7279,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":4,"size":10512,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":4,"size":10841,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":4,"size":17636,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":4,"size":18449,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":4,"size":25270,"format":"WEBP"}]}}},{"id":"61e32b713441abfa431ca77c","name":"Rime","flags":0,"timestamp":1676732549730,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"61e32b713441abfa431ca77c","name":"Rime","flags":0,"tags":["russel","comedy","rime","lime","nime"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"612a548c529c91532ab271fc","username":"gamermonth","display_name":"gamermonth","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/685406ea-2008-4c4b-9031-7112042f8d7a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e32b713441abfa431ca77c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1138,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":764,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1866,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1800,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2800,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3162,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3710,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4450,"format":"WEBP"}]}}},{"id":"60bd14f67cef73d00a404896","name":"How2Read","flags":0,"timestamp":1676733147124,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60bd14f67cef73d00a404896","name":"How2Read","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b28a4a4f32610f15d19e61","username":"xaeriia","display_name":"xAeriia","avatar_url":"//cdn.7tv.app/user/60b28a4a4f32610f15d19e61/av_647bb5415579ae9e28079f9e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bd14f67cef73d00a404896","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1735,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1228,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3253,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2930,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4854,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4992,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6515,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6368,"format":"WEBP"}]}}},{"id":"6262dbf4e38c52ba50e8c188","name":"NymnPretendingToEnjoyHisCrappyRemix","flags":0,"timestamp":1676733147422,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6262dbf4e38c52ba50e8c188","name":"NymnPretendingToEnjoyHisCrappyRemix","flags":0,"tags":["nymn","jam"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"609ea088a38a46f969b61e98","username":"kufric","display_name":"Kufric","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6262dbf4e38c52ba50e8c188","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":166,"size":26756,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":166,"size":128592,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":166,"size":56944,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":166,"size":302506,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":166,"size":98287,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":166,"size":503142,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":166,"size":322939,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":166,"size":864218,"format":"WEBP"}]}}},{"id":"6398fb5651402d3cdab9b26a","name":"EEEK","flags":0,"timestamp":1676733147802,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6398fb5651402d3cdab9b26a","name":"EEEK","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6398fb5651402d3cdab9b26a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":53,"size":27111,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":53,"size":32980,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":53,"size":56032,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":53,"size":59502,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":53,"size":96767,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":53,"size":86220,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":53,"size":145351,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":53,"size":114542,"format":"WEBP"}]}}},{"id":"60420e5a77137b000de9e676","name":"PepeHands","flags":0,"timestamp":1676819577407,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60420e5a77137b000de9e676","name":"PepeHands","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60420e5a77137b000de9e676","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1633,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1198,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3265,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3038,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5068,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5168,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7013,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7434,"format":"WEBP"}]}}},{"id":"609eebd34c18609a1d984f3f","name":"pepeMeltdown","flags":0,"timestamp":1676819648496,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"609eebd34c18609a1d984f3f","name":"pepeMeltdown","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"609ede7b4c18609a1d94c5ae","username":"yoim5th","display_name":"yoim5th","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/81c11127-ffa9-4b47-a5b0-7e602a998aae-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/609eebd34c18609a1d984f3f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":10160,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":9486,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":17222,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":18430,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":26903,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":30330,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":32248,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":32782,"format":"WEBP"}]}}},{"id":"63f2440c3ebf15a76f4f07e9","name":"VeryClean","flags":0,"timestamp":1676822426010,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f2440c3ebf15a76f4f07e9","name":"VeryClean","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f2440c3ebf15a76f4f07e9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":663,"size":96871,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":663,"size":172392,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":663,"size":188807,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":663,"size":325688,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":663,"size":319908,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":663,"size":488372,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":663,"size":492789,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":663,"size":685540,"format":"WEBP"}]}}},{"id":"63f25606bb16b52ef4a0d27f","name":"DOCBOZO","flags":0,"timestamp":1676826222261,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f25606bb16b52ef4a0d27f","name":"DOCBOZO","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f25606bb16b52ef4a0d27f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":295,"size":48615,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":290,"size":109362,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":295,"size":107498,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":295,"size":232122,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":295,"size":178383,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":295,"size":337418,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":295,"size":273510,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":295,"size":445744,"format":"WEBP"}]}}},{"id":"60ef515648cde2fcc3c699da","name":"PokerFace","flags":0,"timestamp":1676827009948,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ef515648cde2fcc3c699da","name":"PokerFace","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60e022fe50830d688ae2861f","username":"rvdog815","display_name":"rvdog815","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/892a0be4-51f1-4741-9a02-b634e0476a5e-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ef515648cde2fcc3c699da","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":936,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":720,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1502,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1482,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2257,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2576,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3035,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3884,"format":"WEBP"}]}}},{"id":"63f27b343b0894cb3bf5c950","name":"heCrazy","flags":0,"timestamp":1676835729459,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f27b343b0894cb3bf5c950","name":"heCrazy","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f27b343b0894cb3bf5c950","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":86,"size":33566,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":86,"size":51286,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":86,"size":85555,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":86,"size":100972,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":86,"size":148593,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":86,"size":162810,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":86,"size":312044,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":86,"size":264490,"format":"WEBP"}]}}},{"id":"63f2806d08f5788b589253c7","name":"FeelsWeakMan","flags":0,"timestamp":1676837347025,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f2806d08f5788b589253c7","name":"FeelsWeakMan","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f2806d08f5788b589253c7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":144,"size":56267,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":144,"size":108664,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":144,"size":160667,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":144,"size":217332,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":144,"size":302752,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":144,"size":350160,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":144,"size":644296,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":144,"size":581210,"format":"WEBP"}]}}},{"id":"63f28aebf2915b442ca80ce5","name":"BatDisco","flags":0,"timestamp":1676839730607,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63f28aebf2915b442ca80ce5","name":"BatDisco","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f28aebf2915b442ca80ce5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":56,"height":32,"frame_count":57,"size":55576,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":56,"height":32,"frame_count":57,"size":50286,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":112,"height":64,"frame_count":57,"size":129458,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":112,"height":64,"frame_count":57,"size":91294,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":168,"height":96,"frame_count":57,"size":224711,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":168,"height":96,"frame_count":57,"size":150976,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":224,"height":128,"frame_count":57,"size":387429,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":224,"height":128,"frame_count":57,"size":203620,"format":"WEBP"}]}}},{"id":"63f1fe3f5dccf65d6e8d2b39","name":"Lounging","flags":0,"timestamp":1676905977094,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63f1fe3f5dccf65d6e8d2b39","name":"Lounging","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f1fe3f5dccf65d6e8d2b39","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":59,"height":32,"frame_count":1,"size":1471,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":59,"height":32,"frame_count":1,"size":2480,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":118,"height":64,"frame_count":1,"size":7104,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":118,"height":64,"frame_count":1,"size":3051,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":177,"height":96,"frame_count":1,"size":5144,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":177,"height":96,"frame_count":1,"size":12940,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":236,"height":128,"frame_count":1,"size":7600,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":236,"height":128,"frame_count":1,"size":20222,"format":"WEBP"}]}}},{"id":"60d264b560c4a1a365139405","name":"PogTasty","flags":0,"timestamp":1676905977378,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60d264b560c4a1a365139405","name":"PogTasty","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae7907e52a54a8e3a2c668","username":"benjaminyvr","display_name":"benjaminyvr","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1a9471ce-122f-4ba4-963c-0e4260ad8c3c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60d264b560c4a1a365139405","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":120,"size":19869,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":120,"size":67766,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":120,"size":47375,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":120,"size":134206,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":120,"size":84197,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":120,"size":210074,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":120,"size":137974,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":120,"size":224350,"format":"WEBP"}]}}},{"id":"636814770b55276c97956724","name":"donkWalk","flags":0,"timestamp":1676905977660,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"636814770b55276c97956724","name":"donkWalk","flags":0,"tags":["donkwalk","donk","walk"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/636814770b55276c97956724","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":11749,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":10600,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":23866,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":22304,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":38211,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":33296,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":47493,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":43918,"format":"WEBP"}]}}},{"id":"63f36852f2915b442ca820ad","name":"batPls","flags":0,"timestamp":1676919830967,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f36852f2915b442ca820ad","name":"batPls","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f36852f2915b442ca820ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":228,"size":86948,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":228,"size":131842,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":228,"size":231022,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":228,"size":242708,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":228,"size":477101,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":228,"size":397834,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":228,"size":1147268,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":228,"size":711042,"format":"WEBP"}]}}},{"id":"63f3697e0588a70e9a8d1f6f","name":"batJAM","flags":0,"timestamp":1676919847550,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f3697e0588a70e9a8d1f6f","name":"batJAM","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f3697e0588a70e9a8d1f6f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":361,"size":110127,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":361,"size":235124,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":361,"size":289604,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":361,"size":458120,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":361,"size":512981,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":361,"size":728084,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":361,"size":1151536,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":361,"size":1168638,"format":"WEBP"}]}}},{"id":"63f3c388face0f3bbeaad1d1","name":"docL","flags":0,"timestamp":1676919884194,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f3c388face0f3bbeaad1d1","name":"docL","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f3c388face0f3bbeaad1d1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":159,"size":34678,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":159,"size":108502,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":159,"size":81446,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":159,"size":215012,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":159,"size":155120,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":159,"size":314410,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":159,"size":272676,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":159,"size":423374,"format":"WEBP"}]}}},{"id":"6072a067dcae02001b44e604","name":"DANKIES","flags":0,"timestamp":1676992407145,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6072a067dcae02001b44e604","name":"DANKIES","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60729f38bde0639989f2be94","username":"gentvh","display_name":"gentvh","avatar_url":"//cdn.7tv.app/user/60729f38bde0639989f2be94/av_639bf96c5af9734ac8daf760/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6072a067dcae02001b44e604","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":6309,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":8890,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":11495,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":19576,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":18055,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":33276,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":19606,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":41508,"format":"WEBP"}]}}},{"id":"62642a25f95146e0da382bda","name":"Pain","flags":0,"timestamp":1676992407440,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62642a25f95146e0da382bda","name":"Pain","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62642a25f95146e0da382bda","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":65,"height":32,"frame_count":102,"size":13421,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":65,"height":32,"frame_count":102,"size":105572,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":130,"height":64,"frame_count":102,"size":39793,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":130,"height":64,"frame_count":102,"size":325876,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":195,"height":96,"frame_count":102,"size":76758,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":195,"height":96,"frame_count":102,"size":545034,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":260,"height":128,"frame_count":102,"size":169555,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":260,"height":128,"frame_count":102,"size":842150,"format":"WEBP"}]}}},{"id":"62c2e59768a0391cc239cdc2","name":"pikaMine","flags":0,"timestamp":1677078827879,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62c2e59768a0391cc239cdc2","name":"pikaMine","flags":0,"tags":["pika","mine","moonmoon"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62c2e59768a0391cc239cdc2","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":2014,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":3342,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":4184,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":5116,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":7128,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":6736,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":8760,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":8842,"format":"WEBP"}]}}},{"id":"63f6464cf8070da4e44bb855","name":"plink","flags":0,"timestamp":1677088300737,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63f6464cf8070da4e44bb855","name":"plink","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f6464cf8070da4e44bb855","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":80,"height":32,"frame_count":83,"size":37589,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":80,"height":32,"frame_count":83,"size":71714,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":160,"height":64,"frame_count":83,"size":102154,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":160,"height":64,"frame_count":83,"size":141934,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":240,"height":96,"frame_count":83,"size":189836,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":240,"height":96,"frame_count":83,"size":233108,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":320,"height":128,"frame_count":83,"size":395241,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":320,"height":128,"frame_count":83,"size":536878,"format":"WEBP"}]}}},{"id":"60b2fd1aab2a2a9c95bd44a8","name":"YOURM0M","flags":0,"timestamp":1677165258036,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b2fd1aab2a2a9c95bd44a8","name":"YOURM0M","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60b157c63f98974e48c9b7a8","username":"bh4tti","display_name":"bh4tti","avatar_url":"//cdn.7tv.app/user/60b157c63f98974e48c9b7a8/av_647d5e32d4b5d6083e92328c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b2fd1aab2a2a9c95bd44a8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":13,"size":6145,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":13,"size":9950,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":13,"size":12073,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":13,"size":24202,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":13,"size":19522,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":13,"size":38718,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":13,"size":32378,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":13,"size":43644,"format":"WEBP"}]}}},{"id":"61d9b70927a4f6d6544e545e","name":"nymnLulwut","flags":0,"timestamp":1677165258315,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61d9b70927a4f6d6544e545e","name":"nymnLulwut","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61d9b70927a4f6d6544e545e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":159,"size":24445,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":159,"size":134510,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":159,"size":74453,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":159,"size":310182,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":159,"size":150157,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":159,"size":505674,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":159,"size":292595,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":159,"size":550600,"format":"WEBP"}]}}},{"id":"60ae55800e35477634f878fd","name":"forsenParty","flags":0,"timestamp":1677165258619,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae55800e35477634f878fd","name":"forsenParty","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60bb55eac2415f99b55a7731","username":"hadezzishappy","display_name":"hadezzishappy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/23efe3c6-dc9b-4a48-93c3-5eec7b6ca6e0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae55800e35477634f878fd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":60,"size":17955,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":60,"size":36360,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":60,"size":33477,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":60,"size":70460,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":60,"size":51084,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":60,"size":111144,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":60,"size":71276,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":60,"size":128888,"format":"WEBP"}]}}},{"id":"618330c5f1ae15abc7ebb8c6","name":"thIS","flags":0,"timestamp":1677165259269,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"618330c5f1ae15abc7ebb8c6","name":"THIS","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/618330c5f1ae15abc7ebb8c6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":53,"height":32,"frame_count":31,"size":13197,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":53,"height":32,"frame_count":31,"size":42224,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":106,"height":64,"frame_count":31,"size":39278,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":106,"height":64,"frame_count":31,"size":101722,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":159,"height":96,"frame_count":31,"size":68584,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":159,"height":96,"frame_count":31,"size":177156,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":212,"height":128,"frame_count":31,"size":109119,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":212,"height":128,"frame_count":31,"size":195216,"format":"WEBP"}]}}},{"id":"61fc0f1123f0a55b0ba8313d","name":"Fridge","flags":0,"timestamp":1677249038027,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61fc0f1123f0a55b0ba8313d","name":"Fridge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3cb1b2ecb0150521fa1f","username":"waterboiledpizza","display_name":"WaterBoiledPizza","avatar_url":"//cdn.7tv.app/user/60ae3cb1b2ecb0150521fa1f/av_652806843e9323c51e05082e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61fc0f1123f0a55b0ba8313d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1142,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":930,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2161,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2206,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3291,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3872,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4695,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5764,"format":"WEBP"}]}}},{"id":"60aef6b7a564afa26eaabc37","name":"OMGScoots","flags":0,"timestamp":1677251687882,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aef6b7a564afa26eaabc37","name":"OMGScoots","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60aee97511a994a4acbefca7","username":"voidmakesvids","display_name":"VoidMakesVids","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8dd7f316-85f5-414a-bd0d-603d67289cbf-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aef6b7a564afa26eaabc37","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1240,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1008,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2283,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2430,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3334,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3826,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3788,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4522,"format":"AVIF"}]}}},{"id":"60b0e22c4daf0d3e211877ca","name":"PepeA","flags":0,"timestamp":1677251688177,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b0e22c4daf0d3e211877ca","name":"PepeA","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3df2aee2aa55382ba24d","username":"khaltour","display_name":"Khaltour","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f39529e7-1d39-4e94-8a60-d4097c3ec31a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b0e22c4daf0d3e211877ca","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":3,"size":3025,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":3,"size":3398,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":3,"size":4903,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":3,"size":8066,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":3,"size":7186,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":3,"size":13522,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":3,"size":10132,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":3,"size":15128,"format":"WEBP"}]}}},{"id":"60ae9d8f229664e8660449aa","name":"nymnShuffle","flags":0,"timestamp":1677251688471,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae9d8f229664e8660449aa","name":"nymnShuffle","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3ca8aee2aa553822cef3","username":"justmariusz","display_name":"JUSTmariusz","avatar_url":"//cdn.7tv.app/user/60ae3ca8aee2aa553822cef3/av_63ab8d2324f58877cf6dd47f/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae9d8f229664e8660449aa","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":14218,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":7984,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":14737,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":30308,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":22446,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":48902,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":52670,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":33583,"format":"AVIF"}]}}},{"id":"603e6f69284626000d068846","name":"VaN","flags":0,"timestamp":1677338118134,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603e6f69284626000d068846","name":"VaN","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603cac0896832ffa78c463e1","username":"rupusen","display_name":"rupusen","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/998f01ae-def8-11e9-b95c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603e6f69284626000d068846","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":1,"size":1148,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":1,"size":1363,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":1,"size":3086,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":1,"size":2792,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":1,"size":4301,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":1,"size":5306,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":1,"size":6185,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":1,"size":7712,"format":"WEBP"}]}}},{"id":"60ae65b29627f9aff4fd8bef","name":"NOOOO","flags":0,"timestamp":1677338118442,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae65b29627f9aff4fd8bef","name":"NOOOO","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae49350e35477634486602","username":"justrogan","display_name":"JustRogan","avatar_url":"//cdn.7tv.app/pp/60ae49350e35477634486602/88d6e3c4265f4be0a452c812c146da50","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae65b29627f9aff4fd8bef","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":6,"size":5897,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":6,"size":7004,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":6,"size":10420,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":6,"size":15240,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":6,"size":15881,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":6,"size":24568,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":6,"size":21125,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":6,"size":31006,"format":"WEBP"}]}}},{"id":"60aef3aea564afa26e686d8c","name":"5Head","flags":0,"timestamp":1677338118741,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aef3aea564afa26e686d8c","name":"5Head","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae49350e35477634486602","username":"justrogan","display_name":"JustRogan","avatar_url":"//cdn.7tv.app/pp/60ae49350e35477634486602/88d6e3c4265f4be0a452c812c146da50","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aef3aea564afa26e686d8c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":26,"height":32,"frame_count":1,"size":1164,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":26,"height":32,"frame_count":1,"size":866,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":52,"height":64,"frame_count":1,"size":1893,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":52,"height":64,"frame_count":1,"size":1918,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":78,"height":96,"frame_count":1,"size":2959,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":78,"height":96,"frame_count":1,"size":3126,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":104,"height":128,"frame_count":1,"size":3789,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":104,"height":128,"frame_count":1,"size":3652,"format":"WEBP"}]}}},{"id":"616ee25bb6d21adaffbe9177","name":"ApolloWake","flags":0,"timestamp":1677348522278,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"616ee25bb6d21adaffbe9177","name":"ApolloWake","flags":0,"tags":["apollo","wokege","awakege"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/616ee25bb6d21adaffbe9177","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":61,"size":9717,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":61,"size":43672,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":61,"size":31009,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":61,"size":107138,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":61,"size":68123,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":61,"size":181832,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":61,"size":123411,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":61,"size":142316,"format":"WEBP"}]}}},{"id":"63fa2b0117478c0c59fc73c1","name":"MEMONEYING","flags":0,"timestamp":1677359177728,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63fa2b0117478c0c59fc73c1","name":"MEMONEYING","flags":0,"tags":["nymn","memoney","nime","timetonime","scammer","money"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6131dab2af9287c4eb609268","username":"vicneeel","display_name":"vicneeel","avatar_url":"//cdn.7tv.app/user/6131dab2af9287c4eb609268/av_6520576332b1db5b90ef6b24/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fa2b0117478c0c59fc73c1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":6,"size":8387,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":6,"size":7488,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":6,"size":18143,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":6,"size":16872,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":6,"size":28594,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":6,"size":27170,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":6,"size":39152,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":6,"size":38032,"format":"WEBP"}]}}},{"id":"60a1babb3c3362f9a4b8b33a","name":"catKISS","flags":0,"timestamp":1677408784164,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60a1babb3c3362f9a4b8b33a","name":"catKISS","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60867b015e01df61570ab900","username":"cupofeggy","display_name":"CupOfEggy","avatar_url":"//cdn.7tv.app/pp/60867b015e01df61570ab900/cb06710bb97347d6bd78febdab716ac0","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60a1babb3c3362f9a4b8b33a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":51,"size":24237,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":51,"size":40876,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":51,"size":86982,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":51,"size":49974,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":51,"size":139518,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":51,"size":79505,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":51,"size":110441,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":51,"size":146510,"format":"WEBP"}]}}},{"id":"60b38397e42f1681cfbcfc79","name":"tensePls","flags":0,"timestamp":1677416916643,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"60b38397e42f1681cfbcfc79","name":"tensePls","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b1428c213e3888f9638acf","username":"senderak","display_name":"senderak","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9d953c4e-3f61-48b8-8e45-08071276d03d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b38397e42f1681cfbcfc79","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":99,"size":51569,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":99,"size":92598,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":99,"size":116885,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":99,"size":201828,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":99,"size":191035,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":99,"size":334342,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":99,"size":272592,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":99,"size":364564,"format":"WEBP"}]}}},{"id":"6298e43b4e04a1a42ae729e8","name":"FDM","flags":0,"timestamp":1677420364894,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6298e43b4e04a1a42ae729e8","name":"FDM","flags":0,"tags":["feelsdankman","dank","feelsdankerman","feelsdonkman"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b04a7fad7fb4b50bd3a982","username":"brian6932","display_name":"brian6932","avatar_url":"//cdn.7tv.app/user/60b04a7fad7fb4b50bd3a982/av_64f8035f8bef730969094d7a/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6298e43b4e04a1a42ae729e8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1196,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1008,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2398,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2270,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3956,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3283,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6008,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4609,"format":"AVIF"}]}}},{"id":"63fb74b848d607ec9b98f08f","name":"myeah","flags":0,"timestamp":1677423859729,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63fb74b848d607ec9b98f08f","name":"VibeOff","flags":0,"tags":["vibeoff","finger","breakingbad","despair","life","dance"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fb74b848d607ec9b98f08f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":79,"size":21745,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":79,"size":54088,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":79,"size":51556,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":79,"size":122104,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":79,"size":78302,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":79,"size":190482,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":79,"size":121995,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":79,"size":266852,"format":"WEBP"}]}}},{"id":"61bb1c745804e220aa6aafe2","name":"GuitarYime","flags":0,"timestamp":1677424260473,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"61bb1c745804e220aa6aafe2","name":"GuitarYime","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61938ebbd34608492cc37ff1","username":"fluxenis","display_name":"fluxenis","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61bb1c745804e220aa6aafe2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":45,"height":32,"frame_count":105,"size":56455,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":45,"height":32,"frame_count":105,"size":139148,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":90,"height":64,"frame_count":105,"size":155635,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":90,"height":64,"frame_count":105,"size":327836,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":135,"height":96,"frame_count":105,"size":307600,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":135,"height":96,"frame_count":105,"size":564100,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":180,"height":128,"frame_count":105,"size":460686,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":180,"height":128,"frame_count":105,"size":726320,"format":"WEBP"}]}}},{"id":"6040aa41cf6746000db1034e","name":"ppPoof","flags":0,"timestamp":1677428131518,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6040aa41cf6746000db1034e","name":"ppPoof","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6040aa41cf6746000db1034e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":41,"size":9739,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":41,"size":10256,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":41,"size":16918,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":41,"size":14363,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":41,"size":30090,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":41,"size":20506,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":41,"size":20366,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":41,"size":25076,"format":"WEBP"}]}}},{"id":"616b1b7fc52da56cd490a72f","name":"vanish0","flags":1,"timestamp":1677428172120,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"616b1b7fc52da56cd490a72f","name":"vanish","flags":256,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/616b1b7fc52da56cd490a72f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":35,"size":5717,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":35,"size":3734,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":35,"size":7779,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":35,"size":4340,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":35,"size":11368,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":35,"size":6144,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":35,"size":14227,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":35,"size":5480,"format":"WEBP"}]}}},{"id":"60b040934d83b66c44f21992","name":"Borpa","flags":0,"timestamp":1677439952485,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b040934d83b66c44f21992","name":"Borpa","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aebf0ae90f445e43b37fe5","username":"prog0ldfish","display_name":"ProG0ldfish","avatar_url":"//cdn.7tv.app/user/60aebf0ae90f445e43b37fe5/av_6353ef0d7f642dee0e30c1f4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b040934d83b66c44f21992","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1029,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":682,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1599,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1626,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2381,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2590,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2924,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3502,"format":"WEBP"}]}}},{"id":"63fb71e50fd141cefb090fc7","name":"cupFlip","flags":0,"timestamp":1677445161036,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63fb71e50fd141cefb090fc7","name":"cupFlip","flags":0,"tags":["cat","cup","flip"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fb71e50fd141cefb090fc7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":24,"height":32,"frame_count":74,"size":17086,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":24,"height":32,"frame_count":74,"size":33776,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":48,"height":64,"frame_count":74,"size":37097,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":48,"height":64,"frame_count":74,"size":77668,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":72,"height":96,"frame_count":74,"size":61452,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":72,"height":96,"frame_count":74,"size":129584,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":96,"height":128,"frame_count":74,"size":86496,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":96,"height":128,"frame_count":74,"size":177962,"format":"WEBP"}]}}},{"id":"6143bb1d962a60904864c20f","name":"HACKERMANS","flags":0,"timestamp":1677500778645,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6143bb1d962a60904864c20f","name":"HACKERMANS","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b04a7fad7fb4b50bd3a982","username":"brian6932","display_name":"brian6932","avatar_url":"//cdn.7tv.app/user/60b04a7fad7fb4b50bd3a982/av_64f8035f8bef730969094d7a/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6143bb1d962a60904864c20f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":11,"size":5353,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":11,"size":10652,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":11,"size":12171,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":11,"size":28024,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":11,"size":21939,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":11,"size":50764,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":11,"size":38741,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":11,"size":61916,"format":"WEBP"}]}}},{"id":"60b7dc3655c320f0e8aa0096","name":"DonkLeave","flags":0,"timestamp":1677500949180,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b7dc3655c320f0e8aa0096","name":"DonkLeave","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b2a8d9be695c536f66179e","username":"venceslavsquare","display_name":"VenceslavSquare","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9bce07d2-d3f9-4977-8e17-028ede768a35-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b7dc3655c320f0e8aa0096","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":45,"size":19625,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":45,"size":33798,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":45,"size":37888,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":45,"size":64700,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":45,"size":60694,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":45,"size":100520,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":45,"size":82333,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":45,"size":113570,"format":"WEBP"}]}}},{"id":"60b539041fd3f03d860ad49b","name":"DonkEnter","flags":0,"timestamp":1677500951070,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b539041fd3f03d860ad49b","name":"DonkArrive","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b0133774d234a96956705c","username":"gabrelaarves","display_name":"gabrelaarves","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/951765d1-54d7-4573-b890-19072960942e-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b539041fd3f03d860ad49b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":105,"size":27171,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":105,"size":74778,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":105,"size":56872,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":105,"size":143026,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":105,"size":92785,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":105,"size":221362,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":105,"size":139562,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":105,"size":252366,"format":"WEBP"}]}}},{"id":"623c67e4955cccbe23975657","name":"RIPBOZO","flags":0,"timestamp":1677500955520,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"623c67e4955cccbe23975657","name":"RIPBOZO","flags":0,"tags":["bozo","foxenkin"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61938ebbd34608492cc37ff1","username":"fluxenis","display_name":"fluxenis","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/623c67e4955cccbe23975657","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":167,"size":36536,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":167,"size":145334,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":167,"size":129891,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":167,"size":370634,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":167,"size":249623,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":167,"size":642334,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":167,"size":486098,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":167,"size":535824,"format":"WEBP"}]}}},{"id":"6183c747f1ae15abc7ebd487","name":"FEELSWAYTOODONKMAN","flags":0,"timestamp":1677517778023,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6183c747f1ae15abc7ebd487","name":"FEELSWAYTOODONKMAN","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60eed06ef56d067b5420e4f3","username":"tepx","display_name":"tepx","avatar_url":"//cdn.7tv.app/pp/60eed06ef56d067b5420e4f3/f8a8017ef06c4a58bcda4c2074239ba8","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6183c747f1ae15abc7ebd487","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":479,"size":247314,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":479,"size":368000,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":479,"size":532935,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":479,"size":749084,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":479,"size":995676,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":479,"size":1280664,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":479,"size":1570752,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":479,"size":1484408,"format":"WEBP"}]}}},{"id":"6318ae635a703c4a98dad44b","name":"!vanish","flags":0,"timestamp":1677531630837,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6318ae635a703c4a98dad44b","name":"peepoVanish","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60fd5a2b4653f5d6c174f52c","username":"gambloide","display_name":"Gambloide","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/cf482c8f-4f2a-4ab1-878d-00850cc8c1eb-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6318ae635a703c4a98dad44b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":38,"height":32,"frame_count":48,"size":7969,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":38,"height":32,"frame_count":48,"size":27782,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":76,"height":64,"frame_count":48,"size":13005,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":76,"height":64,"frame_count":48,"size":61082,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":114,"height":96,"frame_count":48,"size":18694,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":114,"height":96,"frame_count":48,"size":97866,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":152,"height":128,"frame_count":48,"size":25660,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":152,"height":128,"frame_count":48,"size":134572,"format":"WEBP"}]}}},{"id":"60aebc35e04200b3d12fbc40","name":"donkJam","flags":0,"timestamp":1677554118584,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aebc35e04200b3d12fbc40","name":"donkJam","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6091cdb92fe19cf34b935a66","username":"herohyrule","display_name":"HeroHyrule","avatar_url":"//cdn.7tv.app/pp/6091cdb92fe19cf34b935a66/6396e971b87742d29531c44195778442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aebc35e04200b3d12fbc40","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":2234,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":3500,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":5803,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":4682,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":8304,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":7286,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":8226,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":10773,"format":"AVIF"}]}}},{"id":"62348f2a961be6c9af3d226e","name":"bruhSlide","flags":0,"timestamp":1677554118887,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62348f2a961be6c9af3d226e","name":"bruhSlide","flags":0,"tags":["bruh","bruhsit","cmonbruh"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60be7d86529aefe4a0c5be9a","username":"djoka","display_name":"Djoka","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/888b00cf-48ca-48b9-be2d-490c95d70ced-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62348f2a961be6c9af3d226e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":109,"size":27684,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":109,"size":107200,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":109,"size":61400,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":109,"size":237930,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":109,"size":134863,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":109,"size":386190,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":109,"size":228055,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":109,"size":398224,"format":"WEBP"}]}}},{"id":"63fdf8aeface0f3bbeabcafc","name":"Beerge","flags":0,"timestamp":1677588986625,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"63fdf8aeface0f3bbeabcafc","name":"Beerge","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fdf8aeface0f3bbeabcafc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":166,"size":42697,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":166,"size":140684,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":166,"size":113634,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":166,"size":307658,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":166,"size":218887,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":166,"size":489922,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":166,"size":485016,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":166,"size":786950,"format":"WEBP"}]}}},{"id":"60af808584a2b8e655a47928","name":"Offline","flags":0,"timestamp":1677589043309,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60af808584a2b8e655a47928","name":"Offline","flags":0,"tags":["cry","sad","peeposhut","peepo"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6117c599815bc8f5557a46c9","username":"glhf115","display_name":"glhf115","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af808584a2b8e655a47928","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":40,"size":9040,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":40,"size":36662,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":40,"size":86548,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":40,"size":20233,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":40,"size":35529,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":40,"size":141594,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":40,"size":55466,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":40,"size":165560,"format":"WEBP"}]}}},{"id":"63cd8ca0f4657baa9ed9910f","name":"ppCircle","flags":0,"timestamp":1677599848194,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"63cd8ca0f4657baa9ed9910f","name":"ppCircle","flags":0,"tags":["ppl"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae2af4aee2aa5538ab2144","username":"sunred_","display_name":"SunRed_","avatar_url":"//cdn.7tv.app/pp/60ae2af4aee2aa5538ab2144/acc28924022046e3b790ccaf4c7b4c53","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63cd8ca0f4657baa9ed9910f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":36,"size":11614,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":36,"size":10456,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":36,"size":24385,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":36,"size":21364,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":36,"size":39818,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":36,"size":32686,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":36,"size":55003,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":36,"size":43610,"format":"WEBP"}]}}},{"id":"63fd1165face0f3bbeabb6ed","name":"nimeJAM","flags":0,"timestamp":1677614622369,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63fd1165face0f3bbeabb6ed","name":"nimeJAM","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fd1165face0f3bbeabb6ed","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":283,"size":80975,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":283,"size":151268,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":283,"size":189197,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":283,"size":276066,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":283,"size":352684,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":283,"size":430194,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":283,"size":657412,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":283,"size":569284,"format":"WEBP"}]}}},{"id":"61827c364ea2f24e50092f69","name":"partyTime","flags":0,"timestamp":1677760013961,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"61827c364ea2f24e50092f69","name":"partyTime","flags":0,"tags":["cat","kitten","kitty","meow","rave","rainbow"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60d37adc205cf63c7eef6871","username":"eazylemnsqeezy","display_name":"eazylemnsqeezy","avatar_url":"//cdn.7tv.app/user/60d37adc205cf63c7eef6871/av_64200b330ef35e7ab89f2db4/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61827c364ea2f24e50092f69","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":69,"height":32,"frame_count":300,"size":135603,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":69,"height":32,"frame_count":300,"size":375690,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":138,"height":64,"frame_count":300,"size":404899,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":138,"height":64,"frame_count":300,"size":929366,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":207,"height":96,"frame_count":300,"size":740567,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":207,"height":96,"frame_count":300,"size":1521364,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":276,"height":128,"frame_count":300,"size":1086988,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":276,"height":128,"frame_count":300,"size":1776472,"format":"WEBP"}]}}},{"id":"6102a37ba57eeb23c0e3e5cb","name":"peepoDJ","flags":0,"timestamp":1677760019738,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6102a37ba57eeb23c0e3e5cb","name":"peepoDJ","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60f28c1e31ba6ae622a49c16","username":"rsnowwolf","display_name":"rSnowWolf","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f43e8c2d-5eb2-4747-9351-0a3574c535c0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6102a37ba57eeb23c0e3e5cb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":72,"size":48798,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":72,"size":77398,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":72,"size":115826,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":72,"size":179294,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":72,"size":199776,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":72,"size":303142,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":72,"size":301659,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":72,"size":374542,"format":"WEBP"}]}}},{"id":"6360b863593eb316d898eb78","name":"WideRaveTime","flags":1,"timestamp":1677760059276,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6360b863593eb316d898eb78","name":"WideRaveTime","flags":256,"tags":["edm","disco","jam","pls","dance","party"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60f5e290e57bec021618c4a4","username":"ansonx10","display_name":"AnsonX10","avatar_url":"//cdn.7tv.app/user/60f5e290e57bec021618c4a4/av_63617cc39018da6429bc0298/3x_static.webp","style":{"color":401323775},"roles":["60b3f1ea886e63449c5263b1","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6360b863593eb316d898eb78","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":230,"size":222927,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":230,"size":495282,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":230,"size":413033,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":230,"size":1155286,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":230,"size":608043,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":230,"size":1870006,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":230,"size":835329,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":230,"size":2767168,"format":"WEBP"}]}}},{"id":"631d3178d47e611c913db50a","name":"RaveTime","flags":1,"timestamp":1677760142065,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"631d3178d47e611c913db50a","name":"RaveTime","flags":256,"tags":["edm","party","lights","dance","rave","disco"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f5e290e57bec021618c4a4","username":"ansonx10","display_name":"AnsonX10","avatar_url":"//cdn.7tv.app/user/60f5e290e57bec021618c4a4/av_63617cc39018da6429bc0298/3x_static.webp","style":{"color":401323775},"roles":["60b3f1ea886e63449c5263b1","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/631d3178d47e611c913db50a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":184,"size":103285,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":184,"size":190170,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":184,"size":185490,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":184,"size":441520,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":184,"size":279313,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":184,"size":714322,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":184,"size":407913,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":184,"size":1093616,"format":"WEBP"}]}}},{"id":"61335162a1968527a66917c7","name":"Adge","flags":0,"timestamp":1677761995918,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61335162a1968527a66917c7","name":"Adge","flags":0,"tags":["ads","twitch","adblock"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6121baae0c9f70780af1766d","username":"sp4rkillz","display_name":"Sp4rkillz","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/43da3c6e-ac1c-4bdd-b89d-65c4f783bf30-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61335162a1968527a66917c7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":25,"frame_count":31,"size":8740,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":25,"frame_count":31,"size":8056,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":50,"frame_count":31,"size":15901,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":50,"frame_count":31,"size":15074,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":75,"frame_count":31,"size":23510,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":75,"frame_count":31,"size":27160,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":100,"frame_count":31,"size":29244,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":100,"frame_count":31,"size":34862,"format":"WEBP"}]}}},{"id":"62580411131d4588262a76ec","name":"ZZoomer","flags":0,"timestamp":1677763985342,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62580411131d4588262a76ec","name":"ZZoomer","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61390fdcf5c7c1b549d5c10d","username":"skamiro","display_name":"SkaMiro","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/170ee35b-e26b-4558-a2b9-b91f8f16414c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62580411131d4588262a76ec","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":13,"size":7838,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":13,"size":13696,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":13,"size":14702,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":13,"size":31522,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":13,"size":21235,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":13,"size":52680,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":13,"size":22500,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":13,"size":73824,"format":"WEBP"}]}}},{"id":"63fbe1c7f2915b442ca8fe70","name":"Ditching","flags":0,"timestamp":1677770118380,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63fbe1c7f2915b442ca8fe70","name":"Ditching","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"63195e6329a5627b71e31b9b","username":"windycityrockr","display_name":"WindyCityRockr","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4312bc04-0ea5-45fd-8c4d-8fe72d86448b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fbe1c7f2915b442ca8fe70","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":1,"size":2086,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":1,"size":3422,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":1,"size":4014,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":1,"size":9774,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":1,"size":5552,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":1,"size":17528,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":1,"size":7284,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":1,"size":26952,"format":"WEBP"}]}}},{"id":"60cecde0bfcc20ec67dd0ed0","name":"HarryPottah","flags":0,"timestamp":1677770118693,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60cecde0bfcc20ec67dd0ed0","name":"HarryPottah","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b561cc04283ab952bfd4e0","username":"on_a_stack","display_name":"On_a_stack","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1ed36b65-71c8-4eb2-a6a7-83ad2bb7566a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60cecde0bfcc20ec67dd0ed0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1495,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1112,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2910,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2762,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4381,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4656,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5780,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5816,"format":"WEBP"}]}}},{"id":"620425c25ccb247397667d59","name":"NOkey","flags":0,"timestamp":1677770119225,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"620425c25ccb247397667d59","name":"NOkey","flags":0,"tags":["notokay","xqcl"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3d75aee2aa55382883c2","username":"victorbaya","display_name":"victorbaya","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4f4c5649-c2f3-4837-a46f-486df3dde891-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/620425c25ccb247397667d59","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1693,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1356,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3678,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3466,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5877,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6052,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8605,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":9294,"format":"WEBP"}]}}},{"id":"60ccf4479f5edeff9938fa77","name":"SUSSY","flags":0,"timestamp":1677770119542,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ccf4479f5edeff9938fa77","name":"SUSSY","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"6144a9f80969108b67193814","username":"epicdonutdude_","display_name":"EpicDonutDude_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/624c1b52-530b-4707-87d3-45b4c9cd1acf-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ccf4479f5edeff9938fa77","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":163,"size":49082,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":163,"size":145236,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":163,"size":116649,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":163,"size":323622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":163,"size":204573,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":163,"size":526310,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":163,"size":314301,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":163,"size":558662,"format":"WEBP"}]}}},{"id":"60ae3714aee2aa553806de31","name":"forsenShuffle","flags":0,"timestamp":1677770119875,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae3714aee2aa553806de31","name":"forsenShuffle","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae3714aee2aa553806de31","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":8118,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":5769,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":10930,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":17212,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":29134,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":16653,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":31612,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":22222,"format":"AVIF"}]}}},{"id":"6401add417478c0c59fd2620","name":"nymnLove","flags":0,"timestamp":1677831811845,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6401add417478c0c59fd2620","name":"nymnLove","flags":0,"tags":["gondola","heart","nymn","love"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"612fad78a77c17adb4478f0f","username":"rexinus1","display_name":"Rexinus1","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/47acff52-7ea8-4d2e-8f40-4c050b1d360c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6401add417478c0c59fd2620","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1421,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1846,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2530,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5684,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3700,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8478,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4637,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13138,"format":"WEBP"}]}}},{"id":"61377f7d1d23ef7131f59b76","name":"WaterIceSaltAyy","flags":0,"timestamp":1677847764664,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61377f7d1d23ef7131f59b76","name":"sonic","flags":0,"tags":["watericesaltayy"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b0c4c788e8246a4b16258b","username":"der_sheff","display_name":"der_sheff","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1add0749-b3be-454b-aa82-cb332c03e843-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61377f7d1d23ef7131f59b76","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":9,"size":8286,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":9,"size":6014,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":9,"size":11561,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":9,"size":16648,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":9,"size":18962,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":9,"size":26394,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":9,"size":29726,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":9,"size":24446,"format":"AVIF"}]}}},{"id":"619c69e370bd99598795c215","name":"BOOMIES","flags":0,"timestamp":1677852227410,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"619c69e370bd99598795c215","name":"BOOMIES","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619c69e370bd99598795c215","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":100,"size":65971,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":100,"size":110776,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":100,"size":185153,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":100,"size":270468,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":100,"size":314422,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":100,"size":470538,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":100,"size":535174,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":100,"size":551310,"format":"WEBP"}]}}},{"id":"64026a10a283055403bd5410","name":"silly","flags":0,"timestamp":1677880510400,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"64026a10a283055403bd5410","name":"Silly","flags":0,"tags":["animal","cat","nymn","silly","funny","cute"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"612fad78a77c17adb4478f0f","username":"rexinus1","display_name":"Rexinus1","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/47acff52-7ea8-4d2e-8f40-4c050b1d360c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64026a10a283055403bd5410","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":1165,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1758,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":1885,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":4620,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":2623,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":8498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":3350,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":13386,"format":"WEBP"}]}}},{"id":"61178e9c25a41a1170572a0b","name":"forseninsane","flags":0,"timestamp":1677986118159,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61178e9c25a41a1170572a0b","name":"forsenInsane","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae82ae8d6322a2a8db908e","username":"servasoida","display_name":"servasoida","avatar_url":"//cdn.7tv.app/user/60ae82ae8d6322a2a8db908e/av_63b6014c888238aa9917dc3b/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61178e9c25a41a1170572a0b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":3,"size":3144,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":3,"size":2690,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":3,"size":5415,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":3,"size":6170,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":3,"size":8009,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":3,"size":10458,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":3,"size":12090,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":3,"size":12202,"format":"WEBP"}]}}},{"id":"611670497327a61fe25e56b0","name":"OkeyL","flags":0,"timestamp":1677986118929,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"611670497327a61fe25e56b0","name":"OkeyL","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60c86e09bfc4a1dd77bf4bc0","username":"tahadm_","display_name":"TahaDM_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8ebf1343-968d-4156-9b18-0fa78f2268c2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/611670497327a61fe25e56b0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":1533,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1362,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":3328,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":3630,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":5249,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":6364,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":7798,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":9486,"format":"WEBP"}]}}},{"id":"60af0da312d7701491c6f071","name":"donkRun","flags":0,"timestamp":1677986119219,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60af0da312d7701491c6f071","name":"donkRun","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aeee9ba564afa26e0f6f43","username":"bigggestbelly","display_name":"BigggestBelly","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c38cedf7-7b6a-4f8a-bb4f-7016300032b4-profile_image-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af0da312d7701491c6f071","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":7507,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":7000,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":14534,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":14400,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":22405,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":23262,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":30203,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":27394,"format":"WEBP"}]}}},{"id":"60a439fbb36c6d95937ba56e","name":"ForsenLookingAtYou","flags":0,"timestamp":1677986119520,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60a439fbb36c6d95937ba56e","name":"ForsenLookingAtYou","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"609ef692b55466cf07525c1f","username":"supernoahtv","display_name":"SupernoahTV","avatar_url":"//cdn.7tv.app/pp/609ef692b55466cf07525c1f/59d9c67280094d24b913ecdbd48c7d10","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60a439fbb36c6d95937ba56e","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1032,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1261,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2408,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2204,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3221,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4114,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4333,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4516,"format":"WEBP"}]}}},{"id":"60b231dafdd2d7d7bd7d5d9d","name":"peepoFlute","flags":0,"timestamp":1678023556495,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b231dafdd2d7d7bd7d5d9d","name":"peepoFlute","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b22c4ab1b03f6c99497d9b","username":"moneyhoarder","display_name":"MoneyHoarder","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/90b964fa-58b3-426a-b914-c968eee0f57e-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b231dafdd2d7d7bd7d5d9d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":6499,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":5624,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":13142,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":13616,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":21026,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":23570,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":28468,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":28034,"format":"WEBP"}]}}},{"id":"62992d7a9f69ab702281d45a","name":"nymnSNIFFA","flags":0,"timestamp":1678036394943,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62992d7a9f69ab702281d45a","name":"nymnSNIFFA","flags":0,"tags":["sniffa","nymn","yabbe","sniff","pepe","feet"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62992d7a9f69ab702281d45a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":140,"size":16631,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":139,"size":137348,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":140,"size":37007,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":140,"size":314970,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":140,"size":73785,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":140,"size":530186,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":140,"size":165048,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":140,"size":718648,"format":"WEBP"}]}}},{"id":"60f4e98ce57bec02166796a5","name":"peepoCute","flags":0,"timestamp":1678191996591,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60f4e98ce57bec02166796a5","name":"peepoCute","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b7f8f555c320f0e8c6a27e","username":"vinn700","display_name":"vinn700","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8d225acd-892b-4ef8-bb06-00cde9018ab2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f4e98ce57bec02166796a5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":1,"size":1647,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":1,"size":1356,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":1,"size":3058,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":1,"size":3120,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":1,"size":4571,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":1,"size":5144,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":1,"size":6148,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":1,"size":7564,"format":"WEBP"}]}}},{"id":"634aef3b9e9ae5efe290e3a4","name":"GIGA","flags":0,"timestamp":1678196671553,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"634aef3b9e9ae5efe290e3a4","name":"GIGA","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/634aef3b9e9ae5efe290e3a4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1199,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2106,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2432,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6662,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3728,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12580,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4902,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":19796,"format":"WEBP"}]}}},{"id":"617ad142e0801fb98788432c","name":"nymnBOOBA","flags":0,"timestamp":1678202118330,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"617ad142e0801fb98788432c","name":"nymnBOOBA","flags":0,"tags":["nymn","booba","lamonting"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3cb1b2ecb0150521fa1f","username":"waterboiledpizza","display_name":"WaterBoiledPizza","avatar_url":"//cdn.7tv.app/user/60ae3cb1b2ecb0150521fa1f/av_652806843e9323c51e05082e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/617ad142e0801fb98788432c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":64,"size":14871,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":64,"size":55156,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":64,"size":48036,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":64,"size":140002,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":64,"size":93940,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":64,"size":234602,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":64,"size":173963,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":64,"size":200156,"format":"WEBP"}]}}},{"id":"610e61f3900cbd77c695815a","name":"BillySmoke","flags":0,"timestamp":1678202118666,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"610e61f3900cbd77c695815a","name":"BillySmoke","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f8d2e7e57bec021669e176","username":"derxiatos","display_name":"DerXiatos","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/998f01ae-def8-11e9-b95c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610e61f3900cbd77c695815a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":263,"size":67635,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":263,"size":220400,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":263,"size":218669,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":263,"size":529960,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":263,"size":404061,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":263,"size":890546,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":263,"size":742381,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":263,"size":1173820,"format":"WEBP"}]}}},{"id":"6388785d0d4985e50d524483","name":"Stare","flags":0,"timestamp":1678202118996,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6388785d0d4985e50d524483","name":"Stare","flags":0,"tags":["apollo","aploplo","apluplu","nymn","hugo","bozz"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6054fb35b4d31e459f7cde73","username":"21mtd","display_name":"21mtd","avatar_url":"//cdn.7tv.app/user/6054fb35b4d31e459f7cde73/av_657c1e5fa8b03226340b14d7/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6388785d0d4985e50d524483","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":1,"size":1182,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":1,"size":1838,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":1,"size":2121,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":1,"size":4828,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":1,"size":3048,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":1,"size":9046,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":1,"size":3970,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":1,"size":13036,"format":"WEBP"}]}}},{"id":"6216d2f73808dfe5c465bc4a","name":"ALERT","flags":1,"timestamp":1678202119745,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6216d2f73808dfe5c465bc4a","name":"ALERT","flags":256,"tags":["clickbait","arrow","red","circle","bruh","boom"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61ccf4795fa851bfdf0f3da8","username":"pen15827","display_name":"pen15827","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/ce57700a-def9-11e9-842d-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6216d2f73808dfe5c465bc4a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":2,"size":2843,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":2,"size":1116,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":2,"size":4082,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":2,"size":2410,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":2,"size":5224,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":2,"size":4142,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":2,"size":6456,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":2,"size":5144,"format":"WEBP"}]}}},{"id":"623c29a61aeb248de8494e7c","name":"angy","flags":0,"timestamp":1678364637566,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"623c29a61aeb248de8494e7c","name":"angy","flags":0,"tags":["mad","peepo"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6121288754434411dd0d1fdc","username":"mardexgaming","display_name":"Mardexgaming","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/70ead9d8-9a1a-4703-b49d-15050f91c3c1-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/623c29a61aeb248de8494e7c","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":790,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1121,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1847,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1856,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2622,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2844,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3434,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4164,"format":"WEBP"}]}}},{"id":"60ae3ca4aee2aa553822c4a6","name":"docOkay","flags":0,"timestamp":1678418118907,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae3ca4aee2aa553822c4a6","name":"docOkay","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60772a85a807bed00612d1ee","username":"lnsc","display_name":"LNSc","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4207f38c-73f0-4487-a7b2-07ccb27667d1-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae3ca4aee2aa553822c4a6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1313,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":976,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2683,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2460,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4279,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4278,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5866,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5230,"format":"WEBP"}]}}},{"id":"60baf728f34d2d2acec74711","name":"Phone","flags":0,"timestamp":1678418119662,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60baf728f34d2d2acec74711","name":"Phone","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3df2aee2aa55382ba24d","username":"khaltour","display_name":"Khaltour","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f39529e7-1d39-4e94-8a60-d4097c3ec31a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60baf728f34d2d2acec74711","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1092,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":832,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2046,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2111,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3057,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3348,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4020,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3736,"format":"WEBP"}]}}},{"id":"60b0c3c86a06830c3de91bba","name":"monkaGIGA","flags":0,"timestamp":1678418120060,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b0c3c86a06830c3de91bba","name":"monkaGIGA","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b0c24b7ec2882b3dd6489d","username":"pulshly","display_name":"pulshly","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7c893266-4d63-4c1b-9b5e-54af97af8b16-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b0c3c86a06830c3de91bba","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1328,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1080,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2569,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3966,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4428,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5203,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6156,"format":"WEBP"}]}}},{"id":"603cb600c20d020014423c6e","name":"forsenSWA","flags":0,"timestamp":1678418120390,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cb600c20d020014423c6e","name":"forsenSWA","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603cb1c696832ffa78cc3bc2","username":"clyvere","display_name":"clyverE","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3ff40972-0188-4cfc-adbf-8db119d7cf2a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb600c20d020014423c6e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":3,"size":3577,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":3,"size":3232,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":3,"size":6389,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":3,"size":7110,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":3,"size":10340,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":3,"size":12454,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":3,"size":20721,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":3,"size":19450,"format":"WEBP"}]}}},{"id":"60af0382b38361ea91337096","name":"!rebel","flags":0,"timestamp":1678544756520,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60af0382b38361ea91337096","name":"peepoRiot","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae49350e35477634486602","username":"justrogan","display_name":"JustRogan","avatar_url":"//cdn.7tv.app/pp/60ae49350e35477634486602/88d6e3c4265f4be0a452c812c146da50","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af0382b38361ea91337096","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":8,"size":6045,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":8,"size":7868,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":8,"size":12690,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":8,"size":18234,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":8,"size":19890,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":8,"size":31674,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":8,"size":29071,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":8,"size":37002,"format":"WEBP"}]}}},{"id":"619ad4b970bd9959879595f9","name":"!vote","flags":0,"timestamp":1678544762723,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"619ad4b970bd9959879595f9","name":"SOCIALCREDITING","flags":0,"tags":["social","credits","china","lemao"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae5b8d0e354776345188c1","username":"en_djinn","display_name":"En_Djinn","avatar_url":"//cdn.7tv.app/pp/60ae5b8d0e354776345188c1/0c71ab67a84141ed9185919c432137d7","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619ad4b970bd9959879595f9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":80,"size":14178,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":80,"size":55796,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":80,"size":30040,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":80,"size":122566,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":80,"size":53764,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":80,"size":194826,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":80,"size":94876,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":80,"size":223152,"format":"WEBP"}]}}},{"id":"62495a74fba06b9273b2de7f","name":"Medievalge","flags":0,"timestamp":1678544767075,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62495a74fba06b9273b2de7f","name":"Medievalge","flags":0,"tags":["evilge","dge"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"610301ee44c8b7eb9204ff9b","username":"limecookii","display_name":"limecookii","avatar_url":"//cdn.7tv.app/pp/610301ee44c8b7eb9204ff9b/3a308d4c0fe040dd9d02783df9a4ae8a","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62495a74fba06b9273b2de7f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":1,"size":1680,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":1,"size":1410,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":1,"size":3548,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":1,"size":3792,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":1,"size":5782,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":1,"size":6826,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":1,"size":8012,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":1,"size":10388,"format":"WEBP"}]}}},{"id":"610ebd56900cbd77c695858a","name":"peepoKing","flags":0,"timestamp":1678544770123,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"610ebd56900cbd77c695858a","name":"peepoKing","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60f244dfc07d1ac193c6314e","username":"kushala_0001","display_name":"Kushala_0001","avatar_url":"//cdn.7tv.app/user/60f244dfc07d1ac193c6314e/av_640be52567f0badff748097e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610ebd56900cbd77c695858a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1808,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1342,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":4241,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3782,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7002,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":7244,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":10980,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":10876,"format":"WEBP"}]}}},{"id":"61957f6e6467596b1d624a02","name":"peepoQueen","flags":0,"timestamp":1678553855456,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61957f6e6467596b1d624a02","name":"peepoQueen","flags":0,"tags":["peepoqueen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"618bd6bad34608492cc29b9b","username":"tomato_es","display_name":"tomato_es","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fb464f3e372e54f2-profile_image-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61957f6e6467596b1d624a02","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1483,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1218,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3073,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2990,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4829,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5306,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6330,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7760,"format":"WEBP"}]}}},{"id":"60ae3028b2ecb01505cf58f1","name":"gachiPRIDE","flags":0,"timestamp":1678634148451,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae3028b2ecb01505cf58f1","name":"gachiPRIDE","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae3028b2ecb01505cf58f1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":90,"size":40875,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":90,"size":98430,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":90,"size":129956,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":90,"size":239976,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":90,"size":232304,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":90,"size":416644,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":90,"size":377466,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":90,"size":455352,"format":"WEBP"}]}}},{"id":"60ae39d5b2ecb0150511882d","name":"Painsge","flags":0,"timestamp":1678634148783,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae39d5b2ecb0150511882d","name":"Painsge","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae39d5b2ecb0150511882d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1270,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":922,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2321,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2274,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3545,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3858,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4733,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4942,"format":"WEBP"}]}}},{"id":"61da356b600369a98b38a1f0","name":"nymnNerd","flags":0,"timestamp":1678634149106,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61da356b600369a98b38a1f0","name":"nymnNerd","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae485c0e35477634456d8b","username":"imdor_","display_name":"ImDor_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/71a13ff9-ac92-432e-9577-1f5d3809a5dc-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61da356b600369a98b38a1f0","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1044,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1290,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2596,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2517,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4068,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4562,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5774,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7280,"format":"WEBP"}]}}},{"id":"60b0f5b2824db02a4103592d","name":"FEELSWAYTOOGOOD","flags":0,"timestamp":1678634149429,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b0f5b2824db02a4103592d","name":"FEELSWAYTOOGOOD","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae4a8b9986a00349629473","username":"riier","display_name":"riIer","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/50fb833d-3f45-4675-903e-129848483bd1-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b0f5b2824db02a4103592d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":80,"size":116810,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":80,"size":106800,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":80,"size":332281,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":80,"size":293560,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":80,"size":568421,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":80,"size":547814,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":80,"size":867670,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":80,"size":746256,"format":"WEBP"}]}}},{"id":"633b03457858907ca876a17f","name":"henrE","flags":0,"timestamp":1678634149760,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"633b03457858907ca876a17f","name":"henrE","flags":0,"tags":["lit","bussin","swag","awesome","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"633b02fb7858907ca876a17b","username":"videogamesfan1234","display_name":"videogamesfan1234","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3639c0c0-084c-4071-b4ed-30ed58d2dee2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/633b03457858907ca876a17f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":902,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1536,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4614,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1357,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8698,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1839,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2255,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13620,"format":"WEBP"}]}}},{"id":"60ae2887b2ecb01505a44861","name":"HONEYDETECTED","flags":0,"timestamp":1678718349439,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ae2887b2ecb01505a44861","name":"HONEYDETECTED","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6090833b38a7d4c606e25413","username":"oskar5oskar","display_name":"oskar5oskar","avatar_url":"//cdn.7tv.app/pp/6090833b38a7d4c606e25413/dcaf75045ff94869b50ff27da406c3de","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae2887b2ecb01505a44861","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":27,"height":32,"frame_count":1,"size":1656,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":27,"height":32,"frame_count":1,"size":1334,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":54,"height":64,"frame_count":1,"size":3253,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":54,"height":64,"frame_count":1,"size":3304,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":81,"height":96,"frame_count":1,"size":4997,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":81,"height":96,"frame_count":1,"size":5556,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":108,"height":128,"frame_count":1,"size":6736,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":108,"height":128,"frame_count":1,"size":7470,"format":"WEBP"}]}}},{"id":"64106f45d85152e6648cacd9","name":"Barons","flags":0,"timestamp":1678798669784,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64106f45d85152e6648cacd9","name":"Barons","flags":0,"tags":["kingofthecastle","kotc"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64106f45d85152e6648cacd9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":4964,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":3958,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":9400,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":8280,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":14883,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":13082,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":23926,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":17600,"format":"WEBP"}]}}},{"id":"64106ea9a1db99f27d318959","name":"Patricians","flags":0,"timestamp":1678798670658,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64106ea9a1db99f27d318959","name":"Patricians","flags":0,"tags":["kingofthecastle","kotc"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64106ea9a1db99f27d318959","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":4927,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":3832,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":7998,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":9045,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":14422,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":12716,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":23013,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":17108,"format":"WEBP"}]}}},{"id":"64106eb764170c9686329cd2","name":"Counts","flags":0,"timestamp":1678798671636,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64106eb764170c9686329cd2","name":"Counts","flags":0,"tags":["kingofthecastle","kotc"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64106eb764170c9686329cd2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":4806,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":3840,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":9029,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":8016,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":14417,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":12772,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":22958,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":17186,"format":"WEBP"}]}}},{"id":"64106ee61db1890a6196de57","name":"Chiefs","flags":0,"timestamp":1678798672578,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64106ee61db1890a6196de57","name":"Chiefs","flags":0,"tags":["kingofthecastle","kotc"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64106ee61db1890a6196de57","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":5060,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":3918,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":9512,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":8264,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":15100,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":13070,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":24316,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":17622,"format":"WEBP"}]}}},{"id":"64106eb0dba31716800e6455","name":"Grandees","flags":0,"timestamp":1678798673622,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64106eb0dba31716800e6455","name":"Grandees","flags":0,"tags":["kingofthecastle","kotc"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64106eb0dba31716800e6455","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":5139,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":3974,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":9729,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":8420,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":15483,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":13424,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":24804,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":17752,"format":"WEBP"}]}}},{"id":"60ae9acf3c27a8b79c9be9bf","name":"BigHardo","flags":0,"timestamp":1678875695082,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60ae9acf3c27a8b79c9be9bf","name":"BigHardo","flags":0,"tags":["widehardo","hardo","wide","big","trihard","trihex"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae759bdf5735e04acb69d9","username":"hotbear1110","display_name":"HotBear1110","avatar_url":"//cdn.7tv.app/pp/60ae759bdf5735e04acb69d9/80e2b49378c14dc6914fde8cb72fa673","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae9acf3c27a8b79c9be9bf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":1,"size":2024,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":1,"size":1910,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":1,"size":3705,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":1,"size":4492,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":1,"size":5170,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":1,"size":7012,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":1,"size":6740,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":1,"size":9384,"format":"WEBP"}]}}},{"id":"6362de54b4cd96c8459b4dfa","name":"SwagOff","flags":0,"timestamp":1678884462066,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6362de54b4cd96c8459b4dfa","name":"SwagOff","flags":0,"tags":["emoney","erobb221"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6362de54b4cd96c8459b4dfa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":1045,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":1816,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":1910,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":5510,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":2921,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":10950,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":4045,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":18066,"format":"WEBP"}]}}},{"id":"63995b353a424911dcde9df7","name":"catEat","flags":0,"timestamp":1678893348492,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63995b353a424911dcde9df7","name":"catEat","flags":0,"tags":["eating","kitty","psp1g","silly","cat"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"624476c4b22b98fd69bad800","username":"rementin","display_name":"Rementin","avatar_url":"//cdn.7tv.app/user/624476c4b22b98fd69bad800/av_639613dac5d3143d0fa9317a/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63995b353a424911dcde9df7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":14,"size":6629,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":14,"size":7468,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":14,"size":12571,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":14,"size":13466,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":14,"size":19480,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":14,"size":20158,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":14,"size":25952,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":14,"size":26610,"format":"WEBP"}]}}},{"id":"60ae2e3db2ecb01505c6f69d","name":"ViolinTime","flags":0,"timestamp":1678893348864,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae2e3db2ecb01505c6f69d","name":"ViolinTime","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae2e3db2ecb01505c6f69d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":7519,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":25,"size":25176,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":12324,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":45762,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":20771,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":78378,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":25504,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":83278,"format":"WEBP"}]}}},{"id":"61c1b7ba857d3a3442b21ce0","name":"GivenUp","flags":0,"timestamp":1678893349210,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61c1b7ba857d3a3442b21ce0","name":"GivenUp","flags":0,"tags":["despair","troll","sadge","boobs","sexy","booba"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61056ca46ec21e98cdd05532","username":"takideezy","display_name":"TAKIDEEZY","avatar_url":"//cdn.7tv.app/pp/61056ca46ec21e98cdd05532/b5a16c68dea64472a042214a3b6811f1","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61c1b7ba857d3a3442b21ce0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":1,"size":985,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":1,"size":752,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":1,"size":1870,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":1,"size":1926,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":1,"size":2893,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":1,"size":3240,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":1,"size":4014,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":1,"size":4758,"format":"WEBP"}]}}},{"id":"63583ee508a02a66a37dcd9d","name":"peepoRiot","flags":0,"timestamp":1678893349586,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63583ee508a02a66a37dcd9d","name":"peepoRiot","flags":0,"tags":["angry","rage","showcock","mad","madge"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60aff6d6fd9839f62de3d3c0","username":"tanoshi13","display_name":"Tanoshi13","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63583ee508a02a66a37dcd9d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":21,"size":27038,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":21,"size":43058,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":21,"size":80369,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":21,"size":120762,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":21,"size":142101,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":21,"size":225374,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":21,"size":213679,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":21,"size":368994,"format":"WEBP"}]}}},{"id":"603cb5a2c20d020014423c65","name":"KKomrade","flags":0,"timestamp":1678893349938,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"603cb5a2c20d020014423c65","name":"KKomrade","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"603cb1c696832ffa78cc3bc2","username":"clyvere","display_name":"clyverE","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3ff40972-0188-4cfc-adbf-8db119d7cf2a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cb5a2c20d020014423c65","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1521,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1182,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3470,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3002,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5457,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5456,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8433,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8088,"format":"WEBP"}]}}},{"id":"60b10835f12983cd1da8eae3","name":"POGCRAZY","flags":0,"timestamp":1678958782918,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b10835f12983cd1da8eae3","name":"POGCRAZY","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b3bb1039cff46b8b65d389","username":"nakomaru","display_name":"nakomaru","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/49b33784-b455-4327-bb58-5be4976152b2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b10835f12983cd1da8eae3","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1440,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":2622,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":2964,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":3531,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":4537,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":4498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":5590,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":4736,"format":"WEBP"}]}}},{"id":"60fb3c105b7deb3de0233db8","name":"peepoSweden","flags":0,"timestamp":1679062362260,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60fb3c105b7deb3de0233db8","name":"peepoSweden","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae79fd2d2ea639d67cbe33","username":"mr0lle","display_name":"Mr0lle","avatar_url":"//cdn.7tv.app/user/60ae79fd2d2ea639d67cbe33/av_6482018e80691d3bb17755cc/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60fb3c105b7deb3de0233db8","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":4436,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":5595,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":9754,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":10224,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":15599,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":15812,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":21277,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":16104,"format":"WEBP"}]}}},{"id":"60439ac01d4963000d9dae44","name":"WEEBSDETECTED","flags":0,"timestamp":1679152562394,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60439ac01d4963000d9dae44","name":"WEEBSDETECTED","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042ac8796832ffa7887f10d","username":"jirabruar","display_name":"JirabruaR","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c0004f23-ca18-4f81-82d8-c647b202096a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60439ac01d4963000d9dae44","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":9,"size":5810,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":9,"size":6954,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":9,"size":12032,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":9,"size":15504,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":9,"size":18804,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":9,"size":23846,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":9,"size":32944,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":9,"size":37134,"format":"WEBP"}]}}},{"id":"60aab50dd9ec9c6fc5eef7a0","name":"SOYING","flags":0,"timestamp":1679152562762,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aab50dd9ec9c6fc5eef7a0","name":"SOYING","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aab50dd9ec9c6fc5eef7a0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":46889,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":118366,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":109738,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":248834,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":193248,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":384494,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":291964,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":359740,"format":"WEBP"}]}}},{"id":"613ede4e7b14fdf700b8b3d2","name":"ElNoSabe","flags":0,"timestamp":1679152563147,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"613ede4e7b14fdf700b8b3d2","name":"ElNoSabe","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae659b86fc40d4886293d2","username":"vicesmile_6407","display_name":"vicesmile_6407","avatar_url":"//cdn.7tv.app/pp/60ae659b86fc40d4886293d2/407a4925ae9c40f8bffe20dbb2020276","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613ede4e7b14fdf700b8b3d2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":5619,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":4516,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":11922,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":11957,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":19061,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":20656,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":27490,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":25512,"format":"WEBP"}]}}},{"id":"61eff87af933d586cdda6b2d","name":"apolloPain","flags":0,"timestamp":1679152564084,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61eff87af933d586cdda6b2d","name":"apolloPain","flags":0,"tags":["pain","apollo","yabbe","nymn","sad"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61eff87af933d586cdda6b2d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":853,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":586,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1472,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1534,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2056,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2556,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3133,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4026,"format":"WEBP"}]}}},{"id":"6063c87af4dc10001426b915","name":"LULW","flags":0,"timestamp":1679152766674,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"6063c87af4dc10001426b915","name":"LULW","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"000000000000000000000000","username":"","display_name":"","style":{}},"host":{"url":"//cdn.7tv.app/emote/6063c87af4dc10001426b915","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":28,"height":32,"frame_count":1,"size":1071,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":28,"height":32,"frame_count":1,"size":762,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":56,"height":64,"frame_count":1,"size":1920,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":56,"height":64,"frame_count":1,"size":1797,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":84,"height":96,"frame_count":1,"size":2964,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":84,"height":96,"frame_count":1,"size":2486,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":112,"height":128,"frame_count":1,"size":3008,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":112,"height":128,"frame_count":1,"size":3978,"format":"WEBP"}]}}},{"id":"63b41fe98dbb71dedfddd46a","name":"AlienFeel","flags":0,"timestamp":1679247317678,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63b41fe98dbb71dedfddd46a","name":"AlienFeel","flags":0,"tags":["alienlag","alienpls"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b39e943e203cc169dfc106","username":"nerixyz","display_name":"nerixyz","avatar_url":"//cdn.7tv.app/user/60b39e943e203cc169dfc106/av_64c262f0577886a8184c8a55/3x.webp","style":{"color":401323775},"roles":["60b3f1ea886e63449c5263b1","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b41fe98dbb71dedfddd46a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":374,"size":110700,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":374,"size":204432,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":374,"size":243079,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":374,"size":399628,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":374,"size":411672,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":374,"size":580264,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":374,"size":549883,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":374,"size":749212,"format":"WEBP"}]}}},{"id":"60be95e560f498310bd13d2c","name":"donkAim","flags":0,"timestamp":1679322828499,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60be95e560f498310bd13d2c","name":"donkAim","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60be9049412138e6fa758c6d","username":"b0de","display_name":"b0de","avatar_url":"//cdn.7tv.app/user/60be9049412138e6fa758c6d/av_657d636683833b99670d41a1/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60be95e560f498310bd13d2c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":39,"size":18883,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":39,"size":32340,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":39,"size":45634,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":39,"size":67204,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":39,"size":73812,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":39,"size":109344,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":39,"size":103568,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":39,"size":122614,"format":"WEBP"}]}}},{"id":"6418a27761181dac3d98386c","name":"unbased0","flags":1,"timestamp":1679337473513,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6418a27761181dac3d98386c","name":"unbased0","flags":256,"tags":["based","sunglasses"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60532ec2b4d31e459f7293dc","username":"marrryanx","display_name":"Marrryanx","avatar_url":"//cdn.7tv.app/user/60532ec2b4d31e459f7293dc/av_6570dc7f834e0a119031a679/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6418a27761181dac3d98386c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":93,"size":22856,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":93,"size":37632,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":93,"size":43720,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":93,"size":55312,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":93,"size":75924,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":93,"size":87364,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":93,"size":99848,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":93,"size":94246,"format":"WEBP"}]}}},{"id":"6418c4d336447ca8cfe1c1ad","name":"DiabloIV","flags":0,"timestamp":1679344869038,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6418c4d336447ca8cfe1c1ad","name":"DiabloIV","flags":0,"tags":["diablo","circle","div","diabloiv","diablo4"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6418c4d336447ca8cfe1c1ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":15,"size":7647,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":15,"size":13040,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":15,"size":17801,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":15,"size":30096,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":15,"size":32635,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":15,"size":50892,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":15,"size":49005,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":15,"size":71356,"format":"WEBP"}]}}},{"id":"63ee331a612066cf56f72eda","name":"Aplonk","flags":0,"timestamp":1679411226483,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"63ee331a612066cf56f72eda","name":"Aplonk","flags":0,"tags":["plink","plonk","apollo","nymn","cat"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ee331a612066cf56f72eda","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":80,"height":32,"frame_count":119,"size":26798,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":80,"height":32,"frame_count":119,"size":73464,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":160,"height":64,"frame_count":119,"size":59886,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":160,"height":64,"frame_count":119,"size":164570,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":240,"height":96,"frame_count":119,"size":103277,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":240,"height":96,"frame_count":119,"size":265288,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":320,"height":128,"frame_count":119,"size":456597,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":320,"height":128,"frame_count":119,"size":453864,"format":"WEBP"}]}}},{"id":"6287979263c9f9d7aaa39c73","name":"TriAmigos","flags":0,"timestamp":1679411762865,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6287979263c9f9d7aaa39c73","name":"TriAmigos","flags":0,"tags":["trihex","trihard","hombre","hermano","amigos"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"611bc87d5f3378ee33500b98","username":"sotiris_ael","display_name":"Sotiris_Ael","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38a2b8d2-5fe5-47ab-9726-4c79e568d6fe-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6287979263c9f9d7aaa39c73","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1606,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":1162,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":3542,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":3106,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":5905,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":5576,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":9334,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":8968,"format":"WEBP"}]}}},{"id":"61fe77cf69acfa0715e1f7ef","name":"Chatge","flags":0,"timestamp":1679411763427,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61fe77cf69acfa0715e1f7ef","name":"Chatge","flags":0,"tags":["chat","pepege","okayge","pepe","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61fe77cf69acfa0715e1f7ef","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":83,"height":32,"frame_count":1,"size":1571,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":83,"height":32,"frame_count":1,"size":1546,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":166,"height":64,"frame_count":1,"size":3636,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":166,"height":64,"frame_count":1,"size":3273,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":249,"height":96,"frame_count":1,"size":5756,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":249,"height":96,"frame_count":1,"size":5062,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":332,"height":128,"frame_count":1,"size":6905,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":332,"height":128,"frame_count":1,"size":8238,"format":"WEBP"}]}}},{"id":"60b2654203982475b83b1346","name":"1984","flags":0,"timestamp":1679411763841,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60b2654203982475b83b1346","name":"1984","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b0f5b9726e10b664c44bb5","username":"treoluas","display_name":"treoluas","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/0269f924-627e-46ff-8a23-7c599103d8b7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b2654203982475b83b1346","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":30,"size":10626,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":30,"size":27930,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":30,"size":23861,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":30,"size":67176,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":30,"size":44184,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":30,"size":110942,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":30,"size":68653,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":30,"size":121988,"format":"WEBP"}]}}},{"id":"61fd55b2690425de3c63eae0","name":"BrickTime","flags":0,"timestamp":1679411764311,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61fd55b2690425de3c63eae0","name":"BrickTime","flags":0,"tags":["nymn","fuzer","brick","tomatotime","boo"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61fd55b2690425de3c63eae0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":27,"size":11196,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":27,"size":16600,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":27,"size":19147,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":27,"size":34460,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":27,"size":29876,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":27,"size":54710,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":27,"size":39191,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":27,"size":59616,"format":"WEBP"}]}}},{"id":"61474a7845d00846a86eb11e","name":"Turteg","flags":0,"timestamp":1679413283767,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61474a7845d00846a86eb11e","name":"Turteg","flags":0,"tags":["okayeg","turtle"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60f3577ac07d1ac1939168a5","username":"svenin_","display_name":"svenin_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4096983f-68f5-48b4-ab5c-6c5e084f3b03-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61474a7845d00846a86eb11e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1444,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1018,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2735,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2490,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4243,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4366,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6456,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5916,"format":"AVIF"}]}}},{"id":"640676b06feb26d14dc341a9","name":"FIRE","flags":1,"timestamp":1679429894599,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"640676b06feb26d14dc341a9","name":"onFire","flags":256,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60f9b978c07d1ac1939608e2","username":"ja77man","display_name":"Ja77Man","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7bbef0bf-7cc2-4ebe-9652-fa08e08ef2d0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/640676b06feb26d14dc341a9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":32,"size":19353,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":32,"size":18984,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":32,"size":24776,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":32,"size":26358,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":32,"size":42718,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":32,"size":43962,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":32,"size":40027,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":32,"size":38694,"format":"WEBP"}]}}},{"id":"614098ba0969108b6718d263","name":"pepeL","flags":0,"timestamp":1679592187659,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"614098ba0969108b6718d263","name":"pepeL","flags":0,"tags":["pepe","pepel"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614098ba0969108b6718d263","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":528,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":813,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1073,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":816,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1384,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":1082,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":1585,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":1298,"format":"WEBP"}]}}},{"id":"641c8e06b180e3592d9a0d6c","name":"TimeToLie","flags":0,"timestamp":1679593064081,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"641c8e06b180e3592d9a0d6c","name":"TimeToLie","flags":0,"tags":["lie","lying","ditch","pinocchio","nymn","nime"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/641c8e06b180e3592d9a0d6c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":55,"height":32,"frame_count":30,"size":5005,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":55,"height":32,"frame_count":30,"size":6694,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":110,"height":64,"frame_count":30,"size":8072,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":110,"height":64,"frame_count":30,"size":29216,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":165,"height":96,"frame_count":30,"size":10908,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":165,"height":96,"frame_count":30,"size":48348,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":220,"height":128,"frame_count":30,"size":17787,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":220,"height":128,"frame_count":30,"size":70912,"format":"WEBP"}]}}},{"id":"60b41ac425b841c0d879fc99","name":"SmugTime","flags":0,"timestamp":1679661660720,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b41ac425b841c0d879fc99","name":"SmugTime","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"608bd85b99dcd148faf02644","username":"tehargi_","display_name":"tehargi_","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/294c98b5-e34d-42cd-a8f0-140b72fba9b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b41ac425b841c0d879fc99","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":106,"size":18601,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":106,"size":75518,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":106,"size":32618,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":106,"size":151030,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":106,"size":57292,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":106,"size":240336,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":106,"size":88695,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":106,"size":252196,"format":"WEBP"}]}}},{"id":"63ab1545c29011fd71be1cb5","name":"peepoPag","flags":0,"timestamp":1679663442636,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63ab1545c29011fd71be1cb5","name":"peepoPag","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ab1545c29011fd71be1cb5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1215,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1722,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1857,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4822,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2700,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8828,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3355,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13146,"format":"WEBP"}]}}},{"id":"613150f0a77c17adb4479f1c","name":"SALAMIhand","flags":1,"timestamp":1679670962319,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"613150f0a77c17adb4479f1c","name":"SALAMIhand","flags":256,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b561cc04283ab952bfd4e0","username":"on_a_stack","display_name":"On_a_stack","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1ed36b65-71c8-4eb2-a6a7-83ad2bb7566a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613150f0a77c17adb4479f1c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":2863,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":1944,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":4000,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":3484,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":5652,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":4984,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":6773,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":4960,"format":"WEBP"}]}}},{"id":"62dc3e4c72884f6d6e8b52f2","name":"PotFriendWave","flags":0,"timestamp":1679670962664,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62dc3e4c72884f6d6e8b52f2","name":"PotFriendWave","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62111f1c21b4d5de14470046","username":"bassasas","display_name":"bassasas","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62dc3e4c72884f6d6e8b52f2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":2936,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1976,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":4363,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":4646,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":5813,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":7118,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":7072,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":9664,"format":"WEBP"}]}}},{"id":"60ae759df7c927fad171a104","name":"ppCrazy","flags":0,"timestamp":1679670962993,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60ae759df7c927fad171a104","name":"ppCrazy","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae461c9986a003493358f3","username":"gaib_","display_name":"gaib_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ce7e4c60-a008-4e7e-8972-e905ffc54e71-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae759df7c927fad171a104","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":34,"size":6621,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":34,"size":10318,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":34,"size":8721,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":34,"size":17040,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":34,"size":12673,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":34,"size":24126,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":34,"size":14453,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":34,"size":24896,"format":"WEBP"}]}}},{"id":"60aeaf8b98f4291470c8e64b","name":"COCKA","flags":0,"timestamp":1679752063073,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60aeaf8b98f4291470c8e64b","name":"COCKA","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae9207ac03cad607d3980f","username":"onkel_jodok","display_name":"onkel_jodok","avatar_url":"//cdn.7tv.app/pp/60ae9207ac03cad607d3980f/a7bb23d0f0ad46bb8105768c642ce6a1","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeaf8b98f4291470c8e64b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":8,"size":5766,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":8,"size":8046,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":8,"size":10715,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":8,"size":16452,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":8,"size":16642,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":8,"size":25640,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":8,"size":22394,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":8,"size":28178,"format":"WEBP"}]}}},{"id":"63ee3374ae545fa8d4208337","name":"WEIRDO","flags":0,"timestamp":1679785642323,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63ee3374ae545fa8d4208337","name":"MyHonestReaction","flags":0,"tags":["weird","okay","cat","apollo","nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ee3374ae545fa8d4208337","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":209,"size":27739,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":209,"size":75952,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":209,"size":55019,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":209,"size":161924,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":209,"size":93338,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":209,"size":253128,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":209,"size":240555,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":209,"size":410700,"format":"WEBP"}]}}},{"id":"633dd7eba855bd5c6ce87a37","name":"EDM","flags":0,"timestamp":1679790561792,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"633dd7eba855bd5c6ce87a37","name":"EDM","flags":0,"tags":["dance","bass","xar2edm","party","jam","rave"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60f5e290e57bec021618c4a4","username":"ansonx10","display_name":"AnsonX10","avatar_url":"//cdn.7tv.app/user/60f5e290e57bec021618c4a4/av_63617cc39018da6429bc0298/3x_static.webp","style":{"color":401323775},"roles":["60b3f1ea886e63449c5263b1","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/633dd7eba855bd5c6ce87a37","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":200,"size":25028,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":200,"size":39732,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":200,"size":35404,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":200,"size":54108,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":200,"size":62056,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":200,"size":72538,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":200,"size":61875,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":200,"size":69646,"format":"WEBP"}]}}},{"id":"614b1f0e5765a48b24d95906","name":"SoyJam","flags":0,"timestamp":1679867551976,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"614b1f0e5765a48b24d95906","name":"SoyJam","flags":0,"tags":["nymn","soying","rave","pls","forsen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614b1f0e5765a48b24d95906","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":151,"size":72986,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":151,"size":120624,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":151,"size":163958,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":151,"size":259012,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":151,"size":281242,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":151,"size":420734,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":151,"size":386303,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":151,"size":404650,"format":"WEBP"}]}}},{"id":"6134f99b434bb87c45d91b58","name":"emoteApprove","flags":1,"timestamp":1679930162780,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6134f99b434bb87c45d91b58","name":"emoteApprove","flags":256,"tags":["billy","gachiapprove","billyapprove","zerowidth","monkass","batmanschest"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae21c0aee2aa55388ba741","username":"farbrorbarbro","display_name":"FarbrorBarbro","avatar_url":"//cdn.7tv.app/pp/60ae21c0aee2aa55388ba741/2aa45e11e4474bf8a04c74fe9157bd53","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6134f99b434bb87c45d91b58","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":48,"size":16797,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":48,"size":30122,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":48,"size":60244,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":48,"size":33656,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":48,"size":98182,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":48,"size":58079,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":48,"size":82686,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":48,"size":100660,"format":"WEBP"}]}}},{"id":"63c326a919eab0d59a02bb9c","name":"nymnEmo","flags":0,"timestamp":1679930163301,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63c326a919eab0d59a02bb9c","name":"nymnEmo","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60e8b3593c5b87437a8cd7a4","username":"cybo_","display_name":"Cybo_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/0c870390-5220-4704-8c6e-57cfb9120695-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c326a919eab0d59a02bb9c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1254,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2086,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2551,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6824,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12340,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4074,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16956,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5607,"format":"AVIF"}]}}},{"id":"60aee9d5361b0164e60d02c2","name":"WICKED","flags":0,"timestamp":1679930163788,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60aee9d5361b0164e60d02c2","name":"WICKED","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae7682f7c927fad17bc447","username":"pandabene_","display_name":"Pandabene_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e87ae0b4-fc7c-406d-8e02-67922b54fa15-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aee9d5361b0164e60d02c2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":24,"size":7396,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":24,"size":25554,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":24,"size":18899,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":24,"size":56806,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":24,"size":32920,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":24,"size":90470,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":24,"size":52163,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":24,"size":112870,"format":"WEBP"}]}}},{"id":"60afe5c26fdef7f55180c7b1","name":"POGGERS","flags":0,"timestamp":1680001598113,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60afe5c26fdef7f55180c7b1","name":"POGGERS","flags":0,"tags":["verypog","shroud","poggers"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60afe336e5a5795611c9c482","username":"sleepi3r","display_name":"sleepi3r","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afe5c26fdef7f55180c7b1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":142,"size":28380,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":142,"size":120388,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":142,"size":97690,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":142,"size":299126,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":142,"size":181716,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":142,"size":475696,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":142,"size":316728,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":142,"size":373448,"format":"WEBP"}]}}},{"id":"63eeb99e1471b8a35936a509","name":"OOOO","flags":0,"timestamp":1680011852493,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63eeb99e1471b8a35936a509","name":"OOOO","flags":0,"tags":["rogan","joerogan","pog","ufc"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63eeb99e1471b8a35936a509","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":45,"height":32,"frame_count":249,"size":96404,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":45,"height":32,"frame_count":249,"size":177032,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":90,"height":64,"frame_count":249,"size":213441,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":90,"height":64,"frame_count":249,"size":348008,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":135,"height":96,"frame_count":249,"size":346695,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":135,"height":96,"frame_count":249,"size":525880,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":180,"height":128,"frame_count":249,"size":545159,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":180,"height":128,"frame_count":249,"size":687234,"format":"WEBP"}]}}},{"id":"6420b1dd268d3cdbcb82433b","name":"emiNymN","flags":0,"timestamp":1680020921999,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6420b1dd268d3cdbcb82433b","name":"emiNymN","flags":0,"tags":["nymn","eminem","jam","forsen","bald"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"612fad78a77c17adb4478f0f","username":"rexinus1","display_name":"Rexinus1","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/47acff52-7ea8-4d2e-8f40-4c050b1d360c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6420b1dd268d3cdbcb82433b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":24,"size":8960,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":21,"size":10868,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":24,"size":19514,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":24,"size":31714,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":24,"size":35576,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":24,"size":49188,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":24,"size":56457,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":24,"size":66736,"format":"WEBP"}]}}},{"id":"60df1f34dd6a810dd4399544","name":"TrollGlad","flags":0,"timestamp":1680022153779,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60df1f34dd6a810dd4399544","name":"TrollGlad","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60af909f12f90fadd689522a","username":"baseddex","display_name":"BasedDex","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/36396175-d036-4354-b264-f8bfd8c0142d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60df1f34dd6a810dd4399544","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1209,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1032,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2545,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2640,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4325,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4708,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6331,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7420,"format":"WEBP"}]}}},{"id":"60afdd3ad2e19045eec9be59","name":"BLAPBLAP","flags":0,"timestamp":1680185850385,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60afdd3ad2e19045eec9be59","name":"BLAPBLAP","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae653c9627f9aff4f5ccd1","username":"xoo_6119","display_name":"xoo_6119","avatar_url":"//cdn.7tv.app/user/60ae653c9627f9aff4f5ccd1/av_63ca0eccdedb49b24383ae5c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afdd3ad2e19045eec9be59","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":49,"size":12608,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":49,"size":30148,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":49,"size":26483,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":49,"size":65318,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":49,"size":40972,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":49,"size":110304,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":49,"size":68522,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":49,"size":128098,"format":"WEBP"}]}}},{"id":"6426dab77a943d5766ab5582","name":"RoyaltE","flags":0,"timestamp":1680268654848,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"6426dab77a943d5766ab5582","name":"RoyaltE","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6426dab77a943d5766ab5582","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1333,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2286,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2770,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":7266,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4047,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":13496,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5460,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":21342,"format":"WEBP"}]}}},{"id":"61a24b98e9684edbbc371602","name":"donkTalk","flags":0,"timestamp":1680362192911,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61a24b98e9684edbbc371602","name":"donkTalk","flags":0,"tags":["donk","talk","peepotalk","donktalk","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61a24b98e9684edbbc371602","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":5900,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":15926,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":11222,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":32298,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":18227,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":50868,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":25421,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":61266,"format":"WEBP"}]}}},{"id":"6397e6789b33c1d4937155e6","name":"ReallyGunPull","flags":0,"timestamp":1680524220660,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6397e6789b33c1d4937155e6","name":"ReallyGunPull","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61d0b944c29ed2aed7544871","username":"deividmartini","display_name":"DeividMartini","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ff790138-083a-49c3-8456-52562077e823-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6397e6789b33c1d4937155e6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":54,"height":32,"frame_count":86,"size":29514,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":54,"height":32,"frame_count":86,"size":81004,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":108,"height":64,"frame_count":86,"size":79674,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":108,"height":64,"frame_count":86,"size":164944,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":162,"height":96,"frame_count":86,"size":153516,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":162,"height":96,"frame_count":86,"size":258144,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":216,"height":128,"frame_count":86,"size":283645,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":216,"height":128,"frame_count":86,"size":326764,"format":"WEBP"}]}}},{"id":"60df2685dd6a810dd4aad2d3","name":"PPirate","flags":0,"timestamp":1680528534174,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60df2685dd6a810dd4aad2d3","name":"PPirate","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60d8697d28fb0b0a5466371b","username":"bahkiroff","display_name":"BahkirOFF","avatar_url":"//cdn.7tv.app/pp/60d8697d28fb0b0a5466371b/8dd4341b657447e1af832c71cd496bc6","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60df2685dd6a810dd4aad2d3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":1444,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1202,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":2962,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":3036,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":4669,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":5506,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":6651,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":8402,"format":"WEBP"}]}}},{"id":"63ffc5cef2915b442ca959bb","name":"SCATTER","flags":0,"timestamp":1680714242791,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ffc5cef2915b442ca959bb","name":"SCATTER","flags":0,"tags":["nowfullyscattering"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60afc2ab566c3e1fc9dabe16","username":"goodpostureisgoodforyou","display_name":"GoodPostureIsGoodForYou","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1889c255-13e3-4730-86b4-5769c5d5105c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ffc5cef2915b442ca959bb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":24,"frame_count":17,"size":16728,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":24,"frame_count":17,"size":16170,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":48,"frame_count":17,"size":38997,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":48,"frame_count":17,"size":35418,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":72,"frame_count":17,"size":70183,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":72,"frame_count":17,"size":58848,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":96,"frame_count":17,"size":117561,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":96,"frame_count":17,"size":81076,"format":"WEBP"}]}}},{"id":"63ac69cee1acc51cb86c81c3","name":"Interesting","flags":0,"timestamp":1680797133292,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ac69cee1acc51cb86c81c3","name":"Fascinating","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"61938ebbd34608492cc37ff1","username":"fluxenis","display_name":"fluxenis","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ac69cee1acc51cb86c81c3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":509,"size":147373,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":509,"size":285268,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":509,"size":382870,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":509,"size":572458,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":509,"size":647630,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":509,"size":800520,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":509,"size":882153,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":509,"size":1059398,"format":"WEBP"}]}}},{"id":"6417322147e749c2a990e6eb","name":"VeryPog","flags":0,"timestamp":1680865675601,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6417322147e749c2a990e6eb","name":"VeryPog","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6053a0e8b4d31e459f0133ab","username":"devonanimation","display_name":"DevonAnimation","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e52a289c-9d07-405e-96bb-9bc3b60dbf9f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6417322147e749c2a990e6eb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":96,"size":25695,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":96,"size":43076,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":96,"size":60702,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":96,"size":101444,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":96,"size":107577,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":96,"size":159426,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":96,"size":170784,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":96,"size":219934,"format":"WEBP"}]}}},{"id":"61cfbce5c267a9d0bb2b84e2","name":"PogO","flags":0,"timestamp":1680878160478,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61cfbce5c267a9d0bb2b84e2","name":"PogO","flags":0,"tags":["kripp"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b030c1b3e1671e27940d52","username":"mellen","display_name":"mellen","avatar_url":"//cdn.7tv.app/user/60b030c1b3e1671e27940d52/av_64f429b5592e30195214ca0b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61cfbce5c267a9d0bb2b84e2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1063,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":816,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1900,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2000,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2594,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2952,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3440,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4270,"format":"WEBP"}]}}},{"id":"628b4e31836261e3f0a6caa4","name":"JoiJam","flags":0,"timestamp":1680981637565,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"628b4e31836261e3f0a6caa4","name":"JoiJam","flags":0,"tags":["dance","bladerunner","holo","glow","joi"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61aafd10e9684edbbc38eb42","username":"higherthanstarfire","display_name":"higherthanstarfire","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d1709ed1-9054-491f-93f1-a8367cf3106b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/628b4e31836261e3f0a6caa4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":39,"size":20291,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":39,"size":36174,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":39,"size":62374,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":39,"size":97694,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":39,"size":107565,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":39,"size":174230,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":39,"size":182893,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":39,"size":278282,"format":"WEBP"}]}}},{"id":"60ae36ecb2ecb01505fcc586","name":"KKool","flags":0,"timestamp":1681072126529,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60ae36ecb2ecb01505fcc586","name":"KKool","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60635b50452cea4685f26b34","username":"hecrzy","display_name":"heCrzy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/583dd5ac-2fe8-4ead-a20d-e10770118c5f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae36ecb2ecb01505fcc586","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":25,"height":32,"frame_count":13,"size":6424,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":25,"height":32,"frame_count":13,"size":11550,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":50,"height":64,"frame_count":13,"size":10330,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":50,"height":64,"frame_count":13,"size":24698,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":75,"height":96,"frame_count":13,"size":19040,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":75,"height":96,"frame_count":13,"size":40658,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":100,"height":128,"frame_count":13,"size":24392,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":100,"height":128,"frame_count":13,"size":53032,"format":"WEBP"}]}}},{"id":"64349305872aced0e7d7a28f","name":"yo","flags":0,"timestamp":1681167139177,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64349305872aced0e7d7a28f","name":"yo","flags":0,"tags":["hey","peepohey","cute","cat","hello","greet"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64349305872aced0e7d7a28f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":88,"size":38429,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":88,"size":55674,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":88,"size":83846,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":88,"size":107982,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":88,"size":140946,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":88,"size":166706,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":88,"size":202614,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":88,"size":200830,"format":"WEBP"}]}}},{"id":"614241707b14fdf700b90758","name":"residentCD","flags":0,"timestamp":1681226231672,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"614241707b14fdf700b90758","name":"residentCD","flags":0,"tags":["docpls","docspin","this","that","and","forsencd"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614241707b14fdf700b90758","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":56,"size":20397,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":56,"size":42340,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":56,"size":41619,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":56,"size":80644,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":56,"size":74381,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":56,"size":130132,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":56,"size":108009,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":56,"size":123798,"format":"WEBP"}]}}},{"id":"6266d32530c35f39c8f85bcf","name":"ejik","flags":0,"timestamp":1681387645516,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6266d32530c35f39c8f85bcf","name":"ejik","flags":0,"tags":["hedgehog"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"626547496616fad25e4cfab1","username":"poezdezz","display_name":"POEZDEZZ","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3283f5be-a81b-457d-a239-7115932d00a6-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6266d32530c35f39c8f85bcf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":30,"size":11519,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":30,"size":19354,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":30,"size":28851,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":30,"size":54372,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":30,"size":47873,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":30,"size":92848,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":30,"size":79236,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":30,"size":157664,"format":"WEBP"}]}}},{"id":"63c4b0f3d98b878bc84c3c19","name":"Teleport","flags":0,"timestamp":1681398167041,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c4b0f3d98b878bc84c3c19","name":"Teleport","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"63262f75e6defda92400164c","username":"s65_amg","display_name":"s65_amg","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c4b0f3d98b878bc84c3c19","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":16,"size":4500,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":16,"size":2756,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":16,"size":6599,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":16,"size":4578,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":16,"size":9405,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":16,"size":6740,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":16,"size":12122,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":16,"size":8966,"format":"WEBP"}]}}},{"id":"641f611ab5af37fa0622e15b","name":"HISS","flags":0,"timestamp":1681490797977,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"641f611ab5af37fa0622e15b","name":"apolloHISS","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/641f611ab5af37fa0622e15b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":51,"size":10567,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":51,"size":18394,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":51,"size":22878,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":51,"size":36514,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":51,"size":40844,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":51,"size":55658,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":51,"size":110646,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":51,"size":89046,"format":"WEBP"}]}}},{"id":"6318a71129a5627b71e306d1","name":"missLounge","flags":0,"timestamp":1681575867572,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6318a71129a5627b71e306d1","name":"missLounge","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6159fb1a93686fbfe7fc1c38","username":"yabbe","display_name":"Yabbe","avatar_url":"//cdn.7tv.app/user/6159fb1a93686fbfe7fc1c38/av_634b3ae0d6ba45f7f103a8ca/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6318a71129a5627b71e306d1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":1,"size":1495,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":1,"size":2168,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":1,"size":3386,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":1,"size":6958,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":1,"size":5943,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":1,"size":14156,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":1,"size":8699,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":1,"size":23600,"format":"WEBP"}]}}},{"id":"639b95f1172c1ebed121c965","name":"minusWOT","flags":0,"timestamp":1681575952254,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"639b95f1172c1ebed121c965","name":"minusWOT","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60af8846a3648f409a124ee4","username":"kniteort","display_name":"Kniteort","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/05070f7c-ec6a-47cf-a274-54e062b11bf7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/639b95f1172c1ebed121c965","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1027,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1842,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1852,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5652,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2724,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10580,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3761,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16972,"format":"WEBP"}]}}},{"id":"643ad08339b7f46dd13db217","name":"missSit","flags":0,"timestamp":1681576077668,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"643ad08339b7f46dd13db217","name":"missSit","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643ad08339b7f46dd13db217","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":1373,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1790,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":2978,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":5610,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":4966,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":11054,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":6925,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":17760,"format":"WEBP"}]}}},{"id":"643ada8331e0e987c69fbc9f","name":"apolloArrive","flags":0,"timestamp":1681589011233,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"643ada8331e0e987c69fbc9f","name":"apolloArrive","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643ada8331e0e987c69fbc9f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":91,"size":18232,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":82,"size":23818,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":91,"size":31094,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":89,"size":32528,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":91,"size":43210,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":90,"size":47094,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":91,"size":50735,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":91,"size":51436,"format":"WEBP"}]}}},{"id":"6217c7eb87952f3b8e5a272b","name":"hiss","flags":0,"timestamp":1681642348854,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6217c7eb87952f3b8e5a272b","name":"hiss","flags":0,"tags":["nymn","apollo","minus"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6217c7eb87952f3b8e5a272b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":21,"size":5929,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":21,"size":14052,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":21,"size":12280,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":21,"size":29724,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":21,"size":20719,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":21,"size":46562,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":21,"size":32058,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":21,"size":35980,"format":"WEBP"}]}}},{"id":"6136809e4d2b0b266210f84f","name":"GIMME","flags":1,"timestamp":1681658231717,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6136809e4d2b0b266210f84f","name":"GIMME","flags":256,"tags":["hands","gimme","handl","handr"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8eec229664e8662a0910","username":"fapparamoar","display_name":"FapParaMoar","avatar_url":"//cdn.7tv.app/user/60ae8eec229664e8662a0910/av_6420fc305c90d67918584c61/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6136809e4d2b0b266210f84f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":69,"height":32,"frame_count":1,"size":1755,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":69,"height":32,"frame_count":1,"size":1418,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":138,"height":64,"frame_count":1,"size":2978,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":138,"height":64,"frame_count":1,"size":3189,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":207,"height":96,"frame_count":1,"size":5134,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":207,"height":96,"frame_count":1,"size":4596,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":276,"height":128,"frame_count":1,"size":5811,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":276,"height":128,"frame_count":1,"size":7210,"format":"WEBP"}]}}},{"id":"63ffb102f20c15fd1676c1f6","name":"minusGardening","flags":0,"timestamp":1681988878445,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ffb102f20c15fd1676c1f6","name":"minusGardening","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3d92259ac5a73e532901","username":"ninaturbo","display_name":"NinaTurbo","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4d6af24a-f321-495c-9130-5fd5ded77596-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ffb102f20c15fd1676c1f6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":32,"size":11023,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":32,"size":27620,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":32,"size":26892,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":32,"size":70446,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":32,"size":47805,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":32,"size":120970,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":32,"size":62970,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":32,"size":163824,"format":"WEBP"}]}}},{"id":"614e96dd6251d7e000da7d22","name":"reeferSad","flags":0,"timestamp":1682090261635,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"614e96dd6251d7e000da7d22","name":"ReeferSad","flags":0,"tags":["reefer","reefersad","sad","cry"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61044fdad85f2a3d49a157c7","username":"vizzkie","display_name":"Vizzkie","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/0cd3d724-ddf2-4f14-86d0-809ccac4d78b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614e96dd6251d7e000da7d22","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":158,"size":60650,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":158,"size":142990,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":158,"size":171683,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":158,"size":350388,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":158,"size":311510,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":158,"size":598160,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":158,"size":498217,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":158,"size":700246,"format":"WEBP"}]}}},{"id":"64453b0b661895a026776e76","name":"peepoYELLING","flags":0,"timestamp":1682259531820,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64453b0b661895a026776e76","name":"peepoYELLING","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6131dab2af9287c4eb609268","username":"vicneeel","display_name":"vicneeel","avatar_url":"//cdn.7tv.app/user/6131dab2af9287c4eb609268/av_6520576332b1db5b90ef6b24/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64453b0b661895a026776e76","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":46,"height":32,"frame_count":17,"size":6388,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":46,"height":32,"frame_count":17,"size":13736,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":92,"height":64,"frame_count":17,"size":10627,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":92,"height":64,"frame_count":17,"size":28208,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":138,"height":96,"frame_count":17,"size":16540,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":138,"height":96,"frame_count":17,"size":42312,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":184,"height":128,"frame_count":17,"size":26757,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":184,"height":128,"frame_count":17,"size":54476,"format":"WEBP"}]}}},{"id":"63a637a0294e12e5ef413f68","name":"HUHH","flags":0,"timestamp":1682334651103,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63a637a0294e12e5ef413f68","name":"HUHH","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3ce6aee2aa5538247a88","username":"crunt","display_name":"Crunt","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/291d7d61-b7de-4ffb-8962-aced6ce28602-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63a637a0294e12e5ef413f68","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":93,"height":32,"frame_count":1,"size":2897,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":93,"height":32,"frame_count":1,"size":6214,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":186,"height":64,"frame_count":1,"size":6784,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":186,"height":64,"frame_count":1,"size":18918,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":279,"height":96,"frame_count":1,"size":10370,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":279,"height":96,"frame_count":1,"size":35498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":372,"height":128,"frame_count":1,"size":14676,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":372,"height":128,"frame_count":1,"size":57202,"format":"WEBP"}]}}},{"id":"61857d3d4ea2f24e50097d5d","name":"CleanTheNymN","flags":0,"timestamp":1682349551406,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61857d3d4ea2f24e50097d5d","name":"CleanTheNymN","flags":0,"tags":["nymn","apollo","clean"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61857d3d4ea2f24e50097d5d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":58,"height":32,"frame_count":25,"size":9602,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":58,"height":32,"frame_count":25,"size":32046,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":116,"height":64,"frame_count":25,"size":25234,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":116,"height":64,"frame_count":25,"size":76266,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":174,"height":96,"frame_count":25,"size":46219,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":174,"height":96,"frame_count":25,"size":125652,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":232,"height":128,"frame_count":25,"size":76815,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":232,"height":128,"frame_count":25,"size":146588,"format":"WEBP"}]}}},{"id":"6446c4ec42fcbe26c8558e32","name":"Tickpollo","flags":0,"timestamp":1682359538117,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6446c4ec42fcbe26c8558e32","name":"Tickpollo","flags":0,"tags":["apollo","tick"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6446c4ec42fcbe26c8558e32","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":69,"height":32,"frame_count":1,"size":2090,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":69,"height":32,"frame_count":1,"size":3052,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":138,"height":64,"frame_count":1,"size":9232,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":138,"height":64,"frame_count":1,"size":4721,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":207,"height":96,"frame_count":1,"size":18420,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":207,"height":96,"frame_count":1,"size":7865,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":276,"height":128,"frame_count":1,"size":11704,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":276,"height":128,"frame_count":1,"size":30226,"format":"WEBP"}]}}},{"id":"612e446401327e773335b0d4","name":"peepoKnife","flags":0,"timestamp":1682421571078,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"612e446401327e773335b0d4","name":"peepoKnife","flags":0,"tags":["peepo","peepoknife"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60e66af0840f3a5701e0ef9d","username":"j_reality","display_name":"J_ReAlity","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/09a014b4-cf2e-4bbc-94c9-37e228e2f87e-profile_image-70x70.jpg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/612e446401327e773335b0d4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":6573,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":10062,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":9939,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":19716,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":15790,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":32176,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":19802,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":37960,"format":"WEBP"}]}}},{"id":"60e4fd747fa83e524442eff8","name":"HenryLookingAtYou","flags":0,"timestamp":1682522261962,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60e4fd747fa83e524442eff8","name":"HenryLookingAtYou","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60c268941388fc30a258f20e","username":"charontheferrym8","display_name":"CharonTheFerrym8","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/93efe6b8-ad0c-417f-9a31-f7004fc5dc07-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e4fd747fa83e524442eff8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":24,"height":32,"frame_count":1,"size":1135,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":24,"height":32,"frame_count":1,"size":836,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":48,"height":64,"frame_count":1,"size":1944,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":48,"height":64,"frame_count":1,"size":1906,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":72,"height":96,"frame_count":1,"size":2940,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":72,"height":96,"frame_count":1,"size":3288,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":96,"height":128,"frame_count":1,"size":3872,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":96,"height":128,"frame_count":1,"size":4766,"format":"WEBP"}]}}},{"id":"6365c687fe662b90f8c09b63","name":"MeAndTheBoysWatchingTwitchDotTVSlashNlUnderscoreKripp","flags":0,"timestamp":1682530549955,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6365c687fe662b90f8c09b63","name":"MeAndTheBoysWatchingTwitchDotTVSlashNlUnderscoreKripp","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b030c1b3e1671e27940d52","username":"mellen","display_name":"mellen","avatar_url":"//cdn.7tv.app/user/60b030c1b3e1671e27940d52/av_64f429b5592e30195214ca0b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6365c687fe662b90f8c09b63","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":297,"size":124911,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":297,"size":267942,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":297,"size":372372,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":297,"size":693540,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":297,"size":686095,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":297,"size":1124492,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":297,"size":1262095,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":297,"size":1655902,"format":"WEBP"}]}}},{"id":"643f02a139b7f46dd13dfba4","name":"catmakingpizza","flags":0,"timestamp":1682530858294,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"643f02a139b7f46dd13dfba4","name":"catmakingpizza","flags":0,"tags":["italian","chef","cat","pizza","pamba"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60afe2d16fdef7f551687f1b","username":"tomkaishere","display_name":"tomkaishere","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6fe16bb2-13b4-4386-8c54-4e00812aee64-profile_image-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643f02a139b7f46dd13dfba4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":248,"size":91877,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":227,"size":150898,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":248,"size":218564,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":248,"size":388608,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":248,"size":393711,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":248,"size":640796,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":248,"size":593007,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":248,"size":869160,"format":"WEBP"}]}}},{"id":"616a0ffac52da56cd4908ffa","name":"PeepoPoglin","flags":0,"timestamp":1682537806920,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"616a0ffac52da56cd4908ffa","name":"PeepoPenis","flags":0,"tags":["peepo","pepe"],"lifecycle":3,"state":["NO_PERSONAL"],"listed":false,"animated":true,"owner":{"id":"612b9e52faec51694bc4e97d","username":"lofe____","display_name":"LOFE____","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ce66c9e8-0a09-434a-bcfd-90cc04bcab90-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/616a0ffac52da56cd4908ffa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":6,"size":4323,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":6,"size":7184,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":6,"size":7600,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":6,"size":17736,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":6,"size":12021,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":6,"size":30204,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":6,"size":18217,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":6,"size":41398,"format":"WEBP"}]}}},{"id":"614f426a20eaf897465a5b96","name":"Grabge","flags":0,"timestamp":1682612383467,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"614f426a20eaf897465a5b96","name":"Grabge","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60e45f7ae2a51883fc0779c7","username":"sylwekm10","display_name":"SylwekM10","avatar_url":"//cdn.7tv.app/pp/60e45f7ae2a51883fc0779c7/1478bd91e9454191b279d7c45a63580e","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614f426a20eaf897465a5b96","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1296,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":952,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2556,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2400,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4086,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4382,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5751,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6586,"format":"WEBP"}]}}},{"id":"64022890a27fda24e8076a4b","name":"Ogress","flags":0,"timestamp":1682617570780,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64022890a27fda24e8076a4b","name":"Ogress","flags":0,"tags":["ogre","erobb221","lamont","lamonting","clm","booba"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61afba20ffa9aba101bd4aaa","username":"ketchungus","display_name":"ketchungus","avatar_url":"//cdn.7tv.app/user/61afba20ffa9aba101bd4aaa/av_63ab295c867b3336f4a6706e/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64022890a27fda24e8076a4b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":53,"height":32,"frame_count":1,"size":1755,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":53,"height":32,"frame_count":1,"size":3044,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":106,"height":64,"frame_count":1,"size":3662,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":106,"height":64,"frame_count":1,"size":9412,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":159,"height":96,"frame_count":1,"size":5639,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":159,"height":96,"frame_count":1,"size":17726,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":212,"height":128,"frame_count":1,"size":8451,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":212,"height":128,"frame_count":1,"size":27206,"format":"WEBP"}]}}},{"id":"6442d2d7a9f9fb7c5f27ca5b","name":"NAILSMan","flags":0,"timestamp":1682680237095,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6442d2d7a9f9fb7c5f27ca5b","name":"NAILSMan","flags":0,"tags":["hyru","youtube","pagman"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6442d2d7a9f9fb7c5f27ca5b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":24,"height":24,"frame_count":1,"size":1096,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":24,"height":24,"frame_count":1,"size":774,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":48,"height":48,"frame_count":1,"size":1675,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":48,"height":48,"frame_count":1,"size":884,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":72,"height":72,"frame_count":1,"size":1968,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":72,"height":72,"frame_count":1,"size":958,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":96,"height":96,"frame_count":1,"size":1757,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":96,"height":96,"frame_count":1,"size":1014,"format":"WEBP"}]}}},{"id":"613b2f73e92aa8cd4ed0f83c","name":"POGU","flags":0,"timestamp":1682680266476,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"613b2f73e92aa8cd4ed0f83c","name":"POGU","flags":0,"tags":["pogu","poggers","pagman","emoji"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aecd486cfcffe15f431dca","username":"nozav","display_name":"NOZAV","avatar_url":"//cdn.7tv.app/pp/60aecd486cfcffe15f431dca/63b0f4c0f619477b9cb54f5548c8c1f0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613b2f73e92aa8cd4ed0f83c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":60,"size":21223,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":60,"size":49684,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":60,"size":52035,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":60,"size":111058,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":60,"size":91450,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":60,"size":190054,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":60,"size":133585,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":60,"size":230742,"format":"WEBP"}]}}},{"id":"633d886ca177344f521f91e3","name":"deadass","flags":0,"timestamp":1682771762075,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"633d886ca177344f521f91e3","name":"deadass","flags":0,"tags":["aintnoway","lilbro","skull","nahhh","noo","way"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae759bdf5735e04acb69d9","username":"hotbear1110","display_name":"HotBear1110","avatar_url":"//cdn.7tv.app/pp/60ae759bdf5735e04acb69d9/80e2b49378c14dc6914fde8cb72fa673","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/633d886ca177344f521f91e3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":100,"size":28916,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":100,"size":42822,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":100,"size":70116,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":100,"size":106492,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":100,"size":117505,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":100,"size":172716,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":100,"size":165648,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":100,"size":235626,"format":"WEBP"}]}}},{"id":"6390d948ac9ce9260e2c4b26","name":"deadassPls","flags":0,"timestamp":1682771780233,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6390d948ac9ce9260e2c4b26","name":"deadassPls","flags":0,"tags":["nahhh","skeleton","dead","deadass","bruh","aintnoway"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62cfc134100c47ee28369f87","username":"abehamm","display_name":"Abehamm","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6390d948ac9ce9260e2c4b26","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":65,"size":17110,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":65,"size":28240,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":65,"size":42102,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":65,"size":66708,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":65,"size":78904,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":65,"size":107888,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":65,"size":127986,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":65,"size":153164,"format":"WEBP"}]}}},{"id":"63ae4d85da447d37105b6c82","name":"NAHHH","flags":0,"timestamp":1682771869204,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63ae4d85da447d37105b6c82","name":"NAHHH","flags":0,"tags":["nahhhhhhhhh","deadass","skeleton","skull","aintnoway","diesofcringe"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"626247264f320c1b8cf58d7e","username":"izeeh","display_name":"iZeeh","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ae4d85da447d37105b6c82","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":38,"size":18104,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":38,"size":24548,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":38,"size":41127,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":38,"size":55162,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":38,"size":69529,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":38,"size":87180,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":38,"size":101220,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":38,"size":120246,"format":"WEBP"}]}}},{"id":"6041e1b789b604000d19fbbb","name":"DonkChat","flags":0,"timestamp":1682940664734,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6041e1b789b604000d19fbbb","name":"DonkChat","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603cbe6396832ffa78dea3df","username":"msun_","display_name":"msun_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ed68f02b-4930-4c5e-9f0c-87e407542391-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6041e1b789b604000d19fbbb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":3,"size":3015,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":3,"size":2720,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":3,"size":4493,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":3,"size":6162,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":3,"size":6702,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":3,"size":10378,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":3,"size":9163,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":3,"size":14094,"format":"WEBP"}]}}},{"id":"6430122891e79e6cc532ffa4","name":"Sludge","flags":0,"timestamp":1682954602995,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6430122891e79e6cc532ffa4","name":"Sludge","flags":0,"tags":["pepe","sad","sludge"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62c5a668004dd4ed9b4bf5a1","username":"einglas","display_name":"Einglas","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/da0de763-9529-4ee6-8a00-c2dd0766210a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6430122891e79e6cc532ffa4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":58,"height":32,"frame_count":1,"size":1792,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":58,"height":32,"frame_count":1,"size":2538,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":116,"height":64,"frame_count":1,"size":3454,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":116,"height":64,"frame_count":1,"size":5542,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":174,"height":96,"frame_count":1,"size":5279,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":174,"height":96,"frame_count":1,"size":8610,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":232,"height":128,"frame_count":1,"size":6895,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":232,"height":128,"frame_count":1,"size":12226,"format":"WEBP"}]}}},{"id":"6451088a0b18ef5a58cc9df4","name":"ApolloStairs","flags":0,"timestamp":1683032237584,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6451088a0b18ef5a58cc9df4","name":"ApolloStairs","flags":0,"tags":["apollo","nymn","cat","stairs"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6451088a0b18ef5a58cc9df4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":52,"size":7178,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":51,"size":8864,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":52,"size":11650,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":51,"size":20852,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":52,"size":18585,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":52,"size":32936,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":52,"size":24602,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":52,"size":46434,"format":"WEBP"}]}}},{"id":"60baca0a3285d8b0b8a051c9","name":"FLASHBANG","flags":0,"timestamp":1683033176480,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60baca0a3285d8b0b8a051c9","name":"FLASHBANG","flags":0,"tags":["bright","blind","blinding","flash","cat"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b97c811c4540cdd1d226e0","username":"sakamotokenji","display_name":"sakamotokenji","avatar_url":"//cdn.7tv.app/pp/60b97c811c4540cdd1d226e0/31996f04b338455d94e9817f1f104411","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60baca0a3285d8b0b8a051c9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":74,"height":32,"frame_count":25,"size":8397,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":74,"height":32,"frame_count":25,"size":24392,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":148,"height":64,"frame_count":25,"size":19206,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":148,"height":64,"frame_count":25,"size":49342,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":222,"height":96,"frame_count":25,"size":34529,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":222,"height":96,"frame_count":25,"size":76548,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":296,"height":128,"frame_count":25,"size":51386,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":296,"height":128,"frame_count":25,"size":79586,"format":"WEBP"}]}}},{"id":"6115f1a57c2d738bc1045050","name":"DonkSass","flags":0,"timestamp":1683294719897,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6115f1a57c2d738bc1045050","name":"DonkSass","flags":0,"tags":["sass","donksass","donk","slay","periodt"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61156f4747935f36575c7358","username":"sarcasthicc_","display_name":"Sarcasthicc_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a5d59712-6c0b-49bf-a010-989b363933ed-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6115f1a57c2d738bc1045050","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1688,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1176,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2810,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3344,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5025,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4836,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6946,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7372,"format":"WEBP"}]}}},{"id":"64024a2249184efad4d9da94","name":"Legsy","flags":0,"timestamp":1683372846032,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64024a2249184efad4d9da94","name":"Legsy","flags":0,"tags":["sexy","erobb221","sahajjj","breedable"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60fbf4081153aea242e88c24","username":"norevivanux","display_name":"Norevivanux","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fc6b05c3-067e-4af5-b5aa-094a8a462e10-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64024a2249184efad4d9da94","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":53,"height":32,"frame_count":1,"size":1843,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":53,"height":32,"frame_count":1,"size":2858,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":106,"height":64,"frame_count":1,"size":3980,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":106,"height":64,"frame_count":1,"size":8184,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":159,"height":96,"frame_count":1,"size":6016,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":159,"height":96,"frame_count":1,"size":15860,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":212,"height":128,"frame_count":1,"size":8921,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":212,"height":128,"frame_count":1,"size":25868,"format":"WEBP"}]}}},{"id":"60c9faaaaef6df2115249ad9","name":"FeelsAyayaMan","flags":0,"timestamp":1683398607787,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60c9faaaaef6df2115249ad9","name":"FeelsAyayaMan","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b0faaa8fb21a01bc3c0385","username":"enzo_supercraftz","display_name":"Enzo_SuperCraftZ","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60c9faaaaef6df2115249ad9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1378,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1052,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2779,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2744,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4267,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4604,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5644,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5536,"format":"WEBP"}]}}},{"id":"641dba3b170518c061883185","name":"SOLO","flags":0,"timestamp":1683559572933,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"641dba3b170518c061883185","name":"SOLO","flags":0,"tags":["blanka","solo","poland","eurovision"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/641dba3b170518c061883185","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":118,"size":37109,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":118,"size":50384,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":118,"size":98535,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":118,"size":136876,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":118,"size":178153,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":118,"size":247916,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":118,"size":265719,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":118,"size":374060,"format":"WEBP"}]}}},{"id":"60ae6cdc117ec68ca46650b4","name":"borpaSpin","flags":0,"timestamp":1683629894564,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ae6cdc117ec68ca46650b4","name":"borpaSpin","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3d75aee2aa55382883c2","username":"victorbaya","display_name":"victorbaya","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4f4c5649-c2f3-4837-a46f-486df3dde891-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae6cdc117ec68ca46650b4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":6144,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":8326,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":12000,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":15822,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":16073,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":25796,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":22440,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":28804,"format":"WEBP"}]}}},{"id":"61e438ea77175547b4257e8b","name":"CUM","flags":0,"timestamp":1683646027039,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61e438ea77175547b4257e8b","name":"CUM","flags":0,"tags":["badlands","chug","milk","mommy"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e438ea77175547b4257e8b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":206,"size":49501,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":206,"size":169384,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":206,"size":115606,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":206,"size":367160,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":206,"size":212165,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":206,"size":589842,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":206,"size":339697,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":206,"size":663026,"format":"WEBP"}]}}},{"id":"645aae88c3aa93c6583ad0f5","name":"karija","flags":0,"timestamp":1683666288435,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"645aae88c3aa93c6583ad0f5","name":"karija","flags":0,"tags":["eurovision","nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6151ed9d6251d7e000dacffc","username":"404tintin","display_name":"404tintin","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/afb3a8bd-db21-426e-8a84-cda3e9e66590-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/645aae88c3aa93c6583ad0f5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1381,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2094,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2892,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6430,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4884,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12488,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5791,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18616,"format":"WEBP"}]}}},{"id":"643a067823e9c459eb14df32","name":"WeebShaker","flags":0,"timestamp":1683901140910,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"643a067823e9c459eb14df32","name":"Thugshaker","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aed1d9440f48624dfe523d","username":"notoriousbob69","display_name":"Notoriousbob69","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/18003435-63fd-43cd-a856-e7ebcd5013a2-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643a067823e9c459eb14df32","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":9552,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":11210,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":19812,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":23238,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":32208,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":37210,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":45257,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":51938,"format":"WEBP"}]}}},{"id":"645fd8be20a7827537905488","name":"Fembaj","flags":0,"timestamp":1684003128708,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"645fd8be20a7827537905488","name":"Fembaj","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/645fd8be20a7827537905488","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1394,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2248,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2727,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6548,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4155,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12078,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5905,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":20110,"format":"WEBP"}]}}},{"id":"63e58ed3fc3c5fd739a18e88","name":"3Lass","flags":0,"timestamp":1684007414947,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63e58ed3fc3c5fd739a18e88","name":"3Lass","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63e58ed3fc3c5fd739a18e88","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":58,"size":20027,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":58,"size":33314,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":58,"size":45217,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":58,"size":61030,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":58,"size":79484,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":58,"size":95104,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":58,"size":210863,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":58,"size":121442,"format":"WEBP"}]}}},{"id":"644bd932ecb88decde5c220e","name":"CHACHACHACHA","flags":0,"timestamp":1684018017540,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"644bd932ecb88decde5c220e","name":"CHACHACHACHA","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"618be1c5d34608492cc29d8a","username":"deanfluence","display_name":"deanfluence","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e1afc82a-a096-4aba-bfa8-9a84e2733d67-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/644bd932ecb88decde5c220e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":110,"size":53471,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":110,"size":49700,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":110,"size":132957,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":110,"size":119614,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":110,"size":239052,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":110,"size":203386,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":110,"size":362853,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":110,"size":295256,"format":"WEBP"}]}}},{"id":"63d193c9814f64e4b4840b7d","name":"OverSlept","flags":0,"timestamp":1684078011241,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63d193c9814f64e4b4840b7d","name":"OverSlept","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"617646dbffc7244d797d1e2e","username":"zeudern","display_name":"zeudern","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a5848694-a404-41fc-987e-329a61197a3b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63d193c9814f64e4b4840b7d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":68,"size":30506,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":68,"size":41928,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":68,"size":76369,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":68,"size":79926,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":68,"size":139476,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":68,"size":122880,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":68,"size":223345,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":68,"size":157406,"format":"WEBP"}]}}},{"id":"610436bfd85f2a3d49a15627","name":"apolloSleep","flags":0,"timestamp":1684078037508,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"610436bfd85f2a3d49a15627","name":"apolloSleep","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6103188eaf7a0dae1038a521","username":"ratge","display_name":"Ratge","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/2cd4f383-3062-40d7-927c-245cd9d73455-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610436bfd85f2a3d49a15627","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":40,"size":6545,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":40,"size":16256,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":40,"size":14022,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":40,"size":34712,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":40,"size":24610,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":40,"size":54078,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":40,"size":38640,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":40,"size":40988,"format":"WEBP"}]}}},{"id":"63ab71f6fc86875adb76bec2","name":"SoyLounge","flags":0,"timestamp":1684078167169,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ab71f6fc86875adb76bec2","name":"SoyLounge","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ab71f6fc86875adb76bec2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":66,"height":32,"frame_count":1,"size":2167,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":66,"height":32,"frame_count":1,"size":4152,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":132,"height":64,"frame_count":1,"size":5395,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":132,"height":64,"frame_count":1,"size":13912,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":198,"height":96,"frame_count":1,"size":9671,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":198,"height":96,"frame_count":1,"size":28104,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":264,"height":128,"frame_count":1,"size":13046,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":264,"height":128,"frame_count":1,"size":43520,"format":"WEBP"}]}}},{"id":"63c06b4f37038b884c0bcca7","name":"WAIT","flags":0,"timestamp":1684184420364,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c06b4f37038b884c0bcca7","name":"WAIT","flags":0,"tags":["uhm","huh","waiting","pausechamp","what","erm"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"613c5c8d2d7724a96175bfd5","username":"fixlation","display_name":"Fixlation","avatar_url":"//cdn.7tv.app/pp/613c5c8d2d7724a96175bfd5/2a55acd787cd4d6fa8db45899ef88e4b","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c06b4f37038b884c0bcca7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":47,"height":32,"frame_count":1,"size":1504,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":47,"height":32,"frame_count":1,"size":2524,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":94,"height":64,"frame_count":1,"size":7200,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":94,"height":64,"frame_count":1,"size":2829,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":141,"height":96,"frame_count":1,"size":4147,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":141,"height":96,"frame_count":1,"size":13290,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":188,"height":128,"frame_count":1,"size":5405,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":188,"height":128,"frame_count":1,"size":20544,"format":"WEBP"}]}}},{"id":"639e46ae601476c069424284","name":"minusAUGH","flags":0,"timestamp":1684341245057,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"639e46ae601476c069424284","name":"AUGH","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/639e46ae601476c069424284","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":920,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1814,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1723,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5966,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3099,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":13000,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4939,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":22602,"format":"WEBP"}]}}},{"id":"63cd9a282ba67946677a26eb","name":"LMAAAOOO","flags":0,"timestamp":1684405823582,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"63cd9a282ba67946677a26eb","name":"LMAO","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63cd9a282ba67946677a26eb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":99,"size":29606,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":99,"size":62298,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":99,"size":64407,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":99,"size":109012,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":99,"size":105067,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":99,"size":148242,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":99,"size":154211,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":99,"size":193786,"format":"WEBP"}]}}},{"id":"60420dbe77137b000de9e674","name":"nymnCringe","flags":0,"timestamp":1684442894331,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60420dbe77137b000de9e674","name":"nymnCringe","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"603ca8f696832ffa78c01eb4","username":"mauriplss","display_name":"Mauriplss","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a428f0f0-bdd4-4c93-ac4b-ed174244cb66-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60420dbe77137b000de9e674","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1351,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1104,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2549,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2562,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4412,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3771,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6326,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5272,"format":"AVIF"}]}}},{"id":"6467756958d599a0419f50f1","name":"nymnQ","flags":0,"timestamp":1684501873484,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6467756958d599a0419f50f1","name":"nymnQ","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae759bdf5735e04acb69d9","username":"hotbear1110","display_name":"HotBear1110","avatar_url":"//cdn.7tv.app/pp/60ae759bdf5735e04acb69d9/80e2b49378c14dc6914fde8cb72fa673","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6467756958d599a0419f50f1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":907,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1670,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1492,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4896,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2006,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9762,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2752,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15712,"format":"WEBP"}]}}},{"id":"632362a09e7e06ed5371bce3","name":"OsoPopoToto","flags":0,"timestamp":1684537597610,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"632362a09e7e06ed5371bce3","name":"OsoPopoToto","flags":0,"tags":["awwww","osopopototo","yabbe"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60aeab21229664e8663345dd","username":"barricade0_","display_name":"BARRICADE0_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/948ce321-c188-4c7a-90c0-16169e190ac2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/632362a09e7e06ed5371bce3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":160,"size":24126,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":160,"size":73082,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":160,"size":58284,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":160,"size":180192,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":160,"size":98535,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":160,"size":276716,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":160,"size":514051,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":160,"size":465244,"format":"WEBP"}]}}},{"id":"6465f7c458d599a0419f07cf","name":"OkeyArrive","flags":0,"timestamp":1684580765726,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6465f7c458d599a0419f07cf","name":"OkeyArrive","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae9be6ac03cad60784bd05","username":"cowsareamazing","display_name":"CowSArEAmazinG","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6465f7c458d599a0419f07cf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":11435,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":25,"size":12066,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":23021,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":26238,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":35323,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":42488,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":53935,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":58250,"format":"WEBP"}]}}},{"id":"60ae31b5259ac5a73efa8dc0","name":"peepoWTF","flags":0,"timestamp":1684595120209,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ae31b5259ac5a73efa8dc0","name":"peepoWTF","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"609daaf8bc1178732630e0ac","username":"pynput","display_name":"Pynput","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f2dfd7d1-d373-4ab2-a114-1efa81ede4b7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae31b5259ac5a73efa8dc0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":1,"size":1268,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":1,"size":1112,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":1,"size":2562,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":1,"size":2268,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":1,"size":3224,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":1,"size":4174,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":1,"size":5194,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":1,"size":4058,"format":"AVIF"}]}}},{"id":"6468ad6743c2a132decc24cb","name":"nymnT","flags":0,"timestamp":1684623926083,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6468ad6743c2a132decc24cb","name":"nymnT","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6468ad6743c2a132decc24cb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":897,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1538,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1364,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4462,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1803,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8342,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2160,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13060,"format":"WEBP"}]}}},{"id":"60ae473c5d3fdae583ac6d43","name":"KKonaW","flags":0,"timestamp":1684624140566,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"60ae473c5d3fdae583ac6d43","name":"KKonaW","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3d75aee2aa55382883c2","username":"victorbaya","display_name":"victorbaya","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4f4c5649-c2f3-4837-a46f-486df3dde891-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae473c5d3fdae583ac6d43","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1033,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":722,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1764,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1757,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2324,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2724,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3239,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":2838,"format":"WEBP"}]}}},{"id":"603cbeee73d7a5001441f9e0","name":"BBaper","flags":0,"timestamp":1684678368476,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"603cbeee73d7a5001441f9e0","name":"BBaper","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"603cb87696832ffa78d57767","username":"obscurelambda","display_name":"obscurelambda","avatar_url":"//cdn.7tv.app/user/603cb87696832ffa78d57767/av_647a68a9804ca09ebf23e890/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/603cbeee73d7a5001441f9e0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":35,"size":14722,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":34,"size":28872,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":35,"size":24046,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":35,"size":61326,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":35,"size":39388,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":35,"size":102360,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":35,"size":39424,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":35,"size":127750,"format":"WEBP"}]}}},{"id":"646a6dcfba7e8c5faec2524b","name":"MYAAA","flags":0,"timestamp":1684696550562,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"646a6dcfba7e8c5faec2524b","name":"MYAAA","flags":0,"tags":["meow","apollo","myaaa"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/646a6dcfba7e8c5faec2524b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":45,"size":9290,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":45,"size":16816,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":45,"size":18824,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":45,"size":36522,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":45,"size":32160,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":45,"size":60486,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":45,"size":45478,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":45,"size":89710,"format":"WEBP"}]}}},{"id":"6323a8a720f2964c27fdba03","name":"Bubbles0","flags":1,"timestamp":1684855814425,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6323a8a720f2964c27fdba03","name":"Bubbles0","flags":256,"tags":["bubbles","underwater","zerowidth","0width","water","swimming"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6323a8a720f2964c27fdba03","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":40,"size":8429,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":40,"size":20198,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":40,"size":23551,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":40,"size":46534,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":40,"size":46480,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":40,"size":77366,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":40,"size":71135,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":40,"size":96130,"format":"WEBP"}]}}},{"id":"635dd4ef3d712bde34e6c6af","name":"peepoExplore","flags":0,"timestamp":1684859333530,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"635dd4ef3d712bde34e6c6af","name":"peepoExplore","flags":0,"tags":["peepo","explore","subnautica","seamoth"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613ba9cd90c03f6155d420b1","username":"vetricci","display_name":"Vetricci","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7a797196-3b09-4a7f-8cff-ad832afde1a0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/635dd4ef3d712bde34e6c6af","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":51,"height":32,"frame_count":37,"size":12290,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":51,"height":32,"frame_count":37,"size":19672,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":102,"height":64,"frame_count":37,"size":24491,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":102,"height":64,"frame_count":37,"size":40810,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":153,"height":96,"frame_count":37,"size":36735,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":153,"height":96,"frame_count":37,"size":64442,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":204,"height":128,"frame_count":37,"size":49538,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":204,"height":128,"frame_count":37,"size":89234,"format":"WEBP"}]}}},{"id":"6458075cf9eb48e0c55b3d45","name":"glebeGlebe","flags":0,"timestamp":1685013071948,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6458075cf9eb48e0c55b3d45","name":"glebeGlebe","flags":0,"tags":["tenetene","nymn"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60afecb374d234a969633cf7","username":"tenetener","display_name":"tenetener","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/655b86e0-bd3f-49f6-bca7-ac32b8983f0d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6458075cf9eb48e0c55b3d45","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1255,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2194,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2363,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6736,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3524,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12668,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4574,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":19848,"format":"WEBP"}]}}},{"id":"61575de4b785e05aa26c0b9e","name":"KEKW","flags":0,"timestamp":1685278630544,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61575de4b785e05aa26c0b9e","name":"JebaitedW","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae2af4aee2aa5538ab2144","username":"sunred_","display_name":"SunRed_","avatar_url":"//cdn.7tv.app/pp/60ae2af4aee2aa5538ab2144/acc28924022046e3b790ccaf4c7b4c53","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61575de4b785e05aa26c0b9e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1197,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":888,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2019,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2080,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2974,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3348,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3738,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4624,"format":"WEBP"}]}}},{"id":"62288a7fb027edd02c8bf948","name":"DonkWall","flags":0,"timestamp":1685287940605,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62288a7fb027edd02c8bf948","name":"DonkWall","flags":0,"tags":["donkwall","modwall","donowall","donk","feelsdonkman","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62288a7fb027edd02c8bf948","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":227,"size":49185,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":227,"size":207794,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":227,"size":213612,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":227,"size":554986,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":227,"size":461843,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":227,"size":991608,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":227,"size":877620,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":227,"size":1032878,"format":"WEBP"}]}}},{"id":"60b282e4bc491a6063da7a34","name":"Dogege","flags":0,"timestamp":1685434350041,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60b282e4bc491a6063da7a34","name":"Dogege","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae5bdb5d3fdae583bd854e","username":"krodogs","display_name":"Krodogs","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9bee3bc79edb7d0e-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b282e4bc491a6063da7a34","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1265,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":922,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2268,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2441,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3940,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3712,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4902,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5120,"format":"WEBP"}]}}},{"id":"63b0aa4708b9976dac16f9e3","name":"meandyou","flags":0,"timestamp":1685442925200,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63b0aa4708b9976dac16f9e3","name":"meandyou","flags":0,"tags":["homies"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6143f893962a60904864c969","username":"panshui","display_name":"Panshui","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9ae4a6f6-c985-4df5-bd3a-39e6445c4ff2-profile_image-70x70.png","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b0aa4708b9976dac16f9e3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1200,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2022,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2127,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5740,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3363,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10642,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4119,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15672,"format":"WEBP"}]}}},{"id":"60ae37e4b2ecb01505043293","name":"OFFLINECHAT","flags":0,"timestamp":1685474173175,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60ae37e4b2ecb01505043293","name":"OFFLINECHAT","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3149aee2aa5538cfd30c","username":"pinappl_","display_name":"pinappl_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e73e6876-e3b9-4ed6-9a31-2cb3f8845651-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae37e4b2ecb01505043293","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1104,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1337,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2301,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2404,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3341,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3864,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4574,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4168,"format":"AVIF"}]}}},{"id":"6473e92e48f3329685a8f7ad","name":"catBoom","flags":0,"timestamp":1685483951767,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6473e92e48f3329685a8f7ad","name":"catBoom","flags":0,"tags":["boom","bomb","cat","kitty"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"63fdacc50fd141cefb0944c3","username":"venu_0001","display_name":"venu_0001","avatar_url":"//cdn.7tv.app/user/63fdacc50fd141cefb0944c3/av_64306ace8df6d27e332c94c8/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6473e92e48f3329685a8f7ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":39,"size":11398,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":38,"size":14040,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":39,"size":20529,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":39,"size":28574,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":39,"size":31160,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":39,"size":43930,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":39,"size":43779,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":39,"size":59246,"format":"WEBP"}]}}},{"id":"64771f38cc463d3ee4a97578","name":"nymnCyclops","flags":0,"timestamp":1685528452621,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64771f38cc463d3ee4a97578","name":"nymnCyclops","flags":0,"tags":["cyclops","subnautica"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64771f38cc463d3ee4a97578","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":17,"size":9730,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":17,"size":9126,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":17,"size":21478,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":17,"size":18724,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":17,"size":35373,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":17,"size":29754,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":17,"size":49102,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":17,"size":40504,"format":"WEBP"}]}}},{"id":"64771f490abc2f913ca35a06","name":"nymnPrawn","flags":0,"timestamp":1685528455266,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64771f490abc2f913ca35a06","name":"nymnPrawn","flags":0,"tags":["prawn","subnautica"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64771f490abc2f913ca35a06","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":17,"size":11209,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":17,"size":10374,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":17,"size":23944,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":17,"size":20374,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":17,"size":38633,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":17,"size":31434,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":17,"size":54912,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":17,"size":42048,"format":"WEBP"}]}}},{"id":"64771ff5cde3496c39845303","name":"nymnSeamoth","flags":0,"timestamp":1685528601480,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64771ff5cde3496c39845303","name":"nymnSeamoth","flags":0,"tags":["subnautica","seamoth"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64771ff5cde3496c39845303","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":17,"size":10298,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":17,"size":9232,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":17,"size":21693,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":17,"size":18614,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":17,"size":35534,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":17,"size":29476,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":17,"size":49925,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":17,"size":38774,"format":"WEBP"}]}}},{"id":"629b803d0e9a57f274bef680","name":"ULLE","flags":0,"timestamp":1685634254785,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"629b803d0e9a57f274bef680","name":"ULLE","flags":0,"tags":["lule","lulw","forsen"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61b24584d8d026b1a66b1bd7","username":"3blanche","display_name":"3blanche","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/629b803d0e9a57f274bef680","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1186,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":918,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2383,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2382,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4120,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3601,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6458,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5337,"format":"AVIF"}]}}},{"id":"6173d83d5ff09767de2a030f","name":"Kissahomie","flags":0,"timestamp":1685654295797,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6173d83d5ff09767de2a030f","name":"Kissahomie","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b14a727a157a7f3360fb0a","username":"mellowoke","display_name":"mellowoke","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/054be650-0eef-4c5b-82de-72c108a5b6d2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6173d83d5ff09767de2a030f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":57,"size":15154,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":57,"size":49690,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":57,"size":44747,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":57,"size":127360,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":57,"size":90347,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":57,"size":223178,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":57,"size":160951,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":57,"size":241314,"format":"WEBP"}]}}},{"id":"619fcbe215b3ff4a5bb7ab65","name":"BANANA","flags":0,"timestamp":1685654445576,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"619fcbe215b3ff4a5bb7ab65","name":"BANANA","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3c29b2ecb015051f8f9a","username":"nymn","display_name":"NymN","avatar_url":"//cdn.7tv.app/pp/60ae3c29b2ecb015051f8f9a/71f269555aeb44c29100cae8aa59b56b","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/619fcbe215b3ff4a5bb7ab65","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":76,"size":19597,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":76,"size":59798,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":76,"size":63198,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":76,"size":155226,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":76,"size":120750,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":76,"size":263960,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":76,"size":217737,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":76,"size":223914,"format":"WEBP"}]}}},{"id":"6264401ba456cdaf745fa2ba","name":"MovieNight","flags":0,"timestamp":1685696256004,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6264401ba456cdaf745fa2ba","name":"MovieNight","flags":0,"tags":["movie","popcorn","peepohappy","movienight"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6241e637db3ed5fb67b4692e","username":"makkusu","display_name":"MAKKUSU","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f3eb5473-7e41-4b3a-b4f0-4086279f9a27-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6264401ba456cdaf745fa2ba","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":29,"size":8606,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":29,"size":12956,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":29,"size":14745,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":29,"size":27300,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":29,"size":21910,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":29,"size":45270,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":29,"size":28608,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":29,"size":61478,"format":"WEBP"}]}}},{"id":"6320fd57b5b851e2f4c1ecc2","name":"ClueLookingAtYou","flags":0,"timestamp":1685704375400,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6320fd57b5b851e2f4c1ecc2","name":"ClueLookingAtYou","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"630d1668e6de78f1ac792e55","username":"lovcen","display_name":"lovcen","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6320fd57b5b851e2f4c1ecc2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1133,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":868,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1991,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2100,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3057,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4384,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3718,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6384,"format":"WEBP"}]}}},{"id":"60538edf9d9e96000d244fa4","name":"TANTIES","flags":0,"timestamp":1685787581780,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60538edf9d9e96000d244fa4","name":"TANTIES","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60538de8b4d31e459fe6f49f","username":"moonmoon_has_tiny_teeth","display_name":"moonmoon_has_tiny_teeth","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/49d3f80c-d15a-467c-a644-ed28f8c69806-profile_image-70x70.jpg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60538edf9d9e96000d244fa4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":6527,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":7350,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":10988,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":16372,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":16252,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":28084,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":15439,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":34364,"format":"WEBP"}]}}},{"id":"60b03ce6fd9839f62d261658","name":"GAMBA","flags":0,"timestamp":1685975719932,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b03ce6fd9839f62d261658","name":"GAMBA","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae99afac03cad6076c6cf1","username":"sushiguh","display_name":"sushiguh","avatar_url":"//cdn.7tv.app/user/60ae99afac03cad6076c6cf1/av_656b9d423d10142edc61c2eb/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b03ce6fd9839f62d261658","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":59,"size":14857,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":59,"size":38020,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":59,"size":39011,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":59,"size":89448,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":59,"size":69524,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":59,"size":141586,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":59,"size":104342,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":59,"size":114222,"format":"WEBP"}]}}},{"id":"647a4a8a804ca09ebf23e032","name":"ThugShaker","flags":0,"timestamp":1685981520695,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"647a4a8a804ca09ebf23e032","name":"ThugShaker","flags":0,"tags":["ritz","bussers","zesty","gyat","dance","wiggle"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60f39e8bc07d1ac193652def","username":"shmovy","display_name":"Shmovy","avatar_url":"//cdn.7tv.app/user/60f39e8bc07d1ac193652def/av_63a4e84f5c2aba9b3b60bf46/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/647a4a8a804ca09ebf23e032","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":73,"size":26679,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":73,"size":47720,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":73,"size":50704,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":73,"size":102064,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":73,"size":78921,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":73,"size":160386,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":73,"size":101776,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":73,"size":219922,"format":"WEBP"}]}}},{"id":"61d8ee5e57c70f633ebcff76","name":"YesThisIsTheTwoTime","flags":0,"timestamp":1685987625062,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"61d8ee5e57c70f633ebcff76","name":"YesThisIsTheTwoTime","flags":0,"tags":["forsen","doc","drdisrespect","forsencd","yeahbut7tv"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"616b10e8474b9b7b59a39c9f","username":"shungite_dealer_","display_name":"shungite_dealer_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/afefb8f7-f546-449d-a4e3-b645cfd2f784-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61d8ee5e57c70f633ebcff76","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":62,"height":32,"frame_count":277,"size":162766,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":62,"height":32,"frame_count":277,"size":391436,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":124,"height":64,"frame_count":277,"size":517308,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":124,"height":64,"frame_count":277,"size":1014996,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":186,"height":96,"frame_count":277,"size":963411,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":186,"height":96,"frame_count":277,"size":1819204,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":248,"height":128,"frame_count":277,"size":1561393,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":248,"height":128,"frame_count":277,"size":2318738,"format":"WEBP"}]}}},{"id":"646e860b0dd3d25472875145","name":"hobovibi","flags":0,"timestamp":1686056809424,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"646e860b0dd3d25472875145","name":"forsenmonkeyPls","flags":0,"tags":["monkeypls","forsen","hobo"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"63b584154e0501de67f7a6f3","username":"hansworthelias","display_name":"HansworthElias","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ee633431-905e-4666-bd3c-f59820cf78a4-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/646e860b0dd3d25472875145","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":30,"size":9386,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":30,"size":16162,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":30,"size":12377,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":30,"size":26710,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":30,"size":18499,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":30,"size":32820,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":30,"size":23370,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":30,"size":40614,"format":"WEBP"}]}}},{"id":"647f8d3828b72684e1227725","name":"$$ryba","flags":0,"timestamp":1686083342615,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"647f8d3828b72684e1227725","name":"plFishing","flags":0,"tags":["fishing","poland","polska","nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60e736ef375879d78f881291","username":"jozefbrzeczyszczykiewicz","display_name":"JozefBrzeczyszczykiewicz","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/603e7851-8726-4745-a2c2-4d8bc3c9bdb0-profile_image-70x70.png","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/647f8d3828b72684e1227725","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":1,"size":1473,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":1,"size":1640,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":1,"size":2782,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":1,"size":4304,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":1,"size":4320,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":1,"size":7762,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":1,"size":5633,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":1,"size":11586,"format":"WEBP"}]}}},{"id":"60b305cd0616dd61564fa042","name":"NotSure","flags":0,"timestamp":1686222280306,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b305cd0616dd61564fa042","name":"NotSure","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b305cd0616dd61564fa042","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1332,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1080,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2732,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2610,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4235,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4272,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5783,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4878,"format":"WEBP"}]}}},{"id":"60f604d931ba6ae622bc8e15","name":"Copege","flags":0,"timestamp":1686222310319,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60f604d931ba6ae622bc8e15","name":"Copege","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b0f4cbe726e379899b12d1","username":"sasquatchofny","display_name":"SasquatchOfNY","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/89d63582-48fa-45ce-9e6b-78f63c682d00-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f604d931ba6ae622bc8e15","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":131,"size":21320,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":131,"size":138172,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":131,"size":60556,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":131,"size":341944,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":131,"size":125477,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":131,"size":593606,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":131,"size":287681,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":131,"size":725906,"format":"WEBP"}]}}},{"id":"62c8b1a1a7ffd3f6119c6797","name":"pog","flags":0,"timestamp":1686254101279,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62c8b1a1a7ffd3f6119c6797","name":"pog","flags":0,"tags":["pog","cat"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6104e29e57aa97350b9aa26d","username":"bitsbyalyx","display_name":"bitsbyalyx","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3a048b86-81ea-40e3-b90e-516da431be4f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62c8b1a1a7ffd3f6119c6797","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":72,"size":21316,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":72,"size":39694,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":72,"size":42976,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":72,"size":80974,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":72,"size":70603,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":72,"size":126992,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":72,"size":100385,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":72,"size":175748,"format":"WEBP"}]}}},{"id":"64332e46a23ea3270ac76ae7","name":"wrrr","flags":0,"timestamp":1686254137008,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64332e46a23ea3270ac76ae7","name":"wrrr","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"633604c93521130ec1bf2413","username":"shrekautisticfan_ref","display_name":"shrekautisticfan_ref","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d1e74566-6d24-4efa-b55b-bbd582faa2bc-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64332e46a23ea3270ac76ae7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":51,"height":32,"frame_count":92,"size":52694,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":51,"height":32,"frame_count":92,"size":59684,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":102,"height":64,"frame_count":92,"size":105765,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":102,"height":64,"frame_count":92,"size":119994,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":153,"height":96,"frame_count":92,"size":169762,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":153,"height":96,"frame_count":92,"size":178446,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":204,"height":128,"frame_count":92,"size":243876,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":204,"height":128,"frame_count":92,"size":242486,"format":"WEBP"}]}}},{"id":"615eafea03c9e8ba70eb65b1","name":"docStay","flags":0,"timestamp":1686307406603,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"615eafea03c9e8ba70eb65b1","name":"docStay","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60f3ebef15758a7f9a913b04","username":"betterthanbrooklyn","display_name":"BetterThanBrooklyn","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/13be7f5e-7c2b-45f8-9232-f85e0fd30f9a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/615eafea03c9e8ba70eb65b1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1227,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":918,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2575,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2354,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3985,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4068,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5661,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6574,"format":"WEBP"}]}}},{"id":"6139b13bf7977b64f644cbe2","name":"vp","flags":1,"timestamp":1686307423096,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6139b13bf7977b64f644cbe2","name":"vp","flags":256,"tags":["very","pog","verypog"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae7069b351b8d1c045ed12","username":"csinhache","display_name":"csinhache","avatar_url":"//cdn.7tv.app/pp/60ae7069b351b8d1c045ed12/aa2c2d8b508045208a12527c072dd1d2","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6139b13bf7977b64f644cbe2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":139,"size":35130,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":139,"size":65274,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":139,"size":81194,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":139,"size":145230,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":139,"size":137420,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":139,"size":240182,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":139,"size":209019,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":139,"size":308608,"format":"WEBP"}]}}},{"id":"6484ad4252950de0e19436b3","name":"CatHandshake","flags":0,"timestamp":1686430629970,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6484ad4252950de0e19436b3","name":"CatHandshake","flags":0,"tags":["cat"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6238c0a274dba55ab3b3b42f","username":"jeipey","display_name":"JEIPEY","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ee3cd8df-c1c2-47d9-848b-fb49ef231891-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6484ad4252950de0e19436b3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":64,"height":32,"frame_count":1,"size":1778,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":64,"height":32,"frame_count":1,"size":2710,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":128,"height":64,"frame_count":1,"size":3920,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":128,"height":64,"frame_count":1,"size":7362,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":192,"height":96,"frame_count":1,"size":14066,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":192,"height":96,"frame_count":1,"size":6625,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":256,"height":128,"frame_count":1,"size":9771,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":256,"height":128,"frame_count":1,"size":22156,"format":"WEBP"}]}}},{"id":"60bf23fd3d4a065567751810","name":"PETLEMONKE","flags":0,"timestamp":1686430716136,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60bf23fd3d4a065567751810","name":"PETLEMONKE","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60be9daef21e6ca0a8750609","username":"liquite_","display_name":"liquite_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6f787c6f-a7ee-4a0c-af4a-8dbe7f177cd7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bf23fd3d4a065567751810","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":5,"size":3884,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":5,"size":3768,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":5,"size":6487,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":5,"size":7610,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":5,"size":11342,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":5,"size":9219,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":5,"size":12502,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":5,"size":12434,"format":"WEBP"}]}}},{"id":"643c96089137f98b004c8d5f","name":"happie","flags":0,"timestamp":1686585467060,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"643c96089137f98b004c8d5f","name":"happie","flags":0,"tags":["cat","catpls","happyhappyhappy","hypercat","happycat","catdance"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"641d6068268d3cdbcb81ee87","username":"spartan_puppyy","display_name":"Spartan_Puppyy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/149a191c-e2d0-4aac-adb4-1235c35275ac-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643c96089137f98b004c8d5f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":120,"size":35058,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":120,"size":56046,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":120,"size":64983,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":120,"size":101792,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":120,"size":100942,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":120,"size":143152,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":120,"size":130666,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":120,"size":179356,"format":"WEBP"}]}}},{"id":"641f9d0dd9a6d7994925cbe6","name":"SoScared","flags":0,"timestamp":1686593334115,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"641f9d0dd9a6d7994925cbe6","name":"Scared","flags":0,"tags":["kitten","scaredcat","fear","scared","scawy","cute"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"614cb75a6251d7e000da4ce7","username":"eljugay","display_name":"eljuGay","avatar_url":"//cdn.7tv.app/user/614cb75a6251d7e000da4ce7/av_648f957bb3fdb6379f1e9b9b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/641f9d0dd9a6d7994925cbe6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":29,"height":32,"frame_count":55,"size":15657,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":29,"height":32,"frame_count":55,"size":39586,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":58,"height":64,"frame_count":55,"size":34218,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":58,"height":64,"frame_count":55,"size":82102,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":87,"height":96,"frame_count":55,"size":58281,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":87,"height":96,"frame_count":55,"size":132460,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":116,"height":128,"frame_count":55,"size":86135,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":116,"height":128,"frame_count":55,"size":184290,"format":"WEBP"}]}}},{"id":"64650c366989b9b0d46a6c25","name":"BOOBAPEEK","flags":0,"timestamp":1686927092927,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64650c366989b9b0d46a6c25","name":"BOOBAPEEK","flags":0,"tags":["booba","peepo","frog","eye","peek"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"616ecd1b5ff09767de298fd8","username":"itzmist","display_name":"Itzmist","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1997dc55-1d90-47bb-8f91-b1449633fc83-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64650c366989b9b0d46a6c25","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":2775,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1320,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":4188,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":2762,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":6031,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":4464,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":8933,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":6170,"format":"WEBP"}]}}},{"id":"648f0fd5b3fdb6379f1e7a8a","name":"DiedOfHeat","flags":0,"timestamp":1687260016597,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"648f0fd5b3fdb6379f1e7a8a","name":"DiedOfHeat","flags":0,"tags":["dead","sleep","summer","cat","cute"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61d72ec157c70f633ebc7afe","username":"ovrht","display_name":"ovrhT","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/648f0fd5b3fdb6379f1e7a8a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":1,"size":1074,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":1,"size":2008,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":1,"size":2278,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":1,"size":6542,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":1,"size":4010,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":1,"size":13342,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":1,"size":5949,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":1,"size":22436,"format":"WEBP"}]}}},{"id":"63cf25c8ec685e58d1732f53","name":"PoroSadder","flags":0,"timestamp":1687294345326,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63cf25c8ec685e58d1732f53","name":"PoroSadder","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61b65817d3f1830abc23d4e1","username":"sohchill","display_name":"SohChill","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a7ea4658-5603-4ef1-9333-0dce14728915-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63cf25c8ec685e58d1732f53","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1443,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1982,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3062,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5996,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5168,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11588,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7221,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18404,"format":"WEBP"}]}}},{"id":"6476a00f804ca09ebf232de0","name":"Hypnime","flags":0,"timestamp":1687345367852,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"6476a00f804ca09ebf232de0","name":"Hypnime","flags":0,"tags":["hypno","hypnosis","nymn","nime"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"623bb7e3bc7636f2937da399","username":"papacristobal","display_name":"PapaCristobal","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/20dda529-d361-48aa-a593-d56d6c93dd22-profile_image-70x70.jpg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6476a00f804ca09ebf232de0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":34,"size":13637,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":34,"size":24676,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":34,"size":27581,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":34,"size":45778,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":34,"size":46813,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":34,"size":66386,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":34,"size":65068,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":34,"size":87448,"format":"WEBP"}]}}},{"id":"62dc43a7b98f078c8a422e41","name":"Sneak","flags":0,"timestamp":1687345683660,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62dc43a7b98f078c8a422e41","name":"Sneak","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"615a6806535ab0eaceab9cca","username":"tintillo_5754","display_name":"Tintillo_5754","avatar_url":"//cdn.7tv.app/user/615a6806535ab0eaceab9cca/av_657b9d69d3e43608a9aa455c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62dc43a7b98f078c8a422e41","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":14,"size":9592,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":14,"size":13170,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":14,"size":20384,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":14,"size":29088,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":14,"size":34894,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":14,"size":45998,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":14,"size":48112,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":14,"size":65488,"format":"WEBP"}]}}},{"id":"63ea6d470276acdc6138300c","name":"Versus","flags":0,"timestamp":1687346150337,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63ea6d470276acdc6138300c","name":"VS","flags":0,"tags":["box","brawl","versus","1v1","fight","punch"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ea6d470276acdc6138300c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":75,"size":52725,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":75,"size":73236,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":75,"size":104256,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":75,"size":156730,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":75,"size":159991,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":75,"size":247366,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":75,"size":198725,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":75,"size":331516,"format":"WEBP"}]}}},{"id":"64142ee86b843cb8a70022fa","name":"lava","flags":0,"timestamp":1687353081958,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64142ee86b843cb8a70022fa","name":"lava","flags":0,"tags":["minecraft","lava","cat"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"634d21589f07e970e1f27bf8","username":"vladimirvr","display_name":"VladimirVR","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/51aa0de1-1bae-4bf4-bb80-a51e9e8a048c-profile_image-70x70.jpg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64142ee86b843cb8a70022fa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":30,"height":32,"frame_count":123,"size":41608,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":30,"height":32,"frame_count":121,"size":71054,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":60,"height":64,"frame_count":123,"size":100505,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":60,"height":64,"frame_count":122,"size":161044,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":90,"height":96,"frame_count":123,"size":168727,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":90,"height":96,"frame_count":123,"size":270572,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":120,"height":128,"frame_count":123,"size":229139,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":120,"height":128,"frame_count":123,"size":381194,"format":"WEBP"}]}}},{"id":"62e13e0472db7f79757f9b6a","name":"re","flags":0,"timestamp":1687380327832,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62e13e0472db7f79757f9b6a","name":"re","flags":0,"tags":["cute","kitty","cat","hello"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61ebf2d51a1b2a6e7324d938","username":"one94our","display_name":"one94our","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/656f9f5d-05f8-49aa-bf6a-382a253dcf80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62e13e0472db7f79757f9b6a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":61,"size":15232,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":60,"size":44332,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":61,"size":29462,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":61,"size":105054,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":61,"size":44636,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":61,"size":167756,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":61,"size":61417,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":61,"size":223714,"format":"WEBP"}]}}},{"id":"63ad718b4dd0f31dfdc365ad","name":"WHATDOYOUWANTTOEAT","flags":0,"timestamp":1687430438657,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63ad718b4dd0f31dfdc365ad","name":"WHATDOYOUWANTTOEAT","flags":0,"tags":["dumpy","trizze"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b878c15d373afbd6addf87","username":"aiterace","display_name":"AIterAce","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4b7896e6-7896-491c-8431-ec9c378dba3f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ad718b4dd0f31dfdc365ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":53,"size":11206,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":53,"size":16102,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":53,"size":20543,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":53,"size":34080,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":53,"size":32150,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":53,"size":52258,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":53,"size":52760,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":53,"size":81000,"format":"WEBP"}]}}},{"id":"633affecf89e2afddea7cbd6","name":"reveal0","flags":1,"timestamp":1687465768941,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"633affecf89e2afddea7cbd6","name":"Reveal","flags":256,"tags":["minecraft","speedrun","dream","forsencd"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/633affecf89e2afddea7cbd6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":59,"size":15965,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":49,"size":28588,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":59,"size":29824,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":54,"size":53838,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":59,"size":49369,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":59,"size":88362,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":59,"size":55189,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":59,"size":119408,"format":"WEBP"}]}}},{"id":"60b5abd62b064112660d3795","name":"Flowerge","flags":0,"timestamp":1687511668054,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60b5abd62b064112660d3795","name":"Flowerge","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b561cc04283ab952bfd4e0","username":"on_a_stack","display_name":"On_a_stack","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1ed36b65-71c8-4eb2-a6a7-83ad2bb7566a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b5abd62b064112660d3795","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":950,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1334,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2354,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2521,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3832,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4006,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5075,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5138,"format":"WEBP"}]}}},{"id":"63de797c1d40a5212f9a5f9b","name":"xqcL","flags":0,"timestamp":1687517608748,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63de797c1d40a5212f9a5f9b","name":"xqcL","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"631c97894f3e0f1fc59f8a43","username":"heavenice416","display_name":"HeavenIce416","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6e73ff96-4c24-425e-aeae-f9b5837c16e2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63de797c1d40a5212f9a5f9b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":63,"height":32,"frame_count":1,"size":2033,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":63,"height":32,"frame_count":1,"size":4320,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":126,"height":64,"frame_count":1,"size":5375,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":126,"height":64,"frame_count":1,"size":15046,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":189,"height":96,"frame_count":1,"size":9686,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":189,"height":96,"frame_count":1,"size":30032,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":252,"height":128,"frame_count":1,"size":15155,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":252,"height":128,"frame_count":1,"size":49584,"format":"WEBP"}]}}},{"id":"63e925edad0bbe6a9c23d867","name":"NOTIFICATIONS","flags":0,"timestamp":1687607384879,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63e925edad0bbe6a9c23d867","name":"NOTIFICATIONS","flags":0,"tags":["drdisrespect","disrespect","docing","howdoweturnoffnotifications","doc"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61e1b61e6e676399a0ffab36","username":"pendrive","display_name":"PendrivE","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63e925edad0bbe6a9c23d867","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":64,"size":32025,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":64,"size":35794,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":64,"size":89462,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":64,"size":86194,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":64,"size":189401,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":64,"size":147378,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":64,"size":312249,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":64,"size":216968,"format":"WEBP"}]}}},{"id":"6468dcb6a8c78dc467371865","name":"xQcL","flags":0,"timestamp":1687693334110,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6468dcb6a8c78dc467371865","name":"xQcL","flags":0,"tags":["xqcl","ticket","forsen","juicer","juicercheck","sus"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"64319f6845034e12788d219b","username":"cubedude20","display_name":"CubeDude20","avatar_url":"//cdn.7tv.app/user/64319f6845034e12788d219b/av_6470c813361980b8f005af52/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6468dcb6a8c78dc467371865","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":68,"height":32,"frame_count":1,"size":2808,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":68,"height":32,"frame_count":1,"size":4394,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":136,"height":64,"frame_count":1,"size":6565,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":136,"height":64,"frame_count":1,"size":11644,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":204,"height":96,"frame_count":1,"size":10801,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":204,"height":96,"frame_count":1,"size":20208,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":272,"height":128,"frame_count":1,"size":14408,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":272,"height":128,"frame_count":1,"size":26620,"format":"WEBP"}]}}},{"id":"60f9bd1231ba6ae6228eb8b8","name":"SadCat","flags":0,"timestamp":1687695639764,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60f9bd1231ba6ae6228eb8b8","name":"SadCat","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60d0f7a159c20115e2c6df82","username":"ojoaoh","display_name":"ojoaoh","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5ecf5bba-4ca3-4c3a-9e04-8261bef3b04c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f9bd1231ba6ae6228eb8b8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":95,"size":22857,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":95,"size":83044,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":95,"size":72759,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":95,"size":203964,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":95,"size":139637,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":95,"size":339020,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":95,"size":228459,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":95,"size":255020,"format":"WEBP"}]}}},{"id":"616ecf08b6d21adaffbe8f3f","name":"docFaint","flags":0,"timestamp":1687707226514,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"616ecf08b6d21adaffbe8f3f","name":"docFaint","flags":0,"tags":["bruhfaint","docpls","these","forsencd","bruh","faint"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/616ecf08b6d21adaffbe8f3f","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":42,"size":27874,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":42,"size":16706,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":42,"size":35649,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":42,"size":56356,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":42,"size":89500,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":42,"size":59465,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":42,"size":80786,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":42,"size":89162,"format":"WEBP"}]}}},{"id":"64407ade6fab698d27de11c3","name":"docReadyToFaint","flags":0,"timestamp":1687708016652,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64407ade6fab698d27de11c3","name":"docReadyToFaint","flags":0,"tags":["scary","docfaint","drdisrespect","fainting"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"61ce216cf644a864b441c7fb","username":"fistymart","display_name":"FistyMart","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fistymart-profile_image-63bb6503cd5238a7-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64407ade6fab698d27de11c3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1189,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1686,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2182,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4846,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3439,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9814,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5232,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7936,"format":"WEBP"}]}}},{"id":"6372c6a88d49e69277e3eb79","name":"Poland","flags":0,"timestamp":1687869853382,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6372c6a88d49e69277e3eb79","name":"Poland","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6136dba730c4a960d9509f33","username":"oastria","display_name":"oAstria","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bdb404d6-f73d-4aef-9590-94a7a705de0a-profile_image-70x70.png","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62d86a8419fdcf401421c5ae","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6372c6a88d49e69277e3eb79","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":47,"size":8170,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":47,"size":12326,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":47,"size":15466,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":47,"size":38252,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":47,"size":25693,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":47,"size":55744,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":47,"size":44385,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":47,"size":77524,"format":"WEBP"}]}}},{"id":"62462de10489806c448c86c2","name":"NikoBellicCrashingThroughTheWindshieldButHesASuperSoldierSoHeNeverFuckingDies","flags":0,"timestamp":1687870937328,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62462de10489806c448c86c2","name":"NikoBellicCrashingThroughTheWindshieldButHesASuperSoldierSoHeNeverFuckingDies","flags":0,"tags":["gtaiv"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61eaf44b009502d85be4cedd","username":"carp1g","display_name":"carp1g","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/578da9b7-edf4-403c-80ec-21c3205bedac-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62462de10489806c448c86c2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":45,"size":30285,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":45,"size":51038,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":45,"size":93519,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":45,"size":146776,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":45,"size":189160,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":45,"size":282658,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":45,"size":355653,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":45,"size":441576,"format":"WEBP"}]}}},{"id":"63d5db60202fab2614b784e0","name":"ThisIDidNotExpect","flags":0,"timestamp":1687870939260,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63d5db60202fab2614b784e0","name":"ThisIDidNotExpect","flags":0,"tags":["niko","gtaiv","gta4","despair","droosy"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"624cc42d0df4d672d207030f","username":"droosy","display_name":"Droosy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/62d3ccbe-15b3-4725-a23c-27dcbbc5970b-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63d5db60202fab2614b784e0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":994,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1214,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1575,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2972,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2157,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5306,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2710,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7778,"format":"WEBP"}]}}},{"id":"638a709db2c8f430c66898f3","name":"FeelsNikoMan","flags":0,"timestamp":1687870945970,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"638a709db2c8f430c66898f3","name":"FeelsNikoMan","flags":0,"tags":["niko","gta","gta4","war"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/638a709db2c8f430c66898f3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1245,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1814,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2351,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5468,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3563,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10478,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4534,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16242,"format":"WEBP"}]}}},{"id":"646890d358d599a0419f882f","name":"miniPoro","flags":0,"timestamp":1687950957553,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"646890d358d599a0419f882f","name":"miniPoro","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60d27440c110a67b0f772489","username":"thetoomm","display_name":"THETOOMM","avatar_url":"//cdn.7tv.app/user/60d27440c110a67b0f772489/av_652a6c93b79e22d8df9023a4/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/646890d358d599a0419f882f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":17,"height":17,"frame_count":1,"size":838,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":17,"height":17,"frame_count":1,"size":870,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":34,"height":34,"frame_count":1,"size":992,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":34,"height":34,"frame_count":1,"size":1165,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":51,"height":51,"frame_count":1,"size":1060,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":51,"height":51,"frame_count":1,"size":1558,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":68,"height":68,"frame_count":1,"size":1338,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":68,"height":68,"frame_count":1,"size":1116,"format":"WEBP"}]}}},{"id":"63d701f28d2ddb55395c1f36","name":"stopbeingFrench","flags":0,"timestamp":1688162254457,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63d701f28d2ddb55395c1f36","name":"stopbeingFrench","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"63d701ab6e40981149587713","username":"jdoa","display_name":"jdoa","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d95cc91a-a4dc-4a52-851f-1fda2265c961-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63d701f28d2ddb55395c1f36","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1659,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":2458,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":3263,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":7228,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":4739,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":13486,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":6220,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":21012,"format":"WEBP"}]}}},{"id":"612a803421ca87d781a04fd2","name":"!fund","flags":0,"timestamp":1688217858135,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"612a803421ca87d781a04fd2","name":"Corpa","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/612a803421ca87d781a04fd2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1424,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1152,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2719,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2744,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4209,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4558,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5646,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6384,"format":"WEBP"}]}}},{"id":"60d0c76d226e3fcff8421acc","name":"ForsenAgreeingWithYou","flags":0,"timestamp":1688225675759,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"60d0c76d226e3fcff8421acc","name":"ForsenAgreeingWithYou","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60d0c76d226e3fcff8421acc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":87,"size":21495,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":87,"size":74942,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":87,"size":47909,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":87,"size":146546,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":87,"size":86360,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":87,"size":236502,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":87,"size":134261,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":87,"size":257828,"format":"WEBP"}]}}},{"id":"64a0529cecdb531b02a2e378","name":"buh","flags":0,"timestamp":1688228562825,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a0529cecdb531b02a2e378","name":"buh","flags":0,"tags":["cat","wut","buh","wuh"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a0529cecdb531b02a2e378","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":119,"size":26555,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":119,"size":77144,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":119,"size":74295,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":119,"size":138560,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":119,"size":144871,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":119,"size":214064,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":119,"size":283480,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":119,"size":275200,"format":"WEBP"}]}}},{"id":"60b0221a4d83b66c448ce06e","name":"SchubertWalk","flags":0,"timestamp":1688244733230,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60b0221a4d83b66c448ce06e","name":"SchubertWalk","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aee53ea564afa26ea9e726","username":"patrickmaybe","display_name":"PatrickMaybe","avatar_url":"//cdn.7tv.app/pp/60aee53ea564afa26ea9e726/5ddc6552878941e29bca02ef70362a2e","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b0221a4d83b66c448ce06e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":12,"size":11253,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":12,"size":12098,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":12,"size":24762,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":12,"size":22653,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":12,"size":34034,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":12,"size":39212,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":12,"size":46630,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":12,"size":43548,"format":"WEBP"}]}}},{"id":"60ae43455d3fdae5838274e6","name":"noxWhat","flags":0,"timestamp":1688312557164,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60ae43455d3fdae5838274e6","name":"noxWhat","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6053934bb4d31e459fee53e5","username":"pilzkman","display_name":"pilzkman","avatar_url":"//cdn.7tv.app/pp/6053934bb4d31e459fee53e5/85079bb2587f4cada4dc9d3d31b5a47b","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae43455d3fdae5838274e6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":43,"size":12314,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":43,"size":36076,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":43,"size":24609,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":43,"size":74864,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":43,"size":42828,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":43,"size":121610,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":43,"size":63675,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":43,"size":128584,"format":"WEBP"}]}}},{"id":"613512b7849caeb8a393d8c2","name":"spilledGlue","flags":1,"timestamp":1688366093106,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"613512b7849caeb8a393d8c2","name":"spilledGlue","flags":256,"tags":["spilledglue","zero","width","glue","coomer"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60532ec2b4d31e459f7293dc","username":"marrryanx","display_name":"Marrryanx","avatar_url":"//cdn.7tv.app/user/60532ec2b4d31e459f7293dc/av_6570dc7f834e0a119031a679/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613512b7849caeb8a393d8c2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":13,"size":6850,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":13,"size":8172,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":13,"size":10965,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":13,"size":14764,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":13,"size":18146,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":13,"size":24662,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":13,"size":19628,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":13,"size":20614,"format":"WEBP"}]}}},{"id":"6361b8e5be540ad2374e701b","name":"FeelsYabbeMan","flags":0,"timestamp":1688386423600,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6361b8e5be540ad2374e701b","name":"FeelsYabbeMan","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60af8846a3648f409a124ee4","username":"kniteort","display_name":"Kniteort","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/05070f7c-ec6a-47cf-a274-54e062b11bf7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6361b8e5be540ad2374e701b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1377,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1580,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2441,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4276,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3565,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7670,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4445,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":11846,"format":"WEBP"}]}}},{"id":"64a2bfc8d66481a52b5f256d","name":"mean0","flags":1,"timestamp":1688387617198,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a2bfc8d66481a52b5f256d","name":"mean0","flags":256,"tags":["stopbeingmean","mean","weirdge"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a2bfc8d66481a52b5f256d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":47,"height":32,"frame_count":18,"size":7440,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":47,"height":32,"frame_count":18,"size":8502,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":94,"height":64,"frame_count":18,"size":14307,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":94,"height":64,"frame_count":18,"size":15648,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":141,"height":96,"frame_count":18,"size":23415,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":141,"height":96,"frame_count":18,"size":23052,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":188,"height":128,"frame_count":18,"size":33536,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":188,"height":128,"frame_count":18,"size":27804,"format":"WEBP"}]}}},{"id":"64a3e66d6b69e1b647fd38b5","name":"Programming","flags":0,"timestamp":1688462969905,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64a3e66d6b69e1b647fd38b5","name":"Programming","flags":0,"tags":["cat","programming"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a3e66d6b69e1b647fd38b5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":39,"size":11673,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":39,"size":22194,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":39,"size":27435,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":39,"size":55354,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":39,"size":49266,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":39,"size":97040,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":39,"size":84812,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":39,"size":151438,"format":"WEBP"}]}}},{"id":"64a3fa8a4a9e393297845c61","name":"Kot","flags":0,"timestamp":1688468113986,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64a3fa8a4a9e393297845c61","name":"Kot","flags":0,"tags":["cat","polish","kot"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a3fa8a4a9e393297845c61","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1169,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1390,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2197,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4026,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3556,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7774,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5012,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":12242,"format":"WEBP"}]}}},{"id":"63371ac69af9b93dad7ba9e5","name":"ewpert","flags":0,"timestamp":1688483316246,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63371ac69af9b93dad7ba9e5","name":"ewpert","flags":0,"tags":["robert","philip","cat","ewpert","meow"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61064d0462ceea408a681b33","username":"rakutrash","display_name":"rakutrash","avatar_url":"//cdn.7tv.app/pp/61064d0462ceea408a681b33/dee7b58948224a8a9b288b753cf949f0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63371ac69af9b93dad7ba9e5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":25,"height":32,"frame_count":1,"size":1055,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":25,"height":32,"frame_count":1,"size":1328,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":50,"height":64,"frame_count":1,"size":1695,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":50,"height":64,"frame_count":1,"size":3622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":75,"height":96,"frame_count":1,"size":2561,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":75,"height":96,"frame_count":1,"size":6690,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":100,"height":128,"frame_count":1,"size":3411,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":100,"height":128,"frame_count":1,"size":10516,"format":"WEBP"}]}}},{"id":"60b212d561df920001b3ca58","name":"glizzyR","flags":0,"timestamp":1688549905129,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b212d561df920001b3ca58","name":"glizzyR","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b20b0461df9200018e6eea","username":"m8use","display_name":"M8use","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8232e029-591a-4de3-afd7-d960b4c7a626-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b212d561df920001b3ca58","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":56,"size":33128,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":56,"size":46492,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":56,"size":68607,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":56,"size":95804,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":56,"size":106919,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":56,"size":158314,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":56,"size":140811,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":56,"size":170572,"format":"WEBP"}]}}},{"id":"60b1a41020b432903ad7129a","name":"glizzyL","flags":0,"timestamp":1688550015721,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b1a41020b432903ad7129a","name":"glizzyL","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b1588b50b7256d888b09b2","username":"chubbehmouse","display_name":"ChubbehMouse","avatar_url":"//cdn.7tv.app/pp/60b1588b50b7256d888b09b2/d8772dc7dbaa4cfdb9ae9f30fbaaaf35","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b1a41020b432903ad7129a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":81105,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":112492,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":172441,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":239486,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":291308,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":411804,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":391759,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":470058,"format":"WEBP"}]}}},{"id":"6462bd6c22acdc24c2c5de7e","name":"NeuronActivation","flags":0,"timestamp":1688558350284,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6462bd6c22acdc24c2c5de7e","name":"NeuronActivation","flags":0,"tags":["activation","monkey","brain","neuron"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"62308c6683ac0ba37203727b","username":"sotenbori","display_name":"Sotenbori","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/2a7126a6-4b71-4019-9c14-868934a889a5-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6462bd6c22acdc24c2c5de7e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1330,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2166,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2835,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6730,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4373,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":13018,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6854,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":21488,"format":"WEBP"}]}}},{"id":"647a0242cde3496c3984e286","name":"JozefBrzeczyszczykiewicz","flags":0,"timestamp":1688558848852,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"647a0242cde3496c3984e286","name":"defaultPolishMale","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60aeb501955615deef869415","username":"froglin_","display_name":"Froglin_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5a020ff1-3768-4f68-8d75-d56f2dee8403-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/647a0242cde3496c3984e286","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":28,"height":32,"frame_count":1,"size":1144,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":28,"height":32,"frame_count":1,"size":1810,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":56,"height":64,"frame_count":1,"size":2282,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":56,"height":64,"frame_count":1,"size":5608,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":84,"height":96,"frame_count":1,"size":3840,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":84,"height":96,"frame_count":1,"size":10910,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":112,"height":128,"frame_count":1,"size":5375,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":112,"height":128,"frame_count":1,"size":17632,"format":"WEBP"}]}}},{"id":"63f63d960588a70e9a8d6638","name":"Polska","flags":0,"timestamp":1688559262036,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63f63d960588a70e9a8d6638","name":"Polska","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61bce0e75804e220aa6ae030","username":"raen","display_name":"Raen","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ad71ec52-b146-4181-9fc1-7ea8640f6002-profile_image-70x70.png","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f63d960588a70e9a8d6638","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":884,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1572,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1395,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4690,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1961,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8768,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2401,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13624,"format":"WEBP"}]}}},{"id":"647b6e2dd4b5d6083e91c949","name":"blehE","flags":0,"timestamp":1688560187550,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"647b6e2dd4b5d6083e91c949","name":":b","flags":0,"tags":["psp1g","kitty","silly","cat","bleh","blep"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"64319f6845034e12788d219b","username":"cubedude20","display_name":"CubeDude20","avatar_url":"//cdn.7tv.app/user/64319f6845034e12788d219b/av_6470c813361980b8f005af52/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/647b6e2dd4b5d6083e91c949","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":989,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1714,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1702,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5470,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2452,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10788,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3161,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":17528,"format":"WEBP"}]}}},{"id":"62fe031499e98d38a128d66a","name":"defaultCzechMale","flags":0,"timestamp":1688572144571,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"62fe031499e98d38a128d66a","name":"krecik","flags":0,"tags":["krecik"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"621c9a18df86bac8c42fb9d0","username":"jezykk_1","display_name":"Jezykk_1","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/614f4773-1b72-4b5c-a3a4-09a7135f54de-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62fe031499e98d38a128d66a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":1,"size":1075,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":1,"size":1350,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":1,"size":1929,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":1,"size":3996,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":1,"size":2948,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":1,"size":7462,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":1,"size":4057,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":1,"size":11746,"format":"WEBP"}]}}},{"id":"63ac576c473fefc1365e16bf","name":"pokiEmote","flags":0,"timestamp":1688573640121,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ac576c473fefc1365e16bf","name":"pokiEmote","flags":0,"tags":["trikool","dance","pokimane","tiktok","poki"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ac576c473fefc1365e16bf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":274,"size":122303,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":274,"size":198886,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":274,"size":256459,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":274,"size":384450,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":274,"size":408818,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":274,"size":543776,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":274,"size":556433,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":274,"size":702996,"format":"WEBP"}]}}},{"id":"63ae2479a1fed74764cbccc4","name":"pokiCharm","flags":0,"timestamp":1688573656324,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ae2479a1fed74764cbccc4","name":"pokiCharm","flags":0,"tags":["queen","sassy","yass","flick","poki"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ae2479a1fed74764cbccc4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":86,"size":37447,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":86,"size":62204,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":86,"size":89719,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":86,"size":123844,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":86,"size":152134,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":86,"size":187968,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":86,"size":220578,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":86,"size":254196,"format":"WEBP"}]}}},{"id":"63b09aac5df82557901fdff7","name":"PayUp","flags":0,"timestamp":1688573666612,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63b09aac5df82557901fdff7","name":"PayUp","flags":0,"tags":["gamba","poki","money","myna","scam","lime"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b09aac5df82557901fdff7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":131,"size":43504,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":131,"size":82404,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":131,"size":106587,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":131,"size":163092,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":131,"size":173114,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":131,"size":265492,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":131,"size":370919,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":131,"size":392946,"format":"WEBP"}]}}},{"id":"64a4d159a683f9aba55e66c8","name":"PartyPls","flags":0,"timestamp":1688587795134,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a4d159a683f9aba55e66c8","name":"PartyPls","flags":0,"tags":["elis","jam","dance","gathering","peepopls","monkeypls"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61d99a8f82d5237d6795d934","username":"lithamsterlaze","display_name":"lithamsterlaze","avatar_url":"//cdn.7tv.app/user/61d99a8f82d5237d6795d934/av_656da6af92115eb399e4e677/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a4d159a683f9aba55e66c8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":29,"frame_count":28,"size":24829,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":29,"frame_count":28,"size":47996,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":58,"frame_count":28,"size":42965,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":58,"frame_count":28,"size":83668,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":87,"frame_count":28,"size":53689,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":87,"frame_count":28,"size":113330,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":116,"frame_count":28,"size":60747,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":116,"frame_count":28,"size":138144,"format":"WEBP"}]}}},{"id":"622b9dcc043b2a353ec8c328","name":"Oui","flags":1,"timestamp":1688770295912,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"622b9dcc043b2a353ec8c328","name":"Oui","flags":256,"tags":["brench","french","peepo","lime","texime"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61f080f9f933d586cdda8228","username":"narjuh","display_name":"narjuh","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/41780b5a-def8-11e9-94d9-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/622b9dcc043b2a353ec8c328","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1255,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":980,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2058,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2092,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3614,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3071,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3976,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5178,"format":"WEBP"}]}}},{"id":"64a880287107e45ee482084d","name":"angrERiot","flags":0,"timestamp":1688813732589,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a880287107e45ee482084d","name":"angrERiot","flags":0,"tags":["lule","play","peeporiot","forsen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"62dfad28e2f69efc6a2c84b7","username":"esperdg","display_name":"EsperDG","avatar_url":"//cdn.7tv.app/user/62dfad28e2f69efc6a2c84b7/av_6515a414e66ad3b2e8846aab/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a880287107e45ee482084d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":82,"size":57892,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":82,"size":192636,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":82,"size":187553,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":82,"size":540968,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":82,"size":324006,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":82,"size":937130,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":82,"size":561642,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":82,"size":1469430,"format":"WEBP"}]}}},{"id":"64a853196d4c4af9e2e454f1","name":"nim","flags":0,"timestamp":1688820792593,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a853196d4c4af9e2e454f1","name":"JeSuisNim","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a853196d4c4af9e2e454f1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1360,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2082,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2648,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6296,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4069,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11854,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5902,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18522,"format":"WEBP"}]}}},{"id":"61ea9578009502d85be4c015","name":"forsenPits","flags":0,"timestamp":1688821067206,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61ea9578009502d85be4c015","name":"forsenPits","flags":0,"tags":["armpits"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60af9448199b90afe4b2d467","username":"motakam","display_name":"motaKam","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/3d7afd29-abb3-4774-b6fe-6157806ec9c0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61ea9578009502d85be4c015","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":1,"size":1337,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":1,"size":1150,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":1,"size":2429,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":1,"size":2842,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":1,"size":3657,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":1,"size":4640,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":1,"size":4641,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":1,"size":7106,"format":"WEBP"}]}}},{"id":"61d5e2993d52bb5c33c4e319","name":"Labbe","flags":0,"timestamp":1688825622478,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61d5e2993d52bb5c33c4e319","name":"Labbe","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60af8d3952a13d1adb11c8f7","username":"smagren","display_name":"smagren","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/09e5cbee-835e-4b11-a0f7-ffdc9f6d9dcb-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61d5e2993d52bb5c33c4e319","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1127,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2058,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2082,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3047,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3510,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4595,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5378,"format":"WEBP"}]}}},{"id":"64a96f9ab66fe5cd6f64a453","name":"nymnYell","flags":0,"timestamp":1688825864144,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a96f9ab66fe5cd6f64a453","name":"nymnYell","flags":0,"tags":["forsen","nymn","peepoyell","twitchcon"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3cb1b2ecb0150521fa1f","username":"waterboiledpizza","display_name":"WaterBoiledPizza","avatar_url":"//cdn.7tv.app/user/60ae3cb1b2ecb0150521fa1f/av_652806843e9323c51e05082e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a96f9ab66fe5cd6f64a453","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":58,"height":32,"frame_count":1,"size":1691,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":58,"height":32,"frame_count":1,"size":2192,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":116,"height":64,"frame_count":1,"size":3768,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":116,"height":64,"frame_count":1,"size":6040,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":174,"height":96,"frame_count":1,"size":6123,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":174,"height":96,"frame_count":1,"size":11242,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":232,"height":128,"frame_count":1,"size":8272,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":232,"height":128,"frame_count":1,"size":17738,"format":"WEBP"}]}}},{"id":"64a975352d10dc644f50fac0","name":"TooMuchWork","flags":0,"timestamp":1688827205562,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64a975352d10dc644f50fac0","name":"TooMuchWork","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a975352d10dc644f50fac0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":23,"frame_count":1,"size":1417,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":23,"frame_count":1,"size":1872,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":46,"frame_count":1,"size":2869,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":46,"frame_count":1,"size":5232,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":69,"frame_count":1,"size":4234,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":69,"frame_count":1,"size":9276,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":92,"frame_count":1,"size":6149,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":92,"frame_count":1,"size":13754,"format":"WEBP"}]}}},{"id":"64a98e48c7d082e76f721fc5","name":"POV","flags":0,"timestamp":1688834157787,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a98e48c7d082e76f721fc5","name":"NymnThrottlingYou","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a98e48c7d082e76f721fc5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":54,"height":32,"frame_count":35,"size":23024,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":54,"height":32,"frame_count":35,"size":33324,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":108,"height":64,"frame_count":35,"size":51624,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":108,"height":64,"frame_count":35,"size":68524,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":162,"height":96,"frame_count":35,"size":82573,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":162,"height":96,"frame_count":35,"size":103068,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":216,"height":128,"frame_count":35,"size":114719,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":216,"height":128,"frame_count":35,"size":136548,"format":"WEBP"}]}}},{"id":"6481e830ef5132cc7ab59c2c","name":"$$fish","flags":0,"timestamp":1688886573225,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6481e830ef5132cc7ab59c2c","name":"WeirdFishing","flags":0,"tags":["feelsweirdman"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60879d10fcf1f9923f6e1573","username":"somso2e","display_name":"Somso2e","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7291e0ba-abe4-4928-9951-6becee40fb61-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6481e830ef5132cc7ab59c2c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":55,"height":32,"frame_count":1,"size":1949,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":55,"height":32,"frame_count":1,"size":2494,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":110,"height":64,"frame_count":1,"size":4267,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":110,"height":64,"frame_count":1,"size":7026,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":165,"height":96,"frame_count":1,"size":7092,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":165,"height":96,"frame_count":1,"size":13412,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":220,"height":128,"frame_count":1,"size":9534,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":220,"height":128,"frame_count":1,"size":20684,"format":"WEBP"}]}}},{"id":"624d6dd8e02fe198206673f5","name":"NowWot","flags":0,"timestamp":1688914530730,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"624d6dd8e02fe198206673f5","name":"NowWot","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae6c1e86fc40d488e398cc","username":"kaetnn","display_name":"KAETNN","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8489c26f-9d36-460d-a9ee-ca5bbf5d6518-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/624d6dd8e02fe198206673f5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":1,"size":1332,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":1,"size":1160,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":1,"size":3130,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":1,"size":2746,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":1,"size":5498,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":1,"size":4401,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":1,"size":6565,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":1,"size":8872,"format":"WEBP"}]}}},{"id":"60e5a68c6d2fbedb0118109b","name":"lulWut","flags":0,"timestamp":1688918175633,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60e5a68c6d2fbedb0118109b","name":"lulWut","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60e5a4d8840f3a570116016a","username":"ieatcoomforbreakfast","display_name":"ieatcoomforbreakfast","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/ce57700a-def9-11e9-842d-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e5a68c6d2fbedb0118109b","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1186,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1321,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2801,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3070,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4421,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5364,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6681,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8412,"format":"WEBP"}]}}},{"id":"60c3d56b6d2fcea82fc7658d","name":"modCheck","flags":0,"timestamp":1689017743343,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60c3d56b6d2fcea82fc7658d","name":"modCheck","flags":0,"tags":["cat","cute","check"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b3d2a04496c74949ac8bc5","username":"alphex2","display_name":"alphex2","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5ed841d2-c617-4b34-9c12-a70ce497866c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60c3d56b6d2fcea82fc7658d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":68,"size":17646,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":68,"size":42972,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":68,"size":67683,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":68,"size":113310,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":68,"size":128372,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":68,"size":207250,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":68,"size":190542,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":68,"size":173538,"format":"WEBP"}]}}},{"id":"6466affa58d599a0419f313c","name":"WaitingForNymNToGoLive","flags":0,"timestamp":1689272995286,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6466affa58d599a0419f313c","name":"WaitingForStream","flags":0,"tags":["stream","futurama","waiting"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"632e25835d511ac23bcc8b34","username":"br0wlol","display_name":"Br0wLoL","avatar_url":"//cdn.7tv.app/user/632e25835d511ac23bcc8b34/av_64e262f9e4d325845e86c521/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6466affa58d599a0419f313c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":64,"size":20456,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":64,"size":29976,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":64,"size":51971,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":64,"size":71110,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":64,"size":96254,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":64,"size":117262,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":64,"size":146907,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":64,"size":161274,"format":"WEBP"}]}}},{"id":"63fb2371e5d9925da81238e8","name":"catBruh","flags":0,"timestamp":1689282869873,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63fb2371e5d9925da81238e8","name":"bruh","flags":0,"tags":["bruuuuuuuuuuuuuuuuuuuuuuuuh","catsofcringe","bruh","catbruh","cat","bruuuh"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6311ef9cbdf4c4798bed60f0","username":"mamanesipolotence","display_name":"MamaNesiPOLOTENCE","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7cb5165c-9ebb-4b51-9ef9-33e10027c6a9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fb2371e5d9925da81238e8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":91,"size":21328,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":91,"size":43854,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":91,"size":47722,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":91,"size":94778,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":91,"size":75857,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":91,"size":148042,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":91,"size":152759,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":91,"size":220212,"format":"WEBP"}]}}},{"id":"64aff4ae2b9b9a7b4ba0482e","name":"ribbon0","flags":1,"timestamp":1689330958874,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"64aff4ae2b9b9a7b4ba0482e","name":"peeporibbon","flags":256,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"639b9f25e940ad10c008b5f5","username":"hibike7","display_name":"HiBike7","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/42199548-7b96-4d88-b9b4-5f5ca4e8d4ee-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64aff4ae2b9b9a7b4ba0482e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1336,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":954,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2349,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2836,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3582,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5904,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4762,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7582,"format":"WEBP"}]}}},{"id":"64b1a457c4bd269b16f46fb9","name":"SoyPoint","flags":0,"timestamp":1689365859883,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64b1a457c4bd269b16f46fb9","name":"SoyPoint","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64b1a457c4bd269b16f46fb9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":1333,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1764,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":2627,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":5284,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":4132,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":10354,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":5909,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":16664,"format":"WEBP"}]}}},{"id":"6379879d541f8c821fb1fe17","name":"ppBounce","flags":0,"timestamp":1689430419824,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6379879d541f8c821fb1fe17","name":"ppBounce","flags":0,"tags":["pphop","pphopper","ppoverheat","pepel","rescaled","ppl"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b04a7fad7fb4b50bd3a982","username":"brian6932","display_name":"brian6932","avatar_url":"//cdn.7tv.app/user/60b04a7fad7fb4b50bd3a982/av_64f8035f8bef730969094d7a/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6379879d541f8c821fb1fe17","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":11,"height":28,"frame_count":21,"size":3756,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":11,"height":28,"frame_count":21,"size":2546,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":22,"height":56,"frame_count":21,"size":4194,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":22,"height":56,"frame_count":21,"size":2588,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":33,"height":84,"frame_count":21,"size":4475,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":33,"height":84,"frame_count":21,"size":2938,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":44,"height":112,"frame_count":21,"size":2802,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":44,"height":112,"frame_count":21,"size":4048,"format":"AVIF"}]}}},{"id":"64b2d3d5b230f419266f857b","name":"peepoShaker","flags":0,"timestamp":1689501659208,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64b2d3d5b230f419266f857b","name":"peepoShaker","flags":0,"tags":["partypls","thugshaker","peepopls"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62dfad28e2f69efc6a2c84b7","username":"esperdg","display_name":"EsperDG","avatar_url":"//cdn.7tv.app/user/62dfad28e2f69efc6a2c84b7/av_6515a414e66ad3b2e8846aab/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64b2d3d5b230f419266f857b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":22,"size":9091,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":22,"size":4352,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":22,"size":8636,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":22,"size":4480,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":22,"size":12896,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":22,"size":5822,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":22,"size":10885,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":22,"size":5360,"format":"WEBP"}]}}},{"id":"64b43651b230f419266fd3de","name":"nymnWatchingBuildersRenovatingHisHouse","flags":0,"timestamp":1689532155124,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64b43651b230f419266fd3de","name":"nymnWatchingBuildersRenovatingHisHouse","flags":0,"tags":["nymn","yabbe","house"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64b43651b230f419266fd3de","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":101,"size":20139,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":101,"size":63244,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":101,"size":78232,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":101,"size":169102,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":101,"size":177186,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":101,"size":286280,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":101,"size":455843,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":101,"size":454832,"format":"WEBP"}]}}},{"id":"63d5c4d111bbef1b64b10645","name":"crunch","flags":0,"timestamp":1689610521657,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63d5c4d111bbef1b64b10645","name":"yum","flags":0,"tags":["bussin","verypog","monkeycatluna","cateat","plink"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ff0e7a25bb6dd0b03e40f9","username":"saffybop","display_name":"saffybop","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fd91a409-b82f-474f-a83f-45ab6e4bc3f1-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63d5c4d111bbef1b64b10645","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":114,"size":41444,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":114,"size":69084,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":114,"size":106072,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":114,"size":129374,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":114,"size":179604,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":114,"size":200518,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":114,"size":276161,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":114,"size":238812,"format":"WEBP"}]}}},{"id":"63792919bd65aba244978aac","name":"PoroFlushed","flags":0,"timestamp":1689770538832,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63792919bd65aba244978aac","name":"PoroFlushed","flags":0,"tags":["flushed","porohappy","plm","poro","poropls","porotwerk"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60d27440c110a67b0f772489","username":"thetoomm","display_name":"THETOOMM","avatar_url":"//cdn.7tv.app/user/60d27440c110a67b0f772489/av_652a6c93b79e22d8df9023a4/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63792919bd65aba244978aac","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1444,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2102,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3089,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6366,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4909,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12310,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7271,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":19364,"format":"WEBP"}]}}},{"id":"64b83959accfe6461e7c6ac4","name":"BAND","flags":0,"timestamp":1689870401161,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64b83959accfe6461e7c6ac4","name":"BANND","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62e1946ef20806d3c47d1702","username":"decentbv","display_name":"DecentBV","avatar_url":"//cdn.7tv.app/user/62e1946ef20806d3c47d1702/av_658f15b252e38f90f33448e2/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64b83959accfe6461e7c6ac4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":202,"size":52872,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":202,"size":134694,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":202,"size":121208,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":202,"size":231560,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":202,"size":213518,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":202,"size":391926,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":202,"size":308605,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":202,"size":436614,"format":"WEBP"}]}}},{"id":"64a60032a8b7560b00a3ea06","name":"docalmostnotL","flags":0,"timestamp":1689963720593,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a60032a8b7560b00a3ea06","name":"docalmostnotL","flags":0,"tags":["drdisrespect","smash","slam","docl","docnotl"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62dfad28e2f69efc6a2c84b7","username":"esperdg","display_name":"EsperDG","avatar_url":"//cdn.7tv.app/user/62dfad28e2f69efc6a2c84b7/av_6515a414e66ad3b2e8846aab/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a60032a8b7560b00a3ea06","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":70,"size":39206,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":70,"size":51608,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":70,"size":94148,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":70,"size":112980,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":70,"size":149369,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":70,"size":191174,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":70,"size":220978,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":70,"size":262672,"format":"WEBP"}]}}},{"id":"625d08d278e5be390b9a0427","name":"Gaslight","flags":0,"timestamp":1690111362786,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"625d08d278e5be390b9a0427","name":"peepoGaslight","flags":0,"tags":["gaslight","peepo","pepem","xqcm","evil","manipulate"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6241e637db3ed5fb67b4692e","username":"makkusu","display_name":"MAKKUSU","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f3eb5473-7e41-4b3a-b4f0-4086279f9a27-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/625d08d278e5be390b9a0427","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1318,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1020,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2631,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2414,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3976,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4124,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5367,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5976,"format":"WEBP"}]}}},{"id":"64bd0eba8424fe613723586f","name":"beavsPotFriend","flags":0,"timestamp":1690111758829,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64bd0eba8424fe613723586f","name":"beavsPotFriend","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64bd0eba8424fe613723586f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1633,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1560,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3595,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4526,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5565,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8818,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8083,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13490,"format":"WEBP"}]}}},{"id":"64bd0f2c9a49e5f24ed79c13","name":"beavsAlright","flags":0,"timestamp":1690111837200,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"64bd0f2c9a49e5f24ed79c13","name":"beavsAlright","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64bd0f2c9a49e5f24ed79c13","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1000,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1932,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1552,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5568,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2193,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10362,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2788,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16844,"format":"WEBP"}]}}},{"id":"60d01df40af37284c9cf839b","name":"nymnReady","flags":0,"timestamp":1690111910224,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60d01df40af37284c9cf839b","name":"nymnReady","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b561cc04283ab952bfd4e0","username":"on_a_stack","display_name":"On_a_stack","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1ed36b65-71c8-4eb2-a6a7-83ad2bb7566a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60d01df40af37284c9cf839b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":51,"size":18553,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":51,"size":40672,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":51,"size":37546,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":51,"size":85190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":51,"size":63281,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":51,"size":135746,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":51,"size":94530,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":51,"size":144312,"format":"WEBP"}]}}},{"id":"614919291eb7078240528998","name":"PeepoPoolboy","flags":0,"timestamp":1690112298618,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"614919291eb7078240528998","name":"PeepoPoolboy","flags":0,"tags":["poolboy","peepo","peepopoolboy"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61356041d21b5ea97e5956ad","username":"crylax_","display_name":"Crylax_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/aa4e1684-b5c3-4cbd-bd1d-5efdfbc1f038-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614919291eb7078240528998","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1700,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1346,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3465,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3316,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5235,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5776,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7411,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8714,"format":"WEBP"}]}}},{"id":"60afe9efb254a5e16b8705df","name":"BillyArrive","flags":0,"timestamp":1690112339135,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60afe9efb254a5e16b8705df","name":"BillyArrive","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60afebabfd9839f62d883284","username":"h4te_l1fe","display_name":"h4te_l1fe","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bcfbed83-5005-4ff9-8d95-520d9706280d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afe9efb254a5e16b8705df","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":83,"size":33381,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":83,"size":64020,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":83,"size":92124,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":83,"size":162672,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":83,"size":158339,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":83,"size":269218,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":83,"size":246540,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":83,"size":231444,"format":"WEBP"}]}}},{"id":"64bd1e58fb581ca4e4d1c676","name":"NymnTellingAnotherJoke","flags":0,"timestamp":1690115783776,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"64bd1e58fb581ca4e4d1c676","name":"NymnTellingAnotherJoke","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64bd1e58fb581ca4e4d1c676","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":50,"height":32,"frame_count":159,"size":36567,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":50,"height":32,"frame_count":159,"size":114222,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":100,"height":64,"frame_count":159,"size":92379,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":100,"height":64,"frame_count":159,"size":247516,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":150,"height":96,"frame_count":159,"size":160855,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":150,"height":96,"frame_count":159,"size":362498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":200,"height":128,"frame_count":159,"size":244544,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":200,"height":128,"frame_count":159,"size":484326,"format":"WEBP"}]}}},{"id":"61710fdeffc7244d797ca707","name":"GoslingDrive","flags":0,"timestamp":1690150347927,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"61710fdeffc7244d797ca707","name":"GoslingDrive","flags":0,"tags":["ryan","gosling","drive"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61710d645ff09767de29c651","username":"hawkiman","display_name":"Hawkiman","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/14aa8b93-bca0-423d-85c3-1d8a86089866-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61710fdeffc7244d797ca707","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":73,"height":32,"frame_count":52,"size":22272,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":73,"height":32,"frame_count":52,"size":67962,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":146,"height":64,"frame_count":52,"size":62506,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":146,"height":64,"frame_count":52,"size":167064,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":219,"height":96,"frame_count":52,"size":110285,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":219,"height":96,"frame_count":52,"size":276412,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":292,"height":128,"frame_count":52,"size":186864,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":292,"height":128,"frame_count":52,"size":351922,"format":"WEBP"}]}}},{"id":"62121e6e35d91b821ec0eb26","name":"alizeePls","flags":0,"timestamp":1690151956756,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"62121e6e35d91b821ec0eb26","name":"alizeePls","flags":0,"tags":["alizee","pls","dance"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62121e6e35d91b821ec0eb26","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":137,"size":67419,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":137,"size":101364,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":137,"size":138050,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":137,"size":198658,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":137,"size":226155,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":137,"size":321194,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":137,"size":304185,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":137,"size":310636,"format":"WEBP"}]}}},{"id":"64c01abf0365af129c48e90a","name":"handsWup","flags":0,"timestamp":1690311386377,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64c01abf0365af129c48e90a","name":"handsWup","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64c01abf0365af129c48e90a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":38,"height":32,"frame_count":1,"size":1557,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":38,"height":32,"frame_count":1,"size":1954,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":76,"height":64,"frame_count":1,"size":3290,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":76,"height":64,"frame_count":1,"size":6092,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":114,"height":96,"frame_count":1,"size":5254,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":114,"height":96,"frame_count":1,"size":11754,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":152,"height":128,"frame_count":1,"size":7857,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":152,"height":128,"frame_count":1,"size":19248,"format":"WEBP"}]}}},{"id":"64c034dfe96a5dd41515f3c7","name":"BBQING","flags":0,"timestamp":1690368246237,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64c034dfe96a5dd41515f3c7","name":"BBQING","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64c034dfe96a5dd41515f3c7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":11,"size":5943,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":11,"size":7030,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":11,"size":11307,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":11,"size":15002,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":11,"size":17780,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":11,"size":23444,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":11,"size":23540,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":11,"size":31170,"format":"WEBP"}]}}},{"id":"639b68b03369d865cf442c30","name":"mrbeast","flags":0,"timestamp":1690543312575,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"639b68b03369d865cf442c30","name":"mrbeast","flags":0,"tags":["mrbreaaaaast","breast"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"630674bdbe8c19d70f9d6897","username":"ace____________________","display_name":"ace____________________","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/23c902b6-9c0f-4c47-861a-573c918d6fd9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/639b68b03369d865cf442c30","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":54,"size":26754,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":54,"size":57948,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":54,"size":69147,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":54,"size":128274,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":54,"size":116030,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":54,"size":207440,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":54,"size":167002,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":54,"size":287578,"format":"WEBP"}]}}},{"id":"60e6091f6d2fbedb01ee2cc2","name":"gunpowder","flags":0,"timestamp":1690552648766,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60e6091f6d2fbedb01ee2cc2","name":"gunpowder","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ca44249fd6791bfcea1a39","username":"deaththedoorant","display_name":"DeathTheDoorAnt","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/319d1273-c341-432d-80e7-37cbd65f2b29-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e6091f6d2fbedb01ee2cc2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":869,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":566,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":974,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1044,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1266,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":1410,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":1591,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":1484,"format":"WEBP"}]}}},{"id":"63c56fc3dedcfdd2e2b5d85a","name":"lookBoth","flags":0,"timestamp":1690569526731,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"63c56fc3dedcfdd2e2b5d85a","name":"lookBoth","flags":0,"tags":["lookup","lookdown","apu"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c56fc3dedcfdd2e2b5d85a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":19,"size":9780,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":19,"size":14538,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":27,"size":19653,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":19,"size":31774,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":29,"size":28395,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":19,"size":48828,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":39,"size":37774,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":19,"size":65222,"format":"WEBP"}]}}},{"id":"60b05d483cadd71dffc67135","name":"WifeCheck","flags":0,"timestamp":1690663328812,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60b05d483cadd71dffc67135","name":"WifeCheck","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae653c9627f9aff4f5ccd1","username":"xoo_6119","display_name":"xoo_6119","avatar_url":"//cdn.7tv.app/user/60ae653c9627f9aff4f5ccd1/av_63ca0eccdedb49b24383ae5c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b05d483cadd71dffc67135","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":30,"height":32,"frame_count":156,"size":63891,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":30,"height":32,"frame_count":156,"size":136204,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":60,"height":64,"frame_count":156,"size":179422,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":60,"height":64,"frame_count":156,"size":314350,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":90,"height":96,"frame_count":156,"size":308820,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":90,"height":96,"frame_count":156,"size":526006,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":120,"height":128,"frame_count":156,"size":494417,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":120,"height":128,"frame_count":156,"size":601682,"format":"WEBP"}]}}},{"id":"6191a85dd34608492cc34fbf","name":"Wavegers","flags":0,"timestamp":1690748508029,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6191a85dd34608492cc34fbf","name":"Wavegers","flags":0,"tags":["gers","gladgers","wave"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae57c45d3fdae58382fd51","username":"captaincrohny","display_name":"CaptainCrohny","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c1ee9c3a-9151-445c-891e-371c7fa67b2a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6191a85dd34608492cc34fbf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":3888,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":5326,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":6763,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":11936,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":10603,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":19566,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":16074,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":23580,"format":"WEBP"}]}}},{"id":"61253a326cb0b55c059f7f88","name":"noxSorry","flags":0,"timestamp":1690811316240,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61253a326cb0b55c059f7f88","name":"noxSorry","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60bd5159a8c00a202bf7425f","username":"a_t_m_0_s","display_name":"A_T_M_0_S","avatar_url":"//cdn.7tv.app/user/60bd5159a8c00a202bf7425f/av_6482d40390f619d9af6922f1/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61253a326cb0b55c059f7f88","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":17,"size":6282,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":17,"size":15056,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":17,"size":14314,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":17,"size":34404,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":17,"size":25188,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":17,"size":56448,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":17,"size":39573,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":17,"size":68218,"format":"WEBP"}]}}},{"id":"64c75391e5effffa3c774df9","name":"poglinspotted","flags":0,"timestamp":1690845661080,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"64c75391e5effffa3c774df9","name":"thatAss","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"63c835cefc866ebbc80af66f","username":"sjonkonnerie","display_name":"sJoNkOnNeRiE","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38547da9-7866-4d0d-9816-5f9c00e68cc5-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64c75391e5effffa3c774df9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":46,"height":32,"frame_count":1,"size":1707,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":46,"height":32,"frame_count":1,"size":2164,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":92,"height":64,"frame_count":1,"size":3372,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":92,"height":64,"frame_count":1,"size":6786,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":138,"height":96,"frame_count":1,"size":5401,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":138,"height":96,"frame_count":1,"size":13320,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":184,"height":128,"frame_count":1,"size":7531,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":184,"height":128,"frame_count":1,"size":20970,"format":"WEBP"}]}}},{"id":"6402cc9dd283ec401319df1d","name":"mrah","flags":0,"timestamp":1690882199977,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6402cc9dd283ec401319df1d","name":"mrah","flags":0,"tags":["plonk","ewpert","robert","meow","plink","mrah"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62ebda39f2286f265273d001","username":"dn9n","display_name":"dn9n","avatar_url":"//cdn.7tv.app/user/62ebda39f2286f265273d001/av_653a15e60332d91f335d555a/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6402cc9dd283ec401319df1d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":91,"size":25787,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":91,"size":42342,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":91,"size":62024,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":91,"size":100552,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":91,"size":99774,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":91,"size":157750,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":91,"size":143014,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":91,"size":212436,"format":"WEBP"}]}}},{"id":"64b55da3c431f7a72656d0e7","name":"MYNM","flags":0,"timestamp":1690889982534,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64b55da3c431f7a72656d0e7","name":"MYNM","flags":0,"tags":["cursed","stare","nymn","mynm","158cm","whymypeepeehard"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61648644ea8ead16589dc5b8","username":"floxd","display_name":"FloxD","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/10fce1d7-2d5d-47b8-b0c8-b2d12c1116cb-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64b55da3c431f7a72656d0e7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":970,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1804,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1434,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4808,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1910,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9446,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2265,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":14242,"format":"WEBP"}]}}},{"id":"61ee8a791a1b2a6e73254219","name":"!when","flags":0,"timestamp":1691079746570,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61ee8a791a1b2a6e73254219","name":"Waiting","flags":0,"tags":["bean"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"614aebb05765a48b24d954e0","username":"mafiadanger","display_name":"MafiaDanger","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61ee8a791a1b2a6e73254219","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":72,"size":16999,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":72,"size":51834,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":72,"size":57426,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":72,"size":148350,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":72,"size":115176,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":72,"size":263022,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":72,"size":219702,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":72,"size":240882,"format":"WEBP"}]}}},{"id":"640608a13c3750a0c8281d3f","name":"!sr","flags":0,"timestamp":1691079780830,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"640608a13c3750a0c8281d3f","name":"!sr","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60bb7e5f918e96162caccdfd","username":"sennin_mady","display_name":"Sennin_Mady","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/cc2ba427-f8e2-40bd-985a-ad95d487080b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/640608a13c3750a0c8281d3f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1452,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1992,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2807,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6072,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4211,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11428,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6044,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18052,"format":"WEBP"}]}}},{"id":"64cbd85af014912c29bc1660","name":"docCum","flags":0,"timestamp":1691093148162,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64cbd85af014912c29bc1660","name":"docCooming","flags":0,"tags":["coomer","2time","doccoomer","ambatukam","doc"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61f0445a170449495610d147","username":"winniethepeepo","display_name":"WinnieThepeepo","avatar_url":"//cdn.7tv.app/pp/61f0445a170449495610d147/850f578eb4d147a99064f2eca63c22d1","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64cbd85af014912c29bc1660","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":70,"size":33747,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":70,"size":51936,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":70,"size":71631,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":70,"size":98902,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":70,"size":114089,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":70,"size":154200,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":70,"size":154407,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":70,"size":201104,"format":"WEBP"}]}}},{"id":"61db9dc227a4f6d6544ea84d","name":"HENRY","flags":0,"timestamp":1691094671758,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61db9dc227a4f6d6544ea84d","name":"HENRY","flags":0,"tags":["henry","stare"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6185f323f1ae15abc7ec0e51","username":"tw00fspades","display_name":"tw00fspades","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fcfe3ed3-9940-4d44-af14-935e1d9cd55c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61db9dc227a4f6d6544ea84d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":30,"height":32,"frame_count":1,"size":1290,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":30,"height":32,"frame_count":1,"size":978,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":60,"height":64,"frame_count":1,"size":2264,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":60,"height":64,"frame_count":1,"size":2152,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":90,"height":96,"frame_count":1,"size":3252,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":90,"height":96,"frame_count":1,"size":3558,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":120,"height":128,"frame_count":1,"size":4154,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":120,"height":128,"frame_count":1,"size":4970,"format":"WEBP"}]}}},{"id":"64ccf5dbfdd01a0862695fda","name":"TwoDumbasses","flags":0,"timestamp":1691154218795,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64ccf5dbfdd01a0862695fda","name":"TwoDumbasses","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64ccf5dbfdd01a0862695fda","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":61,"height":32,"frame_count":1,"size":1624,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":61,"height":32,"frame_count":1,"size":3514,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":122,"height":64,"frame_count":1,"size":3278,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":122,"height":64,"frame_count":1,"size":11156,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":183,"height":96,"frame_count":1,"size":4986,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":183,"height":96,"frame_count":1,"size":21178,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":244,"height":128,"frame_count":1,"size":6976,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":244,"height":128,"frame_count":1,"size":35054,"format":"WEBP"}]}}},{"id":"6442e5be2be860de107c1f37","name":"IVEGONEPASTHEPOINTOFINSANITY","flags":0,"timestamp":1691169545240,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6442e5be2be860de107c1f37","name":"IVEGONEPASTHEPOINTOFINSANITY","flags":0,"tags":["goinginsane","malding","tilt","copiumdepleted","outofcope","losingit"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"62ce20e8fa3d15d7e44e6dfd","username":"2syndras1cup","display_name":"2Syndras1Cup","avatar_url":"//cdn.7tv.app/user/62ce20e8fa3d15d7e44e6dfd/av_6476c7d2804ca09ebf233501/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6442e5be2be860de107c1f37","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":64,"size":50644,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":64,"size":44856,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":64,"size":133126,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":64,"size":92136,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":64,"size":231753,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":64,"size":152296,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":64,"size":389093,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":64,"size":215614,"format":"WEBP"}]}}},{"id":"6415b7e1220f8400b8784fd6","name":"OFC","flags":0,"timestamp":1691170549330,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6415b7e1220f8400b8784fd6","name":"OFC","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"63dd2d98a039b6522cdd59a5","username":"rawbh","display_name":"RawbH","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/rawbh-profile_image-20ffd0839bd5b426-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6415b7e1220f8400b8784fd6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1338,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1440,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2497,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4030,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4085,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7796,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5312,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":12582,"format":"WEBP"}]}}},{"id":"64cd3f7517cab280bab4cdf3","name":"guh","flags":0,"timestamp":1691172764946,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64cd3f7517cab280bab4cdf3","name":"guh","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64cd3f7517cab280bab4cdf3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":128,"size":22476,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":118,"size":44614,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":128,"size":41107,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":126,"size":101554,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":128,"size":66506,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":128,"size":171002,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":128,"size":92323,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":128,"size":246714,"format":"WEBP"}]}}},{"id":"60aeabbef39a7552b6b2cf19","name":"POOTERS","flags":0,"timestamp":1691178160153,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60aeabbef39a7552b6b2cf19","name":"POOTERS","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f88f6331ba6ae6229fb346","username":"damunnyhoney","display_name":"damunnyhoney","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/94058f51-2bd7-4a34-83bb-dae1cf5bd244-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeabbef39a7552b6b2cf19","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":9663,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":25,"size":21066,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":17138,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":43430,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":29091,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":70154,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":40316,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":76472,"format":"WEBP"}]}}},{"id":"634ec57e51c7bffc93bfeab1","name":"cumin","flags":0,"timestamp":1691180343205,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"634ec57e51c7bffc93bfeab1","name":"cumin","flags":0,"tags":["henryskitchen","spice","cumin","henry"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62807168bfe678d307584c89","username":"dombeef","display_name":"dombeef","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/07ef4f79-c873-4aca-95f0-0775ff6107b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/634ec57e51c7bffc93bfeab1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1486,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1982,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2877,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4858,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4489,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8882,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6251,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13378,"format":"WEBP"}]}}},{"id":"64ce70c1e1edc2e9f4fc1a9c","name":"rentfree","flags":0,"timestamp":1691250909015,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64ce70c1e1edc2e9f4fc1a9c","name":"rentfree","flags":0,"tags":["baldursgate","bg3","baldursgate3"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64ce70c1e1edc2e9f4fc1a9c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":81,"size":25491,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":81,"size":28096,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":81,"size":57112,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":81,"size":63716,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":81,"size":92863,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":81,"size":104300,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":81,"size":134328,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":81,"size":151992,"format":"WEBP"}]}}},{"id":"64d0dd6e10b8987ddc7c4a31","name":"nuh","flags":0,"timestamp":1691409874723,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64d0dd6e10b8987ddc7c4a31","name":"nuh","flags":0,"tags":["nymn","forsen","buh"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6131d9de492022af58394453","username":"jerrythedoctor","display_name":"JerryTheDoctor","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a4a6f511-4bc7-466b-a73d-f9dc242bdef9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d0dd6e10b8987ddc7c4a31","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":76,"size":17624,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":76,"size":53368,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":76,"size":37743,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":76,"size":130070,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":76,"size":63945,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":76,"size":209210,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":76,"size":118471,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":76,"size":297108,"format":"WEBP"}]}}},{"id":"64d1852517be4dac583385cb","name":"nimeDance","flags":0,"timestamp":1691452820219,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"64d1852517be4dac583385cb","name":"nimeDance","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d1852517be4dac583385cb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":53,"height":32,"frame_count":30,"size":19098,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":53,"height":32,"frame_count":30,"size":26610,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":106,"height":64,"frame_count":30,"size":36570,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":106,"height":64,"frame_count":30,"size":56084,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":159,"height":96,"frame_count":30,"size":61867,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":159,"height":96,"frame_count":30,"size":88766,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":212,"height":128,"frame_count":30,"size":98205,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":212,"height":128,"frame_count":30,"size":121126,"format":"WEBP"}]}}},{"id":"64d18c2aa47f9006a3bfc46d","name":"DonkAndDonker","flags":0,"timestamp":1691489530478,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64d18c2aa47f9006a3bfc46d","name":"DonkAndDonker","flags":0,"tags":["rogue","donk","feelsdonkman","darkanddarker"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ff054ffbd646ea3b221dc9","username":"tunari__","display_name":"Tunari__","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bc530a7a-e04d-4765-a662-bb3efde482e2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d18c2aa47f9006a3bfc46d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":18,"size":5905,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":18,"size":8010,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":18,"size":10946,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":18,"size":16676,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":18,"size":16210,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":18,"size":25522,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":18,"size":20504,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":18,"size":42350,"format":"WEBP"}]}}},{"id":"64d28b7cd07ad5c85a4c0b88","name":"yabbePls","flags":0,"timestamp":1691526426017,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64d28b7cd07ad5c85a4c0b88","name":"yabbePls","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"6159fb1a93686fbfe7fc1c38","username":"yabbe","display_name":"Yabbe","avatar_url":"//cdn.7tv.app/user/6159fb1a93686fbfe7fc1c38/av_634b3ae0d6ba45f7f103a8ca/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d28b7cd07ad5c85a4c0b88","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":204,"size":112205,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":204,"size":157606,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":204,"size":246782,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":204,"size":297072,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":204,"size":396133,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":204,"size":429894,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":204,"size":560198,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":204,"size":553740,"format":"WEBP"}]}}},{"id":"611663359bf574f1fded76e4","name":"BOTHA","flags":0,"timestamp":1691673830586,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"611663359bf574f1fded76e4","name":"BOTHA","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6116631547935f36575c79c2","username":"gloriousbeard","display_name":"GloriousBeard","avatar_url":"//cdn.7tv.app/pp/6116631547935f36575c79c2/f30871a43ca247ecbabb7623cef8d302","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62d86a8419fdcf401421c5ae","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/611663359bf574f1fded76e4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":63,"size":18666,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":63,"size":60664,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":63,"size":43239,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":63,"size":136214,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":63,"size":79634,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":63,"size":231960,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":63,"size":138888,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":63,"size":278148,"format":"WEBP"}]}}},{"id":"61b633d98ffada6c4bafab74","name":"YURRY","flags":0,"timestamp":1691680118413,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61b633d98ffada6c4bafab74","name":"YURRY","flags":0,"tags":["yabbe","furry"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aeab21229664e8663345dd","username":"barricade0_","display_name":"BARRICADE0_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/948ce321-c188-4c7a-90c0-16169e190ac2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61b633d98ffada6c4bafab74","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":44,"height":32,"frame_count":1,"size":1935,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":44,"height":32,"frame_count":1,"size":1358,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":88,"height":64,"frame_count":1,"size":4496,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":88,"height":64,"frame_count":1,"size":3910,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":132,"height":96,"frame_count":1,"size":7404,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":132,"height":96,"frame_count":1,"size":7174,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":176,"height":128,"frame_count":1,"size":10968,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":176,"height":128,"frame_count":1,"size":10988,"format":"WEBP"}]}}},{"id":"61e62b39095be332e347deaa","name":"TouchGrass","flags":1,"timestamp":1691680146936,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"61e62b39095be332e347deaa","name":"TouchGrass","flags":256,"tags":["handrub","outside","petpet","feelsgrassman","zerowidth"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60f39e8bc07d1ac193652def","username":"shmovy","display_name":"Shmovy","avatar_url":"//cdn.7tv.app/user/60f39e8bc07d1ac193652def/av_63a4e84f5c2aba9b3b60bf46/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e62b39095be332e347deaa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":54,"size":14115,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":54,"size":29358,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":54,"size":33812,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":54,"size":72544,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":54,"size":55463,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":54,"size":124094,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":54,"size":96739,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":54,"size":165562,"format":"WEBP"}]}}},{"id":"63aceacc7fe47e928e3e6f44","name":"mods","flags":0,"timestamp":1691858683457,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63aceacc7fe47e928e3e6f44","name":"mods","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"63a761869968b7c4e6ca6f96","username":"bi__","display_name":"bi__","avatar_url":"//cdn.7tv.app/user/63a761869968b7c4e6ca6f96/av_642b2ce2c82a06d25a4e4766/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63aceacc7fe47e928e3e6f44","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":67,"height":32,"frame_count":153,"size":40241,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":67,"height":32,"frame_count":151,"size":96302,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":134,"height":64,"frame_count":153,"size":92981,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":134,"height":64,"frame_count":153,"size":252086,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":201,"height":96,"frame_count":153,"size":172872,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":201,"height":96,"frame_count":153,"size":422284,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":268,"height":128,"frame_count":153,"size":250957,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":268,"height":128,"frame_count":153,"size":598316,"format":"WEBP"}]}}},{"id":"64d7ef64b0552f709e06ded5","name":"yap","flags":0,"timestamp":1691873425709,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64d7ef64b0552f709e06ded5","name":"yap","flags":0,"tags":["kripp","anythingelse","yapping","yappp"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d7ef64b0552f709e06ded5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":39,"height":32,"frame_count":253,"size":46364,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":39,"height":32,"frame_count":253,"size":198444,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":78,"height":64,"frame_count":253,"size":107390,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":78,"height":64,"frame_count":253,"size":426690,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":117,"height":96,"frame_count":253,"size":194281,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":117,"height":96,"frame_count":253,"size":679094,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":156,"height":128,"frame_count":253,"size":354566,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":156,"height":128,"frame_count":253,"size":924446,"format":"WEBP"}]}}},{"id":"647bba24628540685215540b","name":"$fish","flags":0,"timestamp":1691909468429,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"647bba24628540685215540b","name":"$fish","flags":0,"tags":["bot","fishing","supibot","fish","osrs","runescape"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/647bba24628540685215540b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":42,"height":32,"frame_count":120,"size":18693,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":42,"height":32,"frame_count":120,"size":34674,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":84,"height":64,"frame_count":120,"size":35716,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":84,"height":64,"frame_count":120,"size":64698,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":126,"height":96,"frame_count":120,"size":55094,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":126,"height":96,"frame_count":120,"size":93280,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":168,"height":128,"frame_count":120,"size":78531,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":168,"height":128,"frame_count":120,"size":124658,"format":"WEBP"}]}}},{"id":"64d9337e28901aea7c367934","name":"Kissamoney","flags":0,"timestamp":1691957670701,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64d9337e28901aea7c367934","name":"Kissamoney","flags":0,"tags":["nymn","kissahomie","money","nime","krabs"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6131dab2af9287c4eb609268","username":"vicneeel","display_name":"vicneeel","avatar_url":"//cdn.7tv.app/user/6131dab2af9287c4eb609268/av_6520576332b1db5b90ef6b24/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d9337e28901aea7c367934","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":66,"height":32,"frame_count":15,"size":16347,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":66,"height":32,"frame_count":15,"size":22238,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":132,"height":64,"frame_count":15,"size":37095,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":132,"height":64,"frame_count":15,"size":51720,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":198,"height":96,"frame_count":15,"size":60700,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":198,"height":96,"frame_count":15,"size":85674,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":264,"height":128,"frame_count":15,"size":102133,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":264,"height":128,"frame_count":15,"size":115826,"format":"WEBP"}]}}},{"id":"605391a99d9e96000d244fd0","name":"Okayge+1","flags":0,"timestamp":1692036877815,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"605391a99d9e96000d244fd0","name":"Okayge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60538de8b4d31e459fe6f49f","username":"moonmoon_has_tiny_teeth","display_name":"moonmoon_has_tiny_teeth","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/49d3f80c-d15a-467c-a644-ed28f8c69806-profile_image-70x70.jpg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/605391a99d9e96000d244fd0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1342,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":998,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2530,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2516,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3717,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4314,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5055,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6404,"format":"WEBP"}]}}},{"id":"64db5ab6549eec84e19a6932","name":"Pillowfort","flags":0,"timestamp":1692097238188,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64db5ab6549eec84e19a6932","name":"Pillowfort","flags":0,"tags":["pillow","nogirls","fort","moonstrobes","pepe","moonlightstrobes"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64db5ab6549eec84e19a6932","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":68,"height":32,"frame_count":1,"size":2506,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":68,"height":32,"frame_count":1,"size":2876,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":136,"height":64,"frame_count":1,"size":5691,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":136,"height":64,"frame_count":1,"size":8046,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":204,"height":96,"frame_count":1,"size":9261,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":204,"height":96,"frame_count":1,"size":14336,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":272,"height":128,"frame_count":1,"size":12819,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":272,"height":128,"frame_count":1,"size":22670,"format":"WEBP"}]}}},{"id":"64da270fabda45849ab57a5e","name":"shortcut","flags":0,"timestamp":1692101682105,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64da270fabda45849ab57a5e","name":"shortcut","flags":0,"tags":["enter","nymn","apollo","cat"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"63fb9249a27fda24e806d1cc","username":"abithappy","display_name":"abithappy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f7a46513-21a8-46ff-8ef2-d388dc069e8c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64da270fabda45849ab57a5e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":46,"height":32,"frame_count":185,"size":29624,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":46,"height":32,"frame_count":169,"size":54096,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":92,"height":64,"frame_count":185,"size":65476,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":92,"height":64,"frame_count":185,"size":204158,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":138,"height":96,"frame_count":185,"size":118014,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":138,"height":96,"frame_count":185,"size":490700,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":184,"height":128,"frame_count":185,"size":162810,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":184,"height":128,"frame_count":185,"size":802644,"format":"WEBP"}]}}},{"id":"64dbe4259dce677b74f89e52","name":"tickpolloArrive","flags":0,"timestamp":1692132530112,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"64dbe4259dce677b74f89e52","name":"tickpolloArrive","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64dbe4259dce677b74f89e52","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":104,"size":37424,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":104,"size":52366,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":104,"size":79784,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":104,"size":104152,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":104,"size":129036,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":104,"size":159172,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":104,"size":173453,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":104,"size":225866,"format":"WEBP"}]}}},{"id":"61dc53b457c70f633ebd86fb","name":"Tex","flags":1,"timestamp":1692139647734,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"61dc53b457c70f633ebd86fb","name":"Tex","flags":256,"tags":["texime","erobb221"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61c7196612987d64d6ae7fc6","username":"quaxi13","display_name":"Quaxi13","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/39135e01-6d49-40ad-afe5-2973e6176848-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61dc53b457c70f633ebd86fb","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1022,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1403,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2483,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2530,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3871,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4522,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5248,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6792,"format":"WEBP"}]}}},{"id":"60aed84b423a803ccafdd4b4","name":"forsenCoomer","flags":0,"timestamp":1692273754155,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60aed84b423a803ccafdd4b4","name":"forsenCoomer","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae66e086fc40d4887c81cb","username":"etx4n","display_name":"Etx4n","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/62a8869a-ae56-4013-9cbf-31fcd74ede09-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aed84b423a803ccafdd4b4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":97,"size":26772,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":97,"size":72112,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":97,"size":55639,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":97,"size":143332,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":97,"size":95019,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":97,"size":224454,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":97,"size":138168,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":97,"size":237902,"format":"WEBP"}]}}},{"id":"60afafe760e24df01a1172b6","name":"NymNCube","flags":0,"timestamp":1692273991274,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60afafe760e24df01a1172b6","name":"NymNCube","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afafe760e24df01a1172b6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":98,"size":56320,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":98,"size":88150,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":98,"size":135827,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":98,"size":195670,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":98,"size":221126,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":98,"size":326728,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":98,"size":344824,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":98,"size":378176,"format":"WEBP"}]}}},{"id":"610d2fafd53540d5aad11594","name":"Alien360","flags":0,"timestamp":1692281499972,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"610d2fafd53540d5aad11594","name":"AlienPls","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60a24668146e092ea1e0f29b","username":"tichus_1273","display_name":"Tichus_1273","avatar_url":"//cdn.7tv.app/user/60a24668146e092ea1e0f29b/av_656cda4663b9857320914e09/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610d2fafd53540d5aad11594","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":160,"size":91977,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":160,"size":148172,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":160,"size":225717,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":160,"size":356298,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":160,"size":401172,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":160,"size":637662,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":160,"size":613682,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":160,"size":854142,"format":"WEBP"}]}}},{"id":"6140b9a97b14fdf700b8e101","name":"Talk0","flags":1,"timestamp":1692340650955,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6140b9a97b14fdf700b8e101","name":"Talk","flags":256,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60c1708f6cf40799f98c392a","username":"aroopc","display_name":"ArooPC","avatar_url":"//cdn.7tv.app/pp/60c1708f6cf40799f98c392a/d78c02f77fcf4b8e9d8b4452bea5b2ad","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6140b9a97b14fdf700b8e101","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":3711,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":2832,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":5162,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":4430,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":7044,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":7054,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":7772,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":7744,"format":"WEBP"}]}}},{"id":"610eb19d3f3e99ddb462710f","name":"peepoConfused","flags":0,"timestamp":1692615543164,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"610eb19d3f3e99ddb462710f","name":"peepoConfused","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f244dfc07d1ac193c6314e","username":"kushala_0001","display_name":"Kushala_0001","avatar_url":"//cdn.7tv.app/user/60f244dfc07d1ac193c6314e/av_640be52567f0badff748097e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610eb19d3f3e99ddb462710f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":7521,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":13396,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":16703,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":29854,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":28081,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":51390,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":47034,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":62462,"format":"WEBP"}]}}},{"id":"60b955e2f09ea88072efbacd","name":"PagRubiksCube","flags":0,"timestamp":1692629002795,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60b955e2f09ea88072efbacd","name":"PagRubiksCube","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b3fdafdb203df7cd1efca7","username":"blu3smok3","display_name":"Blu3Smok3","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/caa46d28-d9dc-4bb8-a349-f75c428262e3-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b955e2f09ea88072efbacd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":100,"size":42767,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":100,"size":91176,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":100,"size":90811,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":100,"size":196030,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":100,"size":151620,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":100,"size":310840,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":100,"size":219269,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":100,"size":329858,"format":"WEBP"}]}}},{"id":"61bf539792cc54658156334d","name":"hacking0","flags":1,"timestamp":1692705897576,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61bf539792cc54658156334d","name":"HACKERING","flags":256,"tags":["typing","hacking","0width","zythemotes"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61a3dfabffa9aba101bb9716","username":"zyth_dr","display_name":"Zyth_Dr","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/zyth_dr-profile_image-b82b9584dae623a2-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61bf539792cc54658156334d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":11,"size":4156,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":11,"size":6128,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":11,"size":7593,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":11,"size":14786,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":11,"size":12166,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":11,"size":26926,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":11,"size":19646,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":11,"size":35206,"format":"WEBP"}]}}},{"id":"60ae9863ac03cad607437fae","name":"pokiBurrito","flags":0,"timestamp":1692711159433,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60ae9863ac03cad607437fae","name":"pokiBurrito","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60a536d1ac08622846bced71","username":"marcfryd_0","display_name":"marcfryd_0","avatar_url":"//cdn.7tv.app/user/60a536d1ac08622846bced71/av_63537c8f28e6aaaea2bb599e/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","631ef5ea03e9beb96f849a7e","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae9863ac03cad607437fae","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":13208,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":25,"size":22482,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":49140,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":29372,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":81108,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":46956,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":68997,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":83500,"format":"WEBP"}]}}},{"id":"64a94b0d2d10dc644f50f279","name":"POGASS","flags":0,"timestamp":1692716907851,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64a94b0d2d10dc644f50f279","name":"POGASS","flags":0,"tags":["lirik","lirikfr","pog","sussy"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61b3c83015b3ff4a5bba5c1a","username":"weelqa","display_name":"Weelqa","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d3d77294-1a98-46ff-99fe-508c33c58e84-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a94b0d2d10dc644f50f279","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":42,"height":32,"frame_count":11,"size":9201,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":42,"height":32,"frame_count":11,"size":7678,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":84,"height":64,"frame_count":11,"size":16869,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":84,"height":64,"frame_count":11,"size":15832,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":126,"height":96,"frame_count":11,"size":26295,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":126,"height":96,"frame_count":11,"size":24758,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":168,"height":128,"frame_count":11,"size":29011,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":168,"height":128,"frame_count":11,"size":31072,"format":"WEBP"}]}}},{"id":"60aead003c27a8b79c49566e","name":"arnoldProceed","flags":0,"timestamp":1692720563752,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"60aead003c27a8b79c49566e","name":"arnoldProceed","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae99954b1ea4526d8ac75b","username":"evilmessy","display_name":"evilmessy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/aaec06da-90ff-46e4-9dfd-ec57221cd405-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aead003c27a8b79c49566e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1390,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1082,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2627,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2780,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3962,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4566,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5257,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5598,"format":"WEBP"}]}}},{"id":"64e61526e59df5a070a02688","name":"nimenniversary","flags":0,"timestamp":1692800320143,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"64e61526e59df5a070a02688","name":"nimeAnniversary","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e61526e59df5a070a02688","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1444,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2810,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5294,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4202,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9958,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5410,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15024,"format":"WEBP"}]}}},{"id":"62ae2faaa39450ce025aca62","name":"omgE","flags":0,"timestamp":1692876433662,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"62ae2faaa39450ce025aca62","name":"omE","flags":0,"tags":["ome","poki","omegalul","lulw","kekw","pokimane"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b1cefbfdd2d7d7bdd566d6","username":"qdtn","display_name":"QdtN","avatar_url":"//cdn.7tv.app/pp/60b1cefbfdd2d7d7bdd566d6/c954f051a4404463b4bc23377f1d82df","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62ae2faaa39450ce025aca62","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":844,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1090,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2166,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2286,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3776,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3261,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4817,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5880,"format":"WEBP"}]}}},{"id":"64e777ee118d24372670b42c","name":"ApolloPW","flags":0,"timestamp":1692891299007,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64e777ee118d24372670b42c","name":"ApolloPW","flags":0,"tags":["apollo","nymn","cat","dead"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e777ee118d24372670b42c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":179,"size":24526,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":179,"size":77996,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":179,"size":73016,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":179,"size":165448,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":179,"size":162245,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":179,"size":254894,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":179,"size":328007,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":179,"size":343042,"format":"WEBP"}]}}},{"id":"60aeab8df6a2c3b332d21139","name":"FloppaL","flags":0,"timestamp":1692895445418,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60aeab8df6a2c3b332d21139","name":"FloppaL","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae94964b1ea4526d2f7206","username":"nozzlenols","display_name":"NozzleNols","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/nozzlenols-profile_image-dc00b2850378ebb2-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeab8df6a2c3b332d21139","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":8,"size":7488,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":8,"size":8174,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":8,"size":14463,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":8,"size":17758,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":8,"size":21877,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":8,"size":28656,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":8,"size":29462,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":8,"size":30390,"format":"WEBP"}]}}},{"id":"6461d58f5070b2cda24f1b3a","name":"JOEVER","flags":0,"timestamp":1692897053188,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"6461d58f5070b2cda24f1b3a","name":"JOEVER","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6141c6937b14fdf700b8fa63","username":"ninjyte","display_name":"ninjyte","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6461d58f5070b2cda24f1b3a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1267,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1856,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2350,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5442,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3507,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10142,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4820,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16144,"format":"WEBP"}]}}},{"id":"61bbb1b8fba91c72ead7081b","name":"gachiHop","flags":0,"timestamp":1692899358042,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"61bbb1b8fba91c72ead7081b","name":"gachiHop","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60edbe6890b3667a8a3cfb66","username":"flushedjulian","display_name":"flushedjulian","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ede97175-eb3e-4656-967d-1fc0da391dac-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61bbb1b8fba91c72ead7081b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":49,"size":16841,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":49,"size":35284,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":49,"size":31970,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":49,"size":81224,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":49,"size":56233,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":49,"size":132036,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":49,"size":95672,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":49,"size":158318,"format":"WEBP"}]}}},{"id":"64e7a6787937d5233b11c700","name":"docNOWAY","flags":0,"timestamp":1692903391074,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"64e7a6787937d5233b11c700","name":"docNOWAY","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e7a6787937d5233b11c700","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":49,"height":32,"frame_count":62,"size":28374,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":49,"height":32,"frame_count":62,"size":55772,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":98,"height":64,"frame_count":62,"size":64294,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":98,"height":64,"frame_count":62,"size":106508,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":147,"height":96,"frame_count":62,"size":110218,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":147,"height":96,"frame_count":62,"size":168770,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":196,"height":128,"frame_count":62,"size":181524,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":196,"height":128,"frame_count":62,"size":213134,"format":"WEBP"}]}}},{"id":"64e7b738bad5cade546341da","name":"FURRY","flags":0,"timestamp":1692907535873,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64e7b738bad5cade546341da","name":"FURRY","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e7b738bad5cade546341da","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":370,"size":106968,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":370,"size":195256,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":370,"size":279029,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":370,"size":477132,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":370,"size":538120,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":370,"size":789370,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":370,"size":869332,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":370,"size":1125388,"format":"WEBP"}]}}},{"id":"64e805fca6bfa513f8a064f6","name":"CaughtTrolling","flags":0,"timestamp":1692957817513,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64e805fca6bfa513f8a064f6","name":"GotCaughtTrolling","flags":0,"tags":["trump","mugshot","trumpmugshot","myhonestreaction","godblessamerica"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6144997f962a60904864d7f3","username":"ligmatoes88","display_name":"ligmatoes88","avatar_url":"//cdn.7tv.app/user/6144997f962a60904864d7f3/av_649ce9d9319b6f7746193f1e/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e805fca6bfa513f8a064f6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":80,"height":32,"frame_count":1,"size":1633,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":80,"height":32,"frame_count":1,"size":3290,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":160,"height":64,"frame_count":1,"size":3110,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":160,"height":64,"frame_count":1,"size":9652,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":240,"height":96,"frame_count":1,"size":4852,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":240,"height":96,"frame_count":1,"size":17904,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":320,"height":128,"frame_count":1,"size":6405,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":320,"height":128,"frame_count":1,"size":27126,"format":"WEBP"}]}}},{"id":"64e90c9a235d13cff971e1dc","name":"nymnUK","flags":0,"timestamp":1692998613502,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64e90c9a235d13cff971e1dc","name":"nymnUK","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e90c9a235d13cff971e1dc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1209,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2110,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2520,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":7046,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4361,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":13532,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6316,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":22324,"format":"WEBP"}]}}},{"id":"6431b97987c43af56c004b67","name":"Baldge","flags":0,"timestamp":1693053822132,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6431b97987c43af56c004b67","name":"Baldge","flags":0,"tags":["feelssadman","feelsbaldman","malding","balding","sadge","feelsbadman"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62a7977aaa031674e785ccc3","username":"hourshift","display_name":"hourshift","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6431b97987c43af56c004b67","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":42,"height":32,"frame_count":14,"size":5255,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":42,"height":32,"frame_count":14,"size":3690,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":84,"height":64,"frame_count":14,"size":9054,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":84,"height":64,"frame_count":14,"size":6328,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":126,"height":96,"frame_count":14,"size":13286,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":126,"height":96,"frame_count":14,"size":9182,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":168,"height":128,"frame_count":14,"size":17961,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":168,"height":128,"frame_count":14,"size":11480,"format":"WEBP"}]}}},{"id":"64ab423f9675d1e447869c7f","name":"NOSHOT","flags":0,"timestamp":1693053837663,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64ab423f9675d1e447869c7f","name":"NOSHOT","flags":0,"tags":["nahhh","deadass","aintnoway","skull","skeleton","imdead"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"630e92963cfad7b708c47b1c","username":"reapax","display_name":"Reapax","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ae51c1bf-b562-4052-8127-2f205c06c828-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64ab423f9675d1e447869c7f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":81,"size":22576,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":81,"size":39544,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":81,"size":67921,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":81,"size":108962,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":81,"size":128456,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":81,"size":190250,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":81,"size":196765,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":81,"size":271834,"format":"WEBP"}]}}},{"id":"61b0a270e9684edbbc399e6f","name":"KUKLEG","flags":0,"timestamp":1693054171366,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"61b0a270e9684edbbc399e6f","name":"KUKLEG","flags":0,"tags":["kukle","lule","lile","lolw","lulw","okayeg"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042162896832ffa786ffe60","username":"kryha5555","display_name":"kryha5555","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/29953655-12f7-43a1-b93a-4d4b943a97ee-profile_image-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61b0a270e9684edbbc399e6f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1493,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1156,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2892,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3032,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4798,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4785,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6897,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7334,"format":"WEBP"}]}}},{"id":"636edd7db665cca5227a034d","name":"SaguiPls","flags":0,"timestamp":1693054205933,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"636edd7db665cca5227a034d","name":"SaguiPls","flags":0,"tags":["spongepls","geckpls","alienpls","ratjam","catjam"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"633fbc6cb3723828343b7776","username":"apartyy","display_name":"ApartyY","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fa392215-6b65-4a8b-8c33-cf2ca7b13a67-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/636edd7db665cca5227a034d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":246,"size":78690,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":246,"size":111178,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":246,"size":154341,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":246,"size":189980,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":246,"size":247951,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":246,"size":264722,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":246,"size":309656,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":246,"size":337486,"format":"WEBP"}]}}},{"id":"61d0c849ce8bd4a59cb8934c","name":"sweat0","flags":1,"timestamp":1693082991667,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"61d0c849ce8bd4a59cb8934c","name":"SweatTime","flags":256,"tags":["pepes","pepe","peepos","peepo"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ca9694f411fca3bb35060f","username":"tajj","display_name":"Tajj","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8b4b9f3da711dc19-profile_image-70x70.jpeg","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61d0c849ce8bd4a59cb8934c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":4028,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":3304,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":5394,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":6028,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":8199,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":9118,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":8928,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":10280,"format":"WEBP"}]}}},{"id":"64ea6c67b4485800629d313a","name":"POWERWASHING","flags":0,"timestamp":1693084835117,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64ea6c67b4485800629d313a","name":"POWERWASHING","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64ea6c67b4485800629d313a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":59,"height":32,"frame_count":116,"size":56535,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":59,"height":32,"frame_count":116,"size":113420,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":118,"height":64,"frame_count":116,"size":140780,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":118,"height":64,"frame_count":116,"size":249774,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":177,"height":96,"frame_count":116,"size":264084,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":177,"height":96,"frame_count":116,"size":383424,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":236,"height":128,"frame_count":116,"size":395973,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":236,"height":128,"frame_count":116,"size":532334,"format":"WEBP"}]}}},{"id":"646139c75070b2cda24f0776","name":"SKILLISSUE","flags":0,"timestamp":1693139567462,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"646139c75070b2cda24f0776","name":"SKILLISSUE","flags":0,"tags":["skill","issue","mario"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"626247264f320c1b8cf58d7e","username":"izeeh","display_name":"iZeeh","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/646139c75070b2cda24f0776","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":63,"height":32,"frame_count":1,"size":2446,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":63,"height":32,"frame_count":1,"size":2734,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":126,"height":64,"frame_count":1,"size":4528,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":126,"height":64,"frame_count":1,"size":6360,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":189,"height":96,"frame_count":1,"size":6284,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":189,"height":96,"frame_count":1,"size":11074,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":252,"height":128,"frame_count":1,"size":7775,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":252,"height":128,"frame_count":1,"size":16494,"format":"WEBP"}]}}},{"id":"63c47d2317a417b27a307edf","name":"catDance","flags":0,"timestamp":1693146334821,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c47d2317a417b27a307edf","name":"catDance","flags":0,"tags":["cat","maxwell","meme","goodcomplexo","dance","black"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61a24266e9684edbbc371516","username":"psicopompo","display_name":"Psicopompo","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5623ada9-ea90-4902-92c0-72ccbae464cf-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c47d2317a417b27a307edf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":41,"height":32,"frame_count":22,"size":9946,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":41,"height":32,"frame_count":22,"size":17478,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":82,"height":64,"frame_count":22,"size":18988,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":82,"height":64,"frame_count":22,"size":33372,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":123,"height":96,"frame_count":22,"size":30382,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":123,"height":96,"frame_count":22,"size":48968,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":164,"height":128,"frame_count":22,"size":44868,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":164,"height":128,"frame_count":22,"size":64658,"format":"WEBP"}]}}},{"id":"60996ad0b2344260a28b943d","name":"monkaPickle","flags":0,"timestamp":1693235629408,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60996ad0b2344260a28b943d","name":"monkaPickle","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60996ad0b2344260a28b943d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1328,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":936,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2549,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2384,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3904,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4120,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5340,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5778,"format":"WEBP"}]}}},{"id":"629f934a3cfb54ec859bb3ab","name":"PicklePoro","flags":0,"timestamp":1693235642100,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"629f934a3cfb54ec859bb3ab","name":"PicklePoro","flags":0,"tags":["porosad","rick","morty","monkapickle"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60879d10fcf1f9923f6e1573","username":"somso2e","display_name":"Somso2e","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7291e0ba-abe4-4928-9951-6becee40fb61-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/629f934a3cfb54ec859bb3ab","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1129,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":994,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2055,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2314,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3220,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4062,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4615,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6224,"format":"WEBP"}]}}},{"id":"64ed1ca2c79b0b0a6f30ccf6","name":"catWait","flags":0,"timestamp":1693261008646,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64ed1ca2c79b0b0a6f30ccf6","name":"catWait","flags":0,"tags":["wait","cat","waiting"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64ed1ca2c79b0b0a6f30ccf6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":24,"size":6707,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":24,"size":8696,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":24,"size":10687,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":24,"size":13998,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":24,"size":16669,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":24,"size":21862,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":24,"size":21012,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":24,"size":24692,"format":"WEBP"}]}}},{"id":"64edf8c33b7b939f77575f16","name":"apolloLooking","flags":0,"timestamp":1693317359222,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64edf8c33b7b939f77575f16","name":"apolloLooking","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60e87e9277b18d5dd343f852","username":"39matt","display_name":"39matt","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4bef09aa-9fea-4dab-86c9-b0e75352374d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64edf8c33b7b939f77575f16","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":31,"height":32,"frame_count":1,"size":988,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":31,"height":32,"frame_count":1,"size":1312,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":62,"height":64,"frame_count":1,"size":1461,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":62,"height":64,"frame_count":1,"size":3464,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":93,"height":96,"frame_count":1,"size":2072,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":93,"height":96,"frame_count":1,"size":6434,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":124,"height":128,"frame_count":1,"size":2644,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":124,"height":128,"frame_count":1,"size":8858,"format":"WEBP"}]}}},{"id":"64eb5c4ef71356391c1cc1bf","name":"WAYTOOBUH","flags":0,"timestamp":1693474115853,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64eb5c4ef71356391c1cc1bf","name":"WAYTOOBUH","flags":0,"tags":["huh","waytoodank","cat","guh","uuh","meow"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"63b72ca0ed6dce0cb863d88a","username":"dx9er","display_name":"dx9er","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/28b3208f-9272-4802-aeba-ffe4664d3070-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64eb5c4ef71356391c1cc1bf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":45,"height":32,"frame_count":46,"size":16206,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":45,"height":32,"frame_count":46,"size":21582,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":90,"height":64,"frame_count":46,"size":30119,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":90,"height":64,"frame_count":46,"size":47166,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":135,"height":96,"frame_count":46,"size":46040,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":135,"height":96,"frame_count":46,"size":74568,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":180,"height":128,"frame_count":46,"size":65213,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":180,"height":128,"frame_count":46,"size":114488,"format":"WEBP"}]}}},{"id":"63642744b52feba3d73f0781","name":"ChatBelowGetsCoolAir","flags":0,"timestamp":1693482992175,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63642744b52feba3d73f0781","name":"ChatBelowGetsCoolAir","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6363ff9871f78620f1e4775a","username":"jjackwv","display_name":"JJackWV","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63642744b52feba3d73f0781","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":93,"height":32,"frame_count":12,"size":12093,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":93,"height":32,"frame_count":12,"size":12820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":186,"height":64,"frame_count":12,"size":24543,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":186,"height":64,"frame_count":12,"size":23858,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":279,"height":96,"frame_count":12,"size":36474,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":279,"height":96,"frame_count":12,"size":34020,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":372,"height":128,"frame_count":12,"size":46782,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":372,"height":128,"frame_count":12,"size":44970,"format":"WEBP"}]}}},{"id":"64246a2f7a0698a901dab82a","name":"Binoculars","flags":0,"timestamp":1693486809997,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64246a2f7a0698a901dab82a","name":"SusgeBinoculars","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6328a1545fb3f93767c33777","username":"destroyerjaan","display_name":"destroyerjaan","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/de130ab0-def7-11e9-b668-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64246a2f7a0698a901dab82a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":59,"height":32,"frame_count":1,"size":2061,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":59,"height":32,"frame_count":1,"size":2970,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":118,"height":64,"frame_count":1,"size":8748,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":118,"height":64,"frame_count":1,"size":4112,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":177,"height":96,"frame_count":1,"size":16384,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":177,"height":96,"frame_count":1,"size":6178,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":236,"height":128,"frame_count":1,"size":8180,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":236,"height":128,"frame_count":1,"size":25458,"format":"WEBP"}]}}},{"id":"63c9080bec685e58d1727476","name":"Bitrate","flags":0,"timestamp":1693489431481,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c9080bec685e58d1727476","name":"bitrate","flags":0,"tags":["lagpixelsxqcforsen","plink","catdogcuteblinkstare"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"636946b5707319be8c666317","username":"oo00o0o00o000o0o0o0oo00oo","display_name":"oo00o0o00o000o0o0o0oo00oo","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a9bcceb7-6017-4eed-a377-5e87fc24b2dc-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c9080bec685e58d1727476","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":80,"height":32,"frame_count":60,"size":16583,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":80,"height":32,"frame_count":60,"size":33806,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":160,"height":64,"frame_count":60,"size":33826,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":160,"height":64,"frame_count":60,"size":51786,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":240,"height":96,"frame_count":60,"size":56300,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":240,"height":96,"frame_count":60,"size":85042,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":320,"height":128,"frame_count":60,"size":85267,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":320,"height":128,"frame_count":60,"size":94528,"format":"WEBP"}]}}},{"id":"6487b223aaecac0e2a01fae6","name":"ItJustWorks","flags":0,"timestamp":1693746553051,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6487b223aaecac0e2a01fae6","name":"ItJustWorks","flags":0,"tags":["bethesda","todd","skyrim","starfield","oblivion","fallout"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b714aaf09ea88072d4b501","username":"terreezus","display_name":"terreezus","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/dcd6d457-25f3-4e70-9911-0f21c274a626-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6487b223aaecac0e2a01fae6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":18,"size":8105,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":18,"size":11646,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":18,"size":16510,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":18,"size":23906,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":18,"size":24523,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":18,"size":38176,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":18,"size":34860,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":18,"size":53874,"format":"WEBP"}]}}},{"id":"629ebf5936b6f962050a9fea","name":"HappyGachaPlayer","flags":0,"timestamp":1693827425505,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"629ebf5936b6f962050a9fea","name":"HappyGachaPlayer","flags":0,"tags":["epicseven","genshin","bluearchive","nikke","lacari","epic7"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"610172a7c515f125009aee59","username":"bryguy_eh","display_name":"Bryguy_eH","avatar_url":"//cdn.7tv.app/pp/610172a7c515f125009aee59/ff52159856934b7083c3be075ca3e948","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/629ebf5936b6f962050a9fea","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":45,"height":32,"frame_count":1,"size":970,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":45,"height":32,"frame_count":1,"size":1188,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":90,"height":64,"frame_count":1,"size":2230,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":90,"height":64,"frame_count":1,"size":2087,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":135,"height":96,"frame_count":1,"size":3970,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":135,"height":96,"frame_count":1,"size":3080,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":180,"height":128,"frame_count":1,"size":5838,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":180,"height":128,"frame_count":1,"size":4303,"format":"AVIF"}]}}},{"id":"64d6927a1d860423beb3ba2c","name":"catshVibe","flags":0,"timestamp":1693830451579,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64d6927a1d860423beb3ba2c","name":"catshVibe","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"623bd700323d0026d4ee4a11","username":"bre44d","display_name":"bre44d","avatar_url":"//cdn.7tv.app/user/623bd700323d0026d4ee4a11/av_63d05cd36b665ab1f88625c6/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d6927a1d860423beb3ba2c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":88,"size":13851,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":88,"size":29354,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":88,"size":26334,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":88,"size":59102,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":88,"size":42949,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":88,"size":88306,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":88,"size":60280,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":88,"size":118576,"format":"WEBP"}]}}},{"id":"64f5d36a8bef73096908d55e","name":"nymnTick","flags":0,"timestamp":1693832175085,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64f5d36a8bef73096908d55e","name":"nymnTick","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6421e1e704bb57ba4db5ba41","username":"kryt3k","display_name":"kryt3k","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7f303b2e-ff21-4eca-b264-26cebdb7fbb8-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64f5d36a8bef73096908d55e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1192,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1928,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2299,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5428,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3488,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10126,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5322,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15980,"format":"WEBP"}]}}},{"id":"624e576526e9b290e8ff32bb","name":"JUICED","flags":0,"timestamp":1693849732566,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"624e576526e9b290e8ff32bb","name":"JUICED","flags":0,"tags":["xqc","pvc","xqcl","juiced","scary","stare"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613f4a277b14fdf700b8bafb","username":"victorleporc_","display_name":"victorleporc_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ef2c5658-b3ab-499d-8e6e-7ec9cbfe88aa-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/624e576526e9b290e8ff32bb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":41,"height":32,"frame_count":243,"size":29568,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":41,"height":32,"frame_count":242,"size":161038,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":82,"height":64,"frame_count":243,"size":48520,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":82,"height":64,"frame_count":243,"size":441478,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":123,"height":96,"frame_count":243,"size":75961,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":123,"height":96,"frame_count":243,"size":674088,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":164,"height":128,"frame_count":243,"size":118133,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":164,"height":128,"frame_count":243,"size":989096,"format":"WEBP"}]}}},{"id":"64a84580d93e4373b39b2f09","name":"yippee","flags":0,"timestamp":1693937733918,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64a84580d93e4373b39b2f09","name":"NymNing","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a84580d93e4373b39b2f09","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":53,"height":32,"frame_count":80,"size":44565,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":53,"height":32,"frame_count":80,"size":76638,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":106,"height":64,"frame_count":80,"size":98580,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":106,"height":64,"frame_count":80,"size":146566,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":159,"height":96,"frame_count":80,"size":156949,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":159,"height":96,"frame_count":80,"size":211396,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":212,"height":128,"frame_count":80,"size":219885,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":212,"height":128,"frame_count":80,"size":277994,"format":"WEBP"}]}}},{"id":"64f9c55a52ecd4a6aed64d70","name":"GoalpostInMotion","flags":0,"timestamp":1694090670552,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64f9c55a52ecd4a6aed64d70","name":"GoalpostInMotion","flags":0,"tags":["nymn","forsen","goalpost"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6131d9de492022af58394453","username":"jerrythedoctor","display_name":"JerryTheDoctor","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a4a6f511-4bc7-466b-a73d-f9dc242bdef9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64f9c55a52ecd4a6aed64d70","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":25,"frame_count":87,"size":36197,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":25,"frame_count":87,"size":55186,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":50,"frame_count":87,"size":105548,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":50,"frame_count":87,"size":127818,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":75,"frame_count":87,"size":246239,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":75,"frame_count":87,"size":265480,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":100,"frame_count":87,"size":248499,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":100,"frame_count":87,"size":265684,"format":"WEBP"}]}}},{"id":"64cd931ed3cf2f1c8cca5264","name":"juh","flags":0,"timestamp":1694171780247,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64cd931ed3cf2f1c8cca5264","name":"juh","flags":0,"tags":["buh","duh","wuh","juh","guh","cat"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"62e6b7ea8ef065b8483251b7","username":"smowuw","display_name":"smowuw","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/cdcab8d9-1a74-41c7-8f2b-ffc9725626a9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64cd931ed3cf2f1c8cca5264","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":171,"size":61752,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":171,"size":76820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":171,"size":141431,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":171,"size":177010,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":171,"size":240352,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":171,"size":284944,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":171,"size":395492,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":171,"size":422300,"format":"WEBP"}]}}},{"id":"62551c38b0dfc5aeb040d380","name":"potJAM","flags":0,"timestamp":1694172941818,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62551c38b0dfc5aeb040d380","name":"potJAM","flags":0,"tags":["dance","potfriend","jam"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"62551a265c62fecb05d62562","username":"digatsby","display_name":"DiGatsby","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5c79cc6f-6eb1-4c59-9d5d-7e05052d8863-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62551c38b0dfc5aeb040d380","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":9705,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":13218,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":19423,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":31954,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":31612,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":56098,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":44059,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":78560,"format":"WEBP"}]}}},{"id":"639a4f0f61d9ea4a7faf49e1","name":"lilbro","flags":0,"timestamp":1694191381666,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"639a4f0f61d9ea4a7faf49e1","name":"lilbro","flags":0,"tags":["deadass","lilbro","aintnoway","nahhh","skeleton"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"613ee122962a60904864354e","username":"johnny3oak","display_name":"Johnny3Oak","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/c307733e-0145-418f-8f29-4f97d60b62f9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/639a4f0f61d9ea4a7faf49e1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":107,"size":31799,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":107,"size":38796,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":107,"size":64172,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":107,"size":88406,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":107,"size":103753,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":107,"size":132376,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":107,"size":139845,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":107,"size":175474,"format":"WEBP"}]}}},{"id":"60aef784361b0164e6c32ff6","name":"2bSway","flags":0,"timestamp":1694260904577,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"60aef784361b0164e6c32ff6","name":"2bSway","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae6f08df5735e04a70d65d","username":"maayeto","display_name":"Maayeto","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/2d40076c-96e5-49ae-bd83-8676486780b6-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aef784361b0164e6c32ff6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":13,"size":8530,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":13,"size":9996,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":13,"size":21710,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":13,"size":16961,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":13,"size":36800,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":13,"size":28264,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":13,"size":38347,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":13,"size":42764,"format":"WEBP"}]}}},{"id":"64d7c889d37116b21c8f7cf3","name":"docReadyToNotL","flags":0,"timestamp":1694263320724,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"64d7c889d37116b21c8f7cf3","name":"docReadyToNotL","flags":0,"tags":["docnotl","drdisrespect","twotime","forsen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62a6175afc2bf6a7be2d2b96","username":"lizor","display_name":"Lizor","avatar_url":"//cdn.7tv.app/user/62a6175afc2bf6a7be2d2b96/av_64c0f1709025a813bb83e95d/3x_static.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d7c889d37116b21c8f7cf3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1034,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1332,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1630,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3464,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2361,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6310,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2960,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":9430,"format":"WEBP"}]}}},{"id":"632e42e23999124c6544e3bc","name":"Zombey","flags":0,"timestamp":1694360056486,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"632e42e23999124c6544e3bc","name":"Zombey","flags":0,"tags":["okey","nymn","zombie","halloween"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ff0e7a25bb6dd0b03e40f9","username":"saffybop","display_name":"saffybop","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fd91a409-b82f-474f-a83f-45ab6e4bc3f1-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/632e42e23999124c6544e3bc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1618,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2318,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3476,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":7062,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5765,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":13396,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8545,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":21142,"format":"WEBP"}]}}},{"id":"64fe0418516c80510abd7815","name":"nymca","flags":0,"timestamp":1694368853515,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"64fe0418516c80510abd7815","name":"nymca","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"63fb9249a27fda24e806d1cc","username":"abithappy","display_name":"abithappy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f7a46513-21a8-46ff-8ef2-d388dc069e8c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64fe0418516c80510abd7815","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":61,"height":32,"frame_count":1,"size":2755,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":61,"height":32,"frame_count":1,"size":4234,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":122,"height":64,"frame_count":1,"size":6780,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":122,"height":64,"frame_count":1,"size":13054,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":183,"height":96,"frame_count":1,"size":11313,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":183,"height":96,"frame_count":1,"size":25218,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":244,"height":128,"frame_count":1,"size":16012,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":244,"height":128,"frame_count":1,"size":40218,"format":"WEBP"}]}}},{"id":"64fe02659d48c7216c051203","name":"NYMNCA","flags":0,"timestamp":1694368878163,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"64fe02659d48c7216c051203","name":"nymca","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"63fb9249a27fda24e806d1cc","username":"abithappy","display_name":"abithappy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f7a46513-21a8-46ff-8ef2-d388dc069e8c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64fe02659d48c7216c051203","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":61,"height":32,"frame_count":1,"size":2886,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":61,"height":32,"frame_count":1,"size":4452,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":122,"height":64,"frame_count":1,"size":6985,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":122,"height":64,"frame_count":1,"size":13996,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":183,"height":96,"frame_count":1,"size":11573,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":183,"height":96,"frame_count":1,"size":27214,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":244,"height":128,"frame_count":1,"size":16553,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":244,"height":128,"frame_count":1,"size":43692,"format":"WEBP"}]}}},{"id":"60545a83f2b3b1000d623f8e","name":"KKaper","flags":0,"timestamp":1694430543194,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60545a83f2b3b1000d623f8e","name":"KKaper","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6042160396832ffa786fbd8a","username":"noctum2k","display_name":"noctum2k","avatar_url":"//cdn.7tv.app/pp/6042160396832ffa786fbd8a/b6787ae92e55401aa651d208be7563e5","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60545a83f2b3b1000d623f8e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":46,"size":14496,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":46,"size":33144,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":46,"size":22807,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":46,"size":68302,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":46,"size":37926,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":46,"size":112438,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":46,"size":39899,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":46,"size":141064,"format":"WEBP"}]}}},{"id":"612e3150ea1f0fbaa4748b01","name":"peepoRantbutpeepoisnotrantingandweird","flags":0,"timestamp":1694458252973,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"612e3150ea1f0fbaa4748b01","name":"peepoRantbutpeepoisnotrantingandweird","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60f5fe9d15758a7f9ac8b5a2","username":"johhmar","display_name":"Johhmar","avatar_url":"//cdn.7tv.app/user/60f5fe9d15758a7f9ac8b5a2/av_63a7bfa5333fd616dd6b7781/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/612e3150ea1f0fbaa4748b01","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1110,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":912,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1917,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2120,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2835,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3338,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3672,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4610,"format":"WEBP"}]}}},{"id":"637647509656369ac89d45cc","name":"GIGASOY","flags":0,"timestamp":1694518003536,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"637647509656369ac89d45cc","name":"GIGASOY","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"621e04ca14f489808df5bf87","username":"dankjuicer","display_name":"DankJuicer","avatar_url":"//cdn.7tv.app/user/621e04ca14f489808df5bf87/av_658b6684489e267109462da6/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/637647509656369ac89d45cc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":104,"size":22653,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":78,"size":37074,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":104,"size":49570,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":93,"size":91166,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":104,"size":80711,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":102,"size":157650,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":104,"size":111225,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":103,"size":231984,"format":"WEBP"}]}}},{"id":"63ad70b0e1f75a1c252b8013","name":"pokiSip","flags":0,"timestamp":1694540406253,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ad70b0e1f75a1c252b8013","name":"pokiSip","flags":0,"tags":["cum","verypog","based","sip","coffee","tea"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ad70b0e1f75a1c252b8013","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":62,"size":14500,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":62,"size":38604,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":62,"size":33821,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":62,"size":75144,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":62,"size":56923,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":62,"size":115778,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":62,"size":140502,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":62,"size":163956,"format":"WEBP"}]}}},{"id":"64e74933e59df5a070a049cf","name":"LETMEIN","flags":0,"timestamp":1694610734684,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64e74933e59df5a070a049cf","name":"LETMEIN","flags":0,"tags":["apollo","forsen","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6428207a36f5ca3539b8ac95","username":"4cdee","display_name":"4cdee","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ae9420a9-a8f1-4b67-8759-0a6ba7243c77-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64e74933e59df5a070a049cf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":1,"size":1096,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":1,"size":1498,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":1,"size":1791,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":1,"size":4112,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":1,"size":2495,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":1,"size":7584,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":1,"size":3172,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":1,"size":11582,"format":"WEBP"}]}}},{"id":"60b756ea1b94ba7313c8ecd5","name":"doggoArrive","flags":0,"timestamp":1694615078051,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"60b756ea1b94ba7313c8ecd5","name":"doggoArrive","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aed25862270703e6e13d13","username":"mph2210","display_name":"mph2210","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f34fbd4a-836e-42dd-a049-b8920d72cc67-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b756ea1b94ba7313c8ecd5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":53584,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":137022,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":141948,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":310062,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":250942,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":511480,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":391463,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":545640,"format":"WEBP"}]}}},{"id":"60fb37535b7deb3de0b09ba8","name":"doggoLeave","flags":0,"timestamp":1694615089047,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"60fb37535b7deb3de0b09ba8","name":"doggoLeave","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ee0c73a60faa2a918d157f","username":"markzynk","display_name":"MarkZynk","avatar_url":"//cdn.7tv.app/user/60ee0c73a60faa2a918d157f/av_63af1b9d34dd4dc86234f001/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60fb37535b7deb3de0b09ba8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":53776,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":131106,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":141516,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":317422,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":257040,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":547254,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":421281,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":682656,"format":"WEBP"}]}}},{"id":"62d94c7b6b8b3d03efebe33a","name":"Working","flags":0,"timestamp":1694679922767,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"62d94c7b6b8b3d03efebe33a","name":"Working","flags":0,"tags":["worker","work","shovel","working","spade","tired"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61541c3c20eaf897465ad48b","username":"andreimonty","display_name":"AndreiMonty","avatar_url":"//cdn.7tv.app/user/61541c3c20eaf897465ad48b/av_6458e293d3b4256e12d830a9/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62d94c7b6b8b3d03efebe33a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":151,"size":89505,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":151,"size":182882,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":151,"size":271756,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":151,"size":565532,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":151,"size":509969,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":151,"size":1036944,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":151,"size":978597,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":151,"size":1690442,"format":"WEBP"}]}}},{"id":"62ffe0653d44188601bb3372","name":"Swedge","flags":0,"timestamp":1694680293415,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62ffe0653d44188601bb3372","name":"Swedge","flags":0,"tags":["sweden","snus","viking","fika"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62729c305698271cdc3d9b5f","username":"c_judas","display_name":"C_Judas","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/55ccfd7a-3f24-4656-b84c-90cd3cf49f3a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62ffe0653d44188601bb3372","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1756,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2410,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3503,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6938,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5364,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12766,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":7009,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":20144,"format":"WEBP"}]}}},{"id":"639352b7420768a80c21ea69","name":"bulle","flags":0,"timestamp":1694680305455,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"639352b7420768a80c21ea69","name":"bulle","flags":0,"tags":["fika","bulle","frodomon","kanelbulle"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61fe9f5a69acfa0715e1fd93","username":"itsbaskeh","display_name":"ItsBaskeh","avatar_url":"//cdn.7tv.app/user/61fe9f5a69acfa0715e1fd93/av_6587950adbf474d8368cec4a/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/639352b7420768a80c21ea69","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1342,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1454,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2599,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4314,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7964,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4026,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5569,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":12878,"format":"WEBP"}]}}},{"id":"60afe42eaecc11e86cd4559a","name":"FeelsBeaverMan","flags":0,"timestamp":1694687230974,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60afe42eaecc11e86cd4559a","name":"FeelsBeaverMan","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042166196832ffa787051e9","username":"gregoronsen","display_name":"Gregoronsen","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/59667792-f7f2-4992-8e97-fe9b493d27de-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60afe42eaecc11e86cd4559a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":38,"height":32,"frame_count":1,"size":1467,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":38,"height":32,"frame_count":1,"size":1334,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":76,"height":64,"frame_count":1,"size":2951,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":76,"height":64,"frame_count":1,"size":3312,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":114,"height":96,"frame_count":1,"size":4409,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":114,"height":96,"frame_count":1,"size":5696,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":152,"height":128,"frame_count":1,"size":5898,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":152,"height":128,"frame_count":1,"size":7728,"format":"WEBP"}]}}},{"id":"644b3b82a2c7e4540aabe072","name":"FIRSTTIMECHATTER","flags":0,"timestamp":1694701355109,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"644b3b82a2c7e4540aabe072","name":"FIRSTTIMECHATTER","flags":0,"tags":["chatter","first","time"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60cbd92694befb7c93495a60","username":"crafting_table_","display_name":"crafting_table_","avatar_url":"//cdn.7tv.app/user/60cbd92694befb7c93495a60/av_64794969628540685214d7d3/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/644b3b82a2c7e4540aabe072","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":198,"size":15170,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":198,"size":44026,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":198,"size":18946,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":198,"size":90728,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":198,"size":33881,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":198,"size":135586,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":198,"size":91786,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":198,"size":203024,"format":"WEBP"}]}}},{"id":"6131dd5daf9287c4eb60927f","name":"AlienHips","flags":0,"timestamp":1694711483267,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6131dd5daf9287c4eb60927f","name":"AlienHips","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6131dab2af9287c4eb609268","username":"vicneeel","display_name":"vicneeel","avatar_url":"//cdn.7tv.app/user/6131dab2af9287c4eb609268/av_6520576332b1db5b90ef6b24/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6131dd5daf9287c4eb60927f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":18,"size":9966,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":18,"size":12320,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":18,"size":29852,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":18,"size":17225,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":18,"size":49494,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":18,"size":29097,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":18,"size":41428,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":18,"size":60350,"format":"WEBP"}]}}},{"id":"60b53bb4f3fe830b8fe60723","name":"Backseatgaming","flags":0,"timestamp":1694763109863,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b53bb4f3fe830b8fe60723","name":"Backseatgaming","flags":0,"tags":["backseatega"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60532ec2b4d31e459f7293dc","username":"marrryanx","display_name":"Marrryanx","avatar_url":"//cdn.7tv.app/user/60532ec2b4d31e459f7293dc/av_6570dc7f834e0a119031a679/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b53bb4f3fe830b8fe60723","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1585,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1246,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3498,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3510,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6412,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5785,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8621,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8784,"format":"WEBP"}]}}},{"id":"62c36bc8afc685668fea320f","name":"peepoPlant","flags":0,"timestamp":1694768950092,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62c36bc8afc685668fea320f","name":"peepoPlant","flags":0,"tags":["peepo","plant","gardening","farming"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62c36bc8afc685668fea320f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":51,"height":32,"frame_count":4,"size":6013,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":51,"height":32,"frame_count":4,"size":6628,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":102,"height":64,"frame_count":4,"size":12672,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":102,"height":64,"frame_count":4,"size":17622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":153,"height":96,"frame_count":4,"size":19781,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":153,"height":96,"frame_count":4,"size":30090,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":204,"height":128,"frame_count":4,"size":27688,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":204,"height":128,"frame_count":4,"size":41912,"format":"WEBP"}]}}},{"id":"627d581349607a2d9d9b6495","name":"Hedge","flags":0,"timestamp":1694770064728,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"627d581349607a2d9d9b6495","name":"Hedge","flags":0,"tags":["hedge","okayge"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b3723cfdd0ee2f1bd43cad","username":"carnotic","display_name":"carnotic","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/977c1066-cd78-432a-a46e-6c9efd817bb7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/627d581349607a2d9d9b6495","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":83,"height":32,"frame_count":1,"size":3273,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":83,"height":32,"frame_count":1,"size":2720,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":166,"height":64,"frame_count":1,"size":9792,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":166,"height":64,"frame_count":1,"size":8256,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":249,"height":96,"frame_count":1,"size":18121,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":249,"height":96,"frame_count":1,"size":15846,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":332,"height":128,"frame_count":1,"size":28006,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":332,"height":128,"frame_count":1,"size":25218,"format":"WEBP"}]}}},{"id":"6464e403ea74b7d3bef82e88","name":"SNAILS","flags":0,"timestamp":1694770791312,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"6464e403ea74b7d3bef82e88","name":"SNAILS","flags":0,"tags":["xqc","steamhappy","swag","xdd","nails","forsen"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"613cb52b3f2fa247f2ed46ae","username":"pigswitched","display_name":"pigswitched","avatar_url":"//cdn.7tv.app/user/613cb52b3f2fa247f2ed46ae/av_65035387c9920b6284155ec6/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6464e403ea74b7d3bef82e88","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":47,"height":32,"frame_count":1,"size":2166,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":47,"height":32,"frame_count":1,"size":1790,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":94,"height":64,"frame_count":1,"size":5544,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":94,"height":64,"frame_count":1,"size":3862,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":141,"height":96,"frame_count":1,"size":6408,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":141,"height":96,"frame_count":1,"size":10386,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":188,"height":128,"frame_count":1,"size":9131,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":188,"height":128,"frame_count":1,"size":15490,"format":"WEBP"}]}}},{"id":"65042a74bf154a991368de41","name":"JustABaby","flags":0,"timestamp":1694771838681,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"65042a74bf154a991368de41","name":"JustABaby","flags":0,"tags":["apollo","sleep"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65042a74bf154a991368de41","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":63,"height":32,"frame_count":1,"size":1526,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":63,"height":32,"frame_count":1,"size":3056,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":126,"height":64,"frame_count":1,"size":2919,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":126,"height":64,"frame_count":1,"size":8820,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":189,"height":96,"frame_count":1,"size":4348,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":189,"height":96,"frame_count":1,"size":16228,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":252,"height":128,"frame_count":1,"size":5748,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":252,"height":128,"frame_count":1,"size":24130,"format":"WEBP"}]}}},{"id":"640bcebe200ebb849852f1bf","name":"Norse","flags":0,"timestamp":1695043786014,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"640bcebe200ebb849852f1bf","name":"Norse","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"63c835cefc866ebbc80af66f","username":"sjonkonnerie","display_name":"sJoNkOnNeRiE","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38547da9-7866-4d0d-9816-5f9c00e68cc5-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/640bcebe200ebb849852f1bf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":14,"height":32,"frame_count":1,"size":1182,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":14,"height":32,"frame_count":1,"size":1190,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":28,"height":64,"frame_count":1,"size":2235,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":28,"height":64,"frame_count":1,"size":3426,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":42,"height":96,"frame_count":1,"size":3481,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":42,"height":96,"frame_count":1,"size":6424,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":56,"height":128,"frame_count":1,"size":4747,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":56,"height":128,"frame_count":1,"size":10322,"format":"WEBP"}]}}},{"id":"65088068ef1caad468f482a1","name":"DonkEnterButDonkDoesNotEnterBecauseOfTheApparentReasonsYouCanSeeOnYourScreen","flags":0,"timestamp":1695063123105,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"65088068ef1caad468f482a1","name":"DonkEnterButDonkDoesNotEnterBecauseOfTheApparentReasonsYouCanSeeOnYourScreen","flags":0,"tags":["donk","feelsdonkman","enter","leave"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ff054ffbd646ea3b221dc9","username":"tunari__","display_name":"Tunari__","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bc530a7a-e04d-4765-a662-bb3efde482e2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65088068ef1caad468f482a1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":45,"height":32,"frame_count":29,"size":8814,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":45,"height":32,"frame_count":29,"size":11594,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":90,"height":64,"frame_count":29,"size":14359,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":90,"height":64,"frame_count":29,"size":19352,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":135,"height":96,"frame_count":29,"size":21094,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":135,"height":96,"frame_count":29,"size":26626,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":180,"height":128,"frame_count":29,"size":28773,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":180,"height":128,"frame_count":29,"size":33440,"format":"WEBP"}]}}},{"id":"63bdfa34ea6c08ef9218f8ca","name":"!voteskip","flags":0,"timestamp":1695135600848,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63bdfa34ea6c08ef9218f8ca","name":"GoodTake","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63bdfa34ea6c08ef9218f8ca","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":25,"size":4875,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":25,"size":8514,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":25,"size":7823,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":25,"size":36528,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":25,"size":12952,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":25,"size":59754,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":25,"size":22613,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":25,"size":80802,"format":"WEBP"}]}}},{"id":"6509d7bbef1caad468f4cff0","name":"sisyphus","flags":0,"timestamp":1695143921004,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6509d7bbef1caad468f4cff0","name":"sisyphus","flags":0,"tags":["last","loser","sisyphus","nymn","mario","kart"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6509d7bbef1caad468f4cff0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":171,"size":29551,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":147,"size":41242,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":171,"size":58809,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":158,"size":92994,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":171,"size":102232,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":162,"size":136576,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":171,"size":169034,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":163,"size":201634,"format":"WEBP"}]}}},{"id":"613262c4fe9116a00d4b648e","name":"VirtualDonkality","flags":0,"timestamp":1695285110447,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"613262c4fe9116a00d4b648e","name":"VirtualDonkality","flags":0,"tags":["donk","virtualreality","woah"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613262c4fe9116a00d4b648e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":10,"size":6252,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":10,"size":7482,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":10,"size":10772,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":10,"size":13862,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":10,"size":15277,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":10,"size":21530,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":10,"size":20926,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":10,"size":24720,"format":"WEBP"}]}}},{"id":"614c816a6251d7e000da4706","name":"KKiwi","flags":0,"timestamp":1695458370773,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"614c816a6251d7e000da4706","name":"KKiwi","flags":0,"tags":["new","zealand","kiwi"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"611850f37c65a41e6e0b0224","username":"notgards","display_name":"Notgards","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ab07e666-45a4-4844-b3a1-3b15294ff85a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614c816a6251d7e000da4706","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1544,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1196,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2962,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3046,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4784,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6838,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8514,"format":"WEBP"}]}}},{"id":"63b86977ad5d565cef3a2ed7","name":"tablE","flags":0,"timestamp":1695477778074,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63b86977ad5d565cef3a2ed7","name":"tablE","flags":0,"tags":["lule","table","forsene","forsen"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b784cd5d373afbd615e12b","username":"cavel1ghter","display_name":"CaveL1ghter","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8a86cfee-75ae-4794-9149-1c52f188c778-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b86977ad5d565cef3a2ed7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1350,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1510,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2363,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4024,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3652,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7480,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4764,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":11056,"format":"WEBP"}]}}},{"id":"650d854b714d175392387b32","name":"nymblE","flags":0,"timestamp":1695481280842,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"650d854b714d175392387b32","name":"nymblE","flags":0,"tags":["tablnyme","table","etable","nym","nyme"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"650d851b714d175392387b2b","username":"unga_bungasupaflyer","display_name":"unga_bungasupaflyer","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/998f01ae-def8-11e9-b95c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/650d854b714d175392387b32","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1431,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1516,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2565,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4130,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3747,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7678,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4737,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":11804,"format":"WEBP"}]}}},{"id":"650f165958b80a4d09f13110","name":"docReadyToJam","flags":0,"timestamp":1695487595375,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"650f165958b80a4d09f13110","name":"docReadyToJam","flags":0,"tags":["doc","drdisrespect","docjammer"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/650f165958b80a4d09f13110","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1147,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1430,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2023,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4126,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3058,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7698,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4224,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":12162,"format":"WEBP"}]}}},{"id":"64a1d4f0ecdb531b02a33104","name":"forsenUnpleased","flags":0,"timestamp":1695487823951,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"64a1d4f0ecdb531b02a33104","name":"forsenUnpleased","flags":0,"tags":["forsenpls","alienunpleased"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64a1d4f0ecdb531b02a33104","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1120,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1442,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1968,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4290,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2857,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8354,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3759,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13308,"format":"WEBP"}]}}},{"id":"650f393cef1caad468f5ea1b","name":"AlienVibe","flags":0,"timestamp":1695503793611,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"650f393cef1caad468f5ea1b","name":"AlienVibe","flags":0,"tags":["aliendance","vibe","jam","alienpls","dance"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/650f393cef1caad468f5ea1b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":72,"size":23945,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":72,"size":33850,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":72,"size":46138,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":72,"size":60040,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":72,"size":72452,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":72,"size":96750,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":72,"size":79465,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":72,"size":106932,"format":"WEBP"}]}}},{"id":"62b309fb1fd1e9520172f2a1","name":"Riming","flags":0,"timestamp":1695554239222,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"62b309fb1fd1e9520172f2a1","name":"Riming","flags":0,"tags":["rime","russel","comedy","lime","erobb221"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61c7196612987d64d6ae7fc6","username":"quaxi13","display_name":"Quaxi13","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/39135e01-6d49-40ad-afe5-2973e6176848-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62b309fb1fd1e9520172f2a1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":42,"size":7665,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":42,"size":11786,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":42,"size":12448,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":42,"size":20832,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":42,"size":20114,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":42,"size":31786,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":42,"size":25116,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":42,"size":36740,"format":"WEBP"}]}}},{"id":"651028fdef1caad468f619dc","name":"AlienFloss","flags":0,"timestamp":1695558392570,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"651028fdef1caad468f619dc","name":"AlienFloss","flags":0,"tags":["alienpls","dance","pls","vibe","floss","fortnite"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651028fdef1caad468f619dc","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":80,"size":30511,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":80,"size":44540,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":80,"size":63289,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":80,"size":83516,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":80,"size":101063,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":80,"size":133156,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":80,"size":132474,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":80,"size":153750,"format":"WEBP"}]}}},{"id":"6510474dc9920b628417f683","name":"ReadyToRightThere","flags":0,"timestamp":1695565660720,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"6510474dc9920b628417f683","name":"ReadyToRightThere","flags":0,"tags":["rightthere","leo"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6510474dc9920b628417f683","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":1,"size":1193,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":1,"size":1528,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":1,"size":2037,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":1,"size":4162,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":1,"size":2808,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":1,"size":7650,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":1,"size":3828,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":1,"size":11690,"format":"WEBP"}]}}},{"id":"65107032c9920b6284180082","name":"AlienJam","flags":0,"timestamp":1695582213965,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"65107032c9920b6284180082","name":"AlienJam","flags":0,"tags":["alienpls","dance","jam","vibe"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65107032c9920b6284180082","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":762,"size":278008,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":762,"size":395500,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":762,"size":600813,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":762,"size":734784,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":762,"size":976319,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":762,"size":1174168,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":762,"size":1179634,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":762,"size":1326776,"format":"WEBP"}]}}},{"id":"651036c7ef1caad468f61cf1","name":"AlienSilly","flags":0,"timestamp":1695582262039,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"651036c7ef1caad468f61cf1","name":"AlienSilly","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651036c7ef1caad468f61cf1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":46,"size":19894,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":46,"size":25264,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":46,"size":39529,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":46,"size":48214,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":46,"size":66695,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":46,"size":77046,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":46,"size":81010,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":46,"size":89738,"format":"WEBP"}]}}},{"id":"65106e01462d22f767cbc661","name":"AlienWorm","flags":0,"timestamp":1695582290174,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"65106e01462d22f767cbc661","name":"AlienWorm","flags":0,"tags":["pls","vibe","jiggle","alienpls","dance","alien"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65106e01462d22f767cbc661","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":272,"size":106119,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":272,"size":150754,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":272,"size":236801,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":272,"size":285280,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":272,"size":387658,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":272,"size":465112,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":272,"size":484810,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":272,"size":529284,"format":"WEBP"}]}}},{"id":"65106d26bf154a99136b5f48","name":"AlienPump","flags":0,"timestamp":1695582292544,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"65106d26bf154a99136b5f48","name":"AlienPump","flags":0,"tags":["pls","dance","vibe","alien","alienpls"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65106d26bf154a99136b5f48","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":234,"size":71834,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":234,"size":123614,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":234,"size":158659,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":234,"size":234740,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":234,"size":266990,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":234,"size":369228,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":234,"size":331836,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":234,"size":427770,"format":"WEBP"}]}}},{"id":"65102ab258b80a4d09f16b5d","name":"AlienDefault","flags":0,"timestamp":1695582293707,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"65102ab258b80a4d09f16b5d","name":"AlienDefault","flags":0,"tags":["fortnite","dance","pls","vibe","alienpls","alien"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65102ab258b80a4d09f16b5d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":339,"size":107565,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":339,"size":169326,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":339,"size":230067,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":339,"size":325032,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":339,"size":370591,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":339,"size":514796,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":339,"size":478643,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":339,"size":598318,"format":"WEBP"}]}}},{"id":"650f3b0858b80a4d09f13bad","name":"AyyyMacarena","flags":0,"timestamp":1695582302467,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"650f3b0858b80a4d09f13bad","name":"AlienMacarena","flags":0,"tags":["alienpls","alien","dance","jam","pls","vibe"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/650f3b0858b80a4d09f13bad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":248,"size":67699,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":248,"size":111242,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":248,"size":135956,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":248,"size":209060,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":248,"size":213233,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":248,"size":338038,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":248,"size":244674,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":248,"size":378032,"format":"WEBP"}]}}},{"id":"65102db1bf154a99136b50e9","name":"AlienTechno","flags":0,"timestamp":1695586212014,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"65102db1bf154a99136b50e9","name":"AlienTechno","flags":0,"tags":["techno","vibe","fortnite","alien","pls","dance"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65102db1bf154a99136b50e9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":599,"size":221968,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":599,"size":306566,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":599,"size":486970,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":599,"size":573644,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":599,"size":796073,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":599,"size":918798,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":599,"size":993441,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":599,"size":1055732,"format":"WEBP"}]}}},{"id":"629a2fa547051898ec04d2a8","name":"Upgrade","flags":0,"timestamp":1695648654266,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"629a2fa547051898ec04d2a8","name":"Upgrade","flags":0,"tags":["diabloimmortal","diablo","immortal","upgrade","uparrow"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60afa9d9ebfcf7562e59c242","username":"peacepeoplejr","display_name":"PeacePeopleJr","avatar_url":"//cdn.7tv.app/pp/60afa9d9ebfcf7562e59c242/62199af2f21c47f9a0c3b8578d93d979","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/629a2fa547051898ec04d2a8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":18,"size":4686,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":18,"size":10438,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":18,"size":6352,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":18,"size":17040,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":18,"size":9103,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":18,"size":24982,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":18,"size":9155,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":18,"size":27624,"format":"WEBP"}]}}},{"id":"621b0ab487952f3b8e5a4f15","name":"PoroPot","flags":0,"timestamp":1695811857425,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"621b0ab487952f3b8e5a4f15","name":"PoroPot","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae7a593c4727d906a841f6","username":"melonify","display_name":"Melonify","avatar_url":"//cdn.7tv.app/user/60ae7a593c4727d906a841f6/av_644f6ae6ffbdc8aa02c17299/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","63124dcf098bd6b8e5a7cb02","60724f65e93d828bf8858789","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621b0ab487952f3b8e5a4f15","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1282,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1493,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2730,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3422,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4002,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5600,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8004,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5302,"format":"AVIF"}]}}},{"id":"62189328df86bac8c42f87f1","name":"PotL","flags":0,"timestamp":1695811887627,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62189328df86bac8c42f87f1","name":"PotL","flags":0,"tags":["elden","ring","pot","loser","dark","souls"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62189328df86bac8c42f87f1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1456,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1200,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2459,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3058,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3604,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5070,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4415,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7018,"format":"WEBP"}]}}},{"id":"6219b46baff1c45709b4a65f","name":"PotEnemy","flags":0,"timestamp":1695811890109,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6219b46baff1c45709b4a65f","name":"PotEnemy","flags":0,"tags":["potenemy","pot","friend","potfriend","enemy","gun"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60afb03499923bbe7f9d95ad","username":"lynxeption","display_name":"Lynxeption","avatar_url":"//cdn.7tv.app/user/60afb03499923bbe7f9d95ad/av_657b724890ed04f2389fb7b1/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6219b46baff1c45709b4a65f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1628,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1304,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3452,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3083,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6010,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4557,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6038,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8774,"format":"WEBP"}]}}},{"id":"621f66f4f80b7279c4447d59","name":"PotChest","flags":0,"timestamp":1695811891966,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"621f66f4f80b7279c4447d59","name":"PotChest","flags":0,"tags":["potfriend","pot","friend","batchest"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61b838faf93abf1d40701ae7","username":"exjne","display_name":"Exjne","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621f66f4f80b7279c4447d59","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1683,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1376,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3114,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3738,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4472,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5836,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5609,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7986,"format":"WEBP"}]}}},{"id":"621f9b23c286e9a2617ae546","name":"PotLove","flags":0,"timestamp":1695811893614,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"621f9b23c286e9a2617ae546","name":"PotLove","flags":0,"tags":["potfriend","elden","ring"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae21c0aee2aa55388ba741","username":"farbrorbarbro","display_name":"FarbrorBarbro","avatar_url":"//cdn.7tv.app/pp/60ae21c0aee2aa55388ba741/2aa45e11e4474bf8a04c74fe9157bd53","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621f9b23c286e9a2617ae546","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1310,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1556,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3244,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2829,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4260,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5576,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5435,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7700,"format":"WEBP"}]}}},{"id":"6230a0442cbc7e45d4cacf84","name":"KEKPot","flags":0,"timestamp":1695811919037,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6230a0442cbc7e45d4cacf84","name":"KEKPot","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"610d9d28e2fbd2e210e0740f","username":"turtledoves","display_name":"turtledoves","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/b437b23e-e993-4ca1-b500-71747c0c405c-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6230a0442cbc7e45d4cacf84","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1394,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1671,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3666,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2939,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4232,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5624,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5080,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7522,"format":"WEBP"}]}}},{"id":"621f65c1c286e9a2617ae2f7","name":"PotBased","flags":0,"timestamp":1695811931533,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"621f65c1c286e9a2617ae2f7","name":"PotBased","flags":0,"tags":["potfriend","pot","friend","based"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61b838faf93abf1d40701ae7","username":"exjne","display_name":"Exjne","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621f65c1c286e9a2617ae2f7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1527,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1318,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2695,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3544,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3913,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5448,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4822,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7398,"format":"WEBP"}]}}},{"id":"621c4e293808dfe5c465fec4","name":"PotShy","flags":0,"timestamp":1695811936538,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"621c4e293808dfe5c465fec4","name":"PotShy","flags":0,"tags":["pot"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae750eb351b8d1c083f5ec","username":"znixp","display_name":"zNIXp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8569f469-d7ee-468b-b718-e87459b2278a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621c4e293808dfe5c465fec4","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1104,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1485,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3014,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3110,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5270,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4599,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6196,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8382,"format":"WEBP"}]}}},{"id":"62352999b1ac2f158886304a","name":"PotFeet","flags":0,"timestamp":1695811959285,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62352999b1ac2f158886304a","name":"PotFeet","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62352999b1ac2f158886304a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1363,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1126,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2743,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2964,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4371,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5132,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6383,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7966,"format":"WEBP"}]}}},{"id":"62f858aed46fdda0ac66a5e6","name":"DancingPotato","flags":0,"timestamp":1695812000540,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62f858aed46fdda0ac66a5e6","name":"DancingPotato","flags":0,"tags":["dancing","potato","potatodancing"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6201ffa0d668c38c1673ccac","username":"narikonakamura","display_name":"NarikoNakamura","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/548cf9da-e09e-4c61-83c2-a5de4e9691a6-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62f858aed46fdda0ac66a5e6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":60,"size":14723,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":60,"size":50052,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":60,"size":28736,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":60,"size":108136,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":60,"size":42395,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":60,"size":170556,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":60,"size":54397,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":60,"size":235096,"format":"WEBP"}]}}},{"id":"64fb6a0ecef5572ce1b8b5a8","name":"IDontCareIJustWannaSleep","flags":0,"timestamp":1695889381240,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64fb6a0ecef5572ce1b8b5a8","name":"IDontCareIJustWannaSleep","flags":0,"tags":["cat","kitty","kitten","sleep","sleeping","meow"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6169d4c6474b9b7b59a37f56","username":"eropbl4_","display_name":"Eropbl4_","avatar_url":"//cdn.7tv.app/user/6169d4c6474b9b7b59a37f56/av_64305076b0b346d623214893/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64fb6a0ecef5572ce1b8b5a8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":216,"size":42904,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":216,"size":112522,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":216,"size":100801,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":216,"size":237796,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":216,"size":185828,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":216,"size":379546,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":216,"size":564491,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":216,"size":634344,"format":"WEBP"}]}}},{"id":"6514673b462d22f767cc99d3","name":"Ohnoge","flags":0,"timestamp":1695889885984,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6514673b462d22f767cc99d3","name":"Ohnoge","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"613ba5d090c03f6155d42066","username":"oggyerino","display_name":"Oggyerino","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4526524c-7f63-4bb8-bb38-6081d49b3b00-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6514673b462d22f767cc99d3","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1768,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1318,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4710,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2455,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3647,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9092,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4894,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":14662,"format":"WEBP"}]}}},{"id":"6516bb6de71d2b64940d81b8","name":"WatchingForsen","flags":0,"timestamp":1695988793357,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6516bb6de71d2b64940d81b8","name":"WatchingForsen","flags":0,"tags":["forsen","nymn","k4yfour","psp1g","psp","steamhappy"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6516bb6de71d2b64940d81b8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":205,"size":73563,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":205,"size":220088,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":205,"size":264419,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":205,"size":512010,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":205,"size":586048,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":205,"size":859004,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":205,"size":1353808,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":205,"size":1452732,"format":"WEBP"}]}}},{"id":"650cfe4c714d175392386870","name":"moon2Spin","flags":0,"timestamp":1696005219182,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"650cfe4c714d175392386870","name":"moon2Spin","flags":0,"tags":["moonmoon","cum","spin"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b8f6b2d115b05311d90e2c","username":"thelahal","display_name":"TheLahal","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5ce15084-b131-4fda-bbf1-5449e9354712-profile_image-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/650cfe4c714d175392386870","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":5813,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":4672,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":10891,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":10098,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":16219,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":15476,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":20949,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":20540,"format":"WEBP"}]}}},{"id":"646dd022577dcd2c80ef1e75","name":"glorp","flags":0,"timestamp":1696016234867,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"646dd022577dcd2c80ef1e75","name":"glorp","flags":0,"tags":["alien","cute","cat","glorp"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61e824acf20dcd151f05eca2","username":"ltsnickiminaj","display_name":"ltsnickiminaj","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fbf5625c-7370-4023-820a-e002b171a988-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/646dd022577dcd2c80ef1e75","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1155,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1732,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1989,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5004,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2906,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9102,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3759,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":14204,"format":"WEBP"}]}}},{"id":"6420b5d22b8217416a236961","name":"plinktosis","flags":0,"timestamp":1696024005011,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6420b5d22b8217416a236961","name":"plinktosis","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6143b4a97b14fdf700b92cb5","username":"hypnocorn","display_name":"Hypnocorn","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/dd5c5a0c-5f3a-4ff3-979b-b6d03249b80e-profile_image-70x70.png","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6420b5d22b8217416a236961","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":60,"height":32,"frame_count":186,"size":83716,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":60,"height":32,"frame_count":186,"size":133788,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":120,"height":64,"frame_count":186,"size":183482,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":120,"height":64,"frame_count":186,"size":268392,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":180,"height":96,"frame_count":186,"size":314756,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":180,"height":96,"frame_count":186,"size":420886,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":240,"height":128,"frame_count":186,"size":596603,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":240,"height":128,"frame_count":186,"size":611078,"format":"WEBP"}]}}},{"id":"631b3f3c8cf0978e2955accd","name":"NymNsComputer","flags":0,"timestamp":1696091379568,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"631b3f3c8cf0978e2955accd","name":"NymNsComputer","flags":0,"tags":["nymn","computer"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/631b3f3c8cf0978e2955accd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":77,"height":32,"frame_count":1,"size":938,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":77,"height":32,"frame_count":1,"size":1622,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":154,"height":64,"frame_count":1,"size":1975,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":154,"height":64,"frame_count":1,"size":4850,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":231,"height":96,"frame_count":1,"size":3719,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":231,"height":96,"frame_count":1,"size":9340,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":308,"height":128,"frame_count":1,"size":5094,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":308,"height":128,"frame_count":1,"size":14634,"format":"WEBP"}]}}},{"id":"651af7d6461f7419e54eb6b0","name":"Jackass","flags":0,"timestamp":1696266218993,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"651af7d6461f7419e54eb6b0","name":"Jackass","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651af7d6461f7419e54eb6b0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1153,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1844,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2297,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5992,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3912,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11658,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5490,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":19438,"format":"WEBP"}]}}},{"id":"60d3255291b6751bc1a24220","name":"peepoGermany","flags":0,"timestamp":1696324593491,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"60d3255291b6751bc1a24220","name":"peepoGermany","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae8a63f39a7552b62fa966","username":"ch4mpjon","display_name":"ch4mpjon","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7818e1c4-0d4e-43de-a5d0-fef74e905976-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60d3255291b6751bc1a24220","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":4562,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":5250,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":10130,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":9542,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":14312,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":16280,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":18760,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":17544,"format":"WEBP"}]}}},{"id":"651c075f1f6d3af5585223c2","name":"MrNime","flags":0,"timestamp":1696364026904,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"651c075f1f6d3af5585223c2","name":"Nime","flags":0,"tags":["nime","erobb221","nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"629d19b03cfb54ec859b8a39","username":"23pacs","display_name":"23pacs","avatar_url":"//cdn.7tv.app/user/629d19b03cfb54ec859b8a39/av_65989c3c8c708b97a921293a/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651c075f1f6d3af5585223c2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":1,"size":2108,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":1,"size":3220,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":1,"size":4508,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":1,"size":9502,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":1,"size":7131,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":1,"size":18178,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":1,"size":9790,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":1,"size":27810,"format":"WEBP"}]}}},{"id":"61fcc40c690425de3c63d907","name":"CALCULATING","flags":0,"timestamp":1696450159901,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"61fcc40c690425de3c63d907","name":"CALCULATING","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60aeab21229664e8663345dd","username":"barricade0_","display_name":"BARRICADE0_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/948ce321-c188-4c7a-90c0-16169e190ac2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61fcc40c690425de3c63d907","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":150,"size":81314,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":150,"size":140246,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":150,"size":288548,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":150,"size":418762,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":150,"size":541661,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":150,"size":765930,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":150,"size":882844,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":150,"size":889458,"format":"WEBP"}]}}},{"id":"63c608a3f5732004e116197a","name":"Pipe","flags":0,"timestamp":1696511393728,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"63c608a3f5732004e116197a","name":"Pipe","flags":0,"tags":["steel","pipe"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61c2b4845be2d01acc98e2b6","username":"hatsooo","display_name":"Hatsooo","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c608a3f5732004e116197a","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":694,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":887,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1678,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1323,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1835,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3114,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2566,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4904,"format":"WEBP"}]}}},{"id":"63723f479ac5ff30f76fe478","name":"STOCKS","flags":0,"timestamp":1696596330384,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63723f479ac5ff30f76fe478","name":"STOCKS","flags":0,"tags":["nymn","pepew","stock"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63723f479ac5ff30f76fe478","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1185,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1688,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2289,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4974,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3466,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9116,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5116,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":14104,"format":"WEBP"}]}}},{"id":"63508ebd7e96ced99fe04e7e","name":"nymnPlayingMarioKart","flags":0,"timestamp":1696768554143,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"63508ebd7e96ced99fe04e7e","name":"Cope","flags":0,"tags":["mario","kart","mariokart","copium","coping","lastplace"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"603c7fca96832ffa788a5f14","username":"hyruverse","display_name":"hyruverse","avatar_url":"//cdn.7tv.app/pp/603c7fca96832ffa788a5f14/2ed3bd237882444ebccf38ae918e8df6","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63508ebd7e96ced99fe04e7e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":190,"size":59907,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":190,"size":76284,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":190,"size":184890,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":190,"size":186536,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":190,"size":379006,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":190,"size":312498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":190,"size":1171696,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":190,"size":624222,"format":"WEBP"}]}}},{"id":"63c079ff3977df67e32d0c56","name":"RightThere","flags":0,"timestamp":1696852857204,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c079ff3977df67e32d0c56","name":"RightThere","flags":0,"tags":["dicaprio","pointing","movienight","movie","artifact","meme"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c079ff3977df67e32d0c56","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":56,"size":23600,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":56,"size":44950,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":56,"size":52751,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":56,"size":89464,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":56,"size":85565,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":56,"size":132578,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":56,"size":121361,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":56,"size":174096,"format":"WEBP"}]}}},{"id":"60bb6cfe5575fda21c9fef57","name":"SirO","flags":0,"timestamp":1696852861329,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60bb6cfe5575fda21c9fef57","name":"SirO","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60afc6b060e24df01aa39207","username":"raydynn","display_name":"Raydynn","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1c863607-c5f8-4a28-a558-174454984cbf-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bb6cfe5575fda21c9fef57","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1119,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":864,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1903,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1884,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2666,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3008,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3374,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3110,"format":"WEBP"}]}}},{"id":"611a4f329fa9a9dd99b69750","name":"Susge","flags":0,"timestamp":1696852866555,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"611a4f329fa9a9dd99b69750","name":"Susge","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60c73dab77f997672644ac33","username":"mahan_5000","display_name":"MAHAN_5000","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bab41002-ed60-4227-9023-acfb71793143-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/611a4f329fa9a9dd99b69750","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1278,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":914,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2346,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2425,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3806,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4226,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5260,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6268,"format":"WEBP"}]}}},{"id":"652571c5abd4cd85c81e38e6","name":"Glime","flags":0,"timestamp":1696952854553,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"652571c5abd4cd85c81e38e6","name":"Glorpnime","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/652571c5abd4cd85c81e38e6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1326,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1726,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2610,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5252,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4142,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10080,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6475,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15952,"format":"WEBP"}]}}},{"id":"618f440d17e4d50afc0d414f","name":"doctorLeMonkePls","flags":0,"timestamp":1696954637288,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"618f440d17e4d50afc0d414f","name":"monkePls","flags":0,"tags":["monke"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ee19b6242437446f4a501b","username":"lubilive","display_name":"LubiLIVE","avatar_url":"//cdn.7tv.app/user/60ee19b6242437446f4a501b/av_64cad795ee451bd1791e78ca/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/618f440d17e4d50afc0d414f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":87,"size":42041,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":87,"size":67640,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":87,"size":87061,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":87,"size":148328,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":87,"size":143291,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":87,"size":247824,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":87,"size":199643,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":87,"size":307760,"format":"WEBP"}]}}},{"id":"636ac4fc6170e43a8c65ab9a","name":"Loota","flags":0,"timestamp":1697140538178,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"636ac4fc6170e43a8c65ab9a","name":"Loota","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60c09f2478e83358fa8a83af","username":"eclipsy113","display_name":"eclipsy113","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/cf1ef411-5dd0-4738-8039-71842facca93-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/636ac4fc6170e43a8c65ab9a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1370,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1758,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2529,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4982,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3715,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9110,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4558,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13828,"format":"WEBP"}]}}},{"id":"652740549ffed7dc1855d4d7","name":"Humping","flags":0,"timestamp":1697210594774,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"652740549ffed7dc1855d4d7","name":"EmotiHump","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61d0b944c29ed2aed7544871","username":"deividmartini","display_name":"DeividMartini","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ff790138-083a-49c3-8456-52562077e823-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/652740549ffed7dc1855d4d7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":21,"size":10731,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":21,"size":18526,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":21,"size":20001,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":21,"size":38584,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":21,"size":30349,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":21,"size":59826,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":21,"size":41210,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":21,"size":81246,"format":"WEBP"}]}}},{"id":"61e625083441abfa431d1989","name":"Clown","flags":1,"timestamp":1697215702058,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61e625083441abfa431d1989","name":"Clown","flags":256,"tags":["clown","wig","honk","costume","rainbow","zerowidth"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60f39e8bc07d1ac193652def","username":"shmovy","display_name":"Shmovy","avatar_url":"//cdn.7tv.app/user/60f39e8bc07d1ac193652def/av_63a4e84f5c2aba9b3b60bf46/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e625083441abfa431d1989","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1232,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":800,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1917,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1710,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2809,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2752,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3580,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3896,"format":"WEBP"}]}}},{"id":"63ab5fac4e8dd89273fe9b61","name":"DRAMA","flags":0,"timestamp":1697216415814,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ab5fac4e8dd89273fe9b61","name":"DRAMA","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ab5fac4e8dd89273fe9b61","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":65,"size":19409,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":65,"size":36140,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":65,"size":40861,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":65,"size":68820,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":65,"size":68383,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":65,"size":99308,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":65,"size":101183,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":65,"size":131678,"format":"WEBP"}]}}},{"id":"60af9e0c52a13d1adb77dc98","name":"pepeRun","flags":0,"timestamp":1697385307327,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60af9e0c52a13d1adb77dc98","name":"pepeRun","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af9e0c52a13d1adb77dc98","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":17,"size":9236,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":17,"size":15108,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":17,"size":18212,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":17,"size":34190,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":17,"size":29524,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":17,"size":57016,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":17,"size":44004,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":17,"size":67170,"format":"WEBP"}]}}},{"id":"60b0d99b8fb21a01bc62782d","name":"1G","flags":0,"timestamp":1697385313026,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60b0d99b8fb21a01bc62782d","name":"Bald1G","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aeb373955615deef63b6ac","username":"kaserioxx","display_name":"kaserioxx","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b0d99b8fb21a01bc62782d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":42,"height":32,"frame_count":1,"size":1150,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":42,"height":32,"frame_count":1,"size":926,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":84,"height":64,"frame_count":1,"size":2049,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":84,"height":64,"frame_count":1,"size":2202,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":126,"height":96,"frame_count":1,"size":2882,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":126,"height":96,"frame_count":1,"size":3418,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":168,"height":128,"frame_count":1,"size":3863,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":168,"height":128,"frame_count":1,"size":3268,"format":"WEBP"}]}}},{"id":"652410135795d60c78b805ca","name":"ItsglorpingTime","flags":0,"timestamp":1697387325472,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"652410135795d60c78b805ca","name":"ItsglorpingTime","flags":0,"tags":["glorp","alien","cute","cat"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6509a09358b80a4d09f012c7","username":"chewbriel","display_name":"CHEWBRIEL","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/652410135795d60c78b805ca","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":39,"size":18120,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":39,"size":34660,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":39,"size":48400,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":39,"size":86634,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":39,"size":82665,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":39,"size":145074,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":39,"size":112925,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":39,"size":202738,"format":"WEBP"}]}}},{"id":"62f23f0f5591c76fde22c341","name":"peepoPoland","flags":0,"timestamp":1697400938138,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"62f23f0f5591c76fde22c341","name":"peepoPoland","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"610399d141ab14baee7e2c55","username":"nugiar","display_name":"nugIar","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7c768ccc-9284-4f70-8323-967f2960c81c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62f23f0f5591c76fde22c341","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":5197,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":4918,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":9693,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":11072,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":14703,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":17480,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":19878,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":24064,"format":"WEBP"}]}}},{"id":"614615a57b14fdf700b96dca","name":"guzunya","flags":0,"timestamp":1697553214769,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"614615a57b14fdf700b96dca","name":"guzunya","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6118ea8ab3484289bace553b","username":"alakablam","display_name":"Alakablam","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/241bba47-c8e7-4712-910c-c3c513b7a56f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614615a57b14fdf700b96dca","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":71,"size":18323,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":71,"size":60626,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":71,"size":47373,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":71,"size":141188,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":71,"size":91466,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":71,"size":228252,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":71,"size":154372,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":71,"size":283304,"format":"WEBP"}]}}},{"id":"65289a280282011b87739a14","name":"bench0","flags":1,"timestamp":1697630264021,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"65289a280282011b87739a14","name":"lift0","flags":256,"tags":["billy","gachi"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b030c1b3e1671e27940d52","username":"mellen","display_name":"mellen","avatar_url":"//cdn.7tv.app/user/60b030c1b3e1671e27940d52/av_64f429b5592e30195214ca0b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65289a280282011b87739a14","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":10,"size":9174,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":10,"size":8820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":10,"size":15554,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":10,"size":13554,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":10,"size":23402,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":10,"size":20518,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":10,"size":25835,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":10,"size":23192,"format":"WEBP"}]}}},{"id":"60f0544991085fa44058758f","name":"vegan","flags":0,"timestamp":1697643281915,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"60f0544991085fa44058758f","name":"vegan","flags":0,"lifecycle":3,"state":["NO_PERSONAL"],"listed":false,"animated":true,"owner":{"id":"60ae653c9627f9aff4f5ccd1","username":"xoo_6119","display_name":"xoo_6119","avatar_url":"//cdn.7tv.app/user/60ae653c9627f9aff4f5ccd1/av_63ca0eccdedb49b24383ae5c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f0544991085fa44058758f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":90,"size":27001,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":90,"size":86936,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":90,"size":63886,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":90,"size":170068,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":90,"size":101430,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":90,"size":270654,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":90,"size":158681,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":90,"size":295472,"format":"WEBP"}]}}},{"id":"62e94cec80419c32e6821ea1","name":"MONKE","flags":0,"timestamp":1697646157607,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"62e94cec80419c32e6821ea1","name":"MONKE","flags":0,"tags":["monkey","ape","mods"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3dd8aee2aa55382a4b5b","username":"benastro","display_name":"benASTRO","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f8cf25de-bbb0-4e7d-9ca5-14e9a15fbd56-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62e94cec80419c32e6821ea1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":158,"size":61272,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":158,"size":126292,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":158,"size":127304,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":158,"size":250206,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":158,"size":204453,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":158,"size":397584,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":158,"size":271736,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":158,"size":427868,"format":"WEBP"}]}}},{"id":"64fa14d59524349b3b8b0aac","name":"CitiesSkylines","flags":0,"timestamp":1697730879172,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64fa14d59524349b3b8b0aac","name":"CitiesSkylines","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61f18ad3f933d586cddaa141","username":"hikoeu","display_name":"HikoEU","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/151bce6c-d165-48fb-9dc4-fbb6c4e1296a-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64fa14d59524349b3b8b0aac","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":3212,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1856,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":5476,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":3956,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":8202,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":6272,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":11337,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":8380,"format":"WEBP"}]}}},{"id":"6420b3f9fdd6b1a122191cbf","name":"tayPls","flags":0,"timestamp":1697743229078,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6420b3f9fdd6b1a122191cbf","name":"tayPls","flags":0,"tags":["taylor","swift"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6420b3f9fdd6b1a122191cbf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":28,"height":32,"frame_count":345,"size":155661,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":28,"height":32,"frame_count":345,"size":240998,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":56,"height":64,"frame_count":345,"size":391084,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":56,"height":64,"frame_count":345,"size":524968,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":84,"height":96,"frame_count":345,"size":715622,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":84,"height":96,"frame_count":345,"size":826410,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":112,"height":128,"frame_count":345,"size":1084178,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":112,"height":128,"frame_count":345,"size":1134476,"format":"WEBP"}]}}},{"id":"64fb2926af8646950b5865d9","name":"nymnLooking","flags":1,"timestamp":1697743282283,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"64fb2926af8646950b5865d9","name":"nymnLooking","flags":256,"tags":["looking","nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"63fb9249a27fda24e806d1cc","username":"abithappy","display_name":"abithappy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f7a46513-21a8-46ff-8ef2-d388dc069e8c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64fb2926af8646950b5865d9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":60,"height":32,"frame_count":1,"size":987,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":60,"height":32,"frame_count":1,"size":1122,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":120,"height":64,"frame_count":1,"size":1552,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":120,"height":64,"frame_count":1,"size":3006,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":180,"height":96,"frame_count":1,"size":2402,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":180,"height":96,"frame_count":1,"size":5590,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":240,"height":128,"frame_count":1,"size":3140,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":240,"height":128,"frame_count":1,"size":8102,"format":"WEBP"}]}}},{"id":"62f8c9d9b3c3b0ac703cd1b0","name":"Princess","flags":0,"timestamp":1697806601066,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62f8c9d9b3c3b0ac703cd1b0","name":"Princess","flags":0,"tags":["pitbull","pet","dog","goodboy","puppy","hedoesntbiteiswear"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60f39e8bc07d1ac193652def","username":"shmovy","display_name":"Shmovy","avatar_url":"//cdn.7tv.app/user/60f39e8bc07d1ac193652def/av_63a4e84f5c2aba9b3b60bf46/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62f8c9d9b3c3b0ac703cd1b0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":1,"size":1590,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":1,"size":2230,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":1,"size":3451,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":1,"size":6776,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":1,"size":5813,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":1,"size":13218,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":1,"size":8208,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":1,"size":20636,"format":"WEBP"}]}}},{"id":"60ae681a117ec68ca4fc95c2","name":"YOURMOM","flags":0,"timestamp":1697820947825,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60ae681a117ec68ca4fc95c2","name":"YOURMOM","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae57730e354776340c504f","username":"ronic76","display_name":"Ronic76","avatar_url":"//cdn.7tv.app/pp/60ae57730e354776340c504f/d8774f25d7cd49c18763691a00f8eaf6","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60ae681a117ec68ca4fc95c2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":21,"size":6505,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":21,"size":10724,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":21,"size":10944,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":21,"size":19390,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":21,"size":16944,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":21,"size":29332,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":21,"size":21314,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":21,"size":31352,"format":"WEBP"}]}}},{"id":"6532ba3e24551a57d9a0b060","name":"1528","flags":0,"timestamp":1697823410349,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6532ba3e24551a57d9a0b060","name":"1528","flags":0,"tags":["speedrun","godgamer","forsensmug","forsen","minecraft"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3cb1b2ecb0150521fa1f","username":"waterboiledpizza","display_name":"WaterBoiledPizza","avatar_url":"//cdn.7tv.app/user/60ae3cb1b2ecb0150521fa1f/av_652806843e9323c51e05082e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6532ba3e24551a57d9a0b060","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":300,"size":166782,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":300,"size":169778,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":300,"size":448829,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":300,"size":429256,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":300,"size":783936,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":300,"size":720706,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":300,"size":1154149,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":300,"size":1043016,"format":"WEBP"}]}}},{"id":"650b591d714d1753923814f2","name":"docOOOO","flags":0,"timestamp":1697823934616,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"650b591d714d1753923814f2","name":"OOOO","flags":0,"tags":["his","face","off","pagging"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"61ce216cf644a864b441c7fb","username":"fistymart","display_name":"FistyMart","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fistymart-profile_image-63bb6503cd5238a7-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/650b591d714d1753923814f2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":296,"size":147778,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":296,"size":258184,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":296,"size":395421,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":296,"size":543636,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":296,"size":635360,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":296,"size":896030,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":296,"size":1228060,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":296,"size":1305730,"format":"WEBP"}]}}},{"id":"648dc5af0ee2f9ddd76306da","name":"cutecatpet","flags":0,"timestamp":1697983493587,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"648dc5af0ee2f9ddd76306da","name":"cutecatpet","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"614cb75a6251d7e000da4ce7","username":"eljugay","display_name":"eljuGay","avatar_url":"//cdn.7tv.app/user/614cb75a6251d7e000da4ce7/av_648f957bb3fdb6379f1e9b9b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/648dc5af0ee2f9ddd76306da","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":96,"size":31938,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":96,"size":50916,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":96,"size":78592,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":96,"size":129006,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":96,"size":119224,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":96,"size":204822,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":96,"size":180847,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":96,"size":329308,"format":"WEBP"}]}}},{"id":"623a1fef51efa34ad8512bd5","name":"PotPls","flags":0,"timestamp":1698065151299,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"623a1fef51efa34ad8512bd5","name":"PotPls","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"623a1fab77e5ecc084903bf7","username":"ductile_goose","display_name":"ductile_goose","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/623a1fef51efa34ad8512bd5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":8347,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":10552,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":16362,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":25586,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":27563,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":45446,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":38994,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":57232,"format":"WEBP"}]}}},{"id":"65368bce45b19ffda80fcc2d","name":"mondayChat","flags":0,"timestamp":1698073578192,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"65368bce45b19ffda80fcc2d","name":"mondayChat","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65368bce45b19ffda80fcc2d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":10,"frame_count":1,"size":1047,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":10,"frame_count":1,"size":1090,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":20,"frame_count":1,"size":2952,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":20,"frame_count":1,"size":2727,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":30,"frame_count":1,"size":5922,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":30,"frame_count":1,"size":5129,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":40,"frame_count":1,"size":8104,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":40,"frame_count":1,"size":6830,"format":"WEBP"}]}}},{"id":"626bac5655df243a4fa819cd","name":"parasocial","flags":0,"timestamp":1698349550070,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"626bac5655df243a4fa819cd","name":"parasocial","flags":0,"tags":["nerd"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/626bac5655df243a4fa819cd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1416,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1022,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2366,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2294,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3114,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3070,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3888,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4158,"format":"WEBP"}]}}},{"id":"63c615ccfc866ebbc80ab61a","name":"soEepy","flags":0,"timestamp":1698393579296,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63c615ccfc866ebbc80ab61a","name":"soEepy","flags":0,"tags":["eepy","sleepy","tired","asleep","soeepy"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"612a00433bf9791535a8f79c","username":"d_uc","display_name":"d_uc","avatar_url":"//cdn.7tv.app/user/612a00433bf9791535a8f79c/av_638d80c743e5e1ae07e695b1/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c615ccfc866ebbc80ab61a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":152,"size":52239,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":151,"size":69134,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":152,"size":118356,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":152,"size":169822,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":152,"size":188755,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":152,"size":278456,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":152,"size":260018,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":152,"size":392058,"format":"WEBP"}]}}},{"id":"652173aa35c8eec3eb42ac0d","name":"Despair","flags":0,"timestamp":1698393647885,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"652173aa35c8eec3eb42ac0d","name":"Despair","flags":0,"tags":["apollo","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"604137ab96832ffa784e1164","username":"bacond_","display_name":"Bacond_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e800e132-9d8f-4188-856a-ae17a061f0c3-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/652173aa35c8eec3eb42ac0d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":1,"size":915,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":1,"size":1690,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":1,"size":1557,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":1,"size":5020,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":1,"size":2324,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":1,"size":9806,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":1,"size":3036,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":1,"size":15356,"format":"WEBP"}]}}},{"id":"60bb1989778afa8ce9828378","name":"CatNotLikeThisMeow","flags":0,"timestamp":1698507733994,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60bb1989778afa8ce9828378","name":"CatNotLikeThisMeow","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b04e1b3cadd71dff96af6e","username":"dekuu__","display_name":"Dekuu__","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f2c40c31-9d68-481c-bc1c-6f19796e552f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bb1989778afa8ce9828378","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":60,"size":22373,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":60,"size":39434,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":60,"size":54325,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":60,"size":88666,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":60,"size":86193,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":60,"size":145714,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":60,"size":115446,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":60,"size":96486,"format":"WEBP"}]}}},{"id":"651d5c78e5e8e598e85f8e5c","name":"buhKisser","flags":0,"timestamp":1698511454794,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"651d5c78e5e8e598e85f8e5c","name":"buhKisser","flags":0,"tags":["boykisser","buh"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"613cb52b3f2fa247f2ed46ae","username":"pigswitched","display_name":"pigswitched","avatar_url":"//cdn.7tv.app/user/613cb52b3f2fa247f2ed46ae/av_65035387c9920b6284155ec6/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651d5c78e5e8e598e85f8e5c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1240,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1824,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2241,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5196,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3353,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9942,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4337,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15234,"format":"WEBP"}]}}},{"id":"653e2eacee0d08affebcb55d","name":"soyKisser","flags":0,"timestamp":1698582376857,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"653e2eacee0d08affebcb55d","name":"soyKisser","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/653e2eacee0d08affebcb55d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1533,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1936,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3203,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5796,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5331,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11440,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8353,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18738,"format":"WEBP"}]}}},{"id":"63ae2a9041eeaa66119a2ccd","name":"Erm","flags":0,"timestamp":1698583497701,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63ae2a9041eeaa66119a2ccd","name":"Erm","flags":0,"tags":["erm","caterm","cat","cute","uhm","catstare"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"614cb75a6251d7e000da4ce7","username":"eljugay","display_name":"eljuGay","avatar_url":"//cdn.7tv.app/user/614cb75a6251d7e000da4ce7/av_648f957bb3fdb6379f1e9b9b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ae2a9041eeaa66119a2ccd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":51,"size":13432,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":51,"size":35064,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":51,"size":26673,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":51,"size":70654,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":51,"size":44954,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":51,"size":109382,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":51,"size":66785,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":51,"size":149410,"format":"WEBP"}]}}},{"id":"62925d6be81cfdc30c905df1","name":"WideSnow","flags":1,"timestamp":1698670033481,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"62925d6be81cfdc30c905df1","name":"WideSnow","flags":256,"tags":["snowtime","sosnowy","snow","widesnowtime","wide"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60f5e290e57bec021618c4a4","username":"ansonx10","display_name":"AnsonX10","avatar_url":"//cdn.7tv.app/user/60f5e290e57bec021618c4a4/av_63617cc39018da6429bc0298/3x_static.webp","style":{"color":401323775},"roles":["60b3f1ea886e63449c5263b1","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62925d6be81cfdc30c905df1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":250,"size":404858,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":250,"size":723288,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":250,"size":925168,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":250,"size":1640538,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":250,"size":1640095,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":250,"size":2933596,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":250,"size":1602881,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":250,"size":1699058,"format":"WEBP"}]}}},{"id":"60f4b43cc07d1ac1937d06aa","name":"SoSnowy","flags":1,"timestamp":1698670043610,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60f4b43cc07d1ac1937d06aa","name":"SoSnowy","flags":256,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b175f74faf982ecac92159","username":"isaiahdasparkler","display_name":"isaiahdasparkler","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/61feff5b-d4bd-4882-b5d4-bf760a1f6025-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f4b43cc07d1ac1937d06aa","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":20,"size":8565,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":20,"size":7138,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":20,"size":13785,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":20,"size":16106,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":20,"size":24379,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":20,"size":35676,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":20,"size":27685,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":20,"size":38310,"format":"WEBP"}]}}},{"id":"64dca583b7ce014343af3f22","name":"needmoreboulets","flags":0,"timestamp":1698765923760,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"64dca583b7ce014343af3f22","name":"needmoreboulets","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"620eb134e7b1f24a7a9a3b57","username":"heinz1g","display_name":"heinz1g","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/0fde30af-5f2d-405d-9e0b-c294c124e126-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64dca583b7ce014343af3f22","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":113,"size":125254,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":113,"size":152700,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":113,"size":336013,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":113,"size":435622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":113,"size":579468,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":113,"size":775346,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":113,"size":842941,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":113,"size":1129118,"format":"WEBP"}]}}},{"id":"6536ca6545b19ffda80fd6e2","name":"HastalavistaBaby","flags":0,"timestamp":1698766082898,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6536ca6545b19ffda80fd6e2","name":"HastalavistaBaby","flags":0,"tags":["seeyouinthenextbattlefield","boolets","isrhaul","hastalavista","dontneedmorebullets","thanksforallthebulletssir"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61b317b26888fd316ae492e0","username":"sunowi","display_name":"Sunowi","avatar_url":"//cdn.7tv.app/user/61b317b26888fd316ae492e0/av_6593722a64bf757bb2166bdf/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6536ca6545b19ffda80fd6e2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":240,"size":89414,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":240,"size":145026,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":240,"size":215936,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":240,"size":339942,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":240,"size":414100,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":240,"size":549964,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":240,"size":699920,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":240,"size":777108,"format":"WEBP"}]}}},{"id":"6541281823429708afa44d40","name":"buhFriend","flags":0,"timestamp":1698769281395,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6541281823429708afa44d40","name":"buhFriend","flags":0,"tags":["buh","cat","guh","potfriend"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6541281823429708afa44d40","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1440,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1998,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2617,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5884,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3716,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10910,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4609,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16720,"format":"WEBP"}]}}},{"id":"6431d3b16fba94182ee1ae42","name":"NAILSING","flags":0,"timestamp":1698828129995,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6431d3b16fba94182ee1ae42","name":"NAILSING","flags":0,"tags":["monka","shake","youtube","shock","tears"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60b0faaa8fb21a01bc3c0385","username":"enzo_supercraftz","display_name":"Enzo_SuperCraftZ","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6431d3b16fba94182ee1ae42","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":25,"height":32,"frame_count":32,"size":16750,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":25,"height":32,"frame_count":32,"size":23130,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":50,"height":64,"frame_count":32,"size":30341,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":50,"height":64,"frame_count":32,"size":44888,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":75,"height":96,"frame_count":32,"size":38025,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":75,"height":96,"frame_count":32,"size":63790,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":100,"height":128,"frame_count":32,"size":43523,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":100,"height":128,"frame_count":32,"size":82348,"format":"WEBP"}]}}},{"id":"640cf0feb2a921bacda40f57","name":"TolatosPleasePayYourChildSupport","flags":0,"timestamp":1698828291531,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"640cf0feb2a921bacda40f57","name":"PoroFamily","flags":0,"tags":["poropregnant","preggy","poro","porosad","family","pregnant"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae759bdf5735e04acb69d9","username":"hotbear1110","display_name":"HotBear1110","avatar_url":"//cdn.7tv.app/pp/60ae759bdf5735e04acb69d9/80e2b49378c14dc6914fde8cb72fa673","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/640cf0feb2a921bacda40f57","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":1,"size":1733,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":1,"size":2526,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":1,"size":3842,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":1,"size":7420,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":1,"size":6235,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":1,"size":14226,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":1,"size":8915,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":1,"size":22550,"format":"WEBP"}]}}},{"id":"61afbf6dffa9aba101bd4b4e","name":"Backous","flags":0,"timestamp":1698828311827,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61afbf6dffa9aba101bd4b4e","name":"peepoCumsOnBackous","flags":0,"tags":["backous","peepo"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60b0ae0a007f7e8b0e674a70","username":"dagaugi","display_name":"DaGaugI","avatar_url":"//cdn.7tv.app/pp/60b0ae0a007f7e8b0e674a70/bb5d50a0c2d54f7ba7ba9c81b40de191","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61afbf6dffa9aba101bd4b4e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":23,"frame_count":1,"size":1855,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":23,"frame_count":1,"size":1678,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":46,"frame_count":1,"size":3763,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":46,"frame_count":1,"size":4076,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":69,"frame_count":1,"size":7036,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":69,"frame_count":1,"size":5753,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":92,"frame_count":1,"size":10328,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":92,"frame_count":1,"size":7764,"format":"AVIF"}]}}},{"id":"647ba63fd4b5d6083e91db76","name":"PLAAAY","flags":0,"timestamp":1698834569375,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"647ba63fd4b5d6083e91db76","name":"PLAAAY","flags":0,"tags":["rat","play","bbvibe","lulebb","peppah","forsen"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/647ba63fd4b5d6083e91db76","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1323,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2228,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2748,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6882,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4075,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":12814,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5730,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":21248,"format":"WEBP"}]}}},{"id":"63664a29babd459d5c2bbd56","name":"peepoWinter","flags":0,"timestamp":1698858254437,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63664a29babd459d5c2bbd56","name":"peepoWinter","flags":0,"tags":["peepo","xmas","winter","christmas","snow"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63664a29babd459d5c2bbd56","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":48,"size":14449,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":48,"size":35226,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":48,"size":50135,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":48,"size":103164,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":48,"size":99165,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":48,"size":178552,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":48,"size":177787,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":48,"size":257134,"format":"WEBP"}]}}},{"id":"653f4440ee0d08affebce574","name":"RUNNN","flags":0,"timestamp":1698871292926,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"653f4440ee0d08affebce574","name":"RUNNpolentero","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"613ea0837b14fdf700b8aec2","username":"lukqbt","display_name":"lukqbt","avatar_url":"//cdn.7tv.app/user/613ea0837b14fdf700b8aec2/av_651a2d651ad886eb9efe010b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/653f4440ee0d08affebce574","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":11,"size":7436,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":11,"size":5452,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":11,"size":10224,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":11,"size":14842,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":11,"size":24417,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":11,"size":17288,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":11,"size":29698,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":11,"size":19936,"format":"WEBP"}]}}},{"id":"611cb0c5f20f644c3fadb992","name":"HYPERYump","flags":0,"timestamp":1698920194260,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"611cb0c5f20f644c3fadb992","name":"HYPERYump","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60afab3e99923bbe7f7f62bc","username":"mvrkzs","display_name":"MvrkZS","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/611cb0c5f20f644c3fadb992","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":10,"size":5987,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":10,"size":9634,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":10,"size":12661,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":10,"size":22664,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":10,"size":20316,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":10,"size":38022,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":10,"size":30450,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":10,"size":45064,"format":"WEBP"}]}}},{"id":"613c7d9a2d7724a96175c268","name":"peepoTalkbutpeepoisnottalking","flags":0,"timestamp":1698928419153,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"613c7d9a2d7724a96175c268","name":"peepoTalkbutpeepoisnottalking","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aef51812d770149183f968","username":"caz1_","display_name":"Caz1_","avatar_url":"//cdn.7tv.app/pp/60aef51812d770149183f968/1c921aa97b5042ffa2b1bb73287b1337","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613c7d9a2d7724a96175c268","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1160,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":870,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1971,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2014,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3067,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3434,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4292,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5338,"format":"WEBP"}]}}},{"id":"6543f2484c11f20d6c9b38d3","name":"nymnPotFaint","flags":0,"timestamp":1698951820100,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"6543f2484c11f20d6c9b38d3","name":"nymnFaint","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6543f2484c11f20d6c9b38d3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":39,"size":15228,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":39,"size":25678,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":39,"size":31561,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":39,"size":53938,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":39,"size":50971,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":39,"size":83044,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":39,"size":72495,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":39,"size":113134,"format":"WEBP"}]}}},{"id":"622f189214f489808df699e6","name":"PoroDisco","flags":0,"timestamp":1699099495505,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"622f189214f489808df699e6","name":"PoroDisco","flags":0,"tags":["poro","porosad","poroshuffle","porodisco"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61e126106e676399a0ff97ce","username":"newsmaxintern","display_name":"NewsmaxIntern","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ebf8b450-cf6e-42d2-9f08-fb0c643743ce-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/622f189214f489808df699e6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":26,"size":25619,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":26,"size":28252,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":26,"size":58265,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":26,"size":68890,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":26,"size":95329,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":26,"size":121044,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":26,"size":133783,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":26,"size":153290,"format":"WEBP"}]}}},{"id":"64804b36cde3496c398621f0","name":"PoroEZ","flags":0,"timestamp":1699099582386,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64804b36cde3496c398621f0","name":"PoroEZ","flags":0,"tags":["poro"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"626404f9a456cdaf745f9d3b","username":"qulibyash","display_name":"qulibyash","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/qulibyash-profile_image-1ffd26dd101e419f-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64804b36cde3496c398621f0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1383,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1934,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2898,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5850,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4467,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11458,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5964,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":17030,"format":"WEBP"}]}}},{"id":"64d72da602c01a29ab954974","name":"EULKEKUNJEF","flags":0,"timestamp":1699099961091,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64d72da602c01a29ab954974","name":"EULKEKUNJEF","flags":0,"tags":["forsen","lule","kekw"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"64cef037d6294fb8a544e5d6","username":"constantoscillations","display_name":"ConstantOscillations","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/169554ad-7662-4475-8b08-5f92c42d765e-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64d72da602c01a29ab954974","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1129,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2016,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2110,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6054,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3099,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11652,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4591,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18648,"format":"WEBP"}]}}},{"id":"651b31a2941bcb5a83193f25","name":"PerfectSolo","flags":0,"timestamp":1699195045940,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"651b31a2941bcb5a83193f25","name":"PerfectSolo","flags":0,"tags":["rockband","perfect","solo","clonehero","guitarhero"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62379be393f0c6c9106eb2a1","username":"krazygh","display_name":"KrazyGH","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6409e95e-93b0-4c1b-b87e-846ea859722c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651b31a2941bcb5a83193f25","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":59,"height":32,"frame_count":1,"size":1038,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":59,"height":32,"frame_count":1,"size":824,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":118,"height":64,"frame_count":1,"size":2790,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":118,"height":64,"frame_count":1,"size":1935,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":177,"height":96,"frame_count":1,"size":5768,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":177,"height":96,"frame_count":1,"size":3314,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":236,"height":128,"frame_count":1,"size":4567,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":236,"height":128,"frame_count":1,"size":7164,"format":"WEBP"}]}}},{"id":"651b3094e5e8e598e85eca28","name":"AwesomeChoke","flags":0,"timestamp":1699195093271,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"651b3094e5e8e598e85eca28","name":"AwesomeChoke","flags":0,"tags":["choke","guitarhero","rockband","clonehero"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62379be393f0c6c9106eb2a1","username":"krazygh","display_name":"KrazyGH","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/6409e95e-93b0-4c1b-b87e-846ea859722c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651b3094e5e8e598e85eca28","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":60,"height":32,"frame_count":1,"size":1131,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":60,"height":32,"frame_count":1,"size":1068,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":120,"height":64,"frame_count":1,"size":2305,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":120,"height":64,"frame_count":1,"size":3644,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":180,"height":96,"frame_count":1,"size":4027,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":180,"height":96,"frame_count":1,"size":6280,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":240,"height":128,"frame_count":1,"size":5633,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":240,"height":128,"frame_count":1,"size":8360,"format":"WEBP"}]}}},{"id":"6547a97fd4c66f065550eb2c","name":"0Points","flags":0,"timestamp":1699195270426,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"6547a97fd4c66f065550eb2c","name":"0Points","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae518c0e35477634c151f1","username":"fabulouspotato69","display_name":"FabulousPotato69","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6547a97fd4c66f065550eb2c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":61,"height":32,"frame_count":1,"size":940,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":61,"height":32,"frame_count":1,"size":564,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":122,"height":64,"frame_count":1,"size":1590,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":122,"height":64,"frame_count":1,"size":1539,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":183,"height":96,"frame_count":1,"size":2890,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":183,"height":96,"frame_count":1,"size":2115,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":244,"height":128,"frame_count":1,"size":2763,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":244,"height":128,"frame_count":1,"size":4850,"format":"WEBP"}]}}},{"id":"632783ffb2e31c945a54434c","name":"docGuitar","flags":0,"timestamp":1699195886891,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"632783ffb2e31c945a54434c","name":"docGuitar","flags":0,"tags":["guitar","this","doc","drdisrespect"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"629b922fa20ff2b602b2f581","username":"arhamsaa","display_name":"ArhamSAA","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/44ce7a12-db10-4316-a54d-d083ed9db6f0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/632783ffb2e31c945a54434c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":60,"height":32,"frame_count":23,"size":15283,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":60,"height":32,"frame_count":23,"size":25006,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":120,"height":64,"frame_count":23,"size":49866,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":120,"height":64,"frame_count":23,"size":48356,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":180,"height":96,"frame_count":23,"size":87670,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":180,"height":96,"frame_count":23,"size":79376,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":240,"height":128,"frame_count":23,"size":215290,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":240,"height":128,"frame_count":23,"size":117444,"format":"WEBP"}]}}},{"id":"6548045ccf586d12ce2e7ae1","name":"PEEET","flags":0,"timestamp":1699281066144,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6548045ccf586d12ce2e7ae1","name":"APLOPLO","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6548045ccf586d12ce2e7ae1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":980,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1684,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1722,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5250,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2460,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9956,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3249,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":17106,"format":"WEBP"}]}}},{"id":"6548fceb6c3a42995caae6c4","name":"OMGAREYOUAREDDITORANDADISCORDMOD","flags":0,"timestamp":1699282200259,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6548fceb6c3a42995caae6c4","name":"OMGAREYOUAREDDITORANDADISCORDMOD","flags":0,"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6548fceb6c3a42995caae6c4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":235,"size":50975,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":235,"size":138918,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":235,"size":148886,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":235,"size":309856,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":235,"size":296712,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":235,"size":498308,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":235,"size":467091,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":235,"size":670366,"format":"WEBP"}]}}},{"id":"654a596eb8a5c515ec1c93a9","name":"Focused","flags":0,"timestamp":1699371858791,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"654a596eb8a5c515ec1c93a9","name":"Focused","flags":0,"tags":["nymn","balding","hector"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"623bb7e3bc7636f2937da399","username":"papacristobal","display_name":"PapaCristobal","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/20dda529-d361-48aa-a593-d56d6c93dd22-profile_image-70x70.jpg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/654a596eb8a5c515ec1c93a9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":143,"size":14850,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":135,"size":39528,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":143,"size":27487,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":143,"size":149392,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":143,"size":51494,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":143,"size":262574,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":143,"size":90479,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":143,"size":423056,"format":"WEBP"}]}}},{"id":"654a5b6feec810124e2dc851","name":"nymnBang","flags":0,"timestamp":1699373871856,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"654a5b6feec810124e2dc851","name":"nymnBang","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/654a5b6feec810124e2dc851","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":15,"size":9112,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":15,"size":8908,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":15,"size":17750,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":15,"size":17518,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":15,"size":28142,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":15,"size":25910,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":15,"size":36648,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":15,"size":34158,"format":"WEBP"}]}}},{"id":"654cc5ef065a20194ab18dd3","name":"InstaPotFriend","flags":0,"timestamp":1699530384706,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"654cc5ef065a20194ab18dd3","name":"InstaPotFriend","flags":0,"tags":["potfriend","instapot"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6220fc75b825598c205c9b50","username":"okense","display_name":"Okense","avatar_url":"//cdn.7tv.app/pp/6220fc75b825598c205c9b50/468b9e5fef53426fb99076438df891fa","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/654cc5ef065a20194ab18dd3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1461,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1828,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2888,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5074,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4585,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9726,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6582,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15138,"format":"WEBP"}]}}},{"id":"654cdf283071760559efb03d","name":"ApolFriend","flags":0,"timestamp":1699536801984,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"654cdf283071760559efb03d","name":"ApolFriend","flags":0,"tags":["nymn","cat","pot","aploplo","apollo"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6131d9de492022af58394453","username":"jerrythedoctor","display_name":"JerryTheDoctor","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a4a6f511-4bc7-466b-a73d-f9dc242bdef9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/654cdf283071760559efb03d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1657,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2096,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3328,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":6098,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5117,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11544,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6557,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":17902,"format":"WEBP"}]}}},{"id":"62b9f4b4cb8e6bebae27d963","name":"ForsenSingingAtYou","flags":0,"timestamp":1699628488593,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"62b9f4b4cb8e6bebae27d963","name":"ForsenSingingAtYou","flags":0,"tags":["forsenlookingatyou","forsene","forsencd","starege","stare"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae7717dc23eca68e6e13b9","username":"posturelesshobo","display_name":"PosturelessHobo","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9c1c84f1-ed58-44a3-8815-613d69683361-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62b9f4b4cb8e6bebae27d963","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":300,"size":80679,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":300,"size":235082,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":300,"size":268438,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":300,"size":591364,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":300,"size":583846,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":300,"size":1083172,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":300,"size":2257278,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":300,"size":2191378,"format":"WEBP"}]}}},{"id":"654e44d66c3a42995cabdd47","name":"apolloGrumpy","flags":0,"timestamp":1699630776309,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"654e44d66c3a42995cabdd47","name":"apolloGrumpy","flags":0,"tags":["apollo","cat","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"63e11dca559159f548f2b4a6","username":"marv_023","display_name":"marv_023","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/a5e6ea14-adcd-427b-ba15-3f7ec6b9d91e-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/654e44d66c3a42995cabdd47","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1069,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":1950,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":1865,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":5876,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":2886,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":11660,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":3877,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":17776,"format":"WEBP"}]}}},{"id":"611f8a4eabdf5176a9794b2e","name":"SODAING","flags":0,"timestamp":1699824681513,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"611f8a4eabdf5176a9794b2e","name":"SODAING","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b3ce20e08fe2d22d18f125","username":"razfinch","display_name":"RazFinch","avatar_url":"//cdn.7tv.app/pp/60b3ce20e08fe2d22d18f125/496740f813e14e14a1bc62855131ff83","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/611f8a4eabdf5176a9794b2e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":94,"size":23745,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":94,"size":69024,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":94,"size":56040,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":94,"size":158322,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":94,"size":106956,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":94,"size":262516,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":94,"size":163648,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":94,"size":300236,"format":"WEBP"}]}}},{"id":"6553735a9e081c7db7cb5ea8","name":"apolloLove","flags":0,"timestamp":1699969876877,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6553735a9e081c7db7cb5ea8","name":"apolloLove","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"643c881fd2f582316651c1ae","username":"spinynorman","display_name":"spinynorman","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/294c98b5-e34d-42cd-a8f0-140b72fba9b0-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6553735a9e081c7db7cb5ea8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":1,"size":1331,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":1,"size":2290,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":1,"size":2602,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":1,"size":6802,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":1,"size":4031,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":1,"size":13180,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":1,"size":5541,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":1,"size":21568,"format":"WEBP"}]}}},{"id":"610c3d9bd53540d5aad10a2f","name":"Nymntaughtme","flags":0,"timestamp":1700077247298,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"610c3d9bd53540d5aad10a2f","name":"Nymntaughtme","flags":0,"tags":["nymn","peeposmoke","forsen"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"610c3c6ed53540d5aad10a18","username":"kojikon","display_name":"kojikon","avatar_url":"//cdn.7tv.app/user/610c3c6ed53540d5aad10a18/av_656a5edae1df7ad680396513/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/610c3d9bd53540d5aad10a2f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1221,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":946,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":2563,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":2604,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":3785,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":4592,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":5786,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":6884,"format":"WEBP"}]}}},{"id":"651c3ed80812f4f6b96eef31","name":"danPanic","flags":0,"timestamp":1700150151755,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"651c3ed80812f4f6b96eef31","name":"danPanic","flags":0,"tags":["panic","dan","dansgaming"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"618bca3617e4d50afc0cd8ba","username":"marima_a","display_name":"marima_a","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fb236a6f-3c29-4bda-b300-1f985ea0abae-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/651c3ed80812f4f6b96eef31","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":52,"size":31514,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":52,"size":33368,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":52,"size":76458,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":52,"size":72726,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":52,"size":138858,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":52,"size":119366,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":52,"size":204997,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":52,"size":170060,"format":"WEBP"}]}}},{"id":"64520d3c2f52b8e0606e3ff3","name":"notRime","flags":0,"timestamp":1700229385924,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"64520d3c2f52b8e0606e3ff3","name":"notRime","flags":0,"tags":["rime","russel","remon","troy","disguise"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"629994a247051898ec04cad2","username":"clarkpls","display_name":"clarkpls","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/880ec631-b5da-441d-9528-5902d39a5846-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64520d3c2f52b8e0606e3ff3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1344,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1816,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2457,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5196,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3900,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":9738,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5334,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":14876,"format":"WEBP"}]}}},{"id":"6558dc4e51da2a96e6f1cae0","name":"forsenClassic","flags":0,"timestamp":1700322407733,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6558dc4e51da2a96e6f1cae0","name":"forsenClassic","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6558dc4e51da2a96e6f1cae0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":81,"size":25378,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":81,"size":52800,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":81,"size":70616,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":81,"size":97572,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":81,"size":119370,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":81,"size":140866,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":81,"size":169159,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":81,"size":188474,"format":"WEBP"}]}}},{"id":"649c47fc3b4504dd621e735a","name":"LICK","flags":0,"timestamp":1700334021934,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"649c47fc3b4504dd621e735a","name":"LIZUN","flags":0,"tags":["segz","ebu","slime","cat","lick","60fps"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"62442d16030f9bf3f9d4caac","username":"viba","display_name":"VIBA","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e52dd05a-0171-46a5-8b1e-4827d4cab818-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/649c47fc3b4504dd621e735a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":148,"size":55276,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":148,"size":66112,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":148,"size":130931,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":148,"size":144776,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":148,"size":284452,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":148,"size":243454,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":148,"size":537526,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":148,"size":391632,"format":"WEBP"}]}}},{"id":"6532607b1ef5bf2c0c18554c","name":"WEIBOZO","flags":0,"timestamp":1700381675214,"actor_id":"60ae3e98b2ecb0150535c6b7","data":{"id":"6532607b1ef5bf2c0c18554c","name":"WEIBOZO","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"643326208bba52143026d5c0","username":"bonculars","display_name":"bonculars","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/aa5e4d21-2aa3-41b1-ab72-745ceb885281-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6532607b1ef5bf2c0c18554c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":1,"size":1614,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":1,"size":3016,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":1,"size":3515,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":1,"size":9632,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":1,"size":5916,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":1,"size":18644,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":1,"size":8301,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":1,"size":29180,"format":"WEBP"}]}}},{"id":"60eefddf2c24e9e0e6ec9141","name":"peepoComfy","flags":0,"timestamp":1700394836817,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"60eefddf2c24e9e0e6ec9141","name":"peepoComfy","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60eef186119bd10947a2d8ba","username":"zjawa","display_name":"Zjawa","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ab9efacb-da24-42bd-a9af-6f0fdf35a2a6-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60eefddf2c24e9e0e6ec9141","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":3781,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":4790,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":11712,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":6214,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":19572,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":9746,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":15643,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":24752,"format":"WEBP"}]}}},{"id":"6133eef7f1ff750fb9b4f437","name":"peepoSitHey","flags":0,"timestamp":1700394838793,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"6133eef7f1ff750fb9b4f437","name":"peepoSitHey","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f0094ce48dc1dc2fa8f4d9","username":"sylviadark","display_name":"SylviaDark","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5cbc36c8-c412-431b-aadf-f0faa5fa1cd6-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6133eef7f1ff750fb9b4f437","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":4,"size":3561,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":4,"size":2200,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":4,"size":5814,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":4,"size":4682,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":4,"size":8640,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":4,"size":7840,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":4,"size":11562,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":4,"size":9330,"format":"WEBP"}]}}},{"id":"63f3d4e304e4a9fd8ee12dc5","name":"Corncerned","flags":0,"timestamp":1700401515726,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63f3d4e304e4a9fd8ee12dc5","name":"Corncerned","flags":0,"tags":["corn","concerned"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62c9898f9882dfa63c8225ef","username":"jinkies___","display_name":"jinkies___","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f3d4e304e4a9fd8ee12dc5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1440,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1530,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2488,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3648,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3684,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5784,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4514,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8326,"format":"WEBP"}]}}},{"id":"63eb04a088b87ef33e5f4e2f","name":"MovieNightTime","flags":0,"timestamp":1700415071717,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"63eb04a088b87ef33e5f4e2f","name":"vacation","flags":0,"tags":["vacation","banned"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"613077317e4d1ca1b80250d6","username":"jaydeelol","display_name":"Jaydeelol","avatar_url":"//cdn.7tv.app/user/613077317e4d1ca1b80250d6/av_64222b3b2b8217416a238f40/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63eb04a088b87ef33e5f4e2f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":1,"size":1231,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":1,"size":1502,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":1,"size":2681,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":1,"size":4548,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":1,"size":4251,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":1,"size":9188,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":1,"size":5929,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":1,"size":14202,"format":"WEBP"}]}}},{"id":"6357f6c3799befeb23daee61","name":"Lootge","flags":0,"timestamp":1700493207029,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6357f6c3799befeb23daee61","name":"Lootge","flags":0,"tags":["loot","lootge","okayge"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"61001b09767a550afb0e443c","username":"knightmar3frame","display_name":"knightmar3frame","avatar_url":"//cdn.7tv.app/pp/61001b09767a550afb0e443c/0d071f004e69470ea9ac5a2f156b51f1","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6357f6c3799befeb23daee61","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":51,"height":32,"frame_count":1,"size":1994,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":51,"height":32,"frame_count":1,"size":3020,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":102,"height":64,"frame_count":1,"size":4144,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":102,"height":64,"frame_count":1,"size":9236,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":153,"height":96,"frame_count":1,"size":6167,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":153,"height":96,"frame_count":1,"size":17380,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":204,"height":128,"frame_count":1,"size":7712,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":204,"height":128,"frame_count":1,"size":27190,"format":"WEBP"}]}}},{"id":"641e6ae02632d8d9a76e9ca8","name":"mhm","flags":0,"timestamp":1700667332354,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"641e6ae02632d8d9a76e9ca8","name":"mhm","flags":0,"tags":["yep","yeah","yes","nodders","true","agreege"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60da2c98d91a60b97c39b3e2","username":"raidindawgz","display_name":"RaidinDawgZ","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8002607d3deb0904-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/641e6ae02632d8d9a76e9ca8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":37,"height":32,"frame_count":9,"size":3900,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":37,"height":32,"frame_count":9,"size":6462,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":74,"height":64,"frame_count":9,"size":4800,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":74,"height":64,"frame_count":9,"size":4678,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":111,"height":96,"frame_count":9,"size":6259,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":111,"height":96,"frame_count":9,"size":13092,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":148,"height":128,"frame_count":9,"size":5662,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":148,"height":128,"frame_count":9,"size":2518,"format":"WEBP"}]}}},{"id":"60f60522e57bec0216a04191","name":"POK","flags":0,"timestamp":1700683059258,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60f60522e57bec0216a04191","name":"pokeWhat","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60f600c5f7fdd1860a1555ad","username":"maltesaa","display_name":"Maltesaa","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d8d2f014-8160-4c21-9f54-f659ed908a2a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60f60522e57bec0216a04191","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":881,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":612,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":1258,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":1278,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":1558,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":1868,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":1998,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":2432,"format":"WEBP"}]}}},{"id":"635020cbdbe5d048c97a7c0c","name":"Bussin","flags":0,"timestamp":1700755473820,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"635020cbdbe5d048c97a7c0c","name":"Bussin","flags":0,"tags":["food","cat","kitty","kitten","tasty","eat"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6227b409b027edd02c8beede","username":"thefrostydealer","display_name":"thefrostydealer","avatar_url":"//cdn.7tv.app/pp/6227b409b027edd02c8beede/b169caceaaaf4e9c8d91fa44135036b2","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/635020cbdbe5d048c97a7c0c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":270,"size":84407,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":270,"size":127730,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":270,"size":190488,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":270,"size":263164,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":270,"size":321935,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":270,"size":419048,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":270,"size":466056,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":270,"size":563372,"format":"WEBP"}]}}},{"id":"61d5dbb13d52bb5c33c4e21e","name":"docRant","flags":0,"timestamp":1700852645822,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61d5dbb13d52bb5c33c4e21e","name":"docRant","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"611d01d4c7e1fe52005c1769","username":"qullo","display_name":"Qullo","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/91832498-a8dc-4f8c-9b7c-31f419b22bf7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61d5dbb13d52bb5c33c4e21e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":48,"height":32,"frame_count":74,"size":36859,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":48,"height":32,"frame_count":74,"size":93692,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":96,"height":64,"frame_count":74,"size":99699,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":96,"height":64,"frame_count":74,"size":234380,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":144,"height":96,"frame_count":74,"size":176519,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":144,"height":96,"frame_count":74,"size":415316,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":192,"height":128,"frame_count":74,"size":258375,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":192,"height":128,"frame_count":74,"size":572820,"format":"WEBP"}]}}},{"id":"63b7618da18a87a489293080","name":"notok","flags":0,"timestamp":1700854181514,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63b7618da18a87a489293080","name":"notok","flags":0,"tags":["pepe","oke","okay","okey","hmm","meme"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b7618da18a87a489293080","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1125,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1360,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2082,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3574,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3118,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6754,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4085,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8762,"format":"WEBP"}]}}},{"id":"643047cad5322886daf75b01","name":"stopChatting","flags":0,"timestamp":1701007480567,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"643047cad5322886daf75b01","name":"stopChatting","flags":0,"tags":["talking","subs","guy","chatting","not","stop"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61ce216cf644a864b441c7fb","username":"fistymart","display_name":"FistyMart","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fistymart-profile_image-63bb6503cd5238a7-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643047cad5322886daf75b01","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":46,"height":32,"frame_count":116,"size":33094,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":46,"height":32,"frame_count":116,"size":57546,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":92,"height":64,"frame_count":116,"size":84244,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":92,"height":64,"frame_count":116,"size":156320,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":138,"height":96,"frame_count":116,"size":132082,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":138,"height":96,"frame_count":116,"size":253412,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":184,"height":128,"frame_count":116,"size":299929,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":184,"height":128,"frame_count":116,"size":398106,"format":"WEBP"}]}}},{"id":"60b28bb86078b7f956e7ee5f","name":"Danki","flags":0,"timestamp":1701013988330,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60b28bb86078b7f956e7ee5f","name":"Danki","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b28a4a4f32610f15d19e61","username":"xaeriia","display_name":"xAeriia","avatar_url":"//cdn.7tv.app/user/60b28a4a4f32610f15d19e61/av_647bb5415579ae9e28079f9e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60b28bb86078b7f956e7ee5f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1198,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":792,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1956,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1804,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2966,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2946,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3615,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3564,"format":"WEBP"}]}}},{"id":"60e5bbe5a69fc8d27f4d3fe5","name":"ICANT","flags":0,"timestamp":1701172568388,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60e5bbe5a69fc8d27f4d3fe5","name":"ICANT","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60be726ef0eea79ec817e8f8","username":"dor4k","display_name":"dor4k","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/955765bd-1c71-409f-a2d0-15e1ca31997b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60e5bbe5a69fc8d27f4d3fe5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1589,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1174,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3081,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2844,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4584,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4760,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6240,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6722,"format":"WEBP"}]}}},{"id":"63139322bdf4c4798bed8885","name":"o7","flags":0,"timestamp":1701364041094,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63139322bdf4c4798bed8885","name":"o7","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"62c5cb16004dd4ed9b4bf89d","username":"forcevibe","display_name":"forcevibe","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/0a5c930c-6d8d-49ee-8bbf-45cb3989693d-profile_image-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63139322bdf4c4798bed8885","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":1403,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":1788,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":2596,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":4864,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":3915,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":9008,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":5258,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":13858,"format":"WEBP"}]}}},{"id":"61b670a76906591ea6f2005b","name":"SCHIZO","flags":0,"timestamp":1701381684631,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"61b670a76906591ea6f2005b","name":"SCHIZO","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61792e90b0bfad942896803e","username":"hubbles","display_name":"hubbles","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/aa6f2257-1d63-4c36-88b8-98af152b2d4f-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61b670a76906591ea6f2005b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":40,"size":18397,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":40,"size":31318,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":40,"size":50810,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":40,"size":79604,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":40,"size":85557,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":40,"size":134692,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":40,"size":127021,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":40,"size":115894,"format":"WEBP"}]}}},{"id":"61f884289f7bac13c42dc979","name":"docLeave","flags":0,"timestamp":1701386349272,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"61f884289f7bac13c42dc979","name":"DocLeave","flags":0,"tags":["jebaited"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60fd819cfdd2c8ea2df2ee5f","username":"tichyou","display_name":"tichyou","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/cf87a61e-3556-469c-a47e-60db0858ee1d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61f884289f7bac13c42dc979","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":153,"size":54660,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":153,"size":115926,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":153,"size":154108,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":153,"size":285322,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":153,"size":262531,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":153,"size":496188,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":153,"size":396400,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":153,"size":580456,"format":"WEBP"}]}}},{"id":"63ce753dec685e58d1731778","name":"GULP","flags":0,"timestamp":1701468774487,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ce753dec685e58d1731778","name":"GULP","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60d192bccb23a983f0cc6672","username":"tuneira","display_name":"tuneira","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1c9cc985-24a9-442d-84a1-60c7a7bbf388-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ce753dec685e58d1731778","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":21,"size":4582,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":21,"size":7070,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":21,"size":7306,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":21,"size":14326,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":21,"size":12273,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":21,"size":22242,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":21,"size":19253,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":21,"size":31368,"format":"WEBP"}]}}},{"id":"61fdd144690425de3c640404","name":"Bits-100","flags":0,"timestamp":1701518310498,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"61fdd144690425de3c640404","name":"Bits-100","flags":0,"tags":["bits","twitch","twitchbits","donate"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60bcd84e191fa96982c2bc08","username":"squeezyyyy","display_name":"sQueezyyyy","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1d0e830c-5ae2-4573-b99e-1e858735cd1b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61fdd144690425de3c640404","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":77,"size":27102,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":76,"size":55870,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":77,"size":54292,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":77,"size":114422,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":77,"size":95627,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":77,"size":190312,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":77,"size":136000,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":77,"size":259208,"format":"WEBP"}]}}},{"id":"6462aa765070b2cda24f368a","name":"Moo","flags":0,"timestamp":1701518339614,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6462aa765070b2cda24f368a","name":"moo","flags":0,"tags":["meow","wantattention","pleasehelp","imscared","sadcat","cutekitty"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f39e8bc07d1ac193652def","username":"shmovy","display_name":"Shmovy","avatar_url":"//cdn.7tv.app/user/60f39e8bc07d1ac193652def/av_63a4e84f5c2aba9b3b60bf46/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6462aa765070b2cda24f368a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":178,"size":52631,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":178,"size":121314,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":178,"size":101841,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":178,"size":255324,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":178,"size":157603,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":178,"size":395612,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":178,"size":213403,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":178,"size":546190,"format":"WEBP"}]}}},{"id":"65639a7b95e5d35c9f23f686","name":"id","flags":0,"timestamp":1701523007237,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"65639a7b95e5d35c9f23f686","name":"iidiot","flags":0,"tags":["tudou","idiot","cat"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60aed4be4a34e31452493b7e","username":"fookstee","display_name":"Fookstee","avatar_url":"//cdn.7tv.app/user/60aed4be4a34e31452493b7e/av_6563b4f1558736959c301ab8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65639a7b95e5d35c9f23f686","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":58,"size":16564,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":58,"size":27136,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":58,"size":37889,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":58,"size":64078,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":58,"size":55096,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":58,"size":95568,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":58,"size":81322,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":58,"size":140552,"format":"WEBP"}]}}},{"id":"656b5ac4b818b44d6d6f3b87","name":"Nymnunculus","flags":0,"timestamp":1701534459018,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"656b5ac4b818b44d6d6f3b87","name":"Nymnunculus","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/656b5ac4b818b44d6d6f3b87","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":64,"height":32,"frame_count":1,"size":1716,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":64,"height":32,"frame_count":1,"size":2414,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":128,"height":64,"frame_count":1,"size":3655,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":128,"height":64,"frame_count":1,"size":7108,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":192,"height":96,"frame_count":1,"size":5867,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":192,"height":96,"frame_count":1,"size":13934,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":256,"height":128,"frame_count":1,"size":8813,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":256,"height":128,"frame_count":1,"size":20410,"format":"WEBP"}]}}},{"id":"656b84e26faf97ff5fbc45bf","name":"PopCorncerned","flags":0,"timestamp":1701545760647,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"656b84e26faf97ff5fbc45bf","name":"PopCorncerned","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62c6fdf7a7ffd3f6119c4735","username":"merscever","display_name":"merscever","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/656b84e26faf97ff5fbc45bf","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":59,"height":32,"frame_count":28,"size":12496,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":59,"height":32,"frame_count":28,"size":15790,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":118,"height":64,"frame_count":28,"size":21197,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":118,"height":64,"frame_count":28,"size":32668,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":177,"height":96,"frame_count":28,"size":33342,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":177,"height":96,"frame_count":28,"size":48000,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":236,"height":128,"frame_count":28,"size":49969,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":236,"height":128,"frame_count":28,"size":59796,"format":"WEBP"}]}}},{"id":"617666d0ffc7244d797d214f","name":"Smile","flags":0,"timestamp":1701715883543,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"617666d0ffc7244d797d214f","name":"Smile","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6114f77c446a415801b1a923","username":"tristeaf","display_name":"TristeAF","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/144ede91-46ed-462a-ac2f-58245f9b570d-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/617666d0ffc7244d797d214f","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":876,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1113,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1836,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2014,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":3390,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2615,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3428,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":5158,"format":"WEBP"}]}}},{"id":"64af93182b9b9a7b4ba03808","name":"plinkVibe","flags":0,"timestamp":1702041205202,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64af93182b9b9a7b4ba03808","name":"plinkVibe","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"63901e3e5f5382bf533770a8","username":"kennypatron","display_name":"kennypatron","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/e7cf301a-0099-48fa-af9b-191b3bbee277-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64af93182b9b9a7b4ba03808","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":80,"height":32,"frame_count":33,"size":20390,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":80,"height":32,"frame_count":33,"size":26198,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":160,"height":64,"frame_count":33,"size":49144,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":160,"height":64,"frame_count":33,"size":50820,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":240,"height":96,"frame_count":33,"size":88483,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":240,"height":96,"frame_count":33,"size":86808,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":320,"height":128,"frame_count":33,"size":143704,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":320,"height":128,"frame_count":33,"size":116516,"format":"WEBP"}]}}},{"id":"622fa7be53c308389363aa9a","name":"nymnFriend","flags":0,"timestamp":1702060648946,"actor_id":"60ae8fc0ea50f43c9e3ae255","data":{"id":"622fa7be53c308389363aa9a","name":"NymNFriend","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae8fc0ea50f43c9e3ae255","username":"agenttud","display_name":"agenttud","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/273db808-d42f-4dab-9b39-9780ef2777b0-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/622fa7be53c308389363aa9a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1435,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1098,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2743,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2780,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4434,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5028,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6403,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":8146,"format":"WEBP"}]}}},{"id":"646f5df34f0435ef0ae009a9","name":"NimeNoForsen","flags":0,"timestamp":1702129241931,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"646f5df34f0435ef0ae009a9","name":"NimeNoForsen","flags":0,"tags":["nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60afa8c899923bbe7f6e5a33","username":"trippycolour","display_name":"TrippyColour","avatar_url":"//cdn.7tv.app/user/60afa8c899923bbe7f6e5a33/av_6592e20b64e6ee62744a436c/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/646f5df34f0435ef0ae009a9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1322,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1948,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5568,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2490,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10536,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3811,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5678,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16874,"format":"WEBP"}]}}},{"id":"64c5a08efa9b960ee1185c83","name":"minusClean","flags":0,"timestamp":1702144169771,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"64c5a08efa9b960ee1185c83","name":"VeryClean","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"613bdc8feef63902454a4f2d","username":"maxdaxx","display_name":"maxdaxx","avatar_url":"//cdn.7tv.app/user/613bdc8feef63902454a4f2d/av_63b31dc35aa77eca61ec84b8/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64c5a08efa9b960ee1185c83","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":270,"size":72961,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":270,"size":154966,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":270,"size":149429,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":270,"size":260840,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":270,"size":240607,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":270,"size":359560,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":270,"size":326117,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":270,"size":461936,"format":"WEBP"}]}}},{"id":"63e3fb7a1d40a5212f9aeb11","name":":)))","flags":0,"timestamp":1702167791437,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"63e3fb7a1d40a5212f9aeb11","name":":)))","flags":0,"tags":["smile","cute","happy","cat"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"628e9497539b08d3d9084d98","username":"enviben","display_name":"enviben","avatar_url":"//cdn.7tv.app/user/628e9497539b08d3d9084d98/av_63ed520e7edaded517e06c95/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63e3fb7a1d40a5212f9aeb11","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":56,"size":16268,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":56,"size":31352,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":56,"size":41012,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":56,"size":73364,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":56,"size":76820,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":56,"size":121236,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":56,"size":131082,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":56,"size":174418,"format":"WEBP"}]}}},{"id":"6574af6e0c7c0b8e18ab474a","name":"buhShakey","flags":0,"timestamp":1702230394815,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6574af6e0c7c0b8e18ab474a","name":"buhShakey","flags":0,"tags":["buh","femboy"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6574ad961a8b06735a3d93ea","username":"galadd","display_name":"galadd","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6574af6e0c7c0b8e18ab474a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":15,"size":7244,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":15,"size":6372,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":15,"size":14688,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":15,"size":13576,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":15,"size":22572,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":15,"size":20804,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":15,"size":44461,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":15,"size":32802,"format":"WEBP"}]}}},{"id":"65538fe8c7791e2b4b3df928","name":"2023NymN","flags":0,"timestamp":1702304355871,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"65538fe8c7791e2b4b3df928","name":"2023NymN","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65538fe8c7791e2b4b3df928","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1177,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2032,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2321,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5872,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3431,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10590,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4785,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":16820,"format":"WEBP"}]}}},{"id":"63fb91a9187076203e580359","name":"aNIME","flags":0,"timestamp":1702304439474,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63fb91a9187076203e580359","name":"aNIME","flags":0,"tags":["weeb","exposed","forsen","nymn","anime","nime"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60b6746f64faf92496330d3c","username":"littlescampi","display_name":"LittleScampi","avatar_url":"//cdn.7tv.app/pp/60b6746f64faf92496330d3c/a1ff043123944a119d69cf9a7062a442","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63fb91a9187076203e580359","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1205,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":2110,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":2403,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":6630,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":3482,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":12232,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":4905,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":20696,"format":"WEBP"}]}}},{"id":"613a285da2f37936d34177ff","name":"tyrissTail","flags":0,"timestamp":1702304571087,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"613a285da2f37936d34177ff","name":"tyrissTail","flags":0,"tags":["anime","tail","neko","tyriss","cute","ayaya"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f1cac515758a7f9ad0df00","username":"ryujitakasu","display_name":"RyujiTakasu","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9c89a3ec-05a3-4ab3-9c77-5f26cfd6dfea-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/613a285da2f37936d34177ff","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":14,"size":7586,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":14,"size":10576,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":14,"size":14330,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":14,"size":22380,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":14,"size":22995,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":14,"size":37544,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":14,"size":30656,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":14,"size":43912,"format":"WEBP"}]}}},{"id":"6157e7e793686fbfe7fbe2bd","name":"Sparkles","flags":1,"timestamp":1702304611446,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6157e7e793686fbfe7fbe2bd","name":"Sparkles","flags":256,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60af971d12f90fadd6aa9ff8","username":"viscoito","display_name":"Viscoito","avatar_url":"//cdn.7tv.app/user/60af971d12f90fadd6aa9ff8/av_651d964e32b1db5b90eead40/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6157e7e793686fbfe7fbe2bd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":50,"size":23029,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":50,"size":29866,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":50,"size":45822,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":50,"size":53042,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":50,"size":83376,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":50,"size":73089,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":50,"size":82756,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":50,"size":88085,"format":"AVIF"}]}}},{"id":"62ad39d7c498e86eaf5e87af","name":"Blush","flags":1,"timestamp":1702304656225,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"62ad39d7c498e86eaf5e87af","name":"Blush","flags":256,"tags":["blush","shy","flushed","floshed"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61ae535f15b3ff4a5bb9bdcc","username":"stereo_saiyan","display_name":"stereo_saiyan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/ab1ac98d-f38b-4e5b-8961-d0889b391160-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62ad39d7c498e86eaf5e87af","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":924,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":742,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1393,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1646,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2207,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2710,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3014,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":4308,"format":"WEBP"}]}}},{"id":"621d1ff13808dfe5c4660745","name":"WeebsOut","flags":0,"timestamp":1702304822668,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"621d1ff13808dfe5c4660745","name":"WeebsOut","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/621d1ff13808dfe5c4660745","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":37,"size":24817,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":37,"size":48318,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":37,"size":65606,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":37,"size":109460,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":37,"size":117442,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":37,"size":179454,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":37,"size":166112,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":37,"size":224736,"format":"WEBP"}]}}},{"id":"657777cea1c209046c08e210","name":"shakey0","flags":1,"timestamp":1702328296739,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"657777cea1c209046c08e210","name":"shakey0","flags":256,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657777cea1c209046c08e210","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":15,"size":7069,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":15,"size":6210,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":15,"size":13311,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":15,"size":11136,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":15,"size":19489,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":15,"size":16996,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":15,"size":27240,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":15,"size":21484,"format":"WEBP"}]}}},{"id":"63f0212ae7b7262994ed5f38","name":"Pondering","flags":1,"timestamp":1702376113465,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63f0212ae7b7262994ed5f38","name":"Pondering","flags":256,"tags":["life","looking","peepo"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61e229e04f44b95f34665190","username":"f_r_o_n_g","display_name":"F_R_O_N_G","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63f0212ae7b7262994ed5f38","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":719,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":592,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":982,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1584,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2374,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1305,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":1536,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3498,"format":"WEBP"}]}}},{"id":"630d3e623bb08262fb6c32fd","name":"catShake","flags":0,"timestamp":1702500237685,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"630d3e623bb08262fb6c32fd","name":"catShake","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60f635cbe57bec0216c302e8","username":"kamav9","display_name":"KamaV9","avatar_url":"//cdn.7tv.app/pp/60f635cbe57bec0216c302e8/f9768e1437af4c37931ad315f788306b","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/630d3e623bb08262fb6c32fd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":14,"size":5683,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":14,"size":5860,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":14,"size":10725,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":14,"size":10572,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":14,"size":21282,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":14,"size":20956,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":14,"size":26512,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":14,"size":26134,"format":"WEBP"}]}}},{"id":"639e8ea05dca9c0bfcc29cb7","name":"SCATTER0","flags":1,"timestamp":1702560330643,"actor_id":"60ae3c29b2ecb015051f8f9a","data":{"id":"639e8ea05dca9c0bfcc29cb7","name":"SCATTER0","flags":256,"tags":["run","flee"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60eced11d3e38afa0682661b","username":"perry8782","display_name":"perry8782","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d84e9d18-fa4c-4135-8ee5-11a13bb25250-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/639e8ea05dca9c0bfcc29cb7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":14,"size":18164,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":14,"size":15500,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":14,"size":41462,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":14,"size":35664,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":14,"size":71220,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":14,"size":59374,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":14,"size":128043,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":14,"size":84706,"format":"WEBP"}]}}},{"id":"657b0228404cb98d28f6a031","name":"gettingjiggywithit","flags":0,"timestamp":1702560422746,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"657b0228404cb98d28f6a031","name":"gettingjiggywithit","flags":0,"tags":["dance","catjam","catvibe","chipi","chapa","chipichipi"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657b0228404cb98d28f6a031","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":40,"height":32,"frame_count":24,"size":9812,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":40,"height":32,"frame_count":24,"size":13300,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":80,"height":64,"frame_count":24,"size":18566,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":80,"height":64,"frame_count":24,"size":25682,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":120,"height":96,"frame_count":24,"size":28238,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":120,"height":96,"frame_count":24,"size":38078,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":160,"height":128,"frame_count":24,"size":38861,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":160,"height":128,"frame_count":24,"size":51260,"format":"WEBP"}]}}},{"id":"656de6280e2cc09853775c70","name":"AlienParty","flags":0,"timestamp":1702562949807,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"656de6280e2cc09853775c70","name":"AlienParty","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/656de6280e2cc09853775c70","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":89,"height":32,"frame_count":116,"size":301363,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":89,"height":32,"frame_count":116,"size":263938,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":178,"height":64,"frame_count":116,"size":806144,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":178,"height":64,"frame_count":116,"size":615588,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":267,"height":96,"frame_count":116,"size":1403931,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":267,"height":96,"frame_count":116,"size":1063722,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":356,"height":128,"frame_count":116,"size":2080923,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":356,"height":128,"frame_count":116,"size":1331912,"format":"WEBP"}]}}},{"id":"657ae27ed5e16dbf06d00821","name":"nymnVibe","flags":0,"timestamp":1702563432510,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"657ae27ed5e16dbf06d00821","name":"nymnVibe","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60b78f631b94ba73134f0793","username":"realpiggypaps","display_name":"RealPiggyPaps","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/7f3607e7-d807-41ae-93f9-9ed3e0e8a6ea-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657ae27ed5e16dbf06d00821","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":132,"size":34705,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":128,"size":58568,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":132,"size":77981,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":131,"size":139012,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":132,"size":129483,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":132,"size":216944,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":132,"size":182289,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":132,"size":305918,"format":"WEBP"}]}}},{"id":"6258ea1dc7b4050100611b23","name":"4House","flags":0,"timestamp":1702576797462,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6258ea1dc7b4050100611b23","name":"4House","flags":0,"tags":["drhouse","doctor"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aea03698f42914704af3ad","username":"mysztic","display_name":"MYSZTIC","avatar_url":"//cdn.7tv.app/user/60aea03698f42914704af3ad/av_6594935766ad24209ff2fe39/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6258ea1dc7b4050100611b23","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1194,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1611,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3261,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2970,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4864,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":5080,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7702,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6935,"format":"AVIF"}]}}},{"id":"657afdd4061cf22646a84228","name":"catAsk","flags":0,"timestamp":1702627740903,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"657afdd4061cf22646a84228","name":"catAsk","flags":0,"tags":["question","ask","cat","attention"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657afdd4061cf22646a84228","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":21,"height":32,"frame_count":63,"size":20550,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":21,"height":32,"frame_count":63,"size":37168,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":42,"height":64,"frame_count":63,"size":47250,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":42,"height":64,"frame_count":63,"size":79512,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":63,"height":96,"frame_count":63,"size":73425,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":63,"height":96,"frame_count":63,"size":122250,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":84,"height":128,"frame_count":63,"size":102340,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":84,"height":128,"frame_count":63,"size":166624,"format":"WEBP"}]}}},{"id":"6266af7752691b69d9c624ab","name":"pepeWJAM","flags":0,"timestamp":1702658777624,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6266af7752691b69d9c624ab","name":"pepeWJAM","flags":0,"tags":["pepew","jam"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae7143d8d99a9cf82e56d5","username":"gwinsen","display_name":"Gwinsen","avatar_url":"//cdn.7tv.app/pp/60ae7143d8d99a9cf82e56d5/82b4e3f32e7d44178b35bac3aa393ba5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6266af7752691b69d9c624ab","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":18,"size":8886,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":18,"size":19832,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":18,"size":15470,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":18,"size":50226,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":18,"size":26926,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":18,"size":85978,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":18,"size":42856,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":18,"size":125304,"format":"WEBP"}]}}},{"id":"6571cc0f76838e1cababfb6b","name":"apolloDevoursYou","flags":0,"timestamp":1702658851697,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6571cc0f76838e1cababfb6b","name":"apolloDevoursYou","flags":0,"tags":["apollo","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"643c881fd2f582316651c1ae","username":"spinynorman","display_name":"spinynorman","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/294c98b5-e34d-42cd-a8f0-140b72fba9b0-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6571cc0f76838e1cababfb6b","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":32,"size":10616,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":33,"size":9026,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":33,"size":15442,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":32,"size":20736,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":33,"size":31876,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":33,"size":24589,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":33,"size":33738,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":33,"size":43452,"format":"WEBP"}]}}},{"id":"657cdd81fc59ded1d3164c92","name":"buhOverShakey","flags":0,"timestamp":1702682011019,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"657cdd81fc59ded1d3164c92","name":"buhOverShakey","flags":0,"tags":["buh","cokeshakey","pepsishakey","shakey","fast","overheat"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657cdd81fc59ded1d3164c92","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":3,"size":3028,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":3,"size":1302,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":3,"size":2738,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":3,"size":5133,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":3,"size":7611,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":3,"size":4184,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":3,"size":11627,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":3,"size":5652,"format":"WEBP"}]}}},{"id":"61ecfa5acc9507d24fd4cd17","name":"+1","flags":0,"timestamp":1702736168187,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61ecfa5acc9507d24fd4cd17","name":"1G","flags":0,"tags":["summit","one","gee"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6114c18090ef9df34c9349fe","username":"arcchived","display_name":"Arcchived","avatar_url":"//cdn.7tv.app/","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61ecfa5acc9507d24fd4cd17","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1002,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":728,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1651,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1562,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2362,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2696,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2927,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3328,"format":"WEBP"}]}}},{"id":"623506b6b88633b42c0c3532","name":"-1","flags":0,"timestamp":1702736215010,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"623506b6b88633b42c0c3532","name":"WTF","flags":0,"tags":["hewillnever","forsen","nymn"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ec55f50f592e4d9d9a065e","username":"calamita","display_name":"カラミタ","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/1902ea18-b14e-4112-8260-98240e09d605-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/623506b6b88633b42c0c3532","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":940,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":654,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1775,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1688,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2731,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2818,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3807,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3402,"format":"WEBP"}]}}},{"id":"6488e6ac11ffb819a6e41895","name":"BBidiot","flags":0,"timestamp":1702744004619,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6488e6ac11ffb819a6e41895","name":"BBidiot","flags":0,"tags":["idiot","heartbrokendog","peppah","forsen","dog"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"63cbf23675acdcde61643eb9","username":"chrisscreams","display_name":"ChrisScreams","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/59818c70-68ca-4e12-9911-428b60f94629-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6488e6ac11ffb819a6e41895","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1780,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1926,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3978,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5866,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":6397,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11426,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":10290,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18482,"format":"WEBP"}]}}},{"id":"65299d8b582b6c8624866982","name":"ads","flags":0,"timestamp":1702832472090,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"65299d8b582b6c8624866982","name":"ads","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6410e5edb716bba64280a17c","username":"rubenvatle","display_name":"RubenVatle","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/d8a41c8c-8f31-4748-9d95-6cc5858f7092-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65299d8b582b6c8624866982","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":91,"height":32,"frame_count":1,"size":1248,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":91,"height":32,"frame_count":1,"size":1452,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":182,"height":64,"frame_count":1,"size":2384,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":182,"height":64,"frame_count":1,"size":3330,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":273,"height":96,"frame_count":1,"size":3076,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":273,"height":96,"frame_count":1,"size":5308,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":364,"height":128,"frame_count":1,"size":3944,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":364,"height":128,"frame_count":1,"size":8310,"format":"WEBP"}]}}},{"id":"65786bcff33524668d3c59b5","name":"dogSwing","flags":0,"timestamp":1702848248781,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"65786bcff33524668d3c59b5","name":"dogSwing","flags":0,"tags":["happy","swing","xdd","dog"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62365a49b88633b42c0c4892","username":"davidlxw","display_name":"davidlxw","avatar_url":"//cdn.7tv.app/user/62365a49b88633b42c0c4892/av_656f9aa29e9e8657200ddc2e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65786bcff33524668d3c59b5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":113,"size":31552,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":113,"size":52450,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":113,"size":78259,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":113,"size":121274,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":113,"size":146361,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":113,"size":196926,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":113,"size":433892,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":113,"size":430850,"format":"WEBP"}]}}},{"id":"656f83128b22c0384d1607a2","name":"LMAO","flags":0,"timestamp":1702905127375,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"656f83128b22c0384d1607a2","name":"LMAO","flags":0,"tags":["pepe","funne","moonmoon","lmao"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62f3cac2fb073fdaab609cfa","username":"femboyrell","display_name":"FemboyRell","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5f4557fb-882b-4baf-abe0-f6b86b81235e-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/656f83128b22c0384d1607a2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1370,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":2018,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2391,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5922,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3452,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":10812,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4271,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":15794,"format":"WEBP"}]}}},{"id":"658055b2b34466cf0bafc4b8","name":"superjj","flags":0,"timestamp":1702924626457,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"658055b2b34466cf0bafc4b8","name":"sjjKatze","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6192ce26b1eb03daac7decc3","username":"matus_k6","display_name":"Matus_K6","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9408e0ec-b87c-4b49-ac9d-80fb6e2d3026-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/658055b2b34466cf0bafc4b8","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":123,"size":27303,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":123,"size":67780,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":123,"size":69760,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":123,"size":146868,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":123,"size":134731,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":123,"size":222510,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":123,"size":211755,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":123,"size":294752,"format":"WEBP"}]}}},{"id":"637aa9624bc455c4ba37e936","name":"Banned","flags":0,"timestamp":1702927402810,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"637aa9624bc455c4ba37e936","name":"RIPVOD","flags":0,"tags":["vod","band","banned"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6280f6826e007e074cb8a42f","username":"pilex96","display_name":"Pilex96","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/b8730635-40fb-4f5e-baff-a670d6508b88-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/637aa9624bc455c4ba37e936","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":64,"height":32,"frame_count":1,"size":986,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":64,"height":32,"frame_count":1,"size":946,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":128,"height":64,"frame_count":1,"size":1998,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":128,"height":64,"frame_count":1,"size":2486,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":192,"height":96,"frame_count":1,"size":3387,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":192,"height":96,"frame_count":1,"size":4258,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":256,"height":128,"frame_count":1,"size":4786,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":256,"height":128,"frame_count":1,"size":6426,"format":"WEBP"}]}}},{"id":"6582da34dbf474d8368c6e6e","name":"Nyoshi","flags":0,"timestamp":1703249244720,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"6582da34dbf474d8368c6e6e","name":"Nyoshi","flags":0,"tags":["nymn","forsen","nam","yoshi","mario"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"610c3c6ed53540d5aad10a18","username":"kojikon","display_name":"kojikon","avatar_url":"//cdn.7tv.app/user/610c3c6ed53540d5aad10a18/av_656a5edae1df7ad680396513/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6582da34dbf474d8368c6e6e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":44,"height":32,"frame_count":1,"size":1516,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":44,"height":32,"frame_count":1,"size":1500,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":88,"height":64,"frame_count":1,"size":2962,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":88,"height":64,"frame_count":1,"size":4216,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":132,"height":96,"frame_count":1,"size":4856,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":132,"height":96,"frame_count":1,"size":8018,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":176,"height":128,"frame_count":1,"size":6298,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":176,"height":128,"frame_count":1,"size":12694,"format":"WEBP"}]}}},{"id":"6256dc30b0dfc5aeb040f19a","name":"forsenOverlevel","flags":0,"timestamp":1703271630404,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"6256dc30b0dfc5aeb040f19a","name":"forsenOverlevel","flags":0,"tags":["forsen","level","nymn"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6256dc30b0dfc5aeb040f19a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1163,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":944,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":2242,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":2354,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":3467,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":4226,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":4838,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":6486,"format":"WEBP"}]}}},{"id":"620425c25ccb247397667d59","name":"NOkey","flags":0,"timestamp":1703623389838,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"620425c25ccb247397667d59","name":"NOkey","flags":0,"tags":["notokay","xqcl"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3d75aee2aa55382883c2","username":"victorbaya","display_name":"victorbaya","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/4f4c5649-c2f3-4837-a46f-486df3dde891-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/620425c25ccb247397667d59","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1693,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1356,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":3678,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":3466,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":5877,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":6052,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":8605,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":9294,"format":"WEBP"}]}}},{"id":"64358071b5534f4485d0d5f1","name":"FrankJam","flags":0,"timestamp":1703625239843,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64358071b5534f4485d0d5f1","name":"FrankJam","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"64357faf461ddfc91d9807fa","username":"pepepge","display_name":"PepePge","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/eeff1b67-c4b6-410a-8451-1741cc64dc78-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64358071b5534f4485d0d5f1","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":2,"size":3007,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":2,"size":1624,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":2,"size":5048,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":2,"size":3364,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":2,"size":7527,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":2,"size":5108,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":2,"size":9802,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":2,"size":7026,"format":"WEBP"}]}}},{"id":"657c7bb282b9f8f92c7e8b49","name":"Frank","flags":0,"timestamp":1703625241637,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"657c7bb282b9f8f92c7e8b49","name":"Frank","flags":0,"tags":["feelsdonkman","donk"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ff054ffbd646ea3b221dc9","username":"tunari__","display_name":"Tunari__","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bc530a7a-e04d-4765-a662-bb3efde482e2-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657c7bb282b9f8f92c7e8b49","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1449,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1750,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2772,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4678,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4436,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8750,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6084,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13186,"format":"WEBP"}]}}},{"id":"64674a7358d599a0419f49d7","name":"CAUGHT","flags":0,"timestamp":1703633139185,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"64674a7358d599a0419f49d7","name":"CAUGHT","flags":0,"tags":["xyligun","reallymad","shiza","hands","emotiguy","monkah"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"61541c3c20eaf897465ad48b","username":"andreimonty","display_name":"AndreiMonty","avatar_url":"//cdn.7tv.app/user/61541c3c20eaf897465ad48b/av_6458e293d3b4256e12d830a9/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64674a7358d599a0419f49d7","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":61,"height":32,"frame_count":1,"size":1799,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":61,"height":32,"frame_count":1,"size":2838,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":122,"height":64,"frame_count":1,"size":3423,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":122,"height":64,"frame_count":1,"size":8144,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":183,"height":96,"frame_count":1,"size":5544,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":183,"height":96,"frame_count":1,"size":16144,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":244,"height":128,"frame_count":1,"size":8139,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":244,"height":128,"frame_count":1,"size":12656,"format":"WEBP"}]}}},{"id":"614b201f0f25350dc5d7a3f5","name":"BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAT","flags":0,"timestamp":1703684030206,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"614b201f0f25350dc5d7a3f5","name":"BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAT","flags":0,"tags":["batchest","batpls","batdisco","hyper","forseninsane"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae81ff0bf2ee96aea05247","username":"snortexx","display_name":"snortexx","avatar_url":"//cdn.7tv.app/pp/60ae81ff0bf2ee96aea05247/183b9b6ab7624a53966fb782ec0963e0","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/614b201f0f25350dc5d7a3f5","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":8,"size":7073,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":8,"size":6652,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":8,"size":13877,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":8,"size":15020,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":8,"size":20637,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":8,"size":24652,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":8,"size":29208,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":8,"size":25858,"format":"WEBP"}]}}},{"id":"60bbf9d8585f017b2352b35e","name":"zyzzPls","flags":0,"timestamp":1703714472119,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60bbf9d8585f017b2352b35e","name":"zyzzPls","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b1428c213e3888f9638acf","username":"senderak","display_name":"senderak","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/9d953c4e-3f61-48b8-8e45-08071276d03d-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60bbf9d8585f017b2352b35e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":173,"size":124066,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":173,"size":165550,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":173,"size":283785,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":173,"size":368430,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":173,"size":446116,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":173,"size":616726,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":173,"size":618727,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":173,"size":703976,"format":"WEBP"}]}}},{"id":"657296ebb994cdd3baf21184","name":"st","flags":0,"timestamp":1703714489958,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"657296ebb994cdd3baf21184","name":"stupid","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6318b1e329a5627b71e308e7","username":"flekyu","display_name":"flekyu","avatar_url":"//cdn.7tv.app/user/6318b1e329a5627b71e308e7/av_651e126332b1db5b90eedcc3/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/657296ebb994cdd3baf21184","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":42,"height":32,"frame_count":138,"size":39647,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":42,"height":32,"frame_count":138,"size":77014,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":84,"height":64,"frame_count":138,"size":82852,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":84,"height":64,"frame_count":138,"size":162924,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":126,"height":96,"frame_count":138,"size":137005,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":126,"height":96,"frame_count":138,"size":256108,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":168,"height":128,"frame_count":138,"size":190513,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":168,"height":128,"frame_count":138,"size":365898,"format":"WEBP"}]}}},{"id":"63187986537cb8092b6dbf8b","name":"sh","flags":0,"timestamp":1703765249027,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"63187986537cb8092b6dbf8b","name":"shithead","flags":0,"tags":["kitten","cat","meme"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"619ebad96467596b1d63540a","username":"cbk_x","display_name":"CBK_x","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/31e2530f-dcd7-446b-8ed7-d9dc75b8a8c7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63187986537cb8092b6dbf8b","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":72,"size":15214,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":72,"size":20734,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":72,"size":26956,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":72,"size":38728,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":72,"size":42584,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":72,"size":59984,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":72,"size":58043,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":72,"size":84046,"format":"WEBP"}]}}},{"id":"64c7b403d5a34030d08ee48a","name":"fu","flags":0,"timestamp":1703765765278,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"64c7b403d5a34030d08ee48a","name":"lurk","flags":0,"tags":["lurking","meow","cat","kitty","kitten"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6169d4c6474b9b7b59a37f56","username":"eropbl4_","display_name":"Eropbl4_","avatar_url":"//cdn.7tv.app/user/6169d4c6474b9b7b59a37f56/av_64305076b0b346d623214893/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64c7b403d5a34030d08ee48a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":122,"size":40610,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":122,"size":63718,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":122,"size":96196,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":122,"size":145220,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":122,"size":161258,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":122,"size":224090,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":122,"size":352359,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":122,"size":348360,"format":"WEBP"}]}}},{"id":"64156a097bb548a1f3de08c9","name":"nimeArrive","flags":0,"timestamp":1703766065881,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"64156a097bb548a1f3de08c9","name":"nimeArrive","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60af8846a3648f409a124ee4","username":"kniteort","display_name":"Kniteort","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/05070f7c-ec6a-47cf-a274-54e062b11bf7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64156a097bb548a1f3de08c9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":23,"size":6509,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":23,"size":6550,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":23,"size":10341,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":23,"size":11262,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":23,"size":15681,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":23,"size":17092,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":23,"size":21508,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":23,"size":22126,"format":"WEBP"}]}}},{"id":"645fd62220a7827537905411","name":"coupleofleeches","flags":0,"timestamp":1703778794428,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"645fd62220a7827537905411","name":"coupleofleeches","flags":0,"tags":["vcu","leech","eddiehd","velcuz"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62dfad28e2f69efc6a2c84b7","username":"esperdg","display_name":"EsperDG","avatar_url":"//cdn.7tv.app/user/62dfad28e2f69efc6a2c84b7/av_6515a414e66ad3b2e8846aab/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/645fd62220a7827537905411","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":45,"height":32,"frame_count":1,"size":1858,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":45,"height":32,"frame_count":1,"size":2644,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":90,"height":64,"frame_count":1,"size":3999,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":90,"height":64,"frame_count":1,"size":8016,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":135,"height":96,"frame_count":1,"size":6230,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":135,"height":96,"frame_count":1,"size":15312,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":180,"height":128,"frame_count":1,"size":8470,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":180,"height":128,"frame_count":1,"size":24016,"format":"WEBP"}]}}},{"id":"634ff9f11d98780318b417cd","name":"peepoGiggles","flags":0,"timestamp":1703778940399,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"634ff9f11d98780318b417cd","name":"peepoGiggles","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61ab1fed15b3ff4a5bb95735","username":"myzlivko","display_name":"Myzlivko","avatar_url":"//cdn.7tv.app/user/61ab1fed15b3ff4a5bb95735/av_63b742ed5f07129c6333abf4/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/634ff9f11d98780318b417cd","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":4155,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":4592,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":6137,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":10080,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":8987,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":15662,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":11857,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":20414,"format":"WEBP"}]}}},{"id":"641f80c0fdd6b1a12218ff0f","name":"ASSEMBLE","flags":0,"timestamp":1703805722000,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"641f80c0fdd6b1a12218ff0f","name":"ASSEMBLE","flags":0,"tags":["assemble","arrive","streameroffline","gathering","scatter","peeposit"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6150b6ea6251d7e000dab48c","username":"prototypezedd","display_name":"PrototypeZedd","avatar_url":"//cdn.7tv.app/user/6150b6ea6251d7e000dab48c/av_6518484d3e8ab458481945f5/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/641f80c0fdd6b1a12218ff0f","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":24,"frame_count":28,"size":30671,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":24,"frame_count":28,"size":31392,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":48,"frame_count":28,"size":77580,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":48,"frame_count":28,"size":64774,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":72,"frame_count":28,"size":125346,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":72,"frame_count":28,"size":106656,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":96,"frame_count":28,"size":223096,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":96,"frame_count":28,"size":145676,"format":"WEBP"}]}}},{"id":"61a457da15b3ff4a5bb83c6d","name":"Tomfoolery","flags":0,"timestamp":1703805791228,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61a457da15b3ff4a5bb83c6d","name":"Tomfoolery","flags":0,"tags":["104x112"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"619b08dc70bd995987959bf9","username":"bttv_handshake_7tv","display_name":"bttv_handshake_7tv","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/0f547378-a3a7-400f-bb1e-c22bd41f131b-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61a457da15b3ff4a5bb83c6d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":26,"height":32,"frame_count":1,"size":1090,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":26,"height":32,"frame_count":1,"size":898,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":52,"height":64,"frame_count":1,"size":1873,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":52,"height":64,"frame_count":1,"size":1948,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":78,"height":96,"frame_count":1,"size":2931,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":78,"height":96,"frame_count":1,"size":3398,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":104,"height":128,"frame_count":1,"size":3814,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":104,"height":128,"frame_count":1,"size":4610,"format":"WEBP"}]}}},{"id":"63779741da38f5d7f3d6d3db","name":"catErm","flags":0,"timestamp":1703805801102,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63779741da38f5d7f3d6d3db","name":"catErm","flags":0,"tags":["meow","uhh","cat","stare","awkward","erm"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"629994a247051898ec04cad2","username":"clarkpls","display_name":"clarkpls","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/880ec631-b5da-441d-9528-5902d39a5846-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63779741da38f5d7f3d6d3db","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":123,"size":16043,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":122,"size":49198,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":123,"size":32962,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":123,"size":121696,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":123,"size":53207,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":123,"size":193398,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":123,"size":76818,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":123,"size":282340,"format":"WEBP"}]}}},{"id":"6460dd9f240cbc62de5f19f6","name":"meow","flags":0,"timestamp":1703805842759,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6460dd9f240cbc62de5f19f6","name":"meow","flags":0,"tags":["meowing","catmeow","cat","kitty","kitten","gato"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"62efedc8fdc408d6e79c3fe5","username":"serapollyon","display_name":"SerApollyon","avatar_url":"//cdn.7tv.app/user/62efedc8fdc408d6e79c3fe5/av_63a87d07abdd8d304da43805/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6460dd9f240cbc62de5f19f6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":200,"size":25486,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":169,"size":58750,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":200,"size":72760,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":200,"size":261454,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":200,"size":127138,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":200,"size":441814,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":200,"size":302716,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":200,"size":811002,"format":"WEBP"}]}}},{"id":"65442b2798e33b64b0468846","name":"rar","flags":0,"timestamp":1703805856280,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"65442b2798e33b64b0468846","name":"rar","flags":0,"tags":["cat","meow","wink","rawr"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"6196c5d8eecae7a725bbdfcd","username":"m4x0nn","display_name":"m4x0nn","avatar_url":"//cdn.7tv.app/pp/6196c5d8eecae7a725bbdfcd/3ba8c2fdd7f44f85a62fd353d8a24e25","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65442b2798e33b64b0468846","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":146,"size":21269,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":146,"size":63140,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":146,"size":49822,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":146,"size":152252,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":146,"size":81334,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":146,"size":241236,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":146,"size":305498,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":146,"size":419944,"format":"WEBP"}]}}},{"id":"63acb7b5acef59270e7cf4b0","name":"TheVoices","flags":0,"timestamp":1703805869623,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"63acb7b5acef59270e7cf4b0","name":"TheVoices","flags":0,"tags":["cat","schizo","possessed","crazy"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae434b5d3fdae58382926a","username":"ayyybubu","display_name":"ayyybubu","avatar_url":"//cdn.7tv.app/user/60ae434b5d3fdae58382926a/av_636fa88735a18fd8b17e7399/3x.webp","style":{"color":-12171521},"roles":["6102002eab1aa12bf648cfcd","60724f65e93d828bf8858789","62d86a8419fdcf401421c5ae","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63acb7b5acef59270e7cf4b0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":202,"size":53443,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":202,"size":114078,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":202,"size":102865,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":202,"size":205308,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":202,"size":165042,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":202,"size":284332,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":202,"size":228738,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":202,"size":369564,"format":"WEBP"}]}}},{"id":"6427e8a5c529cbb0bb3b18b4","name":"Pointless","flags":0,"timestamp":1703805888728,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6427e8a5c529cbb0bb3b18b4","name":"Pointless","flags":0,"tags":["clueless","pain","despair","aware"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6042058896832ffa785800fe","username":"zhark","display_name":"Zhark","avatar_url":"//cdn.7tv.app/pp/6042058896832ffa785800fe/37ee95ffaa9846b286cb5554ff0716c5","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6427e8a5c529cbb0bb3b18b4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":35,"height":32,"frame_count":1,"size":1488,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":35,"height":32,"frame_count":1,"size":1614,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":70,"height":64,"frame_count":1,"size":3039,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":70,"height":64,"frame_count":1,"size":4138,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":105,"height":96,"frame_count":1,"size":4947,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":105,"height":96,"frame_count":1,"size":7766,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":140,"height":128,"frame_count":1,"size":7325,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":140,"height":128,"frame_count":1,"size":11330,"format":"WEBP"}]}}},{"id":"61e66081095be332e347e5a4","name":"kok","flags":0,"timestamp":1703806260027,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61e66081095be332e347e5a4","name":"kok","flags":0,"tags":["cute","yep","cock","cat"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60b8e47b06e1b0897450b580","username":"mallairr","display_name":"MALLAIRR","avatar_url":"//cdn.7tv.app/user/60b8e47b06e1b0897450b580/av_646a7a0b6989b9b0d46b79b7/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e66081095be332e347e5a4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":70,"size":12315,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":70,"size":45208,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":70,"size":31639,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":70,"size":97588,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":70,"size":61792,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":70,"size":154750,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":70,"size":98359,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":70,"size":166080,"format":"WEBP"}]}}},{"id":"630db7e07b84e74996da9552","name":"Classic","flags":0,"timestamp":1703850113502,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"630db7e07b84e74996da9552","name":"classic","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ac827cc7188f3be2120450","username":"7jeo","display_name":"7JEO","avatar_url":"//cdn.7tv.app/pp/60ac827cc7188f3be2120450/60fc0761d7bb46a89a860da11114b9fc","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/630db7e07b84e74996da9552","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":41,"height":32,"frame_count":51,"size":20410,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":41,"height":32,"frame_count":51,"size":48216,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":82,"height":64,"frame_count":51,"size":55236,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":82,"height":64,"frame_count":51,"size":98966,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":123,"height":96,"frame_count":51,"size":102335,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":123,"height":96,"frame_count":51,"size":162674,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":164,"height":128,"frame_count":51,"size":186264,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":164,"height":128,"frame_count":51,"size":199974,"format":"WEBP"}]}}},{"id":"63ae8e510689991218cdcfee","name":"nnysAward","flags":0,"timestamp":1703875725544,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63ae8e510689991218cdcfee","name":"nnysAward","flags":0,"tags":["nnys"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3cb1b2ecb0150521fa1f","username":"waterboiledpizza","display_name":"WaterBoiledPizza","avatar_url":"//cdn.7tv.app/user/60ae3cb1b2ecb0150521fa1f/av_652806843e9323c51e05082e/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63ae8e510689991218cdcfee","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1154,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1648,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1925,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4402,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2934,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8144,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3678,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":12582,"format":"WEBP"}]}}},{"id":"63b201568730fddbe14d62be","name":"happiParty","flags":0,"timestamp":1703875795411,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63b201568730fddbe14d62be","name":"happiParty","flags":0,"tags":["celebrate","happynewyear","plsdance","fireworks","celebration"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60bd5159a8c00a202bf7425f","username":"a_t_m_0_s","display_name":"A_T_M_0_S","avatar_url":"//cdn.7tv.app/user/60bd5159a8c00a202bf7425f/av_6482d40390f619d9af6922f1/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63b201568730fddbe14d62be","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":98,"size":62544,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":98,"size":81682,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":98,"size":154742,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":98,"size":177296,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":98,"size":262110,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":98,"size":279278,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":98,"size":364078,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":98,"size":377828,"format":"WEBP"}]}}},{"id":"60aeec1712d7701491f89cf5","name":"peepoHey","flags":0,"timestamp":1703877223114,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"60aeec1712d7701491f89cf5","name":"peepoHey","flags":0,"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae49350e35477634486602","username":"justrogan","display_name":"JustRogan","avatar_url":"//cdn.7tv.app/pp/60ae49350e35477634486602/88d6e3c4265f4be0a452c812c146da50","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeec1712d7701491f89cf5","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":6,"size":6460,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":6,"size":4185,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":6,"size":15734,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":6,"size":8030,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":6,"size":12574,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":6,"size":26980,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":6,"size":19349,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":6,"size":31712,"format":"WEBP"}]}}},{"id":"658e4bd06ac2c09e4d867d20","name":"BASED","flags":0,"timestamp":1703885235134,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"658e4bd06ac2c09e4d867d20","name":"BASED","flags":0,"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6053853cb4d31e459fdaa2dc","username":"laden","display_name":"Laden","avatar_url":"//cdn.7tv.app/pp/6053853cb4d31e459fdaa2dc/a94c67d7736940feb543e42024b740ef","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/658e4bd06ac2c09e4d867d20","files":[{"name":"1x.webp","static_name":"1x_static.webp","width":34,"height":32,"frame_count":1,"size":1366,"format":"WEBP"},{"name":"1x.avif","static_name":"1x_static.avif","width":34,"height":32,"frame_count":1,"size":1270,"format":"AVIF"},{"name":"2x.avif","static_name":"2x_static.avif","width":68,"height":64,"frame_count":1,"size":2087,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":68,"height":64,"frame_count":1,"size":3580,"format":"WEBP"},{"name":"3x.webp","static_name":"3x_static.webp","width":102,"height":96,"frame_count":1,"size":6368,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":102,"height":96,"frame_count":1,"size":2954,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":136,"height":128,"frame_count":1,"size":3741,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":136,"height":128,"frame_count":1,"size":8640,"format":"WEBP"}]}}},{"id":"60aeb6cce90f445e43c89540","name":"Based","flags":0,"timestamp":1703886055673,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60aeb6cce90f445e43c89540","name":"Based","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60aeb501955615deef869415","username":"froglin_","display_name":"Froglin_","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/5a020ff1-3768-4f68-8d75-d56f2dee8403-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60aeb6cce90f445e43c89540","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1093,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":786,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1712,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1795,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2760,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2728,"format":"WEBP"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3468,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":3470,"format":"AVIF"}]}}},{"id":"63c2fcb567221ee072b9c47c","name":"docYell","flags":0,"timestamp":1703936047266,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63c2fcb567221ee072b9c47c","name":"docYell","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6319e48e29a5627b71e323f0","username":"slippindanny","display_name":"slippindanny","avatar_url":"//cdn.7tv.app/user/6319e48e29a5627b71e323f0/av_63eae041eb1af0564608ee0f/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c2fcb567221ee072b9c47c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":43,"height":32,"frame_count":38,"size":14425,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":43,"height":32,"frame_count":38,"size":27358,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":86,"height":64,"frame_count":38,"size":30676,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":86,"height":64,"frame_count":38,"size":53052,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":129,"height":96,"frame_count":38,"size":52209,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":129,"height":96,"frame_count":38,"size":75112,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":172,"height":128,"frame_count":38,"size":75442,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":172,"height":128,"frame_count":38,"size":95824,"format":"WEBP"}]}}},{"id":"63915e53209bcb04cf0aa45d","name":"(7TV)","flags":0,"timestamp":1703936472668,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63915e53209bcb04cf0aa45d","name":"7tvM","flags":0,"tags":["seventv"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60f5e290e57bec021618c4a4","username":"ansonx10","display_name":"AnsonX10","avatar_url":"//cdn.7tv.app/user/60f5e290e57bec021618c4a4/av_63617cc39018da6429bc0298/3x_static.webp","style":{"color":401323775},"roles":["60b3f1ea886e63449c5263b1","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63915e53209bcb04cf0aa45d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":947,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":514,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1168,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1044,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":1717,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":1610,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2057,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":2412,"format":"WEBP"}]}}},{"id":"612a60c9fef79a90b279b428","name":"GoslingClap","flags":0,"timestamp":1703956600951,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"612a60c9fef79a90b279b428","name":"GoslingClap","flags":0,"tags":["gosling","clap","ryan","drive","blade","runner"],"lifecycle":3,"state":["PERSONAL","LISTED"],"listed":true,"animated":true,"owner":{"id":"61193cfa8efc177ec41f09f9","username":"mentalsway","display_name":"MentalSway","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/20dff717-12cd-4e16-b124-10d56033b425-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/612a60c9fef79a90b279b428","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":9,"size":4174,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":9,"size":6514,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":9,"size":16134,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":9,"size":7852,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":9,"size":26614,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":9,"size":13541,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":9,"size":18866,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":9,"size":34948,"format":"WEBP"}]}}},{"id":"63e79f39743d199fec640849","name":"!join","flags":0,"timestamp":1703971587763,"actor_id":"60ae434b5d3fdae58382926a","data":{"id":"63e79f39743d199fec640849","name":"!join","flags":0,"tags":["xijinping","china"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae3e98b2ecb0150535c6b7","username":"gempir","display_name":"gempir","avatar_url":"//cdn.7tv.app/pp/60ae3e98b2ecb0150535c6b7/4aa1786cec024098be20d7b0683bae72","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63e79f39743d199fec640849","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":60,"height":32,"frame_count":104,"size":30917,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":60,"height":32,"frame_count":104,"size":67704,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":120,"height":64,"frame_count":104,"size":71561,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":120,"height":64,"frame_count":104,"size":118408,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":180,"height":96,"frame_count":104,"size":109221,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":180,"height":96,"frame_count":104,"size":164782,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":240,"height":128,"frame_count":104,"size":135455,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":240,"height":128,"frame_count":104,"size":206924,"format":"WEBP"}]}}},{"id":"655913a651da2a96e6f1d410","name":"2024NymN","flags":0,"timestamp":1704028196129,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"655913a651da2a96e6f1d410","name":"NymNgal","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae409daee2aa55383ebb4b","username":"tolatos","display_name":"tolatos","avatar_url":"//cdn.7tv.app/user/60ae409daee2aa55383ebb4b/av_657b75e8b0d945ef35823739/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/655913a651da2a96e6f1d410","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1121,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1700,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2157,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4892,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3206,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8772,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4513,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13372,"format":"WEBP"}]}}},{"id":"6591c434489e26710946d33c","name":"yawN","flags":0,"timestamp":1704051918056,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6591c434489e26710946d33c","name":"yawN","flags":0,"tags":["apollo","yawn","cat","nymn"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6591c434489e26710946d33c","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":36,"height":32,"frame_count":141,"size":24104,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":36,"height":32,"frame_count":140,"size":48816,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":72,"height":64,"frame_count":141,"size":47728,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":72,"height":64,"frame_count":141,"size":107236,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":108,"height":96,"frame_count":141,"size":80939,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":108,"height":96,"frame_count":141,"size":169578,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":144,"height":128,"frame_count":141,"size":113979,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":144,"height":128,"frame_count":141,"size":231668,"format":"WEBP"}]}}},{"id":"6592018adbf474d8368def08","name":"FirstTimeDrama","flags":0,"timestamp":1704067635418,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6592018adbf474d8368def08","name":"FirstTimeDrama","flags":0,"tags":["firsttime","pokimane","drama"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6592018adbf474d8368def08","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":64,"height":32,"frame_count":64,"size":16696,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":64,"height":32,"frame_count":64,"size":30200,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":128,"height":64,"frame_count":64,"size":52943,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":128,"height":64,"frame_count":64,"size":69196,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":192,"height":96,"frame_count":64,"size":103033,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":192,"height":96,"frame_count":64,"size":107740,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":256,"height":128,"frame_count":64,"size":190853,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":256,"height":128,"frame_count":64,"size":151800,"format":"WEBP"}]}}},{"id":"643215d2ab2e0a86b89ab9c3","name":"cumBo","flags":0,"timestamp":1704069622660,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"643215d2ab2e0a86b89ab9c3","name":"cumBo","flags":0,"tags":["moonmoon","cumbo","wisdom"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"643215bf8f3d9aa185267e1d","username":"patrickhaze","display_name":"PatrickHaze","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/patrickhaze-profile_image-ffdb8f022159bdc5-70x70.jpeg","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/643215d2ab2e0a86b89ab9c3","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1050,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1930,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2260,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5668,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4058,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11498,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5786,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":18740,"format":"WEBP"}]}}},{"id":"6477b0f70c7cd505faf2c1c9","name":"pu","flags":0,"timestamp":1704239586927,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"6477b0f70c7cd505faf2c1c9","name":"CuteKitten","flags":0,"tags":["meow","myah","socute","kitty","cutecat","catwalk"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"614cb75a6251d7e000da4ce7","username":"eljugay","display_name":"eljuGay","avatar_url":"//cdn.7tv.app/user/614cb75a6251d7e000da4ce7/av_648f957bb3fdb6379f1e9b9b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6477b0f70c7cd505faf2c1c9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":322,"size":93436,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":322,"size":161824,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":322,"size":224834,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":322,"size":385032,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":322,"size":346351,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":322,"size":598638,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":322,"size":553038,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":322,"size":938848,"format":"WEBP"}]}}},{"id":"61c340c444cb589796afbb3a","name":"THIS","flags":0,"timestamp":1704282379417,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"61c340c444cb589796afbb3a","name":"THIS","flags":0,"tags":["weeb","lewd","booba"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61ac447affa9aba101bcd05c","username":"leinad_osnola","display_name":"leinad_osnola","avatar_url":"//cdn.7tv.app/user/61ac447affa9aba101bcd05c/av_65939f8e52e38f90f334c199/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61c340c444cb589796afbb3a","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":16,"size":8293,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":16,"size":18096,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":16,"size":19273,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":16,"size":40476,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":16,"size":34005,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":16,"size":65308,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":16,"size":57238,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":16,"size":60052,"format":"WEBP"}]}}},{"id":"62af8dd26f979a8714748dd2","name":"veryFors","flags":0,"timestamp":1704300301975,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"62af8dd26f979a8714748dd2","name":"veryFors","flags":0,"tags":["forsen","dvd","bad361","filmfestival","artifact","dlc"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"61e508463441abfa431cf11a","username":"mlgquikscp420","display_name":"mlgquikscp420","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/470234fe-4a9b-4f72-aed9-c68acdb83724-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62af8dd26f979a8714748dd2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":166,"size":32816,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":166,"size":143096,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":166,"size":69122,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":166,"size":305940,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":166,"size":118528,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":166,"size":496470,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":166,"size":271195,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":166,"size":757692,"format":"WEBP"}]}}},{"id":"6158902693686fbfe7fbf5ad","name":"Brazil","flags":0,"timestamp":1704300309976,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6158902693686fbfe7fbf5ad","name":"Brazil","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae99afac03cad6076c6cf1","username":"sushiguh","display_name":"sushiguh","avatar_url":"//cdn.7tv.app/user/60ae99afac03cad6076c6cf1/av_656b9d423d10142edc61c2eb/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6158902693686fbfe7fbf5ad","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":86,"size":14286,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":86,"size":50008,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":86,"size":58577,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":86,"size":110318,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":86,"size":101709,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":86,"size":172522,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":86,"size":195248,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":86,"size":201206,"format":"WEBP"}]}}},{"id":"61e454f03441abfa431cd1c9","name":"7TV","flags":0,"timestamp":1704300485288,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"61e454f03441abfa431cd1c9","name":"dumpsterFire","flags":0,"tags":["dumpster","garbage","fire","crazy"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"61075734128aa13dd5eaf41a","username":"ader","display_name":"ader","avatar_url":"//cdn.7tv.app/user/61075734128aa13dd5eaf41a/av_63f59532f2915b442ca85cbd/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61e454f03441abfa431cd1c9","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":16,"size":11008,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":16,"size":16286,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":16,"size":26780,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":16,"size":41706,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":16,"size":45291,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":16,"size":74700,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":16,"size":79638,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":16,"size":94470,"format":"WEBP"}]}}},{"id":"62530ebfb0dfc5aeb040acc2","name":"Shrugeg","flags":0,"timestamp":1704300838695,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"62530ebfb0dfc5aeb040acc2","name":"Shrugeg","flags":0,"tags":["shrug","okayeg"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"60aebfce6cfcffe15f119c18","username":"namtheweebs","display_name":"NaMTheWeebs","avatar_url":"//cdn.7tv.app/user/60aebfce6cfcffe15f119c18/av_6579da7eac03816ec8c078b9/3x.webp","style":{"color":849892095},"roles":["60724f65e93d828bf8858789","612c888812a39cc5cdd82ae0","6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62530ebfb0dfc5aeb040acc2","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":39,"height":32,"frame_count":1,"size":1681,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":39,"height":32,"frame_count":1,"size":1310,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":78,"height":64,"frame_count":1,"size":3356,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":78,"height":64,"frame_count":1,"size":3162,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":117,"height":96,"frame_count":1,"size":4927,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":117,"height":96,"frame_count":1,"size":5226,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":156,"height":128,"frame_count":1,"size":6827,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":156,"height":128,"frame_count":1,"size":7814,"format":"WEBP"}]}}},{"id":"6042290277137b000de9e68d","name":"WideHardo","flags":0,"timestamp":1704301185325,"actor_id":"60ae759bdf5735e04acb69d9","data":{"id":"6042290277137b000de9e68d","name":"WideHardo","flags":0,"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"000000000000000000000000","username":"","display_name":"","style":{}},"host":{"url":"//cdn.7tv.app/emote/6042290277137b000de9e68d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":25,"frame_count":1,"size":1890,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":25,"frame_count":1,"size":1750,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":50,"frame_count":1,"size":3503,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":50,"frame_count":1,"size":3952,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":75,"frame_count":1,"size":5259,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":75,"frame_count":1,"size":6426,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":100,"frame_count":1,"size":7138,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":100,"frame_count":1,"size":9450,"format":"WEBP"}]}}},{"id":"6595e92283833b99670ffb8d","name":"!nympts","flags":0,"timestamp":1704323380006,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6595e92283833b99670ffb8d","name":"nymnCoin","flags":0,"tags":["donk","feelsdonkman","nymn","coin","donkcoin"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae3e3eb2ecb01505346ae9","username":"fawcan","display_name":"Fawcan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/38051bdb-83c6-4716-95e0-731462e02b45-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6595e92283833b99670ffb8d","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1486,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1876,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2723,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4648,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4226,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":8572,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5514,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":13336,"format":"WEBP"}]}}},{"id":"62e25bfcd0dac916414d25cb","name":"angrE","flags":0,"timestamp":1704385162382,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"62e25bfcd0dac916414d25cb","name":"angrE","flags":0,"tags":["lule","angry","rage","mad","anger","forsen"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":false,"owner":{"id":"62dfad28e2f69efc6a2c84b7","username":"esperdg","display_name":"EsperDG","avatar_url":"//cdn.7tv.app/user/62dfad28e2f69efc6a2c84b7/av_6515a414e66ad3b2e8846aab/3x.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/62e25bfcd0dac916414d25cb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1224,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1002,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2500,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2540,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4096,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4484,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":5428,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":6558,"format":"WEBP"}]}}},{"id":"658a6f8c1d1f4b980881c762","name":"LacariWide","flags":0,"timestamp":1704386084804,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"658a6f8c1d1f4b980881c762","name":"LacariWide","flags":0,"tags":["lacari","drakewide"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6259b0ce85873c051292d20a","username":"croodini","display_name":"Croodini","avatar_url":"//cdn.7tv.app/user/6259b0ce85873c051292d20a/av_648ec2d5b3fdb6379f1e6a21/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/658a6f8c1d1f4b980881c762","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":59,"height":32,"frame_count":1,"size":1473,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":59,"height":32,"frame_count":1,"size":3160,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":118,"height":64,"frame_count":1,"size":2896,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":118,"height":64,"frame_count":1,"size":10166,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":177,"height":96,"frame_count":1,"size":4592,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":177,"height":96,"frame_count":1,"size":20284,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":236,"height":128,"frame_count":1,"size":6335,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":236,"height":128,"frame_count":1,"size":32738,"format":"WEBP"}]}}},{"id":"65413498dc0468e8c1fbcdc6","name":"hiii","flags":0,"timestamp":1704391602159,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"65413498dc0468e8c1fbcdc6","name":"hiii","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"63032593f7bf6645d6463dd7","username":"ockbephutejlb","display_name":"OcKBePHuTeJlb","avatar_url":"//cdn.7tv.app/user/63032593f7bf6645d6463dd7/av_6475966061d5da625fd8bb56/3x_static.webp","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/65413498dc0468e8c1fbcdc6","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":8,"size":5892,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":8,"size":3384,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":8,"size":13357,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":8,"size":7458,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":8,"size":21392,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":8,"size":11280,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":8,"size":50942,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":8,"size":18516,"format":"WEBP"}]}}},{"id":"6595df3083833b99670ffa40","name":"AlienApprove","flags":0,"timestamp":1704393714241,"actor_id":"60ae518c0e35477634c151f1","data":{"id":"6595df3083833b99670ffa40","name":"AlienPleased","flags":0,"tags":["please","pleased","thumbsup","alien","alienplease","alienpleasing"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6319044a8e1ba8ff6ec1887d","username":"corgicam","display_name":"CorgiCam","avatar_url":"//cdn.7tv.app/user/6319044a8e1ba8ff6ec1887d/av_657b5bca4b9a7c23f770714b/3x.webp","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6595df3083833b99670ffa40","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1379,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1994,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2662,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":5878,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":4264,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":11736,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":6037,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":19554,"format":"WEBP"}]}}},{"id":"6404af5985fbda46564f38c4","name":"plenk","flags":0,"timestamp":1704457395216,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6404af5985fbda46564f38c4","name":"plenk","flags":0,"tags":["basedgebot","plink","plonk","blink","cat","kot"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"62ca13e6a7ffd3f6119c7f6a","username":"howeverbot","display_name":"HoweverBot","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/47dbcfe4-08cf-459d-b90d-e33f10c767d7-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6404af5985fbda46564f38c4","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":67,"height":32,"frame_count":181,"size":33012,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":67,"height":32,"frame_count":181,"size":119098,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":134,"height":64,"frame_count":181,"size":98138,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":134,"height":64,"frame_count":181,"size":276494,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":201,"height":96,"frame_count":181,"size":280358,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":201,"height":96,"frame_count":181,"size":474814,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":268,"height":128,"frame_count":181,"size":623470,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":268,"height":128,"frame_count":181,"size":732886,"format":"WEBP"}]}}},{"id":"6597e7cabf3a5b70a4b2c5bb","name":"catBite","flags":0,"timestamp":1704471409849,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"6597e7cabf3a5b70a4b2c5bb","name":"catBite","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":true,"owner":{"id":"6187ed1e8d50b5f26ee83400","username":"pepewpert","display_name":"pepewpert","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/aa0769ce-5a81-4f78-9fce-f0a8e9d6b033-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6597e7cabf3a5b70a4b2c5bb","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":56,"size":26364,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":55,"size":27820,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":56,"size":57232,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":56,"size":61136,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":56,"size":91709,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":56,"size":93346,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":56,"size":125156,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":56,"size":127748,"format":"WEBP"}]}}},{"id":"64f0a894367809abe2382529","name":"GAGAGA","flags":0,"timestamp":1704471422758,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"64f0a894367809abe2382529","name":"GAGAGA","flags":0,"tags":["laughingatyou","hand","pointing","cat","wajaja"],"lifecycle":3,"state":["LISTED","NO_PERSONAL"],"listed":true,"animated":false,"owner":{"id":"6373b335d222154d7b7c8c29","username":"alchemicalpower","display_name":"Alchemicalpower","avatar_url":"//static-cdn.jtvnw.net/user-default-pictures-uv/ce57700a-def9-11e9-842d-784f43822e80-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/64f0a894367809abe2382529","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":33,"height":32,"frame_count":1,"size":1610,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":33,"height":32,"frame_count":1,"size":2546,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":66,"height":64,"frame_count":1,"size":7590,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":66,"height":64,"frame_count":1,"size":3114,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":99,"height":96,"frame_count":1,"size":4219,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":99,"height":96,"frame_count":1,"size":13812,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":132,"height":128,"frame_count":1,"size":5663,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":132,"height":128,"frame_count":1,"size":22462,"format":"WEBP"}]}}},{"id":"63c2563bb858b11b76ab7555","name":"xddShrug","flags":0,"timestamp":1704478474491,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63c2563bb858b11b76ab7555","name":"xddShrug","flags":0,"tags":["shrug","xdd"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"62e3724644341b225c119238","username":"ultra_mp","display_name":"ULTRA_MP","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/bbf90e67-1360-4d2d-8292-f3ca4aee605c-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63c2563bb858b11b76ab7555","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":96,"height":32,"frame_count":1,"size":1889,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":96,"height":32,"frame_count":1,"size":3746,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":192,"height":64,"frame_count":1,"size":11100,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":192,"height":64,"frame_count":1,"size":3727,"format":"AVIF"},{"name":"3x.avif","static_name":"3x_static.avif","width":288,"height":96,"frame_count":1,"size":5585,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":288,"height":96,"frame_count":1,"size":20922,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":384,"height":128,"frame_count":1,"size":7826,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":384,"height":128,"frame_count":1,"size":33600,"format":"WEBP"}]}}},{"id":"61ed6c9fcc9507d24fd4dc0e","name":"guraFukkireta","flags":0,"timestamp":1704478506291,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"61ed6c9fcc9507d24fd4dc0e","name":"guraFukkireta","flags":0,"tags":["forsen","gawrgura","hololive","fukireta","dance","pls"],"lifecycle":3,"state":["LISTED","PERSONAL"],"listed":true,"animated":true,"owner":{"id":"60ae22bcaee2aa55388ed686","username":"suntgigel","display_name":"SuntGigel","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/f47315ef-8aac-46f3-9668-37f932446f65-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/61ed6c9fcc9507d24fd4dc0e","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":56,"size":33543,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":56,"size":63102,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":56,"size":94754,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":56,"size":165384,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":56,"size":173907,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":56,"size":292326,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":56,"size":284306,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":56,"size":361884,"format":"WEBP"}]}}},{"id":"6254f968b0dfc5aeb040d215","name":"eggsdd","flags":0,"timestamp":1704478538912,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"6254f968b0dfc5aeb040d215","name":"eggsdd","flags":0,"tags":["xdd","eggs"],"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"6188881df1ae15abc7ec4e08","username":"legion2k","display_name":"Legion2k","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/144fd55f-30a1-426c-8fd1-9e10e423acf6-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/6254f968b0dfc5aeb040d215","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":57,"height":32,"frame_count":1,"size":1386,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":57,"height":32,"frame_count":1,"size":1166,"format":"WEBP"},{"name":"2x.webp","static_name":"2x_static.webp","width":114,"height":64,"frame_count":1,"size":2702,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":114,"height":64,"frame_count":1,"size":2483,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":171,"height":96,"frame_count":1,"size":4324,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":171,"height":96,"frame_count":1,"size":3421,"format":"AVIF"},{"name":"4x.avif","static_name":"4x_static.avif","width":228,"height":128,"frame_count":1,"size":4567,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":228,"height":128,"frame_count":1,"size":6014,"format":"WEBP"}]}}},{"id":"60af262d57a061bbef2a54a0","name":"pajaArch","flags":0,"timestamp":1704490819528,"actor_id":"60ae3e3eb2ecb01505346ae9","data":{"id":"60af262d57a061bbef2a54a0","name":"pajaArch","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"60ae750eb351b8d1c083f5ec","username":"znixp","display_name":"zNIXp","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/8569f469-d7ee-468b-b718-e87459b2278a-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/60af262d57a061bbef2a54a0","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":913,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":746,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":1485,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":1622,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":2066,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":2680,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":2742,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":3206,"format":"WEBP"}]}}},{"id":"63d5c78c1608839c49516333","name":"eww","flags":0,"timestamp":1704530399406,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63d5c78c1608839c49516333","name":"eww","flags":0,"tags":["eww","plink","monkeycatluna","dansgame"],"lifecycle":3,"state":["NO_PERSONAL","LISTED"],"listed":true,"animated":false,"owner":{"id":"60ff0e7a25bb6dd0b03e40f9","username":"saffybop","display_name":"saffybop","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fd91a409-b82f-474f-a83f-45ab6e4bc3f1-profile_image-70x70.png","style":{"color":-5635841},"roles":["6076a86b09a4c63a38ebe801","62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63d5c78c1608839c49516333","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1100,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":1578,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2063,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":4390,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3113,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":7878,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4282,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":11668,"format":"WEBP"}]}}},{"id":"63537d16ea29b6bd62ac9b08","name":"Catfoolery","flags":0,"timestamp":1704550681325,"actor_id":"6118facd8efc177ec41f0718","data":{"id":"63537d16ea29b6bd62ac9b08","name":"Catfoolery","flags":0,"lifecycle":3,"state":["LISTED"],"listed":true,"animated":false,"owner":{"id":"615eadc703c9e8ba70eb6591","username":"mokshaman","display_name":"MokshaMan","avatar_url":"//static-cdn.jtvnw.net/jtv_user_pictures/fa9c373f-4818-48b1-8f9b-6b56c3e800b9-profile_image-70x70.png","style":{},"roles":["62b48deb791a15a25c2a0354"]},"host":{"url":"//cdn.7tv.app/emote/63537d16ea29b6bd62ac9b08","files":[{"name":"1x.avif","static_name":"1x_static.avif","width":32,"height":32,"frame_count":1,"size":1123,"format":"AVIF"},{"name":"1x.webp","static_name":"1x_static.webp","width":32,"height":32,"frame_count":1,"size":904,"format":"WEBP"},{"name":"2x.avif","static_name":"2x_static.avif","width":64,"height":64,"frame_count":1,"size":2060,"format":"AVIF"},{"name":"2x.webp","static_name":"2x_static.webp","width":64,"height":64,"frame_count":1,"size":2518,"format":"WEBP"},{"name":"3x.avif","static_name":"3x_static.avif","width":96,"height":96,"frame_count":1,"size":3067,"format":"AVIF"},{"name":"3x.webp","static_name":"3x_static.webp","width":96,"height":96,"frame_count":1,"size":4976,"format":"WEBP"},{"name":"4x.avif","static_name":"4x_static.avif","width":128,"height":128,"frame_count":1,"size":4112,"format":"AVIF"},{"name":"4x.webp","static_name":"4x_static.webp","width":128,"height":128,"frame_count":1,"size":7772,"format":"WEBP"}]}}}],"emote_count":837,"capacity":42069,"owner":{"id":"60ae3c29b2ecb015051f8f9a","username":"nymn","display_name":"NymN","avatar_url":"//cdn.7tv.app/pp/60ae3c29b2ecb015051f8f9a/71f269555aeb44c29100cae8aa59b56b","style":{"color":-1857617921},"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"]}},"user":{"id":"60ae3c29b2ecb015051f8f9a","username":"nymn","display_name":"NymN","created_at":1622031401000,"avatar_url":"//cdn.7tv.app/pp/60ae3c29b2ecb015051f8f9a/71f269555aeb44c29100cae8aa59b56b","biography":"Click on the FOLLOW button maybe you get like noclip or something","style":{"color":-1857617921},"editors":[{"id":"60ae3e98b2ecb0150535c6b7","permissions":255,"visible":true,"added_at":1657657510033},{"id":"60ae8fc0ea50f43c9e3ae255","permissions":223,"visible":true,"added_at":1657657510033},{"id":"6118facd8efc177ec41f0718","permissions":17,"visible":true,"added_at":1657657510033},{"id":"60ae434b5d3fdae58382926a","permissions":223,"visible":true,"added_at":1664132908240},{"id":"60ae3e3eb2ecb01505346ae9","permissions":255,"visible":true,"added_at":1677529142453},{"id":"60ae518c0e35477634c151f1","permissions":17,"visible":true,"added_at":1684838378100},{"id":"60ae759bdf5735e04acb69d9","permissions":17,"visible":true,"added_at":1692188057245}],"roles":["6076a99409a4c63a38ebe802","62b48deb791a15a25c2a0354"],"connections":[{"id":"62300805","platform":"TWITCH","username":"nymn","display_name":"NymN","linked_at":1622031401000,"emote_capacity":42069,"emote_set_id":null,"emote_set":{"id":"63b02874ad025a672cb4969f","name":"","flags":0,"tags":[],"immutable":false,"privileged":false,"capacity":0,"owner":null}}]}} \ No newline at end of file diff --git a/benchmarks/src/Emojis.cpp b/benchmarks/src/Emojis.cpp index 7eb5106e3e3..aa64efc6ad1 100644 --- a/benchmarks/src/Emojis.cpp +++ b/benchmarks/src/Emojis.cpp @@ -55,3 +55,128 @@ static void BM_ShortcodeParsing(benchmark::State &state) } BENCHMARK(BM_ShortcodeParsing); + +static void BM_EmojiParsing(benchmark::State &state) +{ + Emojis emojis; + + emojis.load(); + + struct TestCase { + QString input; + std::vector> expectedOutput; + }; + + const auto &emojiMap = emojis.getEmojis(); + auto getEmoji = [&](auto code) { + std::shared_ptr emoji; + for (const auto &e : emojis.getEmojis()) + { + if (e->unifiedCode == code) + { + emoji = e; + break; + } + } + return emoji->emote; + }; + auto penguinEmoji = getEmoji("1F427"); + assert(penguinEmoji.get() != nullptr); + + std::vector tests{ + { + // 1 emoji + "foo 🐧 bar", + // expected output + { + "foo ", + penguinEmoji, + " bar", + }, + }, + { + // no emoji + "foo bar", + // expected output + { + "foo bar", + }, + }, + { + // many emoji + "foo 🐧 bar 🐧🐧🐧🐧🐧", + // expected output + { + "foo ", + penguinEmoji, + " bar ", + penguinEmoji, + penguinEmoji, + penguinEmoji, + penguinEmoji, + penguinEmoji, + }, + }, + }; + + for (auto _ : state) + { + for (const auto &test : tests) + { + auto output = emojis.parse(test.input); + + bool areEqual = std::equal(output.begin(), output.end(), + test.expectedOutput.begin()); + + if (!areEqual) + { + qDebug() << "BAD BENCH"; + for (const auto &v : output) + { + if (v.type() == typeid(QString)) + { + qDebug() << "output:" << boost::get(v); + } + } + } + } + } +} + +BENCHMARK(BM_EmojiParsing); + +static void BM_EmojiParsing2(benchmark::State &state, const QString &input, + int expectedNumEmojis) +{ + Emojis emojis; + + emojis.load(); + + for (auto _ : state) + { + auto output = emojis.parse(input); + int actualNumEmojis = 0; + for (const auto &part : output) + { + if (part.type() == typeid(EmotePtr)) + { + ++actualNumEmojis; + } + } + + if (actualNumEmojis != expectedNumEmojis) + { + qDebug() << "BAD BENCH, EXPECTED NUM EMOJIS IS WRONG" + << actualNumEmojis; + } + } +} + +BENCHMARK_CAPTURE(BM_EmojiParsing2, one_emoji, "foo 🐧 bar", 1); +BENCHMARK_CAPTURE(BM_EmojiParsing2, two_emoji, "foo 🐧 bar 🐧", 2); +BENCHMARK_CAPTURE( + BM_EmojiParsing2, many_emoji, + "😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 " + "😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 " + "😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂 ", + 61); diff --git a/benchmarks/src/FormatTime.cpp b/benchmarks/src/FormatTime.cpp index cf63e4cad46..d504182906f 100644 --- a/benchmarks/src/FormatTime.cpp +++ b/benchmarks/src/FormatTime.cpp @@ -4,35 +4,41 @@ using namespace chatterino; -template -void BM_TimeFormatting(benchmark::State &state, Args &&...args) +void BM_TimeFormattingQString(benchmark::State &state, const QString &v) { - auto args_tuple = std::make_tuple(std::move(args)...); for (auto _ : state) { - formatTime(std::get<0>(args_tuple)); + formatTime(v); } } -BENCHMARK_CAPTURE(BM_TimeFormatting, 0, 0); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs0, "0"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 1337, 1337); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs1337, "1337"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 623452, 623452); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs623452, "623452"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 8345, 8345); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs8345, "8345"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 314034, 314034); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs314034, "314034"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 27, 27); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs27, "27"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 34589, 34589); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs34589, "34589"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 3659, 3659); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs3659, "3659"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 1045345, 1045345); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs1045345, "1045345"); -BENCHMARK_CAPTURE(BM_TimeFormatting, 86432, 86432); -BENCHMARK_CAPTURE(BM_TimeFormatting, qs86432, "86432"); -BENCHMARK_CAPTURE(BM_TimeFormatting, qsempty, ""); -BENCHMARK_CAPTURE(BM_TimeFormatting, qsinvalid, "asd"); +void BM_TimeFormattingInt(benchmark::State &state, int v) +{ + for (auto _ : state) + { + formatTime(v); + } +} + +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 0, 0); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 1045345, 1045345); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 1337, 1337); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 27, 27); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 314034, 314034); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 34589, 34589); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 3659, 3659); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 623452, 623452); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 8345, 8345); +BENCHMARK_CAPTURE(BM_TimeFormattingInt, 86432, 86432); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs0, "0"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs1045345, "1045345"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs1337, "1337"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs27, "27"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs314034, "314034"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs34589, "34589"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs3659, "3659"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs623452, "623452"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs8345, "8345"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qs86432, "86432"); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qsempty, ""); +BENCHMARK_CAPTURE(BM_TimeFormattingQString, qsinvalid, "asd"); diff --git a/benchmarks/src/Highlights.cpp b/benchmarks/src/Highlights.cpp index e87a38bac37..c363e508c51 100644 --- a/benchmarks/src/Highlights.cpp +++ b/benchmarks/src/Highlights.cpp @@ -1,16 +1,18 @@ #include "Application.hpp" -#include "BaseSettings.hpp" #include "common/Channel.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/highlights/HighlightController.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "messages/Message.hpp" #include "messages/SharedMessageBuilder.hpp" +#include "mocks/EmptyApplication.hpp" +#include "singletons/Settings.hpp" #include "util/Helpers.hpp" #include #include #include +#include using namespace chatterino; @@ -45,61 +47,17 @@ class BenchmarkMessageBuilder : public SharedMessageBuilder } }; -class MockApplication : IApplication +class MockApplication : mock::EmptyApplication { public: - Theme *getThemes() override - { - return nullptr; - } - Fonts *getFonts() override - { - return nullptr; - } - Emotes *getEmotes() override - { - return nullptr; - } AccountController *getAccounts() override { return &this->accounts; } - HotkeyController *getHotkeys() override - { - return nullptr; - } - WindowManager *getWindows() override - { - return nullptr; - } - Toasts *getToasts() override - { - return nullptr; - } - CommandController *getCommands() override - { - return nullptr; - } - NotificationController *getNotifications() override - { - return nullptr; - } HighlightController *getHighlights() override { return &this->highlights; } - TwitchIrcServer *getTwitch() override - { - return nullptr; - } - ChatterinoBadges *getChatterinoBadges() override - { - return nullptr; - } - FfzBadges *getFfzBadges() override - { - return nullptr; - } AccountController accounts; HighlightController highlights; @@ -109,7 +67,8 @@ class MockApplication : IApplication static void BM_HighlightTest(benchmark::State &state) { MockApplication mockApplication; - Settings settings("/tmp/c2-mock"); + QTemporaryDir settingsDir; + Settings settings(settingsDir.path()); std::string message = R"(@badge-info=subscriber/34;badges=moderator/1,subscriber/24;color=#FF0000;display-name=테스트계정420;emotes=41:6-13,15-22;flags=;id=a3196c7e-be4c-4b49-9c5a-8b8302b50c2a;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1590922213730;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :-tags Kreygasm,Kreygasm (no space))"; diff --git a/benchmarks/src/LinkParser.cpp b/benchmarks/src/LinkParser.cpp new file mode 100644 index 00000000000..3a331242845 --- /dev/null +++ b/benchmarks/src/LinkParser.cpp @@ -0,0 +1,40 @@ +#include "common/LinkParser.hpp" + +#include +#include +#include +#include + +#include + +using namespace chatterino; + +const QString INPUT = QStringLiteral( + "If your Chatterino isn't loading FFZ emotes, update to the latest nightly " + "(or 2.4.2 if its out) " + "https://github.com/Chatterino/chatterino2/releases/tag/nightly-build " + "AlienPls https://www.youtube.com/watch?v=ELBBiBDcWc0 " + "127.0.3 aaaa xd 256.256.256.256 AsdQwe xd 127.0.0.1 https://. https://.be " + "https://a http://a.b https://a.be ftp://xdd.com " + "this is a text lol . ://foo.com //aa.de :/foo.de xd.XDDDDDD "); + +static void BM_LinkParsing(benchmark::State &state) +{ + QStringList words = INPUT.split(' '); + + // Make sure the TLDs are loaded + { + benchmark::DoNotOptimize(LinkParser("xd.com").result()); + } + + for (auto _ : state) + { + for (auto word : words) + { + LinkParser parser(word); + benchmark::DoNotOptimize(parser.result()); + } + } +} + +BENCHMARK(BM_LinkParsing); diff --git a/benchmarks/src/RecentMessages.cpp b/benchmarks/src/RecentMessages.cpp new file mode 100644 index 00000000000..9bfc82cfd21 --- /dev/null +++ b/benchmarks/src/RecentMessages.cpp @@ -0,0 +1,223 @@ +#include "common/Literals.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/highlights/HighlightController.hpp" +#include "messages/Emote.hpp" +#include "mocks/EmptyApplication.hpp" +#include "mocks/TwitchIrcServer.hpp" +#include "mocks/UserData.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/chatterino/ChatterinoBadges.hpp" +#include "providers/ffz/FfzBadges.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/recentmessages/Impl.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Resources.hpp" + +#include +#include +#include +#include +#include + +#include + +using namespace chatterino; +using namespace literals; + +namespace { + +class MockApplication : mock::EmptyApplication +{ +public: + IEmotes *getEmotes() override + { + return &this->emotes; + } + + IUserDataController *getUserData() override + { + return &this->userData; + } + + AccountController *getAccounts() override + { + return &this->accounts; + } + + ITwitchIrcServer *getTwitch() override + { + return &this->twitch; + } + + ChatterinoBadges *getChatterinoBadges() override + { + return &this->chatterinoBadges; + } + + FfzBadges *getFfzBadges() override + { + return &this->ffzBadges; + } + + SeventvBadges *getSeventvBadges() override + { + return &this->seventvBadges; + } + + HighlightController *getHighlights() override + { + return &this->highlights; + } + + AccountController accounts; + Emotes emotes; + mock::UserDataController userData; + mock::MockTwitchIrcServer twitch; + ChatterinoBadges chatterinoBadges; + FfzBadges ffzBadges; + SeventvBadges seventvBadges; + HighlightController highlights; +}; + +std::optional tryReadJsonFile(const QString &path) +{ + QFile file(path); + if (!file.open(QFile::ReadOnly)) + { + return std::nullopt; + } + + QJsonParseError e; + auto doc = QJsonDocument::fromJson(file.readAll(), &e); + if (e.error != QJsonParseError::NoError) + { + return std::nullopt; + } + + return doc; +} + +QJsonDocument readJsonFile(const QString &path) +{ + auto opt = tryReadJsonFile(path); + if (!opt) + { + _exit(1); + } + return *opt; +} + +class RecentMessages +{ +public: + explicit RecentMessages(const QString &name_) + : name(name_) + , chan(this->name) + { + const auto seventvEmotes = + tryReadJsonFile(u":/bench/seventvemotes-%1.json"_s.arg(this->name)); + const auto bttvEmotes = + tryReadJsonFile(u":/bench/bttvemotes-%1.json"_s.arg(this->name)); + const auto ffzEmotes = + tryReadJsonFile(u":/bench/ffzemotes-%1.json"_s.arg(this->name)); + + if (seventvEmotes) + { + this->chan.setSeventvEmotes( + std::make_shared(seventv::detail::parseEmotes( + seventvEmotes->object()["emote_set"_L1] + .toObject()["emotes"_L1] + .toArray(), + false))); + } + + if (bttvEmotes) + { + this->chan.setBttvEmotes(std::make_shared( + bttv::detail::parseChannelEmotes(bttvEmotes->object(), + this->name))); + } + + if (ffzEmotes) + { + this->chan.setFfzEmotes(std::make_shared( + ffz::detail::parseChannelEmotes(ffzEmotes->object()))); + } + + this->messages = + readJsonFile(u":/bench/recentmessages-%1.json"_s.arg(this->name)); + } + + ~RecentMessages() + { + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + virtual void run(benchmark::State &state) = 0; + +protected: + QString name; + MockApplication app; + TwitchChannel chan; + QJsonDocument messages; +}; + +class ParseRecentMessages : public RecentMessages +{ +public: + explicit ParseRecentMessages(const QString &name_) + : RecentMessages(name_) + { + } + + void run(benchmark::State &state) + { + for (auto _ : state) + { + auto parsed = recentmessages::detail::parseRecentMessages( + this->messages.object()); + benchmark::DoNotOptimize(parsed); + } + } +}; + +class BuildRecentMessages : public RecentMessages +{ +public: + explicit BuildRecentMessages(const QString &name_) + : RecentMessages(name_) + { + } + + void run(benchmark::State &state) + { + auto parsed = recentmessages::detail::parseRecentMessages( + this->messages.object()); + for (auto _ : state) + { + auto built = recentmessages::detail::buildRecentMessages( + parsed, &this->chan); + benchmark::DoNotOptimize(built); + } + } +}; + +void BM_ParseRecentMessages(benchmark::State &state, const QString &name) +{ + ParseRecentMessages bench(name); + bench.run(state); +} + +void BM_BuildRecentMessages(benchmark::State &state, const QString &name) +{ + BuildRecentMessages bench(name); + bench.run(state); +} + +} // namespace + +BENCHMARK_CAPTURE(BM_ParseRecentMessages, nymn, u"nymn"_s); +BENCHMARK_CAPTURE(BM_BuildRecentMessages, nymn, u"nymn"_s); diff --git a/benchmarks/src/main.cpp b/benchmarks/src/main.cpp index 501b3aa51f0..ac3ca2b8e22 100644 --- a/benchmarks/src/main.cpp +++ b/benchmarks/src/main.cpp @@ -1,18 +1,41 @@ +#include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" + #include #include #include +#include + +using namespace chatterino; int main(int argc, char **argv) { QApplication app(argc, argv); + initResources(); + ::benchmark::Initialize(&argc, argv); - QtConcurrent::run([&app] { + // Ensure settings are initialized before any benchmarks are run + QTemporaryDir settingsDir; + settingsDir.setAutoRemove(false); // we'll remove it manually + chatterino::Settings settings(settingsDir.path()); + + QTimer::singleShot(0, [&]() { ::benchmark::RunSpecifiedBenchmarks(); - app.exit(0); + settingsDir.remove(); + + // Pick up the last events from the eventloop + // Using a loop to catch events queueing other events (e.g. deletions) + for (size_t i = 0; i < 32; i++) + { + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + QApplication::exit(0); }); - return app.exec(); + return QApplication::exec(); } diff --git a/cmake/FindMagicEnum.cmake b/cmake/FindMagicEnum.cmake index 0a77bd279fc..b595075cab5 100644 --- a/cmake/FindMagicEnum.cmake +++ b/cmake/FindMagicEnum.cmake @@ -1,6 +1,6 @@ include(FindPackageHandleStandardArgs) -find_path(MagicEnum_INCLUDE_DIR magic_enum.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/magic_enum/include) +find_path(MagicEnum_INCLUDE_DIR magic_enum/magic_enum.hpp HINTS ${CMAKE_SOURCE_DIR}/lib/magic_enum/include) find_package_handle_standard_args(MagicEnum DEFAULT_MSG MagicEnum_INCLUDE_DIR) diff --git a/cmake/FindWinToast.cmake b/cmake/FindWinToast.cmake deleted file mode 100644 index 3b767fb196d..00000000000 --- a/cmake/FindWinToast.cmake +++ /dev/null @@ -1,12 +0,0 @@ -if (EXISTS ${CMAKE_SOURCE_DIR}/lib/WinToast/src/wintoastlib.cpp) - set(WinToast_FOUND TRUE) - add_library(WinToast ${CMAKE_SOURCE_DIR}/lib/WinToast/src/wintoastlib.cpp) - target_include_directories(WinToast PUBLIC "${CMAKE_SOURCE_DIR}/lib/WinToast/src/") -else () - set(WinToast_FOUND FALSE) - message("WinToast submodule not found!") -endif () - - - - diff --git a/cmake/resources/generate_resources.cmake b/cmake/resources/generate_resources.cmake index 048d81f916c..e6c8c8d816e 100644 --- a/cmake/resources/generate_resources.cmake +++ b/cmake/resources/generate_resources.cmake @@ -6,7 +6,7 @@ set( qt.conf resources.qrc resources_autogenerated.qrc - windows.rc + themes/ChatterinoTheme.schema.json ) set(RES_IMAGE_EXCLUDE_FILTER ^linuxinstall/) @@ -77,7 +77,16 @@ endforeach () list(JOIN RES_HEADER_CONTENT "\n" RES_HEADER_CONTENT) configure_file(${CMAKE_CURRENT_LIST_DIR}/ResourcesAutogen.hpp.in ${CMAKE_BINARY_DIR}/autogen/ResourcesAutogen.hpp @ONLY) -set(RES_AUTOGEN_FILES +if (WIN32) + if (NOT PROJECT_VERSION_TWEAK) + set(PROJECT_VERSION_TWEAK 0) + endif() + string(TIMESTAMP CURRENT_YEAR "%Y") + configure_file(${CMAKE_CURRENT_LIST_DIR}/windows.rc.in ${CMAKE_BINARY_DIR}/autogen/windows.rc @ONLY) + list(APPEND RES_AUTOGEN_FILES "${CMAKE_BINARY_DIR}/autogen/windows.rc") +endif () + +list(APPEND RES_AUTOGEN_FILES "${CMAKE_SOURCE_DIR}/resources/resources_autogenerated.qrc" "${CMAKE_BINARY_DIR}/autogen/ResourcesAutogen.cpp" "${CMAKE_BINARY_DIR}/autogen/ResourcesAutogen.hpp" diff --git a/cmake/resources/windows.rc.in b/cmake/resources/windows.rc.in new file mode 100644 index 00000000000..a43cb8810e2 --- /dev/null +++ b/cmake/resources/windows.rc.in @@ -0,0 +1,34 @@ +#include + +IDI_ICON1 ICON "@RES_DIR@/icon.ico" + +VS_VERSION_INFO VERSIONINFO + FILEVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,@PROJECT_VERSION_TWEAK@ + PRODUCTVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,@PROJECT_VERSION_TWEAK@ + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK + FILEFLAGS VS_FF_SPECIALBUILD + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "ProductName", "Chatterino" + VALUE "ProductVersion", "@PROJECT_VERSION@" + VALUE "CompanyName", "Chatterino, @PROJECT_HOMEPAGE_URL@" + VALUE "FileDescription", "Chatterino" + VALUE "FileVersion", "@PROJECT_VERSION@" + VALUE "SpecialBuild", "@GIT_COMMIT@" + VALUE "InternalName", "Chatterino" + VALUE "OriginalFilename", "Chatterino" + VALUE "LegalCopyright", "Project contributors 2016-@CURRENT_YEAR@" + VALUE "Licence", "MIT" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END \ No newline at end of file diff --git a/cmake/sanitizers-cmake b/cmake/sanitizers-cmake index c3dc841af4d..3f0542e4e03 160000 --- a/cmake/sanitizers-cmake +++ b/cmake/sanitizers-cmake @@ -1 +1 @@ -Subproject commit c3dc841af4dbf44669e65b82cb68a575864326bd +Subproject commit 3f0542e4e034aab417c51b2b22c94f83355dee15 diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 00000000000..45955432e0f --- /dev/null +++ b/conanfile.py @@ -0,0 +1,55 @@ +from conan import ConanFile +from conan.tools.files import copy +from os import path + + +class Chatterino(ConanFile): + name = "Chatterino" + requires = "boost/1.83.0" + settings = "os", "compiler", "build_type", "arch" + default_options = { + "with_benchmark": False, + "with_openssl3": False, + "openssl*:shared": True, + "boost*:header_only": True, + } + options = { + "with_benchmark": [True, False], + # Qt is built with OpenSSL 3 from version 6.5.0 onwards + "with_openssl3": [True, False], + } + generators = "CMakeDeps", "CMakeToolchain" + + def requirements(self): + if self.options.get_safe("with_benchmark", False): + self.requires("benchmark/1.7.1") + + if self.options.get_safe("with_openssl3", False): + self.requires("openssl/3.2.0") + else: + self.requires("openssl/1.1.1t") + + def generate(self): + def copy_bin(dep, selector, subdir): + src = path.realpath(dep.cpp_info.bindirs[0]) + dst = path.realpath(path.join(self.build_folder, subdir)) + + if src == dst: + return + + copy(self, selector, src, dst, keep_path=False) + + for dep in self.dependencies.values(): + # macOS + copy_bin(dep, "*.dylib", "bin") + # Windows + copy_bin(dep, "*.dll", "bin") + copy_bin(dep, "*.dll", "Chatterino2") # used in CI + # Linux + copy( + self, + "*.so*", + dep.cpp_info.libdirs[0], + path.join(self.build_folder, "bin"), + keep_path=False, + ) diff --git a/conanfile.txt b/conanfile.txt deleted file mode 100644 index 52f1d9109aa..00000000000 --- a/conanfile.txt +++ /dev/null @@ -1,15 +0,0 @@ -[requires] -openssl/1.1.1s -boost/1.80.0 - -[generators] -CMakeDeps -CMakeToolchain - -[options] -openssl:shared=True - -[imports] -bin, *.dll -> ./bin @ keep_path=False -bin, *.dll -> ./Chatterino2 @ keep_path=False -lib, *.so* -> ./bin @ keep_path=False diff --git a/docs/ChatterinoTheme.schema.json b/docs/ChatterinoTheme.schema.json new file mode 100644 index 00000000000..a0c7972dd37 --- /dev/null +++ b/docs/ChatterinoTheme.schema.json @@ -0,0 +1,398 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Chatterino Theme", + "description": "Colors and metadata for a Chatterino 2 theme", + "definitions": { + "qt-color": { + "type": "string", + "$comment": "https://doc.qt.io/qt-5/qcolor.html#setNamedColor", + "anyOf": [ + { + "title": "#RGB", + "pattern": "^#[a-fA-F0-9]{3}$" + }, + { + "title": "#RRGGBB", + "pattern": "^#[a-fA-F0-9]{6}$" + }, + { + "title": "#AARRGGBB", + "$comment": "Note that this isn't identical to the CSS Color Moudle Level 4 where the alpha value is at the end.", + "pattern": "^#[a-fA-F0-9]{8}$" + }, + { + "title": "#RRRGGGBBB", + "pattern": "^#[a-fA-F0-9]{9}$" + }, + { + "title": "#RRRRGGGGBBBB", + "pattern": "^#[a-fA-F0-9]{12}$" + }, + { + "title": "SVG Color", + "description": "This enum is stricter than Qt. You could theoretically put tabs and spaces between characters in a named color and capitalize the color.", + "$comment": "https://www.w3.org/TR/SVG11/types.html#ColorKeywords", + "enum": [ + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "fuchsia", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "gray", + "grey", + "green", + "greenyellow", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "lime", + "limegreen", + "linen", + "magenta", + "maroon", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "navy", + "oldlace", + "olive", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "purple", + "red", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "silver", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "teal", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "white", + "whitesmoke", + "yellow", + "yellowgreen" + ] + }, + { + "title": "transparent", + "enum": ["transparent"] + } + ] + }, + "tab-colors": { + "type": "object", + "additionalProperties": false, + "properties": { + "backgrounds": { + "type": "object", + "additionalProperties": false, + "properties": { + "hover": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "unfocused": { "$ref": "#/definitions/qt-color" } + }, + "required": ["hover", "regular", "unfocused"] + }, + "line": { + "type": "object", + "additionalProperties": false, + "properties": { + "hover": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "unfocused": { "$ref": "#/definitions/qt-color" } + }, + "required": ["hover", "regular", "unfocused"] + }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": ["backgrounds", "line", "text"] + } + }, + "type": "object", + "additionalProperties": false, + "properties": { + "colors": { + "type": "object", + "additionalProperties": false, + "properties": { + "accent": { "$ref": "#/definitions/qt-color" }, + "messages": { + "type": "object", + "additionalProperties": false, + "properties": { + "backgrounds": { + "type": "object", + "additionalProperties": false, + "properties": { + "alternate": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" } + }, + "required": ["alternate", "regular"] + }, + "disabled": { "$ref": "#/definitions/qt-color" }, + "highlightAnimationEnd": { "$ref": "#/definitions/qt-color" }, + "highlightAnimationStart": { "$ref": "#/definitions/qt-color" }, + "selection": { "$ref": "#/definitions/qt-color" }, + "textColors": { + "type": "object", + "additionalProperties": false, + "properties": { + "caret": { "$ref": "#/definitions/qt-color" }, + "chatPlaceholder": { "$ref": "#/definitions/qt-color" }, + "link": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "system": { "$ref": "#/definitions/qt-color" } + }, + "required": [ + "caret", + "chatPlaceholder", + "link", + "regular", + "system" + ] + } + }, + "required": [ + "backgrounds", + "disabled", + "highlightAnimationEnd", + "highlightAnimationStart", + "selection", + "textColors" + ] + }, + "scrollbars": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "thumb": { "$ref": "#/definitions/qt-color" }, + "thumbSelected": { "$ref": "#/definitions/qt-color" } + }, + "required": ["background", "thumb", "thumbSelected"] + }, + "splits": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "dropPreview": { "$ref": "#/definitions/qt-color" }, + "dropPreviewBorder": { "$ref": "#/definitions/qt-color" }, + "dropTargetRect": { "$ref": "#/definitions/qt-color" }, + "dropTargetRectBorder": { "$ref": "#/definitions/qt-color" }, + "header": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "border": { "$ref": "#/definitions/qt-color" }, + "focusedBackground": { "$ref": "#/definitions/qt-color" }, + "focusedBorder": { "$ref": "#/definitions/qt-color" }, + "focusedText": { "$ref": "#/definitions/qt-color" }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": [ + "background", + "border", + "focusedBackground", + "focusedBorder", + "focusedText", + "text" + ] + }, + "input": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": ["background", "text"] + }, + "messageSeperator": { "$ref": "#/definitions/qt-color" }, + "resizeHandle": { "$ref": "#/definitions/qt-color" }, + "resizeHandleBackground": { "$ref": "#/definitions/qt-color" } + }, + "required": [ + "background", + "dropPreview", + "dropPreviewBorder", + "dropTargetRect", + "dropTargetRectBorder", + "header", + "input", + "messageSeperator", + "resizeHandle", + "resizeHandleBackground" + ] + }, + "tabs": { + "type": "object", + "additionalProperties": false, + "properties": { + "dividerLine": { "$ref": "#/definitions/qt-color" }, + "highlighted": { + "$ref": "#/definitions/tab-colors" + }, + "newMessage": { + "$ref": "#/definitions/tab-colors" + }, + "regular": { + "$ref": "#/definitions/tab-colors" + }, + "selected": { + "$ref": "#/definitions/tab-colors" + } + }, + "required": [ + "dividerLine", + "highlighted", + "newMessage", + "regular", + "selected" + ] + }, + "window": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": ["background", "text"] + } + }, + "required": [ + "accent", + "messages", + "scrollbars", + "splits", + "tabs", + "window" + ] + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "iconTheme": { + "$comment": "Determines which icons to use. 'dark' will use dark icons (best for a light theme). 'light' will use light icons.", + "enum": ["light", "dark"], + "default": "light" + } + }, + "required": ["iconTheme"] + }, + "$schema": { "type": "string" } + }, + "required": ["colors", "metadata"] +} diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts new file mode 100644 index 00000000000..23cbdc2bbbd --- /dev/null +++ b/docs/chatterino.d.ts @@ -0,0 +1,43 @@ +/** @noSelfInFile */ + +declare module c2 { + enum LogLevel { + Debug, + Info, + Warning, + Critical, + } + class CommandContext { + words: String[]; + channel_name: String; + } + + function log(level: LogLevel, ...data: any[]): void; + function register_command( + name: String, + handler: (ctx: CommandContext) => void + ): boolean; + function send_msg(channel: String, text: String): boolean; + function system_msg(channel: String, text: String): boolean; + + class CompletionList { + values: String[]; + hide_others: boolean; + } + + enum EventType { + CompletionRequested = "CompletionRequested", + } + + type CbFuncCompletionsRequested = ( + query: string, + full_text_content: string, + cursor_position: number, + is_first_word: boolean + ) => CompletionList; + type CbFunc = T extends EventType.CompletionRequested + ? CbFuncCompletionsRequested + : never; + + function register_callback(type: T, func: CbFunc): void; +} diff --git a/docs/make-release.md b/docs/make-release.md index 84cd96c3e3f..c28dead6bdb 100644 --- a/docs/make-release.md +++ b/docs/make-release.md @@ -1,10 +1,23 @@ # Checklist for making a release +## In the release PR + - [ ] Updated version code in `src/common/Version.hpp` - [ ] Updated version code in `CMakeLists.txt` This can only be "whole versions", so if you're releasing `2.4.0-beta` you'll need to condense it to `2.4.0` -- [ ] Updated version code in `resources/com.chatterino.chatterino.appdata.xml` +- [ ] Add a new release at the top of the `releases` key in `resources/com.chatterino.chatterino.appdata.xml` This cannot use dash to denote a pre-release identifier, you have to use a tilde instead. + +- [ ] Updated version code in `.CI/chatterino-installer.iss` - [ ] Update the changelog `## Unreleased` section to the new version `CHANGELOG.md` Make sure to leave the `## Unreleased` line unchanged for easier merges -- [ ] Push directly to master :tf: + +## After the PR has been merged + +- [ ] Tag the release +- [ ] Manually run the [create-installer](https://github.com/Chatterino/chatterino2/actions/workflows/create-installer.yml) workflow. + This is only necessary if the tag was created after the CI in the main branch finished. +- [ ] Start a manual [Launchpad import](https://code.launchpad.net/~pajlada/chatterino/+git/chatterino) - scroll down & click Import Now +- [ ] Make a PPA release to [this repo](https://git.launchpad.net/~pajlada/+git/chatterino-packaging/) with the `debchange` command. + `debchange -v 2.4.0` then add the changelog entries + `debchange --release` then change the distro to be `unstable` diff --git a/docs/plugin-info.schema.json b/docs/plugin-info.schema.json new file mode 100644 index 00000000000..2b5203ef661 --- /dev/null +++ b/docs/plugin-info.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json", + "title": "Plugin info", + "description": "Describes a Chatterino2 plugin (draft)", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Plugin name shown to the user." + }, + "description": { + "type": "string", + "description": "Plugin description shown to the user." + }, + "authors": { + "type": "array", + "description": "An array of authors of this Plugin.", + "items": { + "type": "string" + } + }, + "homepage": { + "type": "string", + "description": "Optional URL to your Plugin's homepage. This could be your GitHub repo for example." + }, + "tags": { + "description": "Something that could in the future be used to find your plugin.", + "type": "array", + "items": { + "type": "string", + "examples": ["moderation", "utility", "commands"] + }, + "uniqueItems": true + }, + "version": { + "type": "string", + "description": "Semver version string, for more info see https://semver.org.", + "examples": ["0.0.1", "1.0.0-rc.1"] + }, + "license": { + "type": "string", + "description": "A small description of your license.", + "examples": ["MIT", "GPL-2.0-or-later"] + }, + "$schema": { "type": "string" } + }, + "required": ["name", "description", "authors", "version", "license"] +} diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua new file mode 100644 index 00000000000..78bdf759b2c --- /dev/null +++ b/docs/plugin-meta.lua @@ -0,0 +1,58 @@ +---@meta Chatterino2 + +-- This file is automatically generated from src/controllers/plugins/LuaAPI.hpp by the scripts/make_luals_meta.py script +-- This file is intended to be used with LuaLS (https://luals.github.io/). +-- Add the folder this file is in to "Lua.workspace.library". + +c2 = {} + +---@alias LogLevel integer +---@type { Debug: LogLevel, Info: LogLevel, Warning: LogLevel, Critical: LogLevel } +c2.LogLevel = {} + +---@alias EventType integer +---@type { CompletionRequested: EventType } +c2.EventType = {} +---@class CommandContext +---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. +---@field channel_name string The name of the channel the command was executed in. + +---@class CompletionList +---@field values string[] The completions +---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. + +--- Registers a new command called `name` which when executed will call `handler`. +--- +---@param name string The name of the command. +---@param handler fun(ctx: CommandContext) The handler to be invoked when the command gets executed. +---@return boolean ok Returns `true` if everything went ok, `false` if a command with this name exists. +function c2.register_command(name, handler) end + +--- Registers a callback to be invoked when completions for a term are requested. +--- +---@param type "CompletionRequested" +---@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. +function c2.register_callback(type, func) end + +--- Sends a message to `channel` with the specified text. Also executes commands. +--- +--- **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop. +--- +---@param channel string The name of the Twitch channel +---@param text string The text to be sent +---@return boolean ok +function c2.send_msg(channel, text) end + +--- Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`. +--- +---@param channel string +---@param text string +---@return boolean ok +function c2.system_msg(channel, text) end + +--- Writes a message to the Chatterino log. +--- +---@param level LogLevel The desired level. +---@param ... any Values to log. Should be convertible to a string with `tostring()`. +function c2.log(level, ...) end + diff --git a/docs/test-and-benchmark.md b/docs/test-and-benchmark.md index e881db6bea0..7e42ab56b5b 100644 --- a/docs/test-and-benchmark.md +++ b/docs/test-and-benchmark.md @@ -1,6 +1,6 @@ # Test and Benchmark -Chatterino includes a set of unit tests and benchmarks. These can be built using cmake by adding the `-DBUILD_TESTS=On` and `-DBUILD_BENCHMARKS=On` flags respectively. +Chatterino includes a set of unit tests and benchmarks. These can be built using CMake by adding the `-DBUILD_TESTS=On` and `-DBUILD_BENCHMARKS=On` flags respectively. ## Adding your own test diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md new file mode 100644 index 00000000000..98016a9328a --- /dev/null +++ b/docs/wip-plugins.md @@ -0,0 +1,220 @@ +# Plugins + +If Chatterino is compiled with the `CHATTERINO_PLUGINS` CMake option, it can +load and execute Lua files. Note that while there are attempts at making this +decently safe, we cannot guarantee safety. + +## Plugin structure + +Chatterino searches for plugins in the `Plugins` directory in the app data, right next to `Settings` and `Logs`. + +Each plugin should have its own directory. + +``` +Chatterino Plugins dir/ +└── plugin_name/ + ├── init.lua + └── info.json +``` + +`init.lua` will be the file loaded when the plugin is enabled. You may load other files using [`require` global function](#requiremodname). + +`info.json` contains metadata about the plugin, like its name, description, +authors, homepage link, tags, version, license name. The version field **must** +be [semver 2.0](https://semver.org/) compliant. The general idea of `info.json` +will not change however the exact contents probably will, for example with +permission system ideas. +Example file: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json", + "name": "Test plugin", + "description": "This plugin is for testing stuff.", + "authors": ["Mm2PL"], + "homepage": "https://github.com/Chatterino/Chatterino2", + "tags": ["test"], + "version": "0.0.0", + "license": "MIT" +} +``` + +An example plugin is available at [https://github.com/Mm2PL/Chatterino-test-plugin](https://github.com/Mm2PL/Chatterino-test-plugin) + +## Plugins with Typescript + +If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io) +to typecheck your plugins. There is a `chatterino.d.ts` file describing the API +in this directory. However, this has several drawbacks like harder debugging at +runtime. + +## API + +The following parts of the Lua standard library are loaded: + +- `_G` (most globals) +- `table` +- `string` +- `math` +- `utf8` + +The official manual for them is available [here](https://www.lua.org/manual/5.4/manual.html#6). + +### Chatterino API + +All Chatterino functions are exposed in a global table called `c2`. The following members are available: + +#### `log(level, args...)` + +Writes a message to the Chatterino log. The `level` argument should be a +`LogLevel` member. All `args` should be convertible to a string with +`tostring()`. + +Example: + +```lua +c2.log(c2.LogLevel.Warning, "Hello, this should show up in the Chatterino log by default") + +c2.log(c2.LogLevel.Debug, "Hello world") +-- Equivalent to doing qCDebug(chatterinoLua) << "[pluginDirectory:Plugin Name]" << "Hello, world"; from C++ +``` + +#### `LogLevel` enum + +This table describes log levels available to Lua Plugins. The values behind the names may change, do not count on them. It has the following keys: + +- `Debug` +- `Info` +- `Warning` +- `Critical` + +#### `register_command(name, handler)` + +Registers a new command called `name` which when executed will call `handler`. +Returns `true` if everything went ok, `false` if there already exists another +command with this name. + +Example: + +```lua +function cmdWords(ctx) + -- ctx contains: + -- words - table of words supplied to the command including the trigger + -- channel_name - name of the channel the command is being run in + c2.system_msg(ctx.channel_name, "Words are: " .. table.concat(ctx.words, " ")) +end + +c2.register_command("/words", cmdWords) +``` + +Limitations/known issues: + +- Commands registered in functions, not in the global scope might not show up in the settings UI, + rebuilding the window content caused by reloading another plugin will solve this. +- Spaces in command names aren't handled very well (https://github.com/Chatterino/chatterino2/issues/1517). + +#### `register_callback("CompletionRequested", handler)` + +Registers a callback (`handler`) to process completions. The callback gets the following parameters: + +- `query`: The queried word. +- `full_text_content`: The whole input. +- `cursor_position`: The position of the cursor in the input. +- `is_first_word`: Flag whether `query` is the first word in the input. + +Example: + +| Input | `query` | `full_text_content` | `cursor_position` | `is_first_word` | +| ---------- | ------- | ------------------- | ----------------- | --------------- | +| `foo│` | `foo` | `foo` | 3 | `true` | +| `fo│o` | `fo` | `foo` | 2 | `true` | +| `foo bar│` | `bar` | `foo bar` | 7 | `false` | +| `foo │bar` | `foo` | `foo bar` | 4 | `false` | + +```lua +function string.startswith(s, other) + return string.sub(s, 1, string.len(other)) == other +end + +c2.register_callback( + "CompletionRequested", + function(query, full_text_content, cursor_position, is_first_word) + if ("!join"):startswith(query) then + ---@type CompletionList + return { hide_others = true, values = { "!join" } } + end + ---@type CompletionList + return { hide_others = false, values = {} } + end +) +``` + +#### `send_msg(channel, text)` + +Sends a message to `channel` with the specified text. Also executes commands. + +Example: + +```lua +function cmdShout(ctx) + table.remove(ctx.words, 1) + local output = table.concat(ctx.words, " ") + c2.send_msg(ctx.channel_name, string.upper(output)) +end +c2.register_command("/shout", cmdShout) +``` + +Limitations/Known issues: + +- It is possible to trigger your own Lua command with this causing a potentially infinite loop. + +#### `system_msg(channel, text)` + +Creates a system message and adds it to the twitch channel specified by +`channel`. Returns `true` if everything went ok, `false` otherwise. It will +throw an error if the number of arguments received doesn't match what it +expects. + +Example: + +```lua +local ok = c2.system_msg("pajlada", "test") +if (not ok) + -- channel not found +end +``` + +### Changed globals + +#### `load(chunk [, chunkname [, mode [, env]]])` + +This function is only available if Chatterino is compiled in debug mode. It is meant for debugging with little exception. +This function behaves really similarity to Lua's `load`, however it does not allow for bytecode to be executed. +It achieves this by forcing all inputs to be encoded with `UTF-8`. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-load) + +#### `require(modname)` + +This is Lua's [`require()`](https://www.lua.org/manual/5.3/manual.html#pdf-require) function. +However, the searcher and load configuration is notably different from the default: + +- Lua's built-in dynamic library searcher is removed, +- `package.path` is not used, in its place are two searchers, +- when `require()` is used, first a file relative to the currently executing + file will be checked, then a file relative to the plugin directory, +- binary chunks are never loaded + +As in normal Lua, dots are converted to the path separators (`'/'` on Linux and Mac, `'\'` on Windows). + +Example: + +```lua +require("stuff") -- executes Plugins/name/stuff.lua or $(dirname $CURR_FILE)/stuff.lua +require("dir.name") -- executes Plugins/name/dir/name.lua or $(dirname $CURR_FILE)/dir/name.lua +require("binary") -- tried to load Plugins/name/binary.lua and errors because binary is not a text file +``` + +#### `print(Args...)` + +The `print` global function is equivalent to calling `c2.log(c2.LogLevel.Debug, Args...)` diff --git a/lib/README.md b/lib/README.md index 40d2acf175d..ea37d0b631a 100644 --- a/lib/README.md +++ b/lib/README.md @@ -1,3 +1,3 @@ -Third party libraries are stored here +Third party libraries are stored here. Fetched via `git submodule update --init --recursive` diff --git a/lib/WinToast b/lib/WinToast index 5e441fd0354..821c4818ade 160000 --- a/lib/WinToast +++ b/lib/WinToast @@ -1 +1 @@ -Subproject commit 5e441fd03543b999edb663caf8df7be37c0d575c +Subproject commit 821c4818ade1aa4da56ac753285c159ce26fd597 diff --git a/lib/crashpad b/lib/crashpad deleted file mode 160000 index ec992578688..00000000000 --- a/lib/crashpad +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ec992578688b4c51c1856d08731cf7dcf10e446a diff --git a/lib/lua/CMakeLists.txt b/lib/lua/CMakeLists.txt new file mode 100644 index 00000000000..086f5949511 --- /dev/null +++ b/lib/lua/CMakeLists.txt @@ -0,0 +1,53 @@ +project(lua CXX) + +#[====[ +Updating this list: +remove all listed files +go to line below, ^y2j4j$@" and then reindent the file names +/LUA_SRC +:r!ls lib/lua/src | grep '\.c' | grep -Ev 'lua\.c|onelua\.c' | sed 's#^#src/#' + +#]====] +set(LUA_SRC + "src/lapi.c" + "src/lauxlib.c" + "src/lbaselib.c" + "src/lcode.c" + "src/lcorolib.c" + "src/lctype.c" + "src/ldblib.c" + "src/ldebug.c" + "src/ldo.c" + "src/ldump.c" + "src/lfunc.c" + "src/lgc.c" + "src/linit.c" + "src/liolib.c" + "src/llex.c" + "src/lmathlib.c" + "src/lmem.c" + "src/loadlib.c" + "src/lobject.c" + "src/lopcodes.c" + "src/loslib.c" + "src/lparser.c" + "src/lstate.c" + "src/lstring.c" + "src/lstrlib.c" + "src/ltable.c" + "src/ltablib.c" + "src/ltests.c" + "src/ltm.c" + "src/lua.c" + "src/lundump.c" + "src/lutf8lib.c" + "src/lvm.c" + "src/lzio.c" +) + +add_library(lua STATIC ${LUA_SRC}) +target_include_directories(lua + PUBLIC + ${LUA_INCLUDE_DIRS} +) +set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE CXX) diff --git a/lib/lua/src b/lib/lua/src new file mode 160000 index 00000000000..e288c5a9188 --- /dev/null +++ b/lib/lua/src @@ -0,0 +1 @@ +Subproject commit e288c5a91883793d14ed9e9d93464f6ee0b08915 diff --git a/lib/magic_enum b/lib/magic_enum index e1a68e9dd3d..e55b9b54d5c 160000 --- a/lib/magic_enum +++ b/lib/magic_enum @@ -1 +1 @@ -Subproject commit e1a68e9dd3d2e9180b04c8aeacd4975db745e6b8 +Subproject commit e55b9b54d5cf61f8e117cafb17846d7d742dd3b4 diff --git a/lib/miniaudio b/lib/miniaudio index c153a947919..4a5b74bef02 160000 --- a/lib/miniaudio +++ b/lib/miniaudio @@ -1 +1 @@ -Subproject commit c153a947919808419b0bf3f56b6f2ee606d6c5f4 +Subproject commit 4a5b74bef029b3592c54b6048650ee5f972c1a48 diff --git a/lib/serialize b/lib/serialize index 7d37cbfd5ac..17946d65a41 160000 --- a/lib/serialize +++ b/lib/serialize @@ -1 +1 @@ -Subproject commit 7d37cbfd5ac3bfbe046118e1cec3d32ba4696469 +Subproject commit 17946d65a41a72b447da37df6e314cded9650c32 diff --git a/lib/settings b/lib/settings index 04792d853c7..53be0788a00 160000 --- a/lib/settings +++ b/lib/settings @@ -1 +1 @@ -Subproject commit 04792d853c7f83c9d7ab4df00279442a658d3be3 +Subproject commit 53be0788a000960dbbc34350315e20ad1e194970 diff --git a/lib/signals b/lib/signals index 25e4ec3b8d6..d06770649a7 160000 --- a/lib/signals +++ b/lib/signals @@ -1 +1 @@ -Subproject commit 25e4ec3b8d6ea94a5e65a26e7cfcbbce3b87c5d6 +Subproject commit d06770649a7e83db780865d09c313a876bf0f4eb diff --git a/mocks/CMakeLists.txt b/mocks/CMakeLists.txt new file mode 100644 index 00000000000..47abd0ef4df --- /dev/null +++ b/mocks/CMakeLists.txt @@ -0,0 +1,7 @@ +project(chatterino-mocks) + +add_library(chatterino-mocks INTERFACE) + +target_include_directories(chatterino-mocks INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) + +target_link_libraries(${PROJECT_NAME} INTERFACE gmock) diff --git a/mocks/include/mocks/Channel.hpp b/mocks/include/mocks/Channel.hpp new file mode 100644 index 00000000000..0af536e4660 --- /dev/null +++ b/mocks/include/mocks/Channel.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "common/Channel.hpp" + +namespace chatterino::mock { + +class MockChannel : public Channel +{ +public: + MockChannel(const QString &name) + : Channel(name, Channel::Type::Twitch) + { + } +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/ChatterinoBadges.hpp b/mocks/include/mocks/ChatterinoBadges.hpp new file mode 100644 index 00000000000..9070a7d7e91 --- /dev/null +++ b/mocks/include/mocks/ChatterinoBadges.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "providers/chatterino/ChatterinoBadges.hpp" + +namespace chatterino::mock { + +class ChatterinoBadges : public IChatterinoBadges +{ +public: + std::optional getBadge(const UserId &id) override + { + (void)id; + return std::nullopt; + } +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp new file mode 100644 index 00000000000..a6a1745d280 --- /dev/null +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -0,0 +1,221 @@ +#pragma once + +#include "Application.hpp" +#include "common/Args.hpp" +#include "singletons/Paths.hpp" +#include "singletons/Updates.hpp" + +namespace chatterino::mock { + +class EmptyApplication : public IApplication +{ +public: + EmptyApplication() + : updates_(this->paths_) + { + } + + virtual ~EmptyApplication() = default; + + const Paths &getPaths() override + { + return this->paths_; + } + + const Args &getArgs() override + { + return this->args_; + } + + Theme *getThemes() override + { + assert( + false && + "EmptyApplication::getThemes was called without being initialized"); + return nullptr; + } + + Fonts *getFonts() override + { + assert( + false && + "EmptyApplication::getFonts was called without being initialized"); + return nullptr; + } + + IEmotes *getEmotes() override + { + assert( + false && + "EmptyApplication::getEmotes was called without being initialized"); + return nullptr; + } + + AccountController *getAccounts() override + { + assert(false && "EmptyApplication::getAccounts was called without " + "being initialized"); + return nullptr; + } + + HotkeyController *getHotkeys() override + { + assert(false && "EmptyApplication::getHotkeys was called without being " + "initialized"); + return nullptr; + } + + WindowManager *getWindows() override + { + assert(false && "EmptyApplication::getWindows was called without being " + "initialized"); + return nullptr; + } + + Toasts *getToasts() override + { + assert( + false && + "EmptyApplication::getToasts was called without being initialized"); + return nullptr; + } + + CrashHandler *getCrashHandler() override + { + assert(false && "EmptyApplication::getCrashHandler was called without " + "being initialized"); + return nullptr; + } + + CommandController *getCommands() override + { + assert(false && "EmptyApplication::getCommands was called without " + "being initialized"); + return nullptr; + } + + NotificationController *getNotifications() override + { + assert(false && "EmptyApplication::getNotifications was called without " + "being initialized"); + return nullptr; + } + + HighlightController *getHighlights() override + { + assert(false && "EmptyApplication::getHighlights was called without " + "being initialized"); + return nullptr; + } + + ITwitchIrcServer *getTwitch() override + { + assert( + false && + "EmptyApplication::getTwitch was called without being initialized"); + return nullptr; + } + + PubSub *getTwitchPubSub() override + { + assert(false && "getTwitchPubSub was called without being initialized"); + return nullptr; + } + + TwitchBadges *getTwitchBadges() override + { + assert(false && "getTwitchBadges was called without being initialized"); + return nullptr; + } + + Logging *getChatLogger() override + { + assert(!"getChatLogger was called without being initialized"); + return nullptr; + } + + IChatterinoBadges *getChatterinoBadges() override + { + assert(false && "EmptyApplication::getChatterinoBadges was called " + "without being initialized"); + return nullptr; + } + + FfzBadges *getFfzBadges() override + { + assert(false && "EmptyApplication::getFfzBadges was called without " + "being initialized"); + return nullptr; + } + + SeventvBadges *getSeventvBadges() override + { + assert(!"getSeventvBadges was called without being initialized"); + return nullptr; + } + + IUserDataController *getUserData() override + { + assert(false && "EmptyApplication::getUserData was called without " + "being initialized"); + return nullptr; + } + + ISoundController *getSound() override + { + assert(!"getSound was called without being initialized"); + return nullptr; + } + + ITwitchLiveController *getTwitchLiveController() override + { + assert(false && "EmptyApplication::getTwitchLiveController was called " + "without being initialized"); + return nullptr; + } + + ImageUploader *getImageUploader() override + { + assert(false && "EmptyApplication::getImageUploader was called without " + "being initialized"); + return nullptr; + } + + SeventvAPI *getSeventvAPI() override + { + return nullptr; + } + + Updates &getUpdates() override + { + return this->updates_; + } + + BttvEmotes *getBttvEmotes() override + { + assert(false && "EmptyApplication::getBttvEmotes was called without " + "being initialized"); + return nullptr; + } + + FfzEmotes *getFfzEmotes() override + { + assert(false && "EmptyApplication::getFfzEmotes was called without " + "being initialized"); + return nullptr; + } + + SeventvEmotes *getSeventvEmotes() override + { + assert(false && "EmptyApplication::getSeventvEmotes was called without " + "being initialized"); + return nullptr; + } + +private: + Paths paths_; + Args args_; + Updates updates_; +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp new file mode 100644 index 00000000000..1771b1e2b8e --- /dev/null +++ b/mocks/include/mocks/Helix.hpp @@ -0,0 +1,409 @@ +#pragma once + +#include "providers/twitch/api/Helix.hpp" +#include "util/CancellationToken.hpp" + +#include +#include +#include + +#include + +namespace chatterino::mock { + +class Helix : public IHelix +{ +public: + virtual ~Helix() = default; + + MOCK_METHOD(void, fetchUsers, + (QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getUserByName, + (QString userName, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + MOCK_METHOD(void, getUserById, + (QString userId, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD( + void, getChannelFollowers, + (QString broadcasterID, + ResultCallback successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, fetchStreams, + (QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getStreamById, + (QString userId, + (ResultCallback successCallback), + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getStreamByName, + (QString userName, + (ResultCallback successCallback), + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, fetchGames, + (QStringList gameIds, QStringList gameNames, + (ResultCallback> successCallback), + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, searchGames, + (QString gameName, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getGameById, + (QString gameId, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, createClip, + (QString channelId, ResultCallback successCallback, + std::function failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, fetchChannels, + (QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getChannel, + (QString broadcasterId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, createStreamMarker, + (QString broadcasterId, QString description, + ResultCallback successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, loadBlocks, + (QString userId, + ResultCallback> successCallback, + FailureCallback failureCallback, + CancellationToken &&token), + (override)); + + MOCK_METHOD(void, blockUser, + (QString targetUserId, const QObject *caller, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, unblockUser, + (QString targetUserId, const QObject *caller, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, updateChannel, + (QString broadcasterId, QString gameId, QString language, + QString title, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, manageAutoModMessages, + (QString userID, QString msgID, QString action, + std::function successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, getCheermotes, + (QString broadcasterId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getEmoteSetData, + (QString emoteSetId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getChannelEmotes, + (QString broadcasterId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getGlobalBadges, + (ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, getChannelBadges, + (QString broadcasterID, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateUserChatColor, + (QString userID, QString color, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, deleteChatMessages, + (QString broadcasterID, QString moderatorID, QString messageID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, addChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, removeChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, sendChatAnnouncement, + (QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, addChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, removeChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, unbanUser, + (QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( // /raid + void, startRaid, + (QString fromBroadcasterID, QString toBroadcasterId, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /raid + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( // /unraid + void, cancelRaid, + (QString broadcasterID, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /unraid + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateEmoteMode, + (QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateFollowerMode, + (QString broadcasterID, QString moderatorID, + std::optional followerModeDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateNonModeratorChatDelay, + (QString broadcasterID, QString moderatorID, + std::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateSlowMode, + (QString broadcasterID, QString moderatorID, + std::optional slowModeWaitTime, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateSubscriberMode, + (QString broadcasterID, QString moderatorID, + bool subscriberMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateUniqueChatMode, + (QString broadcasterID, QString moderatorID, + bool uniqueChatMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + // update chat settings + + // /timeout, /ban + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, banUser, + (QString broadcasterID, QString moderatorID, QString userID, + std::optional duration, QString reason, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /timeout, /ban + + // /w + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, sendWhisper, + (QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /w + + // getChatters + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getChatters, + (QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // getChatters + + // /vips + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getChannelVIPs, + (QString broadcasterID, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /vips + + // /commercial + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, startCommercial, + (QString broadcasterID, int length, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // /commercial + + // /mods + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD( + void, getModerators, + (QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /mods + + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateShieldMode, + (QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // /shoutout + MOCK_METHOD( + void, sendShoutout, + (QString fromBroadcasterID, QString toBroadcasterID, + QString moderatorID, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), + (override)); + +protected: + // The extra parenthesis around the failure callback is because its type + // contains a comma + MOCK_METHOD(void, updateChatSettings, + (QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); +}; + +} // namespace chatterino::mock diff --git a/mocks/include/mocks/TwitchIrcServer.hpp b/mocks/include/mocks/TwitchIrcServer.hpp new file mode 100644 index 00000000000..2ef163db611 --- /dev/null +++ b/mocks/include/mocks/TwitchIrcServer.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "mocks/Channel.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" + +namespace chatterino::mock { + +class MockTwitchIrcServer : public ITwitchIrcServer +{ +public: + MockTwitchIrcServer() + : watchingChannelInner( + std::shared_ptr(new MockChannel("testaccount_420"))) + , watchingChannel(this->watchingChannelInner, + Channel::Type::TwitchWatching) + { + } + + const IndirectChannel &getWatchingChannel() const override + { + return this->watchingChannel; + } + + ChannelPtr watchingChannelInner; + IndirectChannel watchingChannel; +}; + +} // namespace chatterino::mock diff --git a/tests/src/mocks/UserData.hpp b/mocks/include/mocks/UserData.hpp similarity index 84% rename from tests/src/mocks/UserData.hpp rename to mocks/include/mocks/UserData.hpp index df20eb82277..62159a19fcd 100644 --- a/tests/src/mocks/UserData.hpp +++ b/mocks/include/mocks/UserData.hpp @@ -11,9 +11,9 @@ class UserDataController : public IUserDataController // Get extra data about a user // If the user does not have any extra data, return none - boost::optional getUser(const QString &userID) const override + std::optional getUser(const QString &userID) const override { - return boost::none; + return std::nullopt; } // Update or insert extra data for the user's color override diff --git a/resources/avatars/crazysmc.png b/resources/avatars/crazysmc.png new file mode 100644 index 00000000000..433f4e3e9a6 Binary files /dev/null and b/resources/avatars/crazysmc.png differ diff --git a/resources/avatars/cyclone.png b/resources/avatars/cyclone.png new file mode 100644 index 00000000000..d1517180f3a Binary files /dev/null and b/resources/avatars/cyclone.png differ diff --git a/resources/avatars/fraxx.png b/resources/avatars/fraxx.png new file mode 100644 index 00000000000..b9a6a47adfb Binary files /dev/null and b/resources/avatars/fraxx.png differ diff --git a/resources/avatars/techno.png b/resources/avatars/techno.png new file mode 100644 index 00000000000..874acb97318 Binary files /dev/null and b/resources/avatars/techno.png differ diff --git a/resources/avatars/zonianmidian.png b/resources/avatars/zonianmidian.png new file mode 100644 index 00000000000..d0d36ee01dc Binary files /dev/null and b/resources/avatars/zonianmidian.png differ diff --git a/resources/buttons/viewersDark.png b/resources/buttons/chattersDark.png similarity index 100% rename from resources/buttons/viewersDark.png rename to resources/buttons/chattersDark.png diff --git a/resources/buttons/viewersLight.png b/resources/buttons/chattersLight.png similarity index 100% rename from resources/buttons/viewersLight.png rename to resources/buttons/chattersLight.png diff --git a/resources/buttons/streamerModeEnabledDark.png b/resources/buttons/streamerModeEnabledDark.png new file mode 100644 index 00000000000..7ab2b1d8b3f Binary files /dev/null and b/resources/buttons/streamerModeEnabledDark.png differ diff --git a/resources/buttons/streamerModeEnabledDark.svg b/resources/buttons/streamerModeEnabledDark.svg new file mode 100644 index 00000000000..7f95aae5fb6 --- /dev/null +++ b/resources/buttons/streamerModeEnabledDark.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/buttons/streamerModeEnabledLight.png b/resources/buttons/streamerModeEnabledLight.png new file mode 100644 index 00000000000..8da207f7959 Binary files /dev/null and b/resources/buttons/streamerModeEnabledLight.png differ diff --git a/resources/buttons/streamerModeEnabledLight.svg b/resources/buttons/streamerModeEnabledLight.svg new file mode 100644 index 00000000000..5f27d4393ba --- /dev/null +++ b/resources/buttons/streamerModeEnabledLight.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/chatterino.icns b/resources/chatterino.icns index 8b6482ee76f..b200b8f3e6b 100644 Binary files a/resources/chatterino.icns and b/resources/chatterino.icns differ diff --git a/resources/com.chatterino.chatterino.appdata.xml b/resources/com.chatterino.chatterino.appdata.xml index 9ae44b947d1..c45a1599cf4 100644 --- a/resources/com.chatterino.chatterino.appdata.xml +++ b/resources/com.chatterino.chatterino.appdata.xml @@ -8,6 +8,7 @@ intense Chatterino + Chatterino Developers Chat client for twitch.tv @@ -18,7 +19,7 @@ - https://i.imgur.com/CFLDARZ.png + https://chatterino.com/img/example_01.png @@ -32,6 +33,20 @@ chatterino - + + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.6 + + + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.5 + + + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.4 + + + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.3 + + + https://github.com/Chatterino/chatterino2/releases/tag/v2.4.2 + diff --git a/resources/contributors.txt b/resources/contributors.txt index 0870ae2fa18..380d3869cb6 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -57,6 +57,17 @@ Jaxkey | https://github.com/Jaxkey | :/avatars/jaxkey.png | Contributor Explooosion | https://github.com/Explooosion-code | :/avatars/explooosion_code.png | Contributor mohad12211 | https://github.com/mohad12211 | :/avatars/mohad12211.png | Contributor Wissididom | https://github.com/Wissididom | :/avatars/wissididom.png | Contributor +03y | https://github.com/03y | | Contributor +ScrubN | https://github.com/ScrubN | | Contributor +Cyclone | https://github.com/PsycloneTM | :/avatars/cyclone.png | Contributor +2547techno | https://github.com/2547techno | :/avatars/techno.png | Contributor +ZonianMidian | https://github.com/ZonianMidian | :/avatars/zonianmidian.png | Contributor +olafyang | https://github.com/olafyang | | Contributor +chrrs | https://github.com/chrrs | | Contributor +4rneee | https://github.com/4rneee | | Contributor +crazysmc | https://github.com/crazysmc | :/avatars/crazysmc.png | Contributor +SputNikPlop | https://github.com/SputNikPlop | | Contributor +fraxx | https://github.com/fraxxio | :/avatars/fraxx.png | Contributor # If you are a contributor add yourself above this line diff --git a/resources/licenses/fluenticons.txt b/resources/licenses/fluenticons.txt new file mode 100644 index 00000000000..bc9c36b28f4 --- /dev/null +++ b/resources/licenses/fluenticons.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/licenses/lua.txt b/resources/licenses/lua.txt new file mode 100644 index 00000000000..b6ed3539e3c --- /dev/null +++ b/resources/licenses/lua.txt @@ -0,0 +1,7 @@ +Copyright © 1994–2021 Lua.org, PUC-Rio. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/resources/settings/plugins.svg b/resources/settings/plugins.svg new file mode 100644 index 00000000000..e4314ddf206 --- /dev/null +++ b/resources/settings/plugins.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/resources/themes/Black.json b/resources/themes/Black.json new file mode 100644 index 00000000000..79f46dc76b7 --- /dev/null +++ b/resources/themes/Black.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "light" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#0a0a0a", + "regular": "#000000" + }, + "disabled": "#99000000", + "highlightAnimationEnd": "#00e6e6e6", + "highlightAnimationStart": "#6ee6e6e6", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#4d4d4d", + "thumbSelected": "#595959" + }, + "splits": { + "background": "#000000", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#000094ff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#050505", + "border": "#121212", + "focusedBackground": "#1a1a1a", + "focusedBorder": "#1c1c1c", + "focusedText": "#84c1ff", + "text": "#ffffff" + }, + "input": { + "background": "#080808", + "text": "#ffffff" + }, + "messageSeperator": "#3c3c3c", + "resizeHandle": "#700094ff", + "resizeHandleBackground": "#200094ff" + }, + "tabs": { + "dividerLine": "#555555", + "highlighted": { + "backgrounds": { + "hover": "#0b0b0b", + "regular": "#0b0b0b", + "unfocused": "#0b0b0b" + }, + "line": { + "hover": "#ee6166", + "regular": "#ee6166", + "unfocused": "#ee6166" + }, + "text": "#eeeeee" + }, + "newMessage": { + "backgrounds": { + "hover": "#0b0b0b", + "regular": "#0b0b0b", + "unfocused": "#0b0b0b" + }, + "line": { + "hover": "#888888", + "regular": "#888888", + "unfocused": "#888888" + }, + "text": "#eeeeee" + }, + "regular": { + "backgrounds": { + "hover": "#0b0b0b", + "regular": "#0b0b0b", + "unfocused": "#0b0b0b" + }, + "line": { + "hover": "#444444", + "regular": "#444444", + "unfocused": "#444444" + }, + "text": "#aaaaaa" + }, + "selected": { + "backgrounds": { + "hover": "#333333", + "regular": "#333333", + "unfocused": "#333333" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#ffffff" + } + }, + "window": { + "background": "#040404", + "text": "#eeeeee" + } + } +} diff --git a/resources/themes/Dark.json b/resources/themes/Dark.json new file mode 100644 index 00000000000..036ce18a3a3 --- /dev/null +++ b/resources/themes/Dark.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "light" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#222222", + "regular": "#191919" + }, + "disabled": "#99191919", + "highlightAnimationEnd": "#00e6e6e6", + "highlightAnimationStart": "#6ee6e6e6", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#575757", + "thumbSelected": "#616161" + }, + "splits": { + "background": "#191919", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#000094ff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#2e2e2e", + "border": "#383838", + "focusedBackground": "#444444", + "focusedBorder": "#464646", + "focusedText": "#84c1ff", + "text": "#ffffff" + }, + "input": { + "background": "#242424", + "text": "#ffffff" + }, + "messageSeperator": "#3c3c3c", + "resizeHandle": "#700094ff", + "resizeHandleBackground": "#200094ff" + }, + "tabs": { + "dividerLine": "#555555", + "highlighted": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#ee6166", + "regular": "#ee6166", + "unfocused": "#ee6166" + }, + "text": "#eeeeee" + }, + "newMessage": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#888888", + "regular": "#888888", + "unfocused": "#888888" + }, + "text": "#eeeeee" + }, + "regular": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#444444", + "regular": "#444444", + "unfocused": "#444444" + }, + "text": "#aaaaaa" + }, + "selected": { + "backgrounds": { + "hover": "#555555", + "regular": "#555555", + "unfocused": "#555555" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#ffffff" + } + }, + "window": { + "background": "#111111", + "text": "#eeeeee" + } + } +} diff --git a/resources/themes/Light.json b/resources/themes/Light.json new file mode 100644 index 00000000000..338c642e25a --- /dev/null +++ b/resources/themes/Light.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "dark" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#dddddd", + "regular": "#e6e6e6" + }, + "disabled": "#99e6e6e6", + "highlightAnimationEnd": "#00141414", + "highlightAnimationStart": "#6e141414", + "selection": "#40000000", + "textColors": { + "caret": "#000000", + "chatPlaceholder": "#af9f9f", + "link": "#4286f4", + "regular": "#000000", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#a8a8a8", + "thumbSelected": "#9e9e9e" + }, + "splits": { + "background": "#e6e6e6", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#00ffffff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#e6e6e6", + "border": "#e6e6e6", + "focusedBackground": "#dbdbdb", + "focusedBorder": "#d1d1d1", + "focusedText": "#0051a3", + "text": "#000000" + }, + "input": { + "background": "#dbdbdb", + "text": "#000000" + }, + "messageSeperator": "#7f7f7f", + "resizeHandle": "#0094ff", + "resizeHandleBackground": "#500094ff" + }, + "tabs": { + "dividerLine": "#b4d7ff", + "highlighted": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ff0000", + "regular": "#ff0000", + "unfocused": "#ff0000" + }, + "text": "#000000" + }, + "newMessage": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#bbbbbb", + "regular": "#bbbbbb", + "unfocused": "#bbbbbb" + }, + "text": "#222222" + }, + "regular": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ffffff", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "text": "#444444" + }, + "selected": { + "backgrounds": { + "hover": "#b4d7ff", + "regular": "#b4d7ff", + "unfocused": "#b4d7ff" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#000000" + } + }, + "window": { + "background": "#ffffff", + "text": "#000000" + } + } +} diff --git a/resources/themes/White.json b/resources/themes/White.json new file mode 100644 index 00000000000..7676ac629fa --- /dev/null +++ b/resources/themes/White.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "dark" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#f5f5f5", + "regular": "#ffffff" + }, + "disabled": "#99ffffff", + "highlightAnimationEnd": "#00141414", + "highlightAnimationStart": "#6e141414", + "selection": "#40000000", + "textColors": { + "caret": "#000000", + "chatPlaceholder": "#af9f9f", + "link": "#4286f4", + "regular": "#000000", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#b3b3b3", + "thumbSelected": "#a6a6a6" + }, + "splits": { + "background": "#ffffff", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#00ffffff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#ffffff", + "border": "#ffffff", + "focusedBackground": "#f2f2f2", + "focusedBorder": "#e6e6e6", + "focusedText": "#0051a3", + "text": "#000000" + }, + "input": { + "background": "#f2f2f2", + "text": "#000000" + }, + "messageSeperator": "#7f7f7f", + "resizeHandle": "#0094ff", + "resizeHandleBackground": "#500094ff" + }, + "tabs": { + "dividerLine": "#b4d7ff", + "highlighted": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ff0000", + "regular": "#ff0000", + "unfocused": "#ff0000" + }, + "text": "#000000" + }, + "newMessage": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#bbbbbb", + "regular": "#bbbbbb", + "unfocused": "#bbbbbb" + }, + "text": "#222222" + }, + "regular": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ffffff", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "text": "#444444" + }, + "selected": { + "backgrounds": { + "hover": "#b4d7ff", + "regular": "#b4d7ff", + "unfocused": "#b4d7ff" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#000000" + } + }, + "window": { + "background": "#ffffff", + "text": "#000000" + } + } +} diff --git a/resources/twitch-badges.json b/resources/twitch-badges.json new file mode 100644 index 00000000000..12f65d0e639 --- /dev/null +++ b/resources/twitch-badges.json @@ -0,0 +1 @@ +{"badge_sets":{"1979-revolution_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/3","description":"1979 Revolution","title":"1979 Revolution","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/1979%20Revolution/details","last_updated":null}}},"60-seconds_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/3","description":"60 Seconds!","title":"60 Seconds!","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","last_updated":null}}},"60-seconds_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/3","description":"60 Seconds!","title":"60 Seconds!","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","last_updated":null}}},"60-seconds_3":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/3","description":"60 Seconds!","title":"60 Seconds!","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","last_updated":null}}},"H1Z1_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/3","description":"H1Z1","title":"H1Z1","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/H1Z1/details","last_updated":null}}},"admin":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/3","description":"Twitch Admin","title":"Admin","click_action":"none","click_url":"","last_updated":null}}},"ambassador":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/3","description":"Twitch Ambassador","title":"Twitch Ambassador","click_action":"visit_url","click_url":"https://www.twitch.tv/team/ambassadors","last_updated":null}}},"anomaly-2_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/3","description":"Anomaly 2","title":"Anomaly 2","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Anomaly%202/details","last_updated":null}}},"anomaly-warzone-earth_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/3","description":"Anomaly Warzone Earth","title":"Anomaly Warzone Earth","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Anomaly:%20Warzone%20Earth/details","last_updated":null}}},"anonymous-cheerer":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/3","description":"Anonymous Cheerer","title":"Anonymous Cheerer","click_action":"none","click_url":"","last_updated":null}}},"artist-badge":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/3","description":"Artist on this Channel","title":"Artist","click_action":"none","click_url":"","last_updated":null}}},"axiom-verge_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/3","description":"Axiom Verge","title":"Axiom Verge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Axiom%20Verge/details","last_updated":null}}},"battlechefbrigade_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/3","description":"Battle Chef Brigade","title":"Battle Chef Brigade","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","last_updated":null}}},"battlechefbrigade_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/3","description":"Battle Chef Brigade","title":"Battle Chef Brigade","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","last_updated":null}}},"battlechefbrigade_3":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/3","description":"Battle Chef Brigade","title":"Battle Chef Brigade","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","last_updated":null}}},"battlerite_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/3","description":"Battlerite","title":"Battlerite","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battlerite/details","last_updated":null}}},"bits":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/3","description":" ","title":"cheer 1","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"100":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/3","description":" ","title":"cheer 100","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"1000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/3","description":" ","title":"cheer 1000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"10000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/3","description":" ","title":"cheer 10000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"100000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/3","description":" ","title":"cheer 100000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"1000000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/3","description":" ","title":"cheer 1000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"1250000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/3","description":" ","title":"cheer 1250000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"1500000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/3","description":" ","title":"cheer 1500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"1750000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/3","description":" ","title":"cheer 1750000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"200000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/3","description":" ","title":"cheer 200000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"2000000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/3","description":" ","title":"cheer 2000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"25000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/3","description":" ","title":"cheer 25000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"2500000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/3","description":" ","title":"cheer 2500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"300000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/3","description":" ","title":"cheer 300000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"3000000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/3","description":" ","title":"cheer 3000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"3500000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/3","description":" ","title":"cheer 3500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"400000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/3","description":" ","title":"cheer 400000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"4000000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/3","description":" ","title":"cheer 4000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"4500000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/3","description":" ","title":"cheer 4500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"5000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/3","description":" ","title":"cheer 5000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"50000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/3","description":" ","title":"cheer 50000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"500000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/3","description":" ","title":"cheer 500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"5000000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/3","description":" ","title":"cheer 5000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"600000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/3","description":" ","title":"cheer 600000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"700000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/3","description":" ","title":"cheer 700000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"75000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/3","description":" ","title":"cheer 75000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"800000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/3","description":" ","title":"cheer 800000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"900000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/3","description":" ","title":"cheer 900000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null}}},"bits-charity":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/3","description":"Supported their favorite streamer during the 2018 Blizzard of Bits","title":"Direct Relief - Charity 2018","click_action":"visit_url","click_url":"https://link.twitch.tv/blizzardofbits","last_updated":null}}},"bits-leader":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/3","description":"Ranked as a top cheerer on this channel","title":"Bits Leader 1","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/3","description":"Ranked as a top cheerer on this channel","title":"Bits Leader 2","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/3","description":"Ranked as a top cheerer on this channel","title":"Bits Leader 3","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null}}},"brawlhalla_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/3","description":"Brawlhalla","title":"Brawlhalla","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Brawlhalla/details","last_updated":null}}},"broadcaster":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/3","description":"Broadcaster","title":"Broadcaster","click_action":"none","click_url":"","last_updated":null}}},"broken-age_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/3","description":"Broken Age","title":"Broken Age","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Broken%20Age/details","last_updated":null}}},"bubsy-the-woolies_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/3","description":"Bubsy: The Woolies Strike Back","title":"Bubsy: The Woolies Strike Back","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Bubsy:%20The%20Woolies%20Strike%20Back/details","last_updated":null}}},"chatter-cs-go-2022":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/3","description":"Chatted during CS:GO Week Brazil 2022","title":"CS:GO Week Brazil 2022","click_action":"none","click_url":"","last_updated":null}}},"clip-champ":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/3","description":"Power Clipper","title":"Power Clipper","click_action":"visit_url","click_url":"https://help.twitch.tv/customer/portal/articles/2918323-clip-champs-guide","last_updated":null}}},"creator-cs-go-2022":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/3","description":"Streamed during CS:GO Week Brazil 2022","title":"CS:GO Week Brazil 2022","click_action":"none","click_url":"","last_updated":null}}},"cuphead_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/3","description":"Cuphead","title":"Cuphead","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Cuphead/details","last_updated":null}}},"darkest-dungeon_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/3","description":"Darkest Dungeon","title":"Darkest Dungeon","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Darkest%20Dungeon/details","last_updated":null}}},"deceit_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/3","description":"Deceit","title":"Deceit","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Deceit/details","last_updated":null}}},"devil-may-cry-hd_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/3","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devil-may-cry-hd_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/3","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devil-may-cry-hd_3":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/3","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devil-may-cry-hd_4":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/3","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devilian_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/3","description":"Devilian","title":"Devilian","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devilian/details","last_updated":null}}},"duelyst_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_3":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_4":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_5":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_6":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_7":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/3","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"enter-the-gungeon_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/3","description":"Enter The Gungeon","title":"Enter The Gungeon","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Enter%20the%20Gungeon/details","last_updated":null}}},"eso_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/3","description":"Elder Scrolls Online","title":"Elder Scrolls Online","click_action":"none","click_url":"","last_updated":null}}},"extension":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/3","description":"Extension","title":"Extension","click_action":"none","click_url":"","last_updated":null}}},"firewatch_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/3","description":"Firewatch","title":"Firewatch","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Firewatch/details","last_updated":null}}},"founder":{"versions":{"0":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/3","description":"Founder","title":"Founder","click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/founders-badge","last_updated":null}}},"frozen-cortext_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/3","description":"Frozen Cortext","title":"Frozen Cortext","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Frozen%20Cortex/details","last_updated":null}}},"frozen-synapse_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/3","description":"Frozen Synapse","title":"Frozen Synapse","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Frozen%20Synapse/details","last_updated":null}}},"game-developer":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/3","description":"Game Developer for:","title":"Game Developer","click_action":"none","click_url":"","last_updated":null}}},"getting-over-it_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/3","description":"Getting Over It","title":"Getting Over It","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details","last_updated":null}}},"getting-over-it_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/3","description":"Getting Over It","title":"Getting Over It","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details","last_updated":null}}},"glhf-pledge":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/3","description":"Signed the GLHF pledge in support for inclusive gaming communities","title":"GLHF Pledge","click_action":"visit_url","click_url":"https://www.anykey.org/pledge","last_updated":null}}},"glitchcon2020":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/3","description":"Earned for Watching Glitchcon 2020","title":"GlitchCon 2020","click_action":"visit_url","click_url":"https://www.twitchcon.com/","last_updated":null}}},"global_mod":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/3","description":"Global Moderator","title":"Global Moderator","click_action":"none","click_url":"","last_updated":null}}},"heavy-bullets_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/3","description":"Heavy Bullets","title":"Heavy Bullets","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Heavy%20Bullets/details","last_updated":null}}},"hello_neighbor_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/3","description":"Hello Neighbor","title":"Hello Neighbor","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Hello%20Neighbor/details","last_updated":null}}},"hype-train":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/3","description":"Top supporter during the most recent hype train","title":"Current Hype Train Conductor","click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/hype-train-guide","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/3","description":"Top supporter during prior hype trains","title":"Former Hype Train Conductor","click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/hype-train-guide","last_updated":null}}},"innerspace_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/3","description":"Innerspace","title":"Innerspace","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Innerspace/details","last_updated":null}}},"innerspace_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/3","description":"Innerspace","title":"Innerspace","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Innerspace/details","last_updated":null}}},"jackbox-party-pack_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/3","description":"Jackbox Party Pack","title":"Jackbox Party Pack","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Jackbox%20Party%20Pack/details","last_updated":null}}},"kingdom-new-lands_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/3","description":"Kingdom: New Lands","title":"Kingdom: New Lands","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Kingdom:%20New%20Lands/details","last_updated":null}}},"moderator":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/3","description":"Moderator","title":"Moderator","click_action":"none","click_url":"","last_updated":null}}},"moments":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/3","description":"Earned for being a part of at least 1 moment on a channel","title":"Moments Badge - Tier 1","click_action":"none","click_url":"","last_updated":null},"10":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/3","description":"Earned for being a part of at least 75 moments on a channel","title":"Moments Badge - Tier 10","click_action":"none","click_url":"","last_updated":null},"11":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/3","description":"Earned for being a part of at least 90 moments on a channel","title":"Moments Badge - Tier 11","click_action":"none","click_url":"","last_updated":null},"12":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/3","description":"Earned for being a part of at least 105 moments on a channel","title":"Moments Badge - Tier 12","click_action":"none","click_url":"","last_updated":null},"13":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/3","description":"Earned for being a part of at least 120 moments on a channel","title":"Moments Badge - Tier 13","click_action":"none","click_url":"","last_updated":null},"14":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/3","description":"Earned for being a part of at least 140 moments on a channel","title":"Moments Badge - Tier 14","click_action":"none","click_url":"","last_updated":null},"15":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/3","description":"Earned for being a part of at least 160 moments on a channel","title":"Moments Badge - Tier 15","click_action":"none","click_url":"","last_updated":null},"16":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/3","description":"Earned for being a part of at least 180 moments on a channel","title":"Moments Badge - Tier 16","click_action":"none","click_url":"","last_updated":null},"17":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/3","description":"Earned for being a part of at least 200 moments on a channel","title":"Moments Badge - Tier 17","click_action":"none","click_url":"","last_updated":null},"18":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/3","description":"Earned for being a part of at least 225 moments on a channel","title":"Moments Badge - Tier 18","click_action":"none","click_url":"","last_updated":null},"19":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/3","description":"Earned for being a part of at least 250 moments on a channel","title":"Moments Badge - Tier 19","click_action":"none","click_url":"","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/3","description":"Earned for being a part of at least 5 moments on a channel","title":"Moments Badge - Tier 2","click_action":"none","click_url":"","last_updated":null},"20":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/3","description":"Earned for being a part of at least 275 moments on a channel","title":"Moments Badge - Tier 20","click_action":"none","click_url":"","last_updated":null},"3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/3","description":"Earned for being a part of at least 10 moments on a channel","title":"Moments Badge - Tier 3","click_action":"none","click_url":"","last_updated":null},"4":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/3","description":"Earned for being a part of at least 15 moments on a channel","title":"Moments Badge - Tier 4","click_action":"none","click_url":"","last_updated":null},"5":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/3","description":"Earned for being a part of at least 20 moments on a channel","title":"Moments Badge - Tier 5","click_action":"none","click_url":"","last_updated":null},"6":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/3","description":"Earned for being a part of at least 30 moments on a channel","title":"Moments Badge - Tier 6","click_action":"none","click_url":"","last_updated":null},"7":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/3","description":"Earned for being a part of at least 40 moments on a channel","title":"Moments Badge - Tier 7","click_action":"none","click_url":"","last_updated":null},"8":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/3","description":"Earned for being a part of at least 50 moments on a channel","title":"Moments Badge - Tier 8","click_action":"none","click_url":"","last_updated":null},"9":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/3","description":"Earned for being a part of at least 60 moments on a channel","title":"Moments Badge - Tier 9","click_action":"none","click_url":"","last_updated":null}}},"no_audio":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/3","description":"Individuals with unreliable or no sound can select this badge","title":"Watching without audio","click_action":"none","click_url":"","last_updated":null}}},"no_video":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/3","description":"Individuals with unreliable or no video can select this badge","title":"Listening only","click_action":"none","click_url":"","last_updated":null}}},"okhlos_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/3","description":"Okhlos","title":"Okhlos","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Okhlos/details","last_updated":null}}},"overwatch-league-insider_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/3","description":"OWL All-Access Pass 2018","title":"OWL All-Access Pass 2018","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"overwatch-league-insider_2018B":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/3","description":"OWL All-Access Pass 2018","title":"OWL All-Access Pass 2018","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"overwatch-league-insider_2019A":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"overwatch-league-insider_2019B":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"4":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"5":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/3","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"partner":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/3","description":"Verified","title":"Verified","click_action":"visit_url","click_url":"https://blog.twitch.tv/2017/04/24/the-verified-badge-is-here-13381bc05735","last_updated":null}}},"power-rangers":{"versions":{"0":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/3","description":"Black Ranger","title":"Black Ranger","click_action":"none","click_url":"","last_updated":null},"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/3","description":"Blue Ranger","title":"Blue Ranger","click_action":"none","click_url":"","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/3","description":"Green Ranger","title":"Green Ranger","click_action":"none","click_url":"","last_updated":null},"3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/3","description":"Pink Ranger","title":"Pink Ranger","click_action":"none","click_url":"","last_updated":null},"4":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/3","description":"Red Ranger","title":"Red Ranger","click_action":"none","click_url":"","last_updated":null},"5":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/3","description":"White Ranger","title":"White Ranger","click_action":"none","click_url":"","last_updated":null},"6":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/3","description":"Yellow Ranger","title":"Yellow Ranger","click_action":"none","click_url":"","last_updated":null}}},"predictions":{"versions":{"blue-1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/3","description":"Predicted Outcome One","title":"Predicted Blue (1)","click_action":"none","click_url":"","last_updated":null},"blue-10":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/3","description":"Predicted Outcome Ten","title":"Predicted Blue (10)","click_action":"none","click_url":"","last_updated":null},"blue-2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/3","description":"Predicted Outcome Two","title":"Predicted Blue (2)","click_action":"none","click_url":"","last_updated":null},"blue-3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/3","description":"Predicted Outcome Three","title":"Predicted Blue (3)","click_action":"none","click_url":"","last_updated":null},"blue-4":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/3","description":"Predicted Outcome Four","title":"Predicted Blue (4)","click_action":"none","click_url":"","last_updated":null},"blue-5":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/3","description":"Predicted Outcome Five","title":"Predicted Blue (5)","click_action":"none","click_url":"","last_updated":null},"blue-6":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/3","description":"Predicted Outcome Six","title":"Predicted Blue (6)","click_action":"none","click_url":"","last_updated":null},"blue-7":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/3","description":"Predicted Outcome Seven","title":"Predicted Blue (7)","click_action":"none","click_url":"","last_updated":null},"blue-8":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/3","description":"Predicted Outcome Eight","title":"Predicted Blue (8)","click_action":"none","click_url":"","last_updated":null},"blue-9":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/3","description":"Predicted Outcome Nine","title":"Predicted Blue (9)","click_action":"none","click_url":"","last_updated":null},"gray-1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/3","description":"Predicted Gray (1)","title":"Predicted Gray (1)","click_action":"none","click_url":"","last_updated":null},"gray-2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/3","description":"Predicted Gray (2)","title":"Predicted Gray (2)","click_action":"none","click_url":"","last_updated":null},"pink-1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/3","description":"Predicted Outcome One","title":"Predicted Pink (1)","click_action":"none","click_url":"","last_updated":null},"pink-2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/3","description":"Predicted Outcome Two","title":"Predicted Pink (2)","click_action":"none","click_url":"","last_updated":null}}},"premium":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3","description":"Prime Gaming","title":"Prime Gaming","click_action":"visit_url","click_url":"https://gaming.amazon.com","last_updated":null}}},"psychonauts_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/3","description":"Psychonauts","title":"Psychonauts","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Psychonauts/details","last_updated":null}}},"raiden-v-directors-cut_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/3","description":"Raiden V","title":"Raiden V","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Raiden%20V/details","last_updated":null}}},"rift_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/3","description":"RIFT","title":"RIFT","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Rift/details","last_updated":null}}},"samusoffer_beta":{"versions":{"0":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/3","description":"beta_title1","title":"beta_title1","click_action":"visit_url","click_url":"https://twitch.amazon.com/prime","last_updated":null}}},"staff":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/3","description":"Twitch Staff","title":"Staff","click_action":"visit_url","click_url":"https://www.twitch.tv/jobs?ref=chat_badge","last_updated":null}}},"starbound_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/3","description":"Starbound","title":"Starbound","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Starbound/details","last_updated":null}}},"strafe_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/3","description":"Strafe","title":"Strafe","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/strafe/details","last_updated":null}}},"sub-gift-leader":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/3","description":"Ranked as a top subscription gifter in this community","title":"Gifter Leader 1","click_action":"none","click_url":"","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/3","description":"Ranked as a top subscription gifter in this community","title":"Gifter Leader 2","click_action":"none","click_url":"","last_updated":null},"3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/3","description":"Ranked as a top subscription gifter in this community","title":"Gifter Leader 3","click_action":"none","click_url":"","last_updated":null}}},"sub-gifter":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/3","description":"Has gifted a subscription to another viewer in this community","title":"Sub Gifter","click_action":"none","click_url":"","last_updated":null},"10":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/3","description":"Has gifted a subscription to another viewer in this community","title":"10 Gift Subs","click_action":"none","click_url":"","last_updated":null},"100":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/3","description":"Has gifted a subscription to another viewer in this community","title":"100 Gift Subs","click_action":"none","click_url":"","last_updated":null},"1000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/3","description":"Has gifted a subscription to another viewer in this community","title":"1000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"150":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/3","description":"Has gifted a subscription to another viewer in this community","title":"150 Gift Subs","click_action":"none","click_url":"","last_updated":null},"200":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/3","description":"Has gifted a subscription to another viewer in this community","title":"200 Gift Subs","click_action":"none","click_url":"","last_updated":null},"2000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/3","description":"Has gifted a subscription to another viewer in this community","title":"2000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"25":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/3","description":"Has gifted a subscription to another viewer in this community","title":"25 Gift Subs","click_action":"none","click_url":"","last_updated":null},"250":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/3","description":"Has gifted a subscription to another viewer in this community","title":"250 Gift Subs","click_action":"none","click_url":"","last_updated":null},"300":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/3","description":"Has gifted a subscription to another viewer in this community","title":"300 Gift Subs","click_action":"none","click_url":"","last_updated":null},"3000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/3","description":"Has gifted a subscription to another viewer in this community","title":"3000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"350":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/3","description":"Has gifted a subscription to another viewer in this community","title":"350 Gift Subs","click_action":"none","click_url":"","last_updated":null},"400":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/3","description":"Has gifted a subscription to another viewer in this community","title":"400 Gift Subs","click_action":"none","click_url":"","last_updated":null},"4000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/3","description":"Has gifted a subscription to another viewer in this community","title":"4000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"450":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/3","description":"Has gifted a subscription to another viewer in this community","title":"450 Gift Subs","click_action":"none","click_url":"","last_updated":null},"5":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/3","description":"Has gifted a subscription to another viewer in this community","title":"5 Gift Subs","click_action":"none","click_url":"","last_updated":null},"50":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/3","description":"Has gifted a subscription to another viewer in this community","title":"50 Gift Subs","click_action":"none","click_url":"","last_updated":null},"500":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/3","description":"Has gifted a subscription to another viewer in this community","title":"500 Gift Subs","click_action":"none","click_url":"","last_updated":null},"5000":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/3","description":"Has gifted a subscription to another viewer in this community","title":"5000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"550":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/3","description":"Has gifted a subscription to another viewer in this community","title":"550 Gift Subs","click_action":"none","click_url":"","last_updated":null},"600":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/3","description":"Has gifted a subscription to another viewer in this community","title":"600 Gift Subs","click_action":"none","click_url":"","last_updated":null},"650":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/3","description":"Has gifted a subscription to another viewer in this community","title":"650 Gift Subs","click_action":"none","click_url":"","last_updated":null},"700":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/3","description":"Has gifted a subscription to another viewer in this community","title":"700 Gift Subs","click_action":"none","click_url":"","last_updated":null},"750":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/3","description":"Has gifted a subscription to another viewer in this community","title":"750 Gift Subs","click_action":"none","click_url":"","last_updated":null},"800":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/3","description":"Has gifted a subscription to another viewer in this community","title":"800 Gift Subs","click_action":"none","click_url":"","last_updated":null},"850":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/3","description":"Has gifted a subscription to another viewer in this community","title":"850 Gift Subs","click_action":"none","click_url":"","last_updated":null},"900":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/3","description":"Has gifted a subscription to another viewer in this community","title":"900 Gift Subs","click_action":"none","click_url":"","last_updated":null},"950":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/3","description":"Has gifted a subscription to another viewer in this community","title":"950 Gift Subs","click_action":"none","click_url":"","last_updated":null}}},"subscriber":{"versions":{"0":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/3","description":"Subscriber","title":"Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/3","description":"Subscriber","title":"Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"2":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/3","description":"2-Month Subscriber","title":"2-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"3":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/3","description":"3-Month Subscriber","title":"3-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"4":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/3","description":"6-Month Subscriber","title":"6-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"5":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/3","description":"9-Month Subscriber","title":"9-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"6":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/3","description":"1-Year Subscriber","title":"6-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null}}},"superhot_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/3","description":"Superhot","title":"Superhot","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/superhot/details","last_updated":null}}},"the-surge_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/3","description":"The Surge","title":"The Surge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","last_updated":null}}},"the-surge_2":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/3","description":"The Surge","title":"The Surge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","last_updated":null}}},"the-surge_3":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/3","description":"The Surge","title":"The Surge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","last_updated":null}}},"this-war-of-mine_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/3","description":"This War of Mine","title":"This War of Mine","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/This%20War%20of%20Mine/details","last_updated":null}}},"titan-souls_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/3","description":"Titan Souls","title":"Titan Souls","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Titan%20Souls/details","last_updated":null}}},"treasure-adventure-world_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/3","description":"Treasure Adventure World","title":"Treasure Adventure World","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Treasure%20Adventure%20World/details","last_updated":null}}},"turbo":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/3","description":"A subscriber of Twitch's monthly premium user service","title":"Turbo","click_action":"turbo","click_url":"","last_updated":null}}},"twitchbot":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/3","description":"AutoMod","title":"AutoMod","click_action":"visit_url","click_url":"http://link.twitch.tv/automod_blog","last_updated":null}}},"twitchcon2017":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/3","description":"Attended TwitchCon Long Beach 2017","title":"TwitchCon 2017 - Long Beach","click_action":"visit_url","click_url":"https://www.twitchcon.com/","last_updated":null}}},"twitchcon2018":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/3","description":"Attended TwitchCon San Jose 2018","title":"TwitchCon 2018 - San Jose","click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tc18","last_updated":null}}},"twitchconAmsterdam2020":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/3","description":"Registered for TwitchCon Amsterdam 2020","title":"TwitchCon 2020 - Amsterdam","click_action":"visit_url","click_url":"https://www.twitchcon.com/amsterdam/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcamsterdam20","last_updated":null}}},"twitchconEU2019":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/3","description":"Attended TwitchCon Berlin 2019","title":"TwitchCon 2019 - Berlin","click_action":"visit_url","click_url":"https://europe.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu19","last_updated":null}}},"twitchconEU2022":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/3","description":"Attended TwitchCon Amsterdam 2022","title":"TwitchCon 2022 - Amsterdam","click_action":"visit_url","click_url":"https://www.twitchcon.com/amsterdam-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu22","last_updated":null}}},"twitchconNA2019":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/3","description":"Attended TwitchCon San Diego 2019","title":"TwitchCon 2019 - San Diego","click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna19","last_updated":null}}},"twitchconNA2020":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/3","description":"Registered for TwitchCon North America 2020","title":"TwitchCon 2020 - North America","click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna20","last_updated":null}}},"twitchconNA2022":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/3","description":"Attended TwitchCon San Diego 2022","title":"TwitchCon 2022 - San Diego","click_action":"visit_url","click_url":"https://www.twitchcon.com/san-diego-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna22","last_updated":null}}},"tyranny_1":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/3","description":"Tyranny","title":"Tyranny","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Tyranny/details","last_updated":null}}},"user-anniversary":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/3","description":"Staff badge celebrating Twitch tenure","title":"Twitchiversary Badge","click_action":"none","click_url":"","last_updated":null}}},"vga-champ-2017":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/3","description":"2017 VGA Champ","title":"2017 VGA Champ","click_action":"visit_url","click_url":"https://blog.twitch.tv/watch-and-co-stream-the-game-awards-this-thursday-on-twitch-3d8e34d2345d","last_updated":null}}},"vip":{"versions":{"1":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/3","description":"VIP","title":"VIP","click_action":"visit_url","click_url":"https://help.twitch.tv/customer/en/portal/articles/659115-twitch-chat-badges-guide","last_updated":null}}},"warcraft":{"versions":{"alliance":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/3","description":"For Lordaeron!","title":"Alliance","click_action":"visit_url","click_url":"http://warcraftontwitch.tv/","last_updated":null},"horde":{"image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/3","description":"For the Horde!","title":"Horde","click_action":"visit_url","click_url":"http://warcraftontwitch.tv/","last_updated":null}}}}} diff --git a/resources/windows.rc b/resources/windows.rc deleted file mode 100644 index 8f9d9ca03e6..00000000000 --- a/resources/windows.rc +++ /dev/null @@ -1 +0,0 @@ -IDI_ICON1 ICON DISCARDABLE "icon.ico" diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000000..92192ef35fe --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,3 @@ +# scripts + +This directory contains scripts that may be useful for a contributor to run while working on Chatterino diff --git a/tools/build-docker-images.sh b/scripts/build-docker-images.sh similarity index 100% rename from tools/build-docker-images.sh rename to scripts/build-docker-images.sh diff --git a/scripts/check-clang-tidy.sh b/scripts/check-clang-tidy.sh new file mode 100755 index 00000000000..62965189b82 --- /dev/null +++ b/scripts/check-clang-tidy.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -eu + +clang-tidy --version + +find \ + src/ \ + tests/src/ \ + benchmarks/src/ \ + mocks/include/ \ + -type f \( -name "*.hpp" -o -name "*.cpp" \) -print0 | parallel -0 -j16 -I {} clang-tidy --quiet "$@" "{}" diff --git a/tools/check-format.sh b/scripts/check-format.sh similarity index 78% rename from tools/check-format.sh rename to scripts/check-format.sh index e7722ed6f63..1d786901c70 100755 --- a/tools/check-format.sh +++ b/scripts/check-format.sh @@ -11,7 +11,7 @@ while read -r file; do echo "$file differs!!!!!!!" fail="1" fi -done < <(find src/ -type f \( -iname "*.hpp" -o -iname "*.cpp" \)) +done < <(find src/ tests/src benchmarks/src mocks/include -type f \( -iname "*.hpp" -o -iname "*.cpp" \)) if [ "$fail" = "1" ]; then echo "At least one file is poorly formatted - check the output above" diff --git a/tools/check-line-endings.sh b/scripts/check-line-endings.sh similarity index 100% rename from tools/check-line-endings.sh rename to scripts/check-line-endings.sh diff --git a/tools/clang-format-all.sh b/scripts/clang-format-all.sh similarity index 62% rename from tools/clang-format-all.sh rename to scripts/clang-format-all.sh index dc300e330fb..7e1e94eb504 100755 --- a/tools/clang-format-all.sh +++ b/scripts/clang-format-all.sh @@ -5,4 +5,6 @@ echo if [[ $REPLY =~ ^[Yy]$ ]]; then find src/ \( -iname "*.hpp" -o -iname "*.cpp" \) -exec clang-format -i {} \; find tests/src/ \( -iname "*.hpp" -o -iname "*.cpp" \) -exec clang-format -i {} \; + find benchmarks/src/ \( -iname "*.hpp" -o -iname "*.cpp" \) -exec clang-format -i {} \; + find mocks/include/ \( -iname "*.hpp" -o -iname "*.cpp" \) -exec clang-format -i {} \; fi diff --git a/tools/get-tlds-update.sh b/scripts/get-tlds-update.sh old mode 100644 new mode 100755 similarity index 100% rename from tools/get-tlds-update.sh rename to scripts/get-tlds-update.sh diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py new file mode 100644 index 00000000000..6afa3c5e3bb --- /dev/null +++ b/scripts/make_luals_meta.py @@ -0,0 +1,142 @@ +""" +This script generates docs/plugin-meta.lua. It accepts no arguments + +It assumes comments look like: +/** + * Thing + * + * @lua@param thing boolean + * @lua@returns boolean + * @exposed name + */ +- Do not have any useful info on '/**' and '*/' lines. +- Class members are not allowed to have non-@command lines and commands different from @lua@field + +Valid commands are: +1. @exposeenum [dotted.name.in_lua.last_part] + Define a table with keys of the enum. Values behind those keys aren't + written on purpose. + This generates three lines: + - An type alias of [last_part] to integer, + - A type description that describes available values of the enum, + - A global table definition for the num +2. @lua[@command] + Writes [@command] to the file as a comment, usually this is @class, @param, @return, ... + @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines +3. @exposed [c2.name] + Generates a function definition line from the last `@lua@param`s. + +Non-command lines of comments are written with a space after '---' +""" +from pathlib import Path + +BOILERPLATE = """ +---@meta Chatterino2 + +-- This file is automatically generated from src/controllers/plugins/LuaAPI.hpp by the scripts/make_luals_meta.py script +-- This file is intended to be used with LuaLS (https://luals.github.io/). +-- Add the folder this file is in to "Lua.workspace.library". + +c2 = {} +""" + +repo_root = Path(__file__).parent.parent +lua_api_file = repo_root / "src" / "controllers" / "plugins" / "LuaAPI.hpp" +lua_meta = repo_root / "docs" / "plugin-meta.lua" + +print("Reading from", lua_api_file.relative_to(repo_root)) +print("Writing to", lua_meta.relative_to(repo_root)) +with lua_api_file.open("r") as f: + lines = f.read().splitlines() + +# Are we in a doc comment? +comment: bool = False + +# Last `@lua@param`s seen - for @exposed generation +last_params_names: list[str] = [] +# Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier +is_class = False + +# The name of the next enum in lua world +expose_next_enum_as: str | None = None +# Name of the current enum in c++ world, used to generate internal typenames for +current_enum_name: str | None = None + +with lua_meta.open("w") as out: + out.write(BOILERPLATE[1:]) # skip the newline after triple quote + + for line in lines: + line = line.strip() + if line.startswith("enum class "): + line = line.removeprefix("enum class ") + temp = line.split(" ", 2) + current_enum_name = temp[0] + if not expose_next_enum_as: + print( + f"Skipping enum {current_enum_name}, there wasn't a @exposeenum command" + ) + current_enum_name = None + continue + current_enum_name = expose_next_enum_as.split(".", 1)[-1] + out.write("---@alias " + current_enum_name + " integer\n") + out.write("---@type { ") + # temp[1] is '{' + if len(temp) == 2: # no values on this line + continue + line = temp[2] + + if current_enum_name is not None: + for i, tok in enumerate(line.split(" ")): + if tok == "};": + break + entry = tok.removesuffix(",") + if i != 0: + out.write(", ") + out.write(entry + ": " + current_enum_name) + out.write(" }\n" f"{expose_next_enum_as} = {{}}\n") + print(f"Wrote enum {expose_next_enum_as} => {current_enum_name}") + current_enum_name = None + expose_next_enum_as = None + continue + + if line.startswith("/**"): + comment = True + continue + elif "*/" in line: + comment = False + if not is_class: + out.write("\n") + continue + if not comment: + continue + line = line.replace("*", "", 1).lstrip() + if line == "": + out.write("---\n") + elif line.startswith("@exposeenum "): + expose_next_enum_as = line.split(" ", 1)[1] + elif line.startswith("@exposed "): + exp = line.replace("@exposed ", "", 1) + params = ", ".join(last_params_names) + out.write(f"function {exp}({params}) end\n") + print(f"Wrote function {exp}(...)") + last_params_names = [] + elif line.startswith("@lua"): + command = line.replace("@lua", "", 1) + if command.startswith("@param"): + last_params_names.append(command.split(" ", 2)[1]) + elif command.startswith("@class"): + print(f"Writing {command}") + if is_class: + out.write("\n") + is_class = True + elif not command.startswith("@field"): + is_class = False + + out.write("---" + command + "\n") + else: + if is_class: + is_class = False + out.write("\n") + + # note the space difference from the branch above + out.write("--- " + line + "\n") diff --git a/tools/update-emoji-data.sh b/scripts/update-emoji-data.sh similarity index 100% rename from tools/update-emoji-data.sh rename to scripts/update-emoji-data.sh diff --git a/tools/windows-fix-directory-case-sensitivity.sh b/scripts/windows-fix-directory-case-sensitivity.sh old mode 100644 new mode 100755 similarity index 100% rename from tools/windows-fix-directory-case-sensitivity.sh rename to scripts/windows-fix-directory-case-sensitivity.sh diff --git a/src/Application.cpp b/src/Application.cpp index 7804169ce06..0df907907ca 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -10,7 +10,19 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" -#include "controllers/sound/SoundController.hpp" +#include "controllers/sound/ISoundController.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/SeventvAPI.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include "providers/twitch/TwitchBadges.hpp" +#include "singletons/ImageUploader.hpp" +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/PluginController.hpp" +#endif +#include "controllers/sound/MiniaudioBackend.hpp" +#include "controllers/sound/NullBackend.hpp" +#include "controllers/twitch/LiveController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" @@ -27,9 +39,11 @@ #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/PubSubMessages.hpp" +#include "providers/twitch/pubsubmessages/LowTrustUsers.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/CrashHandler.hpp" #include "singletons/Emotes.hpp" #include "singletons/Fonts.hpp" #include "singletons/helper/LoggingChannel.hpp" @@ -51,6 +65,36 @@ #include +namespace { + +using namespace chatterino; + +ISoundController *makeSoundController(Settings &settings) +{ + SoundBackend soundBackend = settings.soundBackend; + switch (soundBackend) + { + case SoundBackend::Miniaudio: { + return new MiniaudioBackend(); + } + break; + + case SoundBackend::Null: { + return new NullBackend(); + } + break; + + default: { + return new MiniaudioBackend(); + } + break; + } +} + +const QString TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv"; + +} // namespace + namespace chatterino { static std::atomic isAppInitialized{false}; @@ -67,46 +111,76 @@ IApplication::IApplication() // It will create the instances of the major classes, and connect their signals // to each other -Application::Application(Settings &_settings, Paths &_paths) - : themes(&this->emplace()) +Application::Application(Settings &_settings, const Paths &paths, + const Args &_args, Updates &_updates) + : paths_(paths) + , args_(_args) + , themes(&this->emplace()) , fonts(&this->emplace()) , emotes(&this->emplace()) , accounts(&this->emplace()) , hotkeys(&this->emplace()) - , windows(&this->emplace()) + , windows(&this->emplace(new WindowManager(paths))) , toasts(&this->emplace()) + , imageUploader(&this->emplace()) + , seventvAPI(&this->emplace()) + , crashHandler(&this->emplace(new CrashHandler(paths))) , commands(&this->emplace()) , notifications(&this->emplace()) , highlights(&this->emplace()) , twitch(&this->emplace()) - , chatterinoBadges(&this->emplace()) , ffzBadges(&this->emplace()) , seventvBadges(&this->emplace()) - , userData(&this->emplace()) - , sound(&this->emplace()) - , logging(&this->emplace()) + , userData(&this->emplace(new UserDataController(paths))) + , sound(&this->emplace(makeSoundController(_settings))) + , twitchLiveController(&this->emplace()) + , twitchPubSub(new PubSub(TWITCH_PUBSUB_URL)) + , twitchBadges(new TwitchBadges) + , chatterinoBadges(new ChatterinoBadges) + , bttvEmotes(new BttvEmotes) + , ffzEmotes(new FfzEmotes) + , seventvEmotes(new SeventvEmotes) + , logging(new Logging(_settings)) +#ifdef CHATTERINO_HAVE_PLUGINS + , plugins(&this->emplace(new PluginController(paths))) +#endif + , updates(_updates) { - this->instance = this; + Application::instance = this; - this->fonts->fontChanged.connect([this]() { + // We can safely ignore this signal's connection since the Application will always + // be destroyed after fonts + std::ignore = this->fonts->fontChanged.connect([this]() { this->windows->layoutChannelViews(); }); } -void Application::initialize(Settings &settings, Paths &paths) +Application::~Application() = default; + +void Application::fakeDtor() +{ + this->twitchPubSub.reset(); + this->twitchBadges.reset(); + this->chatterinoBadges.reset(); + this->bttvEmotes.reset(); + this->ffzEmotes.reset(); + this->seventvEmotes.reset(); +} + +void Application::initialize(Settings &settings, const Paths &paths) { assert(isAppInitialized == false); isAppInitialized = true; // Show changelog - if (!getArgs().isFramelessEmbed && + if (!this->args_.isFramelessEmbed && getSettings()->currentVersion.getValue() != "" && getSettings()->currentVersion.getValue() != CHATTERINO_VERSION) { - auto box = new QMessageBox(QMessageBox::Information, "Chatterino 2", - "Show changelog?", - QMessageBox::Yes | QMessageBox::No); + auto *box = new QMessageBox(QMessageBox::Information, "Chatterino 2", + "Show changelog?", + QMessageBox::Yes | QMessageBox::No); box->setAttribute(Qt::WA_DeleteOnClose); if (box->exec() == QMessageBox::Yes) { @@ -115,7 +189,7 @@ void Application::initialize(Settings &settings, Paths &paths) } } - if (!getArgs().isFramelessEmbed) + if (!this->args_.isFramelessEmbed) { getSettings()->currentVersion.setValue(CHATTERINO_VERSION); @@ -130,13 +204,15 @@ void Application::initialize(Settings &settings, Paths &paths) singleton->initialize(settings, paths); } - // add crash message - if (!getArgs().isFramelessEmbed && getArgs().crashRecovery) + // Show crash message. + // On Windows, the crash message was already shown. +#ifndef Q_OS_WIN + if (!this->args_.isFramelessEmbed && this->args_.crashRecovery) { - if (auto selected = + if (auto *selected = this->windows->getMainWindow().getNotebook().getSelectedPage()) { - if (auto container = dynamic_cast(selected)) + if (auto *container = dynamic_cast(selected)) { for (auto &&split : container->getSplits()) { @@ -151,10 +227,11 @@ void Application::initialize(Settings &settings, Paths &paths) } } } +#endif this->windows->updateWordTypeMask(); - if (!getArgs().isFramelessEmbed) + if (!this->args_.isFramelessEmbed) { this->initNm(paths); } @@ -170,26 +247,37 @@ int Application::run(QApplication &qtApp) this->twitch->connect(); - if (!getArgs().isFramelessEmbed) + if (!this->args_.isFramelessEmbed) { this->windows->getMainWindow().show(); } getSettings()->betaUpdates.connect( - [] { - Updates::instance().checkForUpdates(); + [this] { + this->updates.checkForUpdates(); }, false); - getSettings()->moderationActions.delayedItemsChanged.connect([this] { - this->windows->forceLayoutChannelViews(); - }); - getSettings()->highlightedMessages.delayedItemsChanged.connect([this] { - this->windows->forceLayoutChannelViews(); - }); - getSettings()->highlightedUsers.delayedItemsChanged.connect([this] { - this->windows->forceLayoutChannelViews(); - }); + // We can safely ignore the signal connections since Application will always live longer than + // everything else, including settings. right? + // NOTE: SETTINGS_LIFETIME + std::ignore = + getSettings()->moderationActions.delayedItemsChanged.connect([this] { + this->windows->forceLayoutChannelViews(); + }); + + std::ignore = + getSettings()->highlightedMessages.delayedItemsChanged.connect([this] { + this->windows->forceLayoutChannelViews(); + }); + std::ignore = + getSettings()->highlightedUsers.delayedItemsChanged.connect([this] { + this->windows->forceLayoutChannelViews(); + }); + std::ignore = + getSettings()->highlightedBadges.delayedItemsChanged.connect([this] { + this->windows->forceLayoutChannelViews(); + }); getSettings()->removeSpacesBetweenEmotes.connect([this] { this->windows->forceLayoutChannelViews(); @@ -229,16 +317,203 @@ int Application::run(QApplication &qtApp) return qtApp.exec(); } +Theme *Application::getThemes() +{ + assertInGuiThread(); + + return this->themes; +} + +Fonts *Application::getFonts() +{ + assertInGuiThread(); + + return this->fonts; +} + IEmotes *Application::getEmotes() { + assertInGuiThread(); + return this->emotes; } +AccountController *Application::getAccounts() +{ + assertInGuiThread(); + + return this->accounts; +} + +HotkeyController *Application::getHotkeys() +{ + assertInGuiThread(); + + return this->hotkeys; +} + +WindowManager *Application::getWindows() +{ + assertInGuiThread(); + assert(this->windows); + + return this->windows; +} + +Toasts *Application::getToasts() +{ + assertInGuiThread(); + + return this->toasts; +} + +CrashHandler *Application::getCrashHandler() +{ + assertInGuiThread(); + + return this->crashHandler; +} + +CommandController *Application::getCommands() +{ + assertInGuiThread(); + + return this->commands; +} + +NotificationController *Application::getNotifications() +{ + assertInGuiThread(); + + return this->notifications; +} + +HighlightController *Application::getHighlights() +{ + assertInGuiThread(); + + return this->highlights; +} + +FfzBadges *Application::getFfzBadges() +{ + assertInGuiThread(); + + return this->ffzBadges; +} + +SeventvBadges *Application::getSeventvBadges() +{ + // SeventvBadges handles its own locks, so we don't need to assert that this is called in the GUI thread + + return this->seventvBadges; +} + IUserDataController *Application::getUserData() { + assertInGuiThread(); + return this->userData; } +ISoundController *Application::getSound() +{ + assertInGuiThread(); + + return this->sound; +} + +ITwitchLiveController *Application::getTwitchLiveController() +{ + assertInGuiThread(); + + return this->twitchLiveController; +} + +TwitchBadges *Application::getTwitchBadges() +{ + assertInGuiThread(); + assert(this->twitchBadges); + + return this->twitchBadges.get(); +} + +IChatterinoBadges *Application::getChatterinoBadges() +{ + assertInGuiThread(); + assert(this->chatterinoBadges); + + return this->chatterinoBadges.get(); +} + +ImageUploader *Application::getImageUploader() +{ + assertInGuiThread(); + + return this->imageUploader; +} + +SeventvAPI *Application::getSeventvAPI() +{ + assertInGuiThread(); + + return this->seventvAPI; +} + +#ifdef CHATTERINO_HAVE_PLUGINS +PluginController *Application::getPlugins() +{ + assertInGuiThread(); + + return this->plugins; +} +#endif + +ITwitchIrcServer *Application::getTwitch() +{ + assertInGuiThread(); + + return this->twitch; +} + +PubSub *Application::getTwitchPubSub() +{ + assertInGuiThread(); + + return this->twitchPubSub.get(); +} + +Logging *Application::getChatLogger() +{ + assertInGuiThread(); + + return this->logging.get(); +} + +BttvEmotes *Application::getBttvEmotes() +{ + assertInGuiThread(); + assert(this->bttvEmotes); + + return this->bttvEmotes.get(); +} + +FfzEmotes *Application::getFfzEmotes() +{ + assertInGuiThread(); + assert(this->ffzEmotes); + + return this->ffzEmotes.get(); +} + +SeventvEmotes *Application::getSeventvEmotes() +{ + assertInGuiThread(); + assert(this->seventvEmotes); + + return this->seventvEmotes.get(); +} + void Application::save() { for (auto &singleton : this->singletons_) @@ -247,12 +522,12 @@ void Application::save() } } -void Application::initNm(Paths &paths) +void Application::initNm(const Paths &paths) { (void)paths; #ifdef Q_OS_WIN -# if defined QT_NO_DEBUG || defined C_DEBUG_NM +# if defined QT_NO_DEBUG || defined CHATTERINO_DEBUG_NM registerNmHost(paths); this->nmServer.start(); # endif @@ -261,7 +536,9 @@ void Application::initNm(Paths &paths) void Application::initPubSub() { - this->twitch->pubsub->signals_.moderation.chatCleared.connect( + // We can safely ignore these signal connections since the twitch object will always + // be destroyed before the Application + std::ignore = this->twitchPubSub->moderation.chatCleared.connect( [this](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); if (chan->isEmpty()) @@ -270,7 +547,7 @@ void Application::initPubSub() } QString text = - QString("%1 cleared the chat").arg(action.source.login); + QString("%1 cleared the chat.").arg(action.source.login); auto msg = makeSystemMessage(text); postToThread([chan, msg] { @@ -278,7 +555,7 @@ void Application::initPubSub() }); }); - this->twitch->pubsub->signals_.moderation.modeChanged.connect( + std::ignore = this->twitchPubSub->moderation.modeChanged.connect( [this](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); if (chan->isEmpty()) @@ -287,7 +564,7 @@ void Application::initPubSub() } QString text = - QString("%1 turned %2 %3 mode") + QString("%1 turned %2 %3 mode.") .arg(action.source.login) .arg(action.state == ModeChangedAction::State::On ? "on" : "off") @@ -304,7 +581,7 @@ void Application::initPubSub() }); }); - this->twitch->pubsub->signals_.moderation.moderationStateChanged.connect( + std::ignore = this->twitchPubSub->moderation.moderationStateChanged.connect( [this](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); if (chan->isEmpty()) @@ -314,7 +591,7 @@ void Application::initPubSub() QString text; - text = QString("%1 %2 %3") + text = QString("%1 %2 %3.") .arg(action.source.login, (action.modded ? "modded" : "unmodded"), action.target.login); @@ -325,7 +602,7 @@ void Application::initPubSub() }); }); - this->twitch->pubsub->signals_.moderation.userBanned.connect( + std::ignore = this->twitchPubSub->moderation.userBanned.connect( [&](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); @@ -340,7 +617,7 @@ void Application::initPubSub() chan->addOrReplaceTimeout(msg.release()); }); }); - this->twitch->pubsub->signals_.moderation.messageDeleted.connect( + std::ignore = this->twitchPubSub->moderation.messageDeleted.connect( [&](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); @@ -364,7 +641,7 @@ void Application::initPubSub() for (int i = snapshotLength - 1; i >= end; --i) { - auto &s = snapshot[i]; + const auto &s = snapshot[i]; if (!s->flags.has(MessageFlag::PubSub) && s->timeoutUser == msg->timeoutUser) { @@ -380,7 +657,7 @@ void Application::initPubSub() }); }); - this->twitch->pubsub->signals_.moderation.userUnbanned.connect( + std::ignore = this->twitchPubSub->moderation.userUnbanned.connect( [&](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); @@ -396,7 +673,96 @@ void Application::initPubSub() }); }); - this->twitch->pubsub->signals_.moderation.autoModMessageCaught.connect( + std::ignore = + this->twitchPubSub->moderation.suspiciousMessageReceived.connect( + [&](const auto &action) { + if (action.treatment == + PubSubLowTrustUsersMessage::Treatment::INVALID) + { + qCWarning(chatterinoTwitch) + << "Received suspicious message with unknown " + "treatment:" + << action.treatmentString; + return; + } + + // monitored chats are received over irc; in the future, we will use pubsub instead + if (action.treatment != + PubSubLowTrustUsersMessage::Treatment::Restricted) + { + return; + } + + if (getSettings()->streamerModeHideModActions && + isInStreamerMode()) + { + return; + } + + auto chan = + this->twitch->getChannelOrEmptyByID(action.channelID); + + if (chan->isEmpty()) + { + return; + } + + auto twitchChannel = + std::dynamic_pointer_cast(chan); + if (!twitchChannel) + { + return; + } + + postToThread([twitchChannel, action] { + const auto p = + TwitchMessageBuilder::makeLowTrustUserMessage( + action, twitchChannel->getName(), + twitchChannel.get()); + twitchChannel->addMessage(p.first); + twitchChannel->addMessage(p.second); + }); + }); + + std::ignore = + this->twitchPubSub->moderation.suspiciousTreatmentUpdated.connect( + [&](const auto &action) { + if (action.treatment == + PubSubLowTrustUsersMessage::Treatment::INVALID) + { + qCWarning(chatterinoTwitch) + << "Received suspicious user update with unknown " + "treatment:" + << action.treatmentString; + return; + } + + if (action.updatedByUserLogin.isEmpty()) + { + return; + } + + if (getSettings()->streamerModeHideModActions && + isInStreamerMode()) + { + return; + } + + auto chan = + this->twitch->getChannelOrEmptyByID(action.channelID); + if (chan->isEmpty()) + { + return; + } + + postToThread([chan, action] { + auto msg = + TwitchMessageBuilder::makeLowTrustUpdateMessage(action); + chan->addMessage(msg); + }); + }); + + std::ignore = this->twitchPubSub->moderation.autoModMessageCaught.connect( [&](const auto &msg, const QString &channelID) { auto chan = this->twitch->getChannelOrEmptyByID(channelID); if (chan->isEmpty()) @@ -471,9 +837,16 @@ void Application::initPubSub() ActionUser{msg.senderUserID, msg.senderUserLogin, senderDisplayName, senderColor}; postToThread([chan, action] { - const auto p = makeAutomodMessage(action); + const auto p = + TwitchMessageBuilder::makeAutomodMessage( + action, chan->getName()); chan->addMessage(p.first); chan->addMessage(p.second); + + getApp()->twitch->automodChannel->addMessage( + p.first); + getApp()->twitch->automodChannel->addMessage( + p.second); }); } // "ALLOWED" and "DENIED" statuses remain unimplemented @@ -488,7 +861,7 @@ void Application::initPubSub() } }); - this->twitch->pubsub->signals_.moderation.autoModMessageBlocked.connect( + std::ignore = this->twitchPubSub->moderation.autoModMessageBlocked.connect( [&](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); if (chan->isEmpty()) @@ -497,14 +870,21 @@ void Application::initPubSub() } postToThread([chan, action] { - const auto p = makeAutomodMessage(action); + const auto p = TwitchMessageBuilder::makeAutomodMessage( + action, chan->getName()); chan->addMessage(p.first); chan->addMessage(p.second); }); }); - this->twitch->pubsub->signals_.moderation.automodUserMessage.connect( + std::ignore = this->twitchPubSub->moderation.automodUserMessage.connect( [&](const auto &action) { + // This condition has been set up to execute isInStreamerMode() as the last thing + // as it could end up being expensive. + if (getSettings()->streamerModeHideModActions && isInStreamerMode()) + { + return; + } auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); if (chan->isEmpty()) @@ -520,7 +900,7 @@ void Application::initPubSub() chan->deleteMessage(msg->id); }); - this->twitch->pubsub->signals_.moderation.automodInfoMessage.connect( + std::ignore = this->twitchPubSub->moderation.automodInfoMessage.connect( [&](const auto &action) { auto chan = this->twitch->getChannelOrEmptyByID(action.roomID); @@ -530,13 +910,14 @@ void Application::initPubSub() } postToThread([chan, action] { - const auto p = makeAutomodInfoMessage(action); + const auto p = + TwitchMessageBuilder::makeAutomodInfoMessage(action); chan->addMessage(p); }); }); - this->twitch->pubsub->signals_.pointReward.redeemed.connect( - [&](auto &data) { + std::ignore = + this->twitchPubSub->pointReward.redeemed.connect([&](auto &data) { QString channelId = data.value("channel_id").toString(); if (channelId.isEmpty()) { @@ -550,35 +931,26 @@ void Application::initPubSub() auto reward = ChannelPointReward(data); postToThread([chan, reward] { - if (auto channel = dynamic_cast(chan.get())) + if (auto *channel = dynamic_cast(chan.get())) { channel->addChannelPointReward(reward); } }); }); - this->twitch->pubsub->start(); - - auto RequestModerationActions = [this]() { - this->twitch->pubsub->setAccount( - getApp()->accounts->twitch.getCurrent()); - // TODO(pajlada): Unlisten to all authed topics instead of only - // moderation topics this->twitch->pubsub->UnlistenAllAuthedTopics(); - - this->twitch->pubsub->listenToWhispers(); - }; + this->twitchPubSub->start(); + this->twitchPubSub->setAccount(this->accounts->twitch.getCurrent()); this->accounts->twitch.currentUserChanged.connect( [this] { - this->twitch->pubsub->unlistenAllModerationActions(); - this->twitch->pubsub->unlistenAutomod(); - this->twitch->pubsub->unlistenWhispers(); + this->twitchPubSub->unlistenChannelModerationActions(); + this->twitchPubSub->unlistenAutomod(); + this->twitchPubSub->unlistenLowTrustUsers(); + this->twitchPubSub->unlistenChannelPointRewards(); + + this->twitchPubSub->setAccount(this->accounts->twitch.getCurrent()); }, boost::signals2::at_front); - - this->accounts->twitch.currentUserChanged.connect(RequestModerationActions); - - RequestModerationActions(); } void Application::initBttvLiveUpdates() @@ -590,7 +962,9 @@ void Application::initBttvLiveUpdates() return; } - this->twitch->bttvLiveUpdates->signals_.emoteAdded.connect( + // We can safely ignore these signal connections since the twitch object will always + // be destroyed before the Application + std::ignore = this->twitch->bttvLiveUpdates->signals_.emoteAdded.connect( [&](const auto &data) { auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); @@ -601,7 +975,7 @@ void Application::initBttvLiveUpdates() } }); }); - this->twitch->bttvLiveUpdates->signals_.emoteUpdated.connect( + std::ignore = this->twitch->bttvLiveUpdates->signals_.emoteUpdated.connect( [&](const auto &data) { auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); @@ -612,7 +986,7 @@ void Application::initBttvLiveUpdates() } }); }); - this->twitch->bttvLiveUpdates->signals_.emoteRemoved.connect( + std::ignore = this->twitch->bttvLiveUpdates->signals_.emoteRemoved.connect( [&](const auto &data) { auto chan = this->twitch->getChannelOrEmptyByID(data.channelID); @@ -635,7 +1009,9 @@ void Application::initSeventvEventAPI() return; } - this->twitch->seventvEventAPI->signals_.emoteAdded.connect( + // We can safely ignore these signal connections since the twitch object will always + // be destroyed before the Application + std::ignore = this->twitch->seventvEventAPI->signals_.emoteAdded.connect( [&](const auto &data) { postToThread([this, data] { this->twitch->forEachSeventvEmoteSet( @@ -644,7 +1020,7 @@ void Application::initSeventvEventAPI() }); }); }); - this->twitch->seventvEventAPI->signals_.emoteUpdated.connect( + std::ignore = this->twitch->seventvEventAPI->signals_.emoteUpdated.connect( [&](const auto &data) { postToThread([this, data] { this->twitch->forEachSeventvEmoteSet( @@ -653,7 +1029,7 @@ void Application::initSeventvEventAPI() }); }); }); - this->twitch->seventvEventAPI->signals_.emoteRemoved.connect( + std::ignore = this->twitch->seventvEventAPI->signals_.emoteRemoved.connect( [&](const auto &data) { postToThread([this, data] { this->twitch->forEachSeventvEmoteSet( @@ -662,7 +1038,7 @@ void Application::initSeventvEventAPI() }); }); }); - this->twitch->seventvEventAPI->signals_.userUpdated.connect( + std::ignore = this->twitch->seventvEventAPI->signals_.userUpdated.connect( [&](const auto &data) { this->twitch->forEachSeventvUser(data.userID, [data](TwitchChannel &chan) { @@ -677,8 +1053,6 @@ Application *getApp() { assert(Application::instance != nullptr); - assertInGuiThread(); - return Application::instance; } @@ -686,8 +1060,6 @@ IApplication *getIApp() { assert(IApplication::instance != nullptr); - assertInGuiThread(); - return IApplication::instance; } diff --git a/src/Application.hpp b/src/Application.hpp index dada8d02a09..eef02be70e8 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -1,16 +1,23 @@ #pragma once #include "common/Singleton.hpp" +#include "debug/AssertInGuiThread.hpp" #include "singletons/NativeMessaging.hpp" +#include +#include #include +#include #include namespace chatterino { +class Args; class TwitchIrcServer; +class ITwitchIrcServer; class PubSub; +class Updates; class CommandController; class AccountController; @@ -19,7 +26,14 @@ class HighlightController; class HotkeyController; class IUserDataController; class UserDataController; +class ISoundController; class SoundController; +class ITwitchLiveController; +class TwitchLiveController; +class TwitchBadges; +#ifdef CHATTERINO_HAVE_PLUGINS +class PluginController; +#endif class Theme; class WindowManager; @@ -30,9 +44,16 @@ class IEmotes; class Settings; class Fonts; class Toasts; +class IChatterinoBadges; class ChatterinoBadges; class FfzBadges; class SeventvBadges; +class ImageUploader; +class SeventvAPI; +class CrashHandler; +class BttvEmotes; +class FfzEmotes; +class SeventvEmotes; class IApplication { @@ -42,6 +63,8 @@ class IApplication static IApplication *instance; + virtual const Paths &getPaths() = 0; + virtual const Args &getArgs() = 0; virtual Theme *getThemes() = 0; virtual Fonts *getFonts() = 0; virtual IEmotes *getEmotes() = 0; @@ -49,27 +72,58 @@ class IApplication virtual HotkeyController *getHotkeys() = 0; virtual WindowManager *getWindows() = 0; virtual Toasts *getToasts() = 0; + virtual CrashHandler *getCrashHandler() = 0; virtual CommandController *getCommands() = 0; virtual HighlightController *getHighlights() = 0; virtual NotificationController *getNotifications() = 0; - virtual TwitchIrcServer *getTwitch() = 0; - virtual ChatterinoBadges *getChatterinoBadges() = 0; + virtual ITwitchIrcServer *getTwitch() = 0; + virtual PubSub *getTwitchPubSub() = 0; + virtual Logging *getChatLogger() = 0; + virtual IChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; + virtual SeventvBadges *getSeventvBadges() = 0; virtual IUserDataController *getUserData() = 0; + virtual ISoundController *getSound() = 0; + virtual ITwitchLiveController *getTwitchLiveController() = 0; + virtual TwitchBadges *getTwitchBadges() = 0; + virtual ImageUploader *getImageUploader() = 0; + virtual SeventvAPI *getSeventvAPI() = 0; +#ifdef CHATTERINO_HAVE_PLUGINS + virtual PluginController *getPlugins() = 0; +#endif + virtual Updates &getUpdates() = 0; + virtual BttvEmotes *getBttvEmotes() = 0; + virtual FfzEmotes *getFfzEmotes() = 0; + virtual SeventvEmotes *getSeventvEmotes() = 0; }; class Application : public IApplication { + const Paths &paths_; + const Args &args_; std::vector> singletons_; - int argc_; - char **argv_; + int argc_{}; + char **argv_{}; public: static Application *instance; - Application(Settings &settings, Paths &paths); + Application(Settings &_settings, const Paths &paths, const Args &_args, + Updates &_updates); + ~Application() override; - void initialize(Settings &settings, Paths &paths); + Application(const Application &) = delete; + Application(Application &&) = delete; + Application &operator=(const Application &) = delete; + Application &operator=(Application &&) = delete; + + /** + * In the interim, before we remove _exit(0); from RunGui.cpp, + * this will destroy things we know can be destroyed + */ + void fakeDtor(); + + void initialize(Settings &settings, const Paths &paths); void load(); void save(); @@ -77,6 +131,7 @@ class Application : public IApplication friend void test(); +private: Theme *const themes{}; Fonts *const fonts{}; Emotes *const emotes{}; @@ -84,76 +139,87 @@ class Application : public IApplication HotkeyController *const hotkeys{}; WindowManager *const windows{}; Toasts *const toasts{}; - + ImageUploader *const imageUploader{}; + SeventvAPI *const seventvAPI{}; + CrashHandler *const crashHandler{}; CommandController *const commands{}; NotificationController *const notifications{}; HighlightController *const highlights{}; + +public: TwitchIrcServer *const twitch{}; - ChatterinoBadges *const chatterinoBadges{}; + +private: FfzBadges *const ffzBadges{}; SeventvBadges *const seventvBadges{}; UserDataController *const userData{}; - SoundController *const sound{}; + ISoundController *const sound{}; + TwitchLiveController *const twitchLiveController{}; + std::unique_ptr twitchPubSub; + std::unique_ptr twitchBadges; + std::unique_ptr chatterinoBadges; + std::unique_ptr bttvEmotes; + std::unique_ptr ffzEmotes; + std::unique_ptr seventvEmotes; + const std::unique_ptr logging; +#ifdef CHATTERINO_HAVE_PLUGINS + PluginController *const plugins{}; +#endif - /*[[deprecated]]*/ Logging *const logging{}; - - Theme *getThemes() override +public: + const Paths &getPaths() override { - return this->themes; + return this->paths_; } - Fonts *getFonts() override + const Args &getArgs() override { - return this->fonts; + return this->args_; } + Theme *getThemes() override; + Fonts *getFonts() override; IEmotes *getEmotes() override; - AccountController *getAccounts() override - { - return this->accounts; - } - HotkeyController *getHotkeys() override - { - return this->hotkeys; - } - WindowManager *getWindows() override - { - return this->windows; - } - Toasts *getToasts() override - { - return this->toasts; - } - CommandController *getCommands() override - { - return this->commands; - } - NotificationController *getNotifications() override - { - return this->notifications; - } - HighlightController *getHighlights() override - { - return this->highlights; - } - TwitchIrcServer *getTwitch() override - { - return this->twitch; - } - ChatterinoBadges *getChatterinoBadges() override - { - return this->chatterinoBadges; - } - FfzBadges *getFfzBadges() override + AccountController *getAccounts() override; + HotkeyController *getHotkeys() override; + WindowManager *getWindows() override; + Toasts *getToasts() override; + CrashHandler *getCrashHandler() override; + CommandController *getCommands() override; + NotificationController *getNotifications() override; + HighlightController *getHighlights() override; + ITwitchIrcServer *getTwitch() override; + PubSub *getTwitchPubSub() override; + Logging *getChatLogger() override; + FfzBadges *getFfzBadges() override; + SeventvBadges *getSeventvBadges() override; + IUserDataController *getUserData() override; + ISoundController *getSound() override; + ITwitchLiveController *getTwitchLiveController() override; + TwitchBadges *getTwitchBadges() override; + IChatterinoBadges *getChatterinoBadges() override; + ImageUploader *getImageUploader() override; + SeventvAPI *getSeventvAPI() override; +#ifdef CHATTERINO_HAVE_PLUGINS + PluginController *getPlugins() override; +#endif + Updates &getUpdates() override { - return this->ffzBadges; + assertInGuiThread(); + + return this->updates; } - IUserDataController *getUserData() override; + + BttvEmotes *getBttvEmotes() override; + FfzEmotes *getFfzEmotes() override; + SeventvEmotes *getSeventvEmotes() override; + + pajlada::Signals::NoArgSignal streamerModeChanged; private: void addSingleton(Singleton *singleton); void initPubSub(); void initBttvLiveUpdates(); void initSeventvEventAPI(); - void initNm(Paths &paths); + void initNm(const Paths &paths); template ::value>> @@ -164,7 +230,16 @@ class Application : public IApplication return *t; } + template ::value>> + T &emplace(T *t) + { + this->singletons_.push_back(std::unique_ptr(t)); + return *t; + } + NativeMessagingServer nmServer{}; + Updates &updates; }; Application *getApp(); diff --git a/src/BaseSettings.cpp b/src/BaseSettings.cpp deleted file mode 100644 index 441ed08b499..00000000000 --- a/src/BaseSettings.cpp +++ /dev/null @@ -1,128 +0,0 @@ -#include "BaseSettings.hpp" - -#include "util/Clamp.hpp" - -#include - -namespace chatterino { - -std::vector> _settings; - -AB_SETTINGS_CLASS *AB_SETTINGS_CLASS::instance = nullptr; - -void _actuallyRegisterSetting( - std::weak_ptr setting) -{ - _settings.push_back(std::move(setting)); -} - -AB_SETTINGS_CLASS::AB_SETTINGS_CLASS(const QString &settingsDirectory) -{ - AB_SETTINGS_CLASS::instance = this; - - QString settingsPath = settingsDirectory + "/settings.json"; - - // get global instance of the settings library - auto settingsInstance = pajlada::Settings::SettingManager::getInstance(); - - settingsInstance->load(qPrintable(settingsPath)); - - settingsInstance->setBackupEnabled(true); - settingsInstance->setBackupSlots(9); - settingsInstance->saveMethod = - pajlada::Settings::SettingManager::SaveMethod::SaveOnExit; -} - -void AB_SETTINGS_CLASS::saveSnapshot() -{ - rapidjson::Document *d = new rapidjson::Document(rapidjson::kObjectType); - rapidjson::Document::AllocatorType &a = d->GetAllocator(); - - for (const auto &weakSetting : _settings) - { - auto setting = weakSetting.lock(); - if (!setting) - { - continue; - } - - rapidjson::Value key(setting->getPath().c_str(), a); - auto curVal = setting->unmarshalJSON(); - if (curVal == nullptr) - { - continue; - } - - rapidjson::Value val; - val.CopyFrom(*curVal, a); - d->AddMember(key.Move(), val.Move(), a); - } - - // log("Snapshot state: {}", rj::stringify(*d)); - - this->snapshot_.reset(d); -} - -void AB_SETTINGS_CLASS::restoreSnapshot() -{ - if (!this->snapshot_) - { - return; - } - - const auto &snapshot = *(this->snapshot_.get()); - - if (!snapshot.IsObject()) - { - return; - } - - for (const auto &weakSetting : _settings) - { - auto setting = weakSetting.lock(); - if (!setting) - { - continue; - } - - const char *path = setting->getPath().c_str(); - - if (!snapshot.HasMember(path)) - { - continue; - } - - setting->marshalJSON(snapshot[path]); - } -} - -float AB_SETTINGS_CLASS::getClampedUiScale() const -{ - return clamp(this->uiScale.getValue(), 0.2f, 10); -} - -void AB_SETTINGS_CLASS::setClampedUiScale(float value) -{ - this->uiScale.setValue(clamp(value, 0.2f, 10)); -} - -#ifndef AB_CUSTOM_SETTINGS -Settings *getSettings() -{ - static_assert(std::is_same_v, - "`AB_SETTINGS_CLASS` must be the same as `Settings`"); - - assert(AB_SETTINGS_CLASS::instance != nullptr); - - return AB_SETTINGS_CLASS::instance; -} -#endif - -AB_SETTINGS_CLASS *getABSettings() -{ - assert(AB_SETTINGS_CLASS::instance); - - return AB_SETTINGS_CLASS::instance; -} - -} // namespace chatterino diff --git a/src/BaseSettings.hpp b/src/BaseSettings.hpp deleted file mode 100644 index 89ce45d06cf..00000000000 --- a/src/BaseSettings.hpp +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include "common/ChatterinoSetting.hpp" - -#include -#include -#include - -#include - -#ifdef AB_CUSTOM_SETTINGS -# define AB_SETTINGS_CLASS ABSettings -#else -# define AB_SETTINGS_CLASS Settings -#endif - -namespace chatterino { - -class Settings; - -void _actuallyRegisterSetting( - std::weak_ptr setting); - -class AB_SETTINGS_CLASS -{ -public: - AB_SETTINGS_CLASS(const QString &settingsDirectory); - - void saveSnapshot(); - void restoreSnapshot(); - - static AB_SETTINGS_CLASS *instance; - - FloatSetting uiScale = {"/appearance/uiScale2", 1}; - BoolSetting windowTopMost = {"/appearance/windowAlwaysOnTop", false}; - - float getClampedUiScale() const; - void setClampedUiScale(float value); - -private: - std::unique_ptr snapshot_; -}; - -Settings *getSettings(); -AB_SETTINGS_CLASS *getABSettings(); - -} // namespace chatterino diff --git a/src/BrowserExtension.cpp b/src/BrowserExtension.cpp index dad0ac2af9e..3ac8dc2dded 100644 --- a/src/BrowserExtension.cpp +++ b/src/BrowserExtension.cpp @@ -30,7 +30,7 @@ namespace { #endif } - void runLoop(NativeMessagingClient &client) + void runLoop() { auto received_message = std::make_shared(true); @@ -73,8 +73,9 @@ namespace { received_message->store(true); - client.sendMessage(data); + nm::client::sendMessage(data); } + _Exit(0); } } // namespace @@ -82,9 +83,7 @@ void runBrowserExtensionHost() { initFileMode(); - NativeMessagingClient client; - - runLoop(client); + runLoop(); } } // namespace chatterino diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5b8ad1bb832..03f88f6024a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,12 +1,14 @@ set(LIBRARY_PROJECT "${PROJECT_NAME}-lib") +set(VERSION_PROJECT "${LIBRARY_PROJECT}-version") set(EXECUTABLE_PROJECT "${PROJECT_NAME}") add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x050F00) +# registers the native messageing host +option(CHATTERINO_DEBUG_NATIVE_MESSAGES "Debug native messages" OFF) + set(SOURCE_FILES Application.cpp Application.hpp - BaseSettings.cpp - BaseSettings.hpp BrowserExtension.cpp BrowserExtension.hpp RunGui.cpp @@ -22,35 +24,35 @@ set(SOURCE_FILES common/ChatterinoSetting.hpp common/ChatterSet.cpp common/ChatterSet.hpp - common/CompletionModel.cpp - common/CompletionModel.hpp common/Credentials.cpp common/Credentials.hpp - common/DownloadManager.cpp - common/DownloadManager.hpp common/Env.cpp common/Env.hpp common/LinkParser.cpp common/LinkParser.hpp + common/Literals.hpp common/Modes.cpp common/Modes.hpp - common/NetworkCommon.cpp - common/NetworkCommon.hpp - common/NetworkManager.cpp - common/NetworkManager.hpp - common/NetworkPrivate.cpp - common/NetworkPrivate.hpp - common/NetworkRequest.cpp - common/NetworkRequest.hpp - common/NetworkResult.cpp - common/NetworkResult.hpp common/QLogging.cpp common/QLogging.hpp - common/Version.cpp - common/Version.hpp common/WindowDescriptors.cpp common/WindowDescriptors.hpp + common/enums/MessageOverflow.hpp + + common/network/NetworkCommon.cpp + common/network/NetworkCommon.hpp + common/network/NetworkManager.cpp + common/network/NetworkManager.hpp + common/network/NetworkPrivate.cpp + common/network/NetworkPrivate.hpp + common/network/NetworkRequest.cpp + common/network/NetworkRequest.hpp + common/network/NetworkResult.cpp + common/network/NetworkResult.hpp + common/network/NetworkTask.cpp + common/network/NetworkTask.hpp + controllers/accounts/Account.cpp controllers/accounts/Account.hpp controllers/accounts/AccountController.cpp @@ -58,8 +60,52 @@ set(SOURCE_FILES controllers/accounts/AccountModel.cpp controllers/accounts/AccountModel.hpp + controllers/commands/builtin/chatterino/Debugging.cpp + controllers/commands/builtin/chatterino/Debugging.hpp + controllers/commands/builtin/Misc.cpp + controllers/commands/builtin/Misc.hpp + controllers/commands/builtin/twitch/AddModerator.cpp + controllers/commands/builtin/twitch/AddModerator.hpp + controllers/commands/builtin/twitch/AddVIP.cpp + controllers/commands/builtin/twitch/AddVIP.hpp + controllers/commands/builtin/twitch/Announce.cpp + controllers/commands/builtin/twitch/Announce.hpp + controllers/commands/builtin/twitch/Ban.cpp + controllers/commands/builtin/twitch/Ban.hpp + controllers/commands/builtin/twitch/Block.cpp + controllers/commands/builtin/twitch/Block.hpp controllers/commands/builtin/twitch/ChatSettings.cpp controllers/commands/builtin/twitch/ChatSettings.hpp + controllers/commands/builtin/twitch/Chatters.cpp + controllers/commands/builtin/twitch/Chatters.hpp + controllers/commands/builtin/twitch/DeleteMessages.cpp + controllers/commands/builtin/twitch/DeleteMessages.hpp + controllers/commands/builtin/twitch/GetModerators.cpp + controllers/commands/builtin/twitch/GetModerators.hpp + controllers/commands/builtin/twitch/GetVIPs.cpp + controllers/commands/builtin/twitch/GetVIPs.hpp + controllers/commands/builtin/twitch/Raid.cpp + controllers/commands/builtin/twitch/Raid.hpp + controllers/commands/builtin/twitch/RemoveModerator.cpp + controllers/commands/builtin/twitch/RemoveModerator.hpp + controllers/commands/builtin/twitch/RemoveVIP.cpp + controllers/commands/builtin/twitch/RemoveVIP.hpp + controllers/commands/builtin/twitch/SendReply.cpp + controllers/commands/builtin/twitch/SendReply.hpp + controllers/commands/builtin/twitch/SendWhisper.cpp + controllers/commands/builtin/twitch/SendWhisper.hpp + controllers/commands/builtin/twitch/ShieldMode.cpp + controllers/commands/builtin/twitch/ShieldMode.hpp + controllers/commands/builtin/twitch/Shoutout.cpp + controllers/commands/builtin/twitch/Shoutout.hpp + controllers/commands/builtin/twitch/StartCommercial.cpp + controllers/commands/builtin/twitch/StartCommercial.hpp + controllers/commands/builtin/twitch/Unban.cpp + controllers/commands/builtin/twitch/Unban.hpp + controllers/commands/builtin/twitch/UpdateChannel.cpp + controllers/commands/builtin/twitch/UpdateChannel.hpp + controllers/commands/builtin/twitch/UpdateColor.cpp + controllers/commands/builtin/twitch/UpdateColor.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp @@ -68,18 +114,56 @@ set(SOURCE_FILES controllers/commands/CommandModel.cpp controllers/commands/CommandModel.hpp + controllers/completion/CompletionModel.cpp + controllers/completion/CompletionModel.hpp + controllers/completion/sources/Source.hpp + controllers/completion/sources/CommandSource.cpp + controllers/completion/sources/CommandSource.hpp + controllers/completion/sources/EmoteSource.cpp + controllers/completion/sources/EmoteSource.hpp + controllers/completion/sources/Helpers.hpp + controllers/completion/sources/UnifiedSource.cpp + controllers/completion/sources/UnifiedSource.hpp + controllers/completion/sources/UserSource.cpp + controllers/completion/sources/UserSource.hpp + controllers/completion/strategies/ClassicEmoteStrategy.cpp + controllers/completion/strategies/ClassicEmoteStrategy.hpp + controllers/completion/strategies/ClassicUserStrategy.cpp + controllers/completion/strategies/ClassicUserStrategy.hpp + controllers/completion/strategies/CommandStrategy.cpp + controllers/completion/strategies/CommandStrategy.hpp + controllers/completion/strategies/SmartEmoteStrategy.cpp + controllers/completion/strategies/SmartEmoteStrategy.cpp + controllers/completion/strategies/Strategy.hpp + controllers/completion/TabCompletionModel.cpp + controllers/completion/TabCompletionModel.hpp + controllers/filters/FilterModel.cpp controllers/filters/FilterModel.hpp controllers/filters/FilterRecord.cpp controllers/filters/FilterRecord.hpp controllers/filters/FilterSet.cpp controllers/filters/FilterSet.hpp - controllers/filters/parser/FilterParser.cpp - controllers/filters/parser/FilterParser.hpp - controllers/filters/parser/Tokenizer.cpp - controllers/filters/parser/Tokenizer.hpp - controllers/filters/parser/Types.cpp - controllers/filters/parser/Types.hpp + controllers/filters/lang/expressions/Expression.cpp + controllers/filters/lang/expressions/Expression.hpp + controllers/filters/lang/expressions/BinaryOperation.cpp + controllers/filters/lang/expressions/BinaryOperation.hpp + controllers/filters/lang/expressions/ListExpression.cpp + controllers/filters/lang/expressions/ListExpression.hpp + controllers/filters/lang/expressions/RegexExpression.cpp + controllers/filters/lang/expressions/RegexExpression.hpp + controllers/filters/lang/expressions/UnaryOperation.hpp + controllers/filters/lang/expressions/UnaryOperation.cpp + controllers/filters/lang/expressions/ValueExpression.cpp + controllers/filters/lang/expressions/ValueExpression.hpp + controllers/filters/lang/Filter.cpp + controllers/filters/lang/Filter.hpp + controllers/filters/lang/FilterParser.cpp + controllers/filters/lang/FilterParser.hpp + controllers/filters/lang/Tokenizer.cpp + controllers/filters/lang/Tokenizer.hpp + controllers/filters/lang/Types.cpp + controllers/filters/lang/Types.hpp controllers/highlights/BadgeHighlightModel.cpp controllers/highlights/BadgeHighlightModel.hpp @@ -136,13 +220,28 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.hpp + controllers/plugins/LuaAPI.cpp + controllers/plugins/LuaAPI.hpp + controllers/plugins/Plugin.cpp + controllers/plugins/Plugin.hpp + controllers/plugins/PluginController.hpp + controllers/plugins/PluginController.cpp + controllers/plugins/LuaUtilities.cpp + controllers/plugins/LuaUtilities.hpp + + controllers/sound/ISoundController.hpp + controllers/sound/MiniaudioBackend.cpp + controllers/sound/MiniaudioBackend.hpp + controllers/sound/NullBackend.cpp + controllers/sound/NullBackend.hpp + + controllers/twitch/LiveController.cpp + controllers/twitch/LiveController.hpp + controllers/userdata/UserDataController.cpp controllers/userdata/UserDataController.hpp controllers/userdata/UserData.hpp - controllers/sound/SoundController.cpp - controllers/sound/SoundController.hpp - debug/Benchmark.cpp debug/Benchmark.hpp @@ -172,6 +271,8 @@ set(SOURCE_FILES messages/layouts/MessageLayout.hpp messages/layouts/MessageLayoutContainer.cpp messages/layouts/MessageLayoutContainer.hpp + messages/layouts/MessageLayoutContext.cpp + messages/layouts/MessageLayoutContext.hpp messages/layouts/MessageLayoutElement.cpp messages/layouts/MessageLayoutElement.hpp messages/search/AuthorPredicate.cpp @@ -191,16 +292,12 @@ set(SOURCE_FILES messages/search/SubtierPredicate.cpp messages/search/SubtierPredicate.hpp - providers/Crashpad.cpp - providers/Crashpad.hpp providers/IvrApi.cpp providers/IvrApi.hpp providers/LinkResolver.cpp providers/LinkResolver.hpp providers/NetworkConfigurationProvider.cpp providers/NetworkConfigurationProvider.hpp - providers/RecentMessagesApi.cpp - providers/RecentMessagesApi.hpp providers/bttv/BttvEmotes.cpp providers/bttv/BttvEmotes.hpp @@ -225,6 +322,8 @@ set(SOURCE_FILES providers/ffz/FfzBadges.hpp providers/ffz/FfzEmotes.cpp providers/ffz/FfzEmotes.hpp + providers/ffz/FfzUtil.cpp + providers/ffz/FfzUtil.hpp providers/irc/AbstractIrcServer.cpp providers/irc/AbstractIrcServer.hpp @@ -247,8 +346,16 @@ set(SOURCE_FILES providers/liveupdates/BasicPubSubManager.hpp providers/liveupdates/BasicPubSubWebsocket.hpp + providers/recentmessages/Api.cpp + providers/recentmessages/Api.hpp + providers/recentmessages/Impl.cpp + providers/recentmessages/Impl.hpp + + providers/seventv/SeventvAPI.cpp + providers/seventv/SeventvAPI.hpp providers/seventv/SeventvBadges.cpp providers/seventv/SeventvBadges.hpp + providers/seventv/SeventvCosmetics.hpp providers/seventv/SeventvEmotes.cpp providers/seventv/SeventvEmotes.hpp providers/seventv/SeventvEventAPI.cpp @@ -308,6 +415,8 @@ set(SOURCE_FILES providers/twitch/pubsubmessages/ChatModeratorAction.hpp providers/twitch/pubsubmessages/Listen.cpp providers/twitch/pubsubmessages/Listen.hpp + providers/twitch/pubsubmessages/LowTrustUsers.cpp + providers/twitch/pubsubmessages/LowTrustUsers.hpp providers/twitch/pubsubmessages/Message.hpp providers/twitch/pubsubmessages/Unlisten.cpp providers/twitch/pubsubmessages/Unlisten.hpp @@ -319,10 +428,14 @@ set(SOURCE_FILES singletons/Badges.cpp singletons/Badges.hpp + singletons/CrashHandler.cpp + singletons/CrashHandler.hpp singletons/Emotes.cpp singletons/Emotes.hpp singletons/Fonts.cpp singletons/Fonts.hpp + singletons/ImageUploader.cpp + singletons/ImageUploader.hpp singletons/Logging.cpp singletons/Logging.hpp singletons/NativeMessaging.cpp @@ -347,8 +460,11 @@ set(SOURCE_FILES singletons/helper/LoggingChannel.cpp singletons/helper/LoggingChannel.hpp + util/AbandonObject.hpp util/AttachToConsole.cpp util/AttachToConsole.hpp + util/CancellationToken.hpp + util/ChannelHelpers.hpp util/Clipboard.cpp util/Clipboard.hpp util/ConcurrentMap.hpp @@ -368,10 +484,10 @@ set(SOURCE_FILES util/IncognitoBrowser.hpp util/InitUpdateButton.cpp util/InitUpdateButton.hpp + util/IpcQueue.cpp + util/IpcQueue.hpp util/LayoutHelper.cpp util/LayoutHelper.hpp - util/NuulsUploader.cpp - util/NuulsUploader.hpp util/RapidjsonHelpers.cpp util/RapidjsonHelpers.hpp util/RatelimitBucket.cpp @@ -389,8 +505,17 @@ set(SOURCE_FILES util/Twitch.cpp util/Twitch.hpp util/TypeName.hpp + util/Variant.hpp + util/WidgetHelpers.cpp + util/WidgetHelpers.hpp util/WindowsHelper.cpp util/WindowsHelper.hpp + util/XDGDesktopFile.cpp + util/XDGDesktopFile.hpp + util/XDGDirectory.cpp + util/XDGDirectory.hpp + util/XDGHelper.cpp + util/XDGHelper.hpp util/serialize/Container.hpp @@ -416,8 +541,8 @@ set(SOURCE_FILES widgets/Notebook.hpp widgets/Scrollbar.cpp widgets/Scrollbar.hpp - widgets/StreamView.cpp - widgets/StreamView.hpp + widgets/TooltipEntryWidget.cpp + widgets/TooltipEntryWidget.hpp widgets/TooltipWidget.cpp widgets/TooltipWidget.hpp widgets/Window.cpp @@ -469,12 +594,25 @@ set(SOURCE_FILES widgets/dialogs/switcher/SwitchSplitItem.cpp widgets/dialogs/switcher/SwitchSplitItem.hpp + widgets/helper/color/AlphaSlider.cpp + widgets/helper/color/AlphaSlider.hpp + widgets/helper/color/Checkerboard.cpp + widgets/helper/color/Checkerboard.hpp + widgets/helper/color/ColorButton.cpp + widgets/helper/color/ColorButton.hpp + widgets/helper/color/ColorInput.cpp + widgets/helper/color/ColorInput.hpp + widgets/helper/color/ColorItemDelegate.cpp + widgets/helper/color/ColorItemDelegate.hpp + widgets/helper/color/HueSlider.cpp + widgets/helper/color/HueSlider.hpp + widgets/helper/color/SBCanvas.cpp + widgets/helper/color/SBCanvas.hpp + widgets/helper/Button.cpp widgets/helper/Button.hpp widgets/helper/ChannelView.cpp widgets/helper/ChannelView.hpp - widgets/helper/ColorButton.cpp - widgets/helper/ColorButton.hpp widgets/helper/ComboBoxItemDelegate.cpp widgets/helper/ComboBoxItemDelegate.hpp widgets/helper/DebugPopup.cpp @@ -483,12 +621,12 @@ set(SOURCE_FILES widgets/helper/EditableModelView.hpp widgets/helper/EffectLabel.cpp widgets/helper/EffectLabel.hpp + widgets/helper/InvisibleSizeGrip.cpp + widgets/helper/InvisibleSizeGrip.hpp widgets/helper/NotebookButton.cpp widgets/helper/NotebookButton.hpp widgets/helper/NotebookTab.cpp widgets/helper/NotebookTab.hpp - widgets/helper/QColorPicker.cpp - widgets/helper/QColorPicker.hpp widgets/helper/RegExpItemDelegate.cpp widgets/helper/RegExpItemDelegate.hpp widgets/helper/TrimRegExpValidator.cpp @@ -505,6 +643,8 @@ set(SOURCE_FILES widgets/helper/SignalLabel.hpp widgets/helper/TitlebarButton.cpp widgets/helper/TitlebarButton.hpp + widgets/helper/TitlebarButtons.cpp + widgets/helper/TitlebarButtons.hpp widgets/listview/GenericItemDelegate.cpp widgets/listview/GenericItemDelegate.hpp @@ -541,6 +681,8 @@ set(SOURCE_FILES widgets/settingspages/NicknamesPage.hpp widgets/settingspages/NotificationPage.cpp widgets/settingspages/NotificationPage.hpp + widgets/settingspages/PluginsPage.cpp + widgets/settingspages/PluginsPage.hpp widgets/settingspages/SettingsPage.cpp widgets/settingspages/SettingsPage.hpp @@ -554,6 +696,7 @@ set(SOURCE_FILES widgets/splits/InputCompletionPopup.hpp widgets/splits/Split.cpp widgets/splits/Split.hpp + widgets/splits/SplitCommon.hpp widgets/splits/SplitContainer.cpp widgets/splits/SplitContainer.hpp widgets/splits/SplitHeader.cpp @@ -566,13 +709,7 @@ set(SOURCE_FILES ${CMAKE_SOURCE_DIR}/resources/resources.qrc ) -if (WIN32) - # clang-cl doesn't support resource files - if (NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") - list(APPEND SOURCE_FILES "${CMAKE_SOURCE_DIR}/resources/windows.rc") - endif () - -elseif (APPLE) +if (APPLE) set(MACOS_BUNDLE_ICON_FILE "${CMAKE_SOURCE_DIR}/resources/chatterino.icns") list(APPEND SOURCE_FILES "${MACOS_BUNDLE_ICON_FILE}") set_source_files_properties(${MACOS_BUNDLE_ICON_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") @@ -586,18 +723,26 @@ list(APPEND SOURCE_FILES ${RES_AUTOGEN_FILES}) add_library(${LIBRARY_PROJECT} OBJECT ${SOURCE_FILES}) +if(CHATTERINO_PLUGINS) + target_compile_definitions(${LIBRARY_PROJECT} + PRIVATE + CHATTERINO_HAVE_PLUGINS + ) + message(STATUS "Building Chatterino with lua plugin support enabled.") +endif() + if (CHATTERINO_GENERATE_COVERAGE) include(CodeCoverage) append_coverage_compiler_flags_to_target(${LIBRARY_PROJECT}) - target_link_libraries(${LIBRARY_PROJECT} PUBLIC gcov) message(STATUS "project source dir: ${PROJECT_SOURCE_DIR}/src") - setup_target_for_coverage_lcov( + setup_target_for_coverage_gcovr_html( NAME coverage - EXECUTABLE ./bin/chatterino-test - BASE_DIRECTORY ${PROJECT_SOURCE_DIR}/src + EXECUTABLE ctest EXCLUDE "/usr/include/*" EXCLUDE "build-*/*" EXCLUDE "lib/*" + EXCLUDE "*/ui_*.h" + EXCLUDE "*/moc_*.cpp" ) endif () @@ -620,6 +765,10 @@ target_link_libraries(${LIBRARY_PROJECT} LRUCache MagicEnum ) +if (CHATTERINO_PLUGINS) + target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua) +endif() + if (BUILD_WITH_QTKEYCHAIN) target_link_libraries(${LIBRARY_PROJECT} PUBLIC @@ -654,7 +803,12 @@ if (BUILD_APP) else() add_executable(${EXECUTABLE_PROJECT} main.cpp) endif() - add_sanitizers(${EXECUTABLE_PROJECT}) + + if(COMMAND add_sanitizers) + add_sanitizers(${EXECUTABLE_PROJECT}) + else() + message(WARNING "Sanitizers support is disabled") + endif() target_include_directories(${EXECUTABLE_PROJECT} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_BINARY_DIR}/autogen/) @@ -663,35 +817,51 @@ if (BUILD_APP) set_target_directory_hierarchy(${EXECUTABLE_PROJECT}) if (WIN32) - if (NOT WINDEPLOYQT_PATH) - get_target_property(Qt_Core_Location Qt${MAJOR_QT_VERSION}::Core LOCATION) - get_filename_component(QT_BIN_DIR ${Qt_Core_Location} DIRECTORY) - string(APPEND WINDEPLOYQT_PATH ${QT_BIN_DIR} /windeployqt.exe) - else() + if (WINDEPLOYQT_PATH) file(TO_CMAKE_PATH "${WINDEPLOYQT_PATH}" WINDEPLOYQT_PATH) + else() + if (VCPKG_INSTALLED_DIR AND (CMAKE_BUILD_TYPE STREQUAL "Debug")) + find_program(WINDEPLOYQT_PATH NAMES windeployqt.debug.bat) + else() + find_program(WINDEPLOYQT_PATH NAMES windeployqt) + endif() + endif() + + if (NOT EXISTS ${WINDEPLOYQT_PATH}) + message(FATAL_ERROR "windeployqt.exe not found") endif() if (CMAKE_BUILD_TYPE STREQUAL "Debug") set(WINDEPLOYQT_MODE --debug) + get_target_property(QT_CORE_LOC Qt${MAJOR_QT_VERSION}::Core LOCATION_DEBUG) else() set(WINDEPLOYQT_MODE --release) + get_target_property(QT_CORE_LOC Qt${MAJOR_QT_VERSION}::Core LOCATION) endif() + get_filename_component(QT_BIN_DIR ${QT_CORE_LOC} DIRECTORY) set(WINDEPLOYQT_COMMAND_ARGV "${WINDEPLOYQT_PATH}" "$" ${WINDEPLOYQT_MODE} --no-compiler-runtime --no-translations --no-opengl-sw) string(REPLACE ";" " " WINDEPLOYQT_COMMAND "${WINDEPLOYQT_COMMAND_ARGV}") - if (X_VCPKG_APPLOCAL_DEPS_INSTALL) - install(TARGETS ${EXECUTABLE_PROJECT} RUNTIME DESTINATION .) - else() - install(TARGETS ${EXECUTABLE_PROJECT} - RUNTIME_DEPENDENCIES - PRE_EXCLUDE_REGEXES "api-ms-" "ext-ms-" - POST_EXCLUDE_REGEXES ".*system32/.*\\.dll" - DIRECTORIES ${QT_BIN_DIR} - RUNTIME DESTINATION .) - install(CODE "message(\"-- Running: ${WINDEPLOYQT_COMMAND} --dir \\\"\${CMAKE_INSTALL_PREFIX}\\\"\")") - install(CODE "execute_process(COMMAND ${WINDEPLOYQT_COMMAND} --dir \"\${CMAKE_INSTALL_PREFIX}\" COMMAND_ERROR_IS_FATAL ANY)") - endif() + install(TARGETS ${EXECUTABLE_PROJECT} + RUNTIME_DEPENDENCIES + PRE_EXCLUDE_REGEXES "api-ms-" "ext-ms-" + POST_EXCLUDE_REGEXES ".*system32/.*\\.dll" + DIRECTORIES ${QT_BIN_DIR} + RUNTIME DESTINATION .) + + # Hardcoded list of DLLs to install from Qt - these are marked as optional since they only exist for vcpkg + install(FILES + ${QT_BIN_DIR}/jpeg62.dll + ${QT_BIN_DIR}/libwebpdemux.dll + ${QT_BIN_DIR}/libwebpmux.dll + ${QT_BIN_DIR}/libwebp.dll + ${QT_BIN_DIR}/libsharpyuv.dll + DESTINATION . + OPTIONAL) + + install(CODE "message(\"-- Running: ${WINDEPLOYQT_COMMAND} --dir \\\"\${CMAKE_INSTALL_PREFIX}\\\"\")") + install(CODE "execute_process(COMMAND ${WINDEPLOYQT_COMMAND} --dir \"\${CMAKE_INSTALL_PREFIX}\" COMMAND_ERROR_IS_FATAL ANY)") elseif (APPLE) install(TARGETS ${EXECUTABLE_PROJECT} RUNTIME DESTINATION bin @@ -738,15 +908,26 @@ set_target_properties(${LIBRARY_PROJECT} AUTOUIC ON ) -# Used to provide a date of build in the About page (for nightly builds). Getting the actual time of -# compilation in CMake is a more involved, as documented in https://stackoverflow.com/q/24292898. -# For CI runs, however, the date of build file generation should be consistent with the date of -# compilation so this approximation is "good enough" for our purpose. -if (DEFINED ENV{CHATTERINO_SKIP_DATE_GEN}) - set(cmake_gen_date "1970-01-01") -else () - string(TIMESTAMP cmake_gen_date "%Y-%m-%d") -endif () +# The version project has definitions about the build. +# To avoid recompilations because of changing preprocessor definitions, +# this is its own project. +set(VERSION_SOURCE_FILES common/Version.cpp common/Version.hpp) +add_library(${VERSION_PROJECT} STATIC ${VERSION_SOURCE_FILES}) + +# source group for IDEs +source_group(TREE ${CMAKE_SOURCE_DIR} FILES ${VERSION_SOURCE_FILES}) +target_include_directories(${VERSION_PROJECT} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(${VERSION_PROJECT} PRIVATE Qt${MAJOR_QT_VERSION}::Core) +target_compile_definitions(${VERSION_PROJECT} PRIVATE + CHATTERINO_GIT_HASH=\"${GIT_HASH}\" + CHATTERINO_GIT_RELEASE=\"${GIT_RELEASE}\" + CHATTERINO_GIT_COMMIT=\"${GIT_COMMIT}\" + CHATTERINO_GIT_MODIFIED=${GIT_MODIFIED} + + CHATTERINO_CMAKE_GEN_DATE=\"${cmake_gen_date}\" +) + +target_link_libraries(${LIBRARY_PROJECT} PRIVATE ${VERSION_PROJECT}) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO @@ -754,18 +935,9 @@ target_compile_definitions(${LIBRARY_PROJECT} PUBLIC AB_CUSTOM_SETTINGS IRC_STATIC IRC_NAMESPACE=Communi - - CHATTERINO_GIT_HASH=\"${GIT_HASH}\" - CHATTERINO_GIT_RELEASE=\"${GIT_RELEASE}\" - CHATTERINO_GIT_COMMIT=\"${GIT_COMMIT}\" - - CHATTERINO_CMAKE_GEN_DATE=\"${cmake_gen_date}\" + $<$:_WIN32_WINNT=0x0A00> # Windows 10 ) -if (GIT_MODIFIED) - target_compile_definitions(${LIBRARY_PROJECT} PUBLIC - CHATTERINO_GIT_MODIFIED - ) -endif () + if (USE_SYSTEM_QTKEYCHAIN) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CMAKE_BUILD @@ -779,9 +951,12 @@ if (WIN32) set_target_properties(${EXECUTABLE_PROJECT} PROPERTIES WIN32_EXECUTABLE TRUE) endif () endif () +if (CHATTERINO_DEBUG_NATIVE_MESSAGES) + target_compile_definitions(${LIBRARY_PROJECT} PRIVATE CHATTERINO_DEBUG_NM) +endif () if (MSVC) - target_compile_options(${LIBRARY_PROJECT} PUBLIC /EHsc /bigobj) + target_compile_options(${LIBRARY_PROJECT} PUBLIC /EHsc /bigobj /utf-8) endif () if (APPLE AND BUILD_APP) @@ -804,7 +979,16 @@ target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/semver/include) # miniaudio dependency https://github.com/mackron/miniaudio -target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/miniaudio) +if (USE_SYSTEM_MINIAUDIO) + message(STATUS "Building with system miniaudio") + include(CheckIncludeFileCXX) + CHECK_INCLUDE_FILE_CXX("miniaudio.h" MINIAUDIO_FOUND) + if (NOT MINIAUDIO_FOUND) + message(FATAL_ERROR "miniaudio.h not found on your system") + endif() +else () + target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/miniaudio) +endif () if (UNIX) if (CMAKE_DL_LIBS) @@ -814,7 +998,7 @@ if (UNIX) endif () endif () -if (WinToast_FOUND) +if (WIN32) target_link_libraries(${LIBRARY_PROJECT} PUBLIC WinToast) @@ -843,7 +1027,6 @@ endif () if (BUILD_WITH_CRASHPAD) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO_WITH_CRASHPAD) target_link_libraries(${LIBRARY_PROJECT} PUBLIC crashpad::client) - set_target_directory_hierarchy(crashpad_handler crashpad) endif() # Configure compiler warnings @@ -866,10 +1049,6 @@ if (MSVC) # Someone adds /W3 before we add /W4. # This makes sure, only /W4 is specified. string(REPLACE "/W3" "/W4" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") - # 4505 - "unreferenced local version has been removed" - # Although this might give hints on dead code, - # there are some cases where it's distracting. - # # 4100 - "unreferenced formal parameter" # There are a lot of functions and methods where # an argument was given a name but never used. @@ -880,6 +1059,11 @@ if (MSVC) # These are implicit conversions from size_t to int/qsizetype. # We don't use size_t in a lot of cases, since # Qt doesn't use it - it uses int (or qsizetype in Qt6). + # + # 4458 - "declaration of 'identifier' hides class member" + # We have a rule of exclusively using `this->` + # to access class members, thus it's fine to reclare a variable + # with the same name as a class member. target_compile_options(${LIBRARY_PROJECT} PUBLIC /W4 # 5038 - warnings about initialization order @@ -887,9 +1071,11 @@ if (MSVC) # 4855 - implicit capture of 'this' via '[=]' is deprecated /w14855 # Disable the following warnings (see reasoning above) - /wd4505 /wd4100 /wd4267 + /wd4458 + # Enable updated '__cplusplus' macro - workaround for CMake#18837 + /Zc:__cplusplus ) # Disable min/max macros from Windows.h target_compile_definitions(${LIBRARY_PROJECT} PUBLIC NOMINMAX) @@ -901,7 +1087,6 @@ else () -Wno-switch -Wno-deprecated-declarations -Wno-sign-compare - -Wno-unused-variable # Disabling strict-aliasing warnings for now, although we probably want to re-enable this in the future -Wno-strict-aliasing @@ -931,3 +1116,8 @@ if(CHATTERINO_ENABLE_LTO) set_property(TARGET ${LIBRARY_PROJECT} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) endif() + +if(NOT CHATTERINO_UPDATER) + message(STATUS "Disabling the updater.") + target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO_DISABLE_UPDATER) +endif() diff --git a/src/PrecompiledHeader.hpp b/src/PrecompiledHeader.hpp index 0f7ac764377..144faf56b47 100644 --- a/src/PrecompiledHeader.hpp +++ b/src/PrecompiledHeader.hpp @@ -1,8 +1,8 @@ #ifdef __cplusplus +# include # include # include -# include -# include +# include # include # include # include @@ -12,40 +12,29 @@ # include # include # include -# include # include # include # include # include -# include # include # include # include # include # include -# include -# include # include # include # include # include # include # include -# include -# include -# include # include -# include # include # include # include # include # include -# include -# include # include # include -# include # include # include # include @@ -58,7 +47,6 @@ # include # include # include -# include # include # include # include @@ -92,32 +80,17 @@ # include # include # include -# include # include # include # include # include # include -# include # include # include # include # include # include # include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include # include # include # include @@ -143,6 +116,7 @@ # include # include # include +# include # include # include # include diff --git a/src/RunGui.cpp b/src/RunGui.cpp index fdf557e6125..87adf72d84f 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -3,8 +3,9 @@ #include "Application.hpp" #include "common/Args.hpp" #include "common/Modes.hpp" -#include "common/NetworkManager.hpp" +#include "common/network/NetworkManager.hpp" #include "common/QLogging.hpp" +#include "singletons/CrashHandler.hpp" #include "singletons/Paths.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" @@ -20,6 +21,7 @@ #include #include +#include #ifdef USEWINSDK # include "util/WindowsHelper.hpp" @@ -44,28 +46,30 @@ namespace { dark.setColor(QPalette::Window, QColor(22, 22, 22)); dark.setColor(QPalette::WindowText, Qt::white); dark.setColor(QPalette::Text, Qt::white); - dark.setColor(QPalette::Disabled, QPalette::WindowText, - QColor(127, 127, 127)); dark.setColor(QPalette::Base, QColor("#333")); dark.setColor(QPalette::AlternateBase, QColor("#444")); dark.setColor(QPalette::ToolTipBase, Qt::white); dark.setColor(QPalette::ToolTipText, Qt::white); - dark.setColor(QPalette::Disabled, QPalette::Text, - QColor(127, 127, 127)); dark.setColor(QPalette::Dark, QColor(35, 35, 35)); dark.setColor(QPalette::Shadow, QColor(20, 20, 20)); dark.setColor(QPalette::Button, QColor(70, 70, 70)); dark.setColor(QPalette::ButtonText, Qt::white); - dark.setColor(QPalette::Disabled, QPalette::ButtonText, - QColor(127, 127, 127)); dark.setColor(QPalette::BrightText, Qt::red); dark.setColor(QPalette::Link, QColor(42, 130, 218)); dark.setColor(QPalette::Highlight, QColor(42, 130, 218)); + dark.setColor(QPalette::HighlightedText, Qt::white); + dark.setColor(QPalette::PlaceholderText, QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::Highlight, QColor(80, 80, 80)); - dark.setColor(QPalette::HighlightedText, Qt::white); dark.setColor(QPalette::Disabled, QPalette::HighlightedText, QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::ButtonText, + QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::Text, + QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::WindowText, + QColor(127, 127, 127)); qApp->setPalette(dark); } @@ -74,34 +78,32 @@ namespace { { // set up the QApplication flags QApplication::setAttribute(Qt::AA_Use96Dpi, true); -#ifdef Q_OS_WIN32 +#if defined(Q_OS_WIN32) && QT_VERSION < QT_VERSION_CHECK(6, 0, 0) QApplication::setAttribute(Qt::AA_DisableHighDpiScaling, true); #endif QApplication::setStyle(QStyleFactory::create("Fusion")); +#ifndef Q_OS_MAC QApplication::setWindowIcon(QIcon(":/icon.ico")); +#endif + +#ifdef Q_OS_MAC + // On the Mac/Cocoa platform this attribute is enabled by default + // We override it to ensure shortcuts show in context menus on that platform + QApplication::setAttribute(Qt::AA_DontShowShortcutsInContextMenus, + false); +#endif installCustomPalette(); } - void showLastCrashDialog() + void showLastCrashDialog(const Args &args, const Paths &paths) { - //#ifndef C_DISABLE_CRASH_DIALOG - // LastRunCrashDialog dialog; - - // switch (dialog.exec()) - // { - // case QDialog::Accepted: - // { - // }; - // break; - // default: - // { - // _exit(0); - // } - // } - //#endif + auto *dialog = new LastRunCrashDialog(args, paths); + // Use exec() over open() to block the app from being loaded + // and to be able to set the safe mode. + dialog->exec(); } void createRunningFile(const QString &path) @@ -119,14 +121,13 @@ namespace { } std::chrono::steady_clock::time_point signalsInitTime; - bool restartOnSignal = false; [[noreturn]] void handleSignal(int signum) { using namespace std::chrono_literals; - if (restartOnSignal && - std::chrono::steady_clock::now() - signalsInitTime > 30s) + if (std::chrono::steady_clock::now() - signalsInitTime > 30s && + getIApp()->getCrashHandler()->shouldRecover()) { QProcess proc; @@ -173,29 +174,68 @@ namespace { // improved in the future. void clearCache(const QDir &dir) { - int deletedCount = 0; - for (auto &&info : dir.entryInfoList(QDir::Files)) + size_t deletedCount = 0; + for (const auto &info : dir.entryInfoList(QDir::Files)) { if (info.lastModified().addDays(14) < QDateTime::currentDateTime()) { bool res = QFile(info.absoluteFilePath()).remove(); if (res) + { ++deletedCount; + } } } - qCDebug(chatterinoCache) << "Deleted" << deletedCount << "files"; + qCDebug(chatterinoCache) + << "Deleted" << deletedCount << "files in" << dir.path(); + } + + // We delete all but the five most recent crashdumps. This strategy may be + // improved in the future. + void clearCrashes(QDir dir) + { + // crashpad crashdumps are stored inside the Crashes/report directory + if (!dir.cd("reports")) + { + // no reports directory exists = no files to delete + return; + } + + dir.setNameFilters({"*.dmp"}); + + size_t deletedCount = 0; + // TODO: use std::views::drop once supported by all compilers + size_t filesToSkip = 5; + for (auto &&info : dir.entryInfoList(QDir::Files, QDir::Time)) + { + if (filesToSkip > 0) + { + filesToSkip--; + continue; + } + + if (QFile(info.absoluteFilePath()).remove()) + { + deletedCount++; + } + } + qCDebug(chatterinoApp) << "Deleted" << deletedCount << "crashdumps"; } } // namespace -void runGui(QApplication &a, Paths &paths, Settings &settings) +void runGui(QApplication &a, const Paths &paths, Settings &settings, + const Args &args, Updates &updates) { initQt(); initResources(); initSignalHandler(); - settings.restartOnCrash.connect([](const bool &value) { - restartOnSignal = value; - }); +#ifdef Q_OS_WIN + if (args.crashRecovery) + { + showLastCrashDialog(args, paths); + } +#endif auto thread = std::thread([dir = paths.miscDirectory] { { @@ -215,40 +255,29 @@ void runGui(QApplication &a, Paths &paths, Settings &settings) }); // Clear the cache 1 minute after start. - QTimer::singleShot(60 * 1000, [cachePath = paths.cacheDirectory()] { - QtConcurrent::run([cachePath]() { + QTimer::singleShot(60 * 1000, [cachePath = paths.cacheDirectory(), + crashDirectory = paths.crashdumpDirectory, + avatarPath = paths.twitchProfileAvatars] { + std::ignore = QtConcurrent::run([cachePath] { clearCache(cachePath); }); + std::ignore = QtConcurrent::run([avatarPath] { + clearCache(avatarPath); + }); + std::ignore = QtConcurrent::run([crashDirectory] { + clearCrashes(crashDirectory); + }); }); chatterino::NetworkManager::init(); - chatterino::Updates::instance().checkForUpdates(); - -#ifdef C_USE_BREAKPAD - QBreakpadInstance.setDumpPath(getPaths()->settingsFolderPath + "/Crashes"); -#endif - - // Running file - auto runningPath = - paths.miscDirectory + "/running_" + paths.applicationFilePathHash; - - if (QFile::exists(runningPath)) - { - showLastCrashDialog(); - } - else - { - createRunningFile(runningPath); - } + updates.checkForUpdates(); - Application app(settings, paths); + Application app(settings, paths, args, updates); app.initialize(settings, paths); app.run(a); app.save(); - removeRunningFile(runningPath); - - if (!getArgs().dontSaveSettings) + if (!args.dontSaveSettings) { pajlada::Settings::SettingManager::gSave(); } @@ -260,6 +289,8 @@ void runGui(QApplication &a, Paths &paths, Settings &settings) flushClipboard(); #endif + app.fakeDtor(); + _exit(0); } } // namespace chatterino diff --git a/src/RunGui.hpp b/src/RunGui.hpp index 3381644043c..daf4cf15580 100644 --- a/src/RunGui.hpp +++ b/src/RunGui.hpp @@ -3,8 +3,13 @@ class QApplication; namespace chatterino { + +class Args; class Paths; class Settings; +class Updates; + +void runGui(QApplication &a, const Paths &paths, Settings &settings, + const Args &args, Updates &updates); -void runGui(QApplication &a, Paths &paths, Settings &settings); } // namespace chatterino diff --git a/src/common/Args.cpp b/src/common/Args.cpp index 2894c3f7bab..eeebbfc201e 100644 --- a/src/common/Args.cpp +++ b/src/common/Args.cpp @@ -1,6 +1,7 @@ -#include "Args.hpp" +#include "common/Args.hpp" #include "common/QLogging.hpp" +#include "debug/AssertInGuiThread.hpp" #include "singletons/Paths.hpp" #include "singletons/WindowManager.hpp" #include "util/AttachToConsole.hpp" @@ -14,44 +15,136 @@ #include #include +namespace { + +using namespace chatterino; + +template +QCommandLineOption hiddenOption(Args... args) +{ + QCommandLineOption opt(args...); + opt.setFlags(QCommandLineOption::HiddenFromHelp); + return opt; +} + +QStringList extractCommandLine( + const QCommandLineParser &parser, + std::initializer_list options) +{ + QStringList args; + for (const auto &option : options) + { + if (parser.isSet(option)) + { + auto optionName = option.names().first(); + if (optionName.length() == 1) + { + optionName.prepend(u'-'); + } + else + { + optionName.prepend("--"); + } + + auto values = parser.values(option); + if (values.empty()) + { + args += optionName; + } + else + { + for (const auto &value : values) + { + args += optionName; + args += value; + } + } + } + } + return args; +} + +std::optional parseActivateOption(QString input) +{ + auto colon = input.indexOf(u':'); + if (colon >= 0) + { + auto ty = input.left(colon); + if (ty != u"t") + { + qCWarning(chatterinoApp).nospace() + << "Failed to parse active channel (unknown type: " << ty + << ")"; + return std::nullopt; + } + + input = input.mid(colon + 1); + } + + return Args::Channel{ + .provider = ProviderId::Twitch, + .name = input, + }; +} + +} // namespace + namespace chatterino { -Args::Args(const QApplication &app) +Args::Args(const QApplication &app, const Paths &paths) { QCommandLineParser parser; parser.setApplicationDescription("Chatterino 2 Client for Twitch Chat"); parser.addHelpOption(); // Used internally by app to restart after unexpected crashes - QCommandLineOption crashRecoveryOption("crash-recovery"); - crashRecoveryOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto crashRecoveryOption = hiddenOption("crash-recovery"); + auto exceptionCodeOption = hiddenOption("cr-exception-code", "", "code"); + auto exceptionMessageOption = + hiddenOption("cr-exception-message", "", "message"); // Added to ignore the parent-window option passed during native messaging - QCommandLineOption parentWindowOption("parent-window"); - parentWindowOption.setFlags(QCommandLineOption::HiddenFromHelp); - QCommandLineOption parentWindowIdOption("x-attach-split-to-window", "", - "window-id"); - parentWindowIdOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto parentWindowOption = hiddenOption("parent-window"); + auto parentWindowIdOption = + hiddenOption("x-attach-split-to-window", "", "window-id"); // Verbose - QCommandLineOption verboseOption({{"v", "verbose"}, - "Attaches to the Console on windows, " - "allowing you to see debug output."}); - crashRecoveryOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto verboseOption = QCommandLineOption( + QStringList{"v", "verbose"}, "Attaches to the Console on windows, " + "allowing you to see debug output."); + // Safe mode + QCommandLineOption safeModeOption( + "safe-mode", "Starts Chatterino without loading Plugins and always " + "show the settings button."); + + // Channel layout + auto channelLayout = QCommandLineOption( + {"c", "channels"}, + "Joins only supplied channels on startup. Use letters with colons to " + "specify platform. Only Twitch channels are supported at the moment.\n" + "If platform isn't specified, default is Twitch.", + "t:channel1;t:channel2;..."); + + QCommandLineOption activateOption( + {"a", "activate"}, + "Activate the tab with this channel or add one in the main " + "window.\nOnly Twitch is " + "supported at the moment (prefix: 't:').\nIf the platform isn't " + "specified, Twitch is assumed.", + "t:channel"); parser.addOptions({ {{"V", "version"}, "Displays version information."}, crashRecoveryOption, + exceptionCodeOption, + exceptionMessageOption, parentWindowOption, parentWindowIdOption, verboseOption, + safeModeOption, + channelLayout, + activateOption, }); - parser.addOption(QCommandLineOption( - {"c", "channels"}, - "Joins only supplied channels on startup. Use letters with colons to " - "specify platform. Only Twitch channels are supported at the moment.\n" - "If platform isn't specified, default is Twitch.", - "t:channel1;t:channel2;...")); if (!parser.parse(app.arguments())) { @@ -71,15 +164,25 @@ Args::Args(const QApplication &app) (args.size() > 0 && (args[0].startsWith("chrome-extension://") || args[0].endsWith(".json"))); - if (parser.isSet("c")) + if (parser.isSet(channelLayout)) { - this->applyCustomChannelLayout(parser.value("c")); + this->applyCustomChannelLayout(parser.value(channelLayout), paths); } this->verbose = parser.isSet(verboseOption); this->printVersion = parser.isSet("V"); - this->crashRecovery = parser.isSet("crash-recovery"); + + this->crashRecovery = parser.isSet(crashRecoveryOption); + if (parser.isSet(exceptionCodeOption)) + { + this->exceptionCode = + static_cast(parser.value(exceptionCodeOption).toULong()); + } + if (parser.isSet(exceptionMessageOption)) + { + this->exceptionMessage = parser.value(exceptionMessageOption); + } if (parser.isSet(parentWindowIdOption)) { @@ -89,9 +192,31 @@ Args::Args(const QApplication &app) this->parentWindowId = parser.value(parentWindowIdOption).toULongLong(); } + if (parser.isSet(safeModeOption)) + { + this->safeMode = true; + } + + if (parser.isSet(activateOption)) + { + this->activateChannel = + parseActivateOption(parser.value(activateOption)); + } + + this->currentArguments_ = extractCommandLine(parser, { + verboseOption, + safeModeOption, + channelLayout, + activateOption, + }); } -void Args::applyCustomChannelLayout(const QString &argValue) +QStringList Args::currentArguments() const +{ + return this->currentArguments_; +} + +void Args::applyCustomChannelLayout(const QString &argValue, const Paths &paths) { WindowLayout layout; WindowDescriptor window; @@ -103,10 +228,9 @@ void Args::applyCustomChannelLayout(const QString &argValue) window.type_ = WindowType::Main; // Load main window layout from config file so we can use the same geometry - const QRect configMainLayout = [] { - const QString windowLayoutFile = - combinePath(getPaths()->settingsDirectory, - WindowManager::WINDOW_LAYOUT_FILENAME); + const QRect configMainLayout = [paths] { + const QString windowLayoutFile = combinePath( + paths.settingsDirectory, WindowManager::WINDOW_LAYOUT_FILENAME); const WindowLayout configLayout = WindowLayout::loadFromFile(windowLayoutFile); @@ -114,7 +238,9 @@ void Args::applyCustomChannelLayout(const QString &argValue) for (const WindowDescriptor &window : configLayout.windows_) { if (window.type_ != WindowType::Main) + { continue; + } return window.geometry_; } @@ -128,7 +254,9 @@ void Args::applyCustomChannelLayout(const QString &argValue) for (const QString &channelArg : channelArgList) { if (channelArg.isEmpty()) + { continue; + } // Twitch is default platform QString platform = "t"; @@ -164,18 +292,4 @@ void Args::applyCustomChannelLayout(const QString &argValue) } } -static Args *instance = nullptr; - -void initArgs(const QApplication &app) -{ - instance = new Args(app); -} - -const Args &getArgs() -{ - assert(instance); - - return *instance; -} - } // namespace chatterino diff --git a/src/common/Args.hpp b/src/common/Args.hpp index 386f6f50da9..b3eeb20ffe1 100644 --- a/src/common/Args.hpp +++ b/src/common/Args.hpp @@ -1,36 +1,74 @@ #pragma once +#include "common/ProviderId.hpp" #include "common/WindowDescriptors.hpp" -#include #include +#include + namespace chatterino { +class Paths; + /// Command line arguments passed to Chatterino. +/// +/// All accepted arguments: +/// +/// Crash recovery: +/// --crash-recovery +/// --cr-exception-code code +/// --cr-exception-message message +/// +/// Native messaging: +/// --parent-window +/// --x-attach-split-to-window=window-id +/// +/// -v, --verbose +/// -V, --version +/// -c, --channels=t:channel1;t:channel2;... +/// -a, --activate=t:channel +/// --safe-mode +/// +/// See documentation on `QGuiApplication` for documentation on Qt arguments like -platform. class Args { public: - Args(const QApplication &app); + struct Channel { + ProviderId provider; + QString name; + }; + + Args() = default; + Args(const QApplication &app, const Paths &paths); bool printVersion{}; + bool crashRecovery{}; + /// Native, platform-specific exception code from crashpad + std::optional exceptionCode{}; + /// Text version of the exception code. Potentially contains more context. + std::optional exceptionMessage{}; + bool shouldRunBrowserExtensionHost{}; // Shows a single chat. Used on windows to embed in another application. bool isFramelessEmbed{}; - boost::optional parentWindowId{}; + std::optional parentWindowId{}; // Not settings directly bool dontSaveSettings{}; bool dontLoadMainWindow{}; - boost::optional customChannelLayout; + std::optional customChannelLayout; + std::optional activateChannel; bool verbose{}; + bool safeMode{}; + + QStringList currentArguments() const; private: - void applyCustomChannelLayout(const QString &argValue); -}; + void applyCustomChannelLayout(const QString &argValue, const Paths &paths); -void initArgs(const QApplication &app); -const Args &getArgs(); + QStringList currentArguments_; +}; } // namespace chatterino diff --git a/src/common/Atomic.hpp b/src/common/Atomic.hpp index 4ea3e8ae35e..cb6686c5f66 100644 --- a/src/common/Atomic.hpp +++ b/src/common/Atomic.hpp @@ -1,24 +1,29 @@ #pragma once -#include - +#include +#include #include namespace chatterino { template -class Atomic : boost::noncopyable +class Atomic { public: - Atomic() - { - } + Atomic() = default; + ~Atomic() = default; Atomic(T &&val) - : value_(val) + : value_(std::move(val)) { } + Atomic(const Atomic &) = delete; + Atomic &operator=(const Atomic &) = delete; + + Atomic(Atomic &&) = delete; + Atomic &operator=(Atomic &&) = delete; + T get() const { std::lock_guard guard(this->mutex_); @@ -45,4 +50,67 @@ class Atomic : boost::noncopyable T value_; }; +#if defined(__cpp_lib_atomic_shared_ptr) && defined(__cpp_concepts) + +template +class Atomic> +{ + // Atomic> must be instantated with a const T +}; + +template + requires std::is_const_v +class Atomic> +{ +public: + Atomic() = default; + ~Atomic() = default; + + Atomic(T &&val) + : value_(std::make_shared(std::move(val))) + { + } + + Atomic(std::shared_ptr &&val) + : value_(std::move(val)) + { + } + + Atomic(const Atomic &) = delete; + Atomic &operator=(const Atomic &) = delete; + + Atomic(Atomic &&) = delete; + Atomic &operator=(Atomic &&) = delete; + + std::shared_ptr get() const + { + return this->value_.load(); + } + + void set(const T &val) + { + this->value_.store(std::make_shared(val)); + } + + void set(T &&val) + { + this->value_.store(std::make_shared(std::move(val))); + } + + void set(const std::shared_ptr &val) + { + this->value_.store(val); + } + + void set(std::shared_ptr &&val) + { + this->value_.store(std::move(val)); + } + +private: + std::atomic> value_; +}; + +#endif + } // namespace chatterino diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index e47362572da..dc46c1ce690 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -10,6 +10,7 @@ #include "singletons/Logging.hpp" #include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" +#include "util/ChannelHelpers.hpp" #include #include @@ -25,9 +26,10 @@ namespace chatterino { // Channel // Channel::Channel(const QString &name, Type type) - : completionModel(*this) + : completionModel(*this, nullptr) , lastDate_(QDate::currentDate()) , name_(name) + , messages_(getSettings()->scrollbackSplitLimit) , type_(type) { } @@ -78,9 +80,8 @@ LimitedQueueSnapshot Channel::getMessageSnapshot() } void Channel::addMessage(MessagePtr message, - boost::optional overridingFlags) + std::optional overridingFlags) { - auto app = getApp(); MessagePtr deleted; if (!overridingFlags || !overridingFlags->has(MessageFlag::DoNotLog)) @@ -99,12 +100,13 @@ void Channel::addMessage(MessagePtr message, { channelPlatform = "twitch"; } - app->logging->addMessage(this->name_, message, channelPlatform); + getIApp()->getChatLogger()->addMessage(this->name_, message, + channelPlatform); } if (this->messages_.pushBack(message, deleted)) { - this->messageRemovedFromStart.invoke(deleted); + this->messageRemovedFromStart(deleted); } this->messageAppended.invoke(message, overridingFlags); @@ -112,95 +114,15 @@ void Channel::addMessage(MessagePtr message, void Channel::addOrReplaceTimeout(MessagePtr message) { - LimitedQueueSnapshot snapshot = this->getMessageSnapshot(); - int snapshotLength = snapshot.size(); - - int end = std::max(0, snapshotLength - 20); - - bool addMessage = true; - - QTime minimumTime = QTime::currentTime().addSecs(-5); - - auto timeoutStackStyle = static_cast( - getSettings()->timeoutStackStyle.getValue()); - - for (int i = snapshotLength - 1; i >= end; --i) - { - auto &s = snapshot[i]; - - if (s->parseTime < minimumTime) - { - break; - } - - if (s->flags.has(MessageFlag::Untimeout) && - s->timeoutUser == message->timeoutUser) - { - break; - } - - if (timeoutStackStyle == TimeoutStackStyle::DontStackBeyondUserMessage) - { - if (s->loginName == message->timeoutUser && - s->flags.hasNone({MessageFlag::Disabled, MessageFlag::Timeout, - MessageFlag::Untimeout})) - { - break; - } - } - - if (s->flags.has(MessageFlag::Timeout) && - s->timeoutUser == message->timeoutUser) - { - if (message->flags.has(MessageFlag::PubSub) && - !s->flags.has(MessageFlag::PubSub)) - { - this->replaceMessage(s, message); - addMessage = false; - break; - } - if (!message->flags.has(MessageFlag::PubSub) && - s->flags.has(MessageFlag::PubSub)) - { - addMessage = timeoutStackStyle == TimeoutStackStyle::DontStack; - break; - } - - int count = s->count + 1; - - MessageBuilder replacement(timeoutMessage, message->timeoutUser, - message->loginName, message->searchText, - count); - - replacement->timeoutUser = message->timeoutUser; - replacement->count = count; - replacement->flags = message->flags; - - this->replaceMessage(s, replacement.release()); - - addMessage = false; - break; - } - } - - // disable the messages from the user - for (int i = 0; i < snapshotLength; i++) - { - auto &s = snapshot[i]; - if (s->loginName == message->timeoutUser && - s->flags.hasNone({MessageFlag::Timeout, MessageFlag::Untimeout, - MessageFlag::Whisper})) - { - // FOURTF: disabled for now - // PAJLADA: Shitty solution described in Message.hpp - s->flags.set(MessageFlag::Disabled); - } - } - - if (addMessage) - { - this->addMessage(message); - } + addOrReplaceChannelTimeout( + this->getMessageSnapshot(), std::move(message), QTime::currentTime(), + [this](auto /*idx*/, auto msg, auto replacement) { + this->replaceMessage(msg, replacement); + }, + [this](auto msg) { + this->addMessage(msg); + }, + true); // XXX: Might need the following line // WindowManager::instance().repaintVisibleChatWidgets(this); @@ -212,7 +134,7 @@ void Channel::disableAllMessages() int snapshotLength = snapshot.size(); for (int i = 0; i < snapshotLength; i++) { - auto &message = snapshot[i]; + const auto &message = snapshot[i]; if (message->flags.hasAny({MessageFlag::System, MessageFlag::Timeout, MessageFlag::Whisper})) { @@ -256,7 +178,7 @@ void Channel::fillInMissingMessages(const std::vector &messages) existingMessageIds.reserve(snapshot.size()); // First, collect the ids of every message already present in the channel - for (auto &msg : snapshot) + for (const auto &msg : snapshot) { if (msg->flags.has(MessageFlag::System) || msg->id.isEmpty()) { @@ -273,7 +195,7 @@ void Channel::fillInMissingMessages(const std::vector &messages) // being able to insert just-loaded historical messages at the end // in the correct place. auto lastMsg = snapshot[snapshot.size() - 1]; - for (auto &msg : messages) + for (const auto &msg : messages) { // check if message already exists if (existingMessageIds.count(msg->id) != 0) @@ -285,7 +207,7 @@ void Channel::fillInMissingMessages(const std::vector &messages) anyInserted = true; bool insertedFlag = false; - for (auto &snapshotMsg : snapshot) + for (const auto &snapshotMsg : snapshot) { if (snapshotMsg->flags.has(MessageFlag::System)) { @@ -373,7 +295,8 @@ bool Channel::isWritable() const { using Type = Channel::Type; auto type = this->getType(); - return type != Type::TwitchMentions && type != Type::TwitchLive; + return type != Type::TwitchMentions && type != Type::TwitchLive && + type != Type::TwitchAutomod; } void Channel::sendMessage(const QString &message) @@ -392,7 +315,6 @@ bool Channel::isBroadcaster() const bool Channel::hasModRights() const { - // fourtf: check if staff return this->isMod() || this->isBroadcaster(); } @@ -408,7 +330,8 @@ bool Channel::isLive() const bool Channel::shouldIgnoreHighlights() const { - return this->type_ == Type::TwitchMentions || + return this->type_ == Type::TwitchAutomod || + this->type_ == Type::TwitchMentions || this->type_ == Type::TwitchWhispers; } @@ -431,6 +354,10 @@ void Channel::onConnected() { } +void Channel::messageRemovedFromStart(const MessagePtr &msg) +{ +} + // // Indirect channel // diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 3c2a82017fd..de134b121d3 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -1,16 +1,16 @@ #pragma once -#include "common/CompletionModel.hpp" #include "common/FlagsEnum.hpp" +#include "controllers/completion/TabCompletionModel.hpp" #include "messages/LimitedQueue.hpp" -#include #include #include #include #include #include +#include namespace chatterino { @@ -38,6 +38,7 @@ class Channel : public std::enable_shared_from_this TwitchWatching, TwitchMentions, TwitchLive, + TwitchAutomod, TwitchEnd, Irc, Misc @@ -52,8 +53,7 @@ class Channel : public std::enable_shared_from_this pajlada::Signals::Signal sendReplySignal; - pajlada::Signals::Signal messageRemovedFromStart; - pajlada::Signals::Signal> + pajlada::Signals::Signal> messageAppended; pajlada::Signals::Signal &> messagesAddedAtStart; pajlada::Signals::Signal messageReplaced; @@ -61,8 +61,6 @@ class Channel : public std::enable_shared_from_this pajlada::Signals::Signal &> filledInMessages; pajlada::Signals::NoArgSignal destroyed; pajlada::Signals::NoArgSignal displayNameChanged; - /// Invoked when AbstractIrcServer::onReadConnected occurs - pajlada::Signals::NoArgSignal connected; Type getType() const; const QString &getName() const; @@ -76,9 +74,8 @@ class Channel : public std::enable_shared_from_this // overridingFlags can be filled in with flags that should be used instead // of the message's flags. This is useful in case a flag is specific to a // type of split - void addMessage( - MessagePtr message, - boost::optional overridingFlags = boost::none); + void addMessage(MessagePtr message, + std::optional overridingFlags = std::nullopt); void addMessagesAtStart(const std::vector &messages_); /// Inserts the given messages in order by Message::serverReceivedTime. @@ -109,11 +106,12 @@ class Channel : public std::enable_shared_from_this static std::shared_ptr getEmpty(); - CompletionModel completionModel; + TabCompletionModel completionModel; QDate lastDate_; protected: virtual void onConnected(); + virtual void messageRemovedFromStart(const MessagePtr &msg); private: const QString name_; diff --git a/src/common/ChatterSet.cpp b/src/common/ChatterSet.cpp index 46875b45543..aa45b236715 100644 --- a/src/common/ChatterSet.cpp +++ b/src/common/ChatterSet.cpp @@ -27,11 +27,15 @@ void ChatterSet::updateOnlineChatters( for (auto &&chatter : lowerCaseUsernames) { if (this->items.exists(chatter)) + { tmp.put(chatter, this->items.get(chatter)); - // Less chatters than the limit => try to preserve as many as possible. + // Less chatters than the limit => try to preserve as many as possible. + } else if (lowerCaseUsernames.size() < chatterLimit) + { tmp.put(chatter, chatter); + } } this->items = std::move(tmp); @@ -50,10 +54,17 @@ std::vector ChatterSet::filterByPrefix(const QString &prefix) const for (auto &&item : this->items) { if (item.first.startsWith(lowerPrefix)) + { result.push_back(item.second); + } } return result; } +std::vector> ChatterSet::all() const +{ + return {this->items.begin(), this->items.end()}; +} + } // namespace chatterino diff --git a/src/common/ChatterSet.hpp b/src/common/ChatterSet.hpp index 1d0b281804e..f1345f43590 100644 --- a/src/common/ChatterSet.hpp +++ b/src/common/ChatterSet.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace chatterino { @@ -38,6 +39,10 @@ class ChatterSet /// are in mixed case if available. std::vector filterByPrefix(const QString &prefix) const; + /// Get all recent chatters. The first pair element contains the username + /// in lowercase, while the second pair element is the original case. + std::vector> all() const; + private: // user name in lower case -> user name in normal case cache::lru_cache items; diff --git a/src/common/ChatterinoSetting.cpp b/src/common/ChatterinoSetting.cpp index b0acb854ae5..284033a6227 100644 --- a/src/common/ChatterinoSetting.cpp +++ b/src/common/ChatterinoSetting.cpp @@ -1,6 +1,6 @@ #include "common/ChatterinoSetting.hpp" -#include "BaseSettings.hpp" +#include "singletons/Settings.hpp" namespace chatterino { diff --git a/src/common/ChatterinoSetting.hpp b/src/common/ChatterinoSetting.hpp index 4245626e033..2f5a0cac436 100644 --- a/src/common/ChatterinoSetting.hpp +++ b/src/common/ChatterinoSetting.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -85,4 +86,58 @@ class EnumSetting } }; +/** + * Setters in this class allow for bad values, it's only the enum-specific getters that are protected. + * If you get a QString from this setting, it will be the raw value from the settings file. + * Use the explicit Enum conversions or getEnum to get a typed check with a default + **/ +template +class EnumStringSetting : public pajlada::Settings::Setting +{ +public: + EnumStringSetting(const std::string &path, const Enum &defaultValue_) + : pajlada::Settings::Setting(path) + , defaultValue(defaultValue_) + { + _registerSetting(this->getData()); + } + + template + EnumStringSetting &operator=(Enum newValue) + { + std::string enumName(magic_enum::enum_name(newValue)); + auto qEnumName = QString::fromStdString(enumName); + + this->setValue(qEnumName.toLower()); + + return *this; + } + + EnumStringSetting &operator=(QString newValue) + { + this->setValue(newValue.toLower()); + + return *this; + } + + operator Enum() + { + return this->getEnum(); + } + + Enum getEnum() + { + return magic_enum::enum_cast(this->getValue().toStdString(), + magic_enum::case_insensitive) + .value_or(this->defaultValue); + } + + Enum defaultValue; + + using pajlada::Settings::Setting::operator==; + using pajlada::Settings::Setting::operator!=; + + using pajlada::Settings::Setting::operator QString; +}; + } // namespace chatterino diff --git a/src/common/Common.hpp b/src/common/Common.hpp index 353b52d7e0e..b0315a8aaf2 100644 --- a/src/common/Common.hpp +++ b/src/common/Common.hpp @@ -1,11 +1,11 @@ #pragma once -#include #include #include #include #include +#include #include namespace chatterino { diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp deleted file mode 100644 index 08bddf2e8b7..00000000000 --- a/src/common/CompletionModel.cpp +++ /dev/null @@ -1,267 +0,0 @@ -#include "common/CompletionModel.hpp" - -#include "Application.hpp" -#include "common/ChatterSet.hpp" -#include "controllers/accounts/AccountController.hpp" -#include "controllers/commands/Command.hpp" -#include "controllers/commands/CommandController.hpp" -#include "messages/Emote.hpp" -#include "providers/twitch/TwitchAccount.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchCommon.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" -#include "singletons/Emotes.hpp" -#include "singletons/Settings.hpp" -#include "util/Helpers.hpp" -#include "util/QStringHash.hpp" - -#include - -#include - -namespace chatterino { - -// -// TaggedString -// - -CompletionModel::TaggedString::TaggedString(QString _string, Type _type) - : string(std::move(_string)) - , type(_type) -{ -} - -bool CompletionModel::TaggedString::isEmote() const -{ - return this->type > Type::EmoteStart && this->type < Type::EmoteEnd; -} - -bool CompletionModel::TaggedString::operator<(const TaggedString &that) const -{ - if (this->isEmote() != that.isEmote()) - { - return this->isEmote(); - } - - return CompletionModel::compareStrings(this->string, that.string); -} - -// -// CompletionModel -// -CompletionModel::CompletionModel(Channel &channel) - : channel_(channel) -{ -} - -int CompletionModel::columnCount(const QModelIndex &parent) const -{ - (void)parent; // unused - - return 1; -} - -QVariant CompletionModel::data(const QModelIndex &index, int role) const -{ - (void)role; // unused - - std::shared_lock lock(this->itemsMutex_); - - auto it = this->items_.begin(); - std::advance(it, index.row()); - return {it->string}; -} - -int CompletionModel::rowCount(const QModelIndex &parent) const -{ - (void)parent; // unused - - std::shared_lock lock(this->itemsMutex_); - - return this->items_.size(); -} - -void CompletionModel::refresh(const QString &prefix, bool isFirstWord) -{ - std::unique_lock lock(this->itemsMutex_); - - this->items_.clear(); - - if (prefix.length() < 2 || !this->channel_.isTwitchChannel()) - { - return; - } - - // Twitch channel - auto *tc = dynamic_cast(&this->channel_); - - auto addString = [=, this](const QString &str, TaggedString::Type type) { - // Special case for handling default Twitch commands - if (type == TaggedString::TwitchCommand) - { - if (prefix.size() < 2) - { - return; - } - - auto prefixChar = prefix.at(0); - - static std::set validPrefixChars{'/', '.'}; - - if (validPrefixChars.find(prefixChar) == validPrefixChars.end()) - { - return; - } - - if (startsWithOrContains((prefixChar + str), prefix, - Qt::CaseInsensitive, - getSettings()->prefixOnlyEmoteCompletion)) - { - this->items_.emplace((prefixChar + str + " "), type); - } - - return; - } - - if (startsWithOrContains(str, prefix, Qt::CaseInsensitive, - getSettings()->prefixOnlyEmoteCompletion)) - { - this->items_.emplace(str + " ", type); - } - }; - - if (auto account = getApp()->accounts->twitch.getCurrent()) - { - // Twitch Emotes available globally - for (const auto &emote : account->accessEmotes()->emotes) - { - addString(emote.first.string, TaggedString::TwitchGlobalEmote); - } - - // Twitch Emotes available locally - auto localEmoteData = account->accessLocalEmotes(); - if (tc != nullptr && - localEmoteData->find(tc->roomId()) != localEmoteData->end()) - { - for (const auto &emote : localEmoteData->at(tc->roomId())) - { - addString(emote.first.string, - TaggedString::Type::TwitchLocalEmote); - } - } - } - - // 7TV Global - for (const auto &emote : - *getApp()->twitch->getSeventvEmotes().globalEmotes()) - { - addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote); - } - // Bttv Global - for (const auto &emote : *getApp()->twitch->getBttvEmotes().emotes()) - { - addString(emote.first.string, TaggedString::Type::BTTVChannelEmote); - } - - // Ffz Global - for (const auto &emote : *getApp()->twitch->getFfzEmotes().emotes()) - { - addString(emote.first.string, TaggedString::Type::FFZChannelEmote); - } - - // Emojis - if (prefix.startsWith(":")) - { - const auto &emojiShortCodes = getApp()->emotes->emojis.shortCodes; - for (const auto &m : emojiShortCodes) - { - addString(QString(":%1:").arg(m), TaggedString::Type::Emoji); - } - } - - // - // Stuff below is available only in regular Twitch channels - if (tc == nullptr) - { - return; - } - - // Usernames - if (prefix.startsWith("@")) - { - QString usernamePrefix = prefix; - usernamePrefix.remove(0, 1); - - auto chatters = tc->accessChatters()->filterByPrefix(usernamePrefix); - - for (const auto &name : chatters) - { - addString( - "@" + formatUserMention(name, isFirstWord, - getSettings()->mentionUsersWithComma), - TaggedString::Type::Username); - } - } - else if (!getSettings()->userCompletionOnlyWithAt) - { - auto chatters = tc->accessChatters()->filterByPrefix(prefix); - - for (const auto &name : chatters) - { - addString(formatUserMention(name, isFirstWord, - getSettings()->mentionUsersWithComma), - TaggedString::Type::Username); - } - } - - // 7TV Channel - for (const auto &emote : *tc->seventvEmotes()) - { - addString(emote.first.string, TaggedString::Type::SeventvChannelEmote); - } - // Bttv Channel - for (const auto &emote : *tc->bttvEmotes()) - { - addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); - } - - // Ffz Channel - for (const auto &emote : *tc->ffzEmotes()) - { - addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); - } - - // Custom Chatterino commands - for (const auto &command : getApp()->commands->items) - { - addString(command.name, TaggedString::CustomCommand); - } - - // Default Chatterino commands - for (const auto &command : - getApp()->commands->getDefaultChatterinoCommandList()) - { - addString(command, TaggedString::ChatterinoCommand); - } - - // Default Twitch commands - for (const auto &command : TWITCH_DEFAULT_COMMANDS) - { - addString(command, TaggedString::TwitchCommand); - } -} - -bool CompletionModel::compareStrings(const QString &a, const QString &b) -{ - // try comparing insensitively, if they are the same then senstively - // (fixes order of LuL and LUL) - int k = QString::compare(a, b, Qt::CaseInsensitive); - if (k == 0) - { - return a > b; - } - - return k < 0; -} - -} // namespace chatterino diff --git a/src/common/CompletionModel.hpp b/src/common/CompletionModel.hpp deleted file mode 100644 index c2670c08ee4..00000000000 --- a/src/common/CompletionModel.hpp +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include - -#include -#include -#include - -namespace chatterino { - -class Channel; - -class CompletionModel : public QAbstractListModel -{ - struct TaggedString { - enum Type { - Username, - - // emotes - EmoteStart, - FFZGlobalEmote, - FFZChannelEmote, - BTTVGlobalEmote, - BTTVChannelEmote, - SeventvGlobalEmote, - SeventvChannelEmote, - TwitchGlobalEmote, - TwitchLocalEmote, - TwitchSubscriberEmote, - Emoji, - EmoteEnd, - // end emotes - - CustomCommand, - ChatterinoCommand, - TwitchCommand, - }; - - TaggedString(QString _string, Type type); - - bool isEmote() const; - bool operator<(const TaggedString &that) const; - - const QString string; - const Type type; - }; - -public: - CompletionModel(Channel &channel); - - int columnCount(const QModelIndex &parent) const override; - QVariant data(const QModelIndex &index, int role) const override; - int rowCount(const QModelIndex &parent) const override; - - void refresh(const QString &prefix, bool isFirstWord = false); - - static bool compareStrings(const QString &a, const QString &b); - -private: - mutable std::shared_mutex itemsMutex_; - std::set items_; - - Channel &channel_; -}; - -} // namespace chatterino diff --git a/src/common/Credentials.cpp b/src/common/Credentials.cpp index f3985701f98..5784436f158 100644 --- a/src/common/Credentials.cpp +++ b/src/common/Credentials.cpp @@ -1,23 +1,31 @@ -#include "Credentials.hpp" +#include "common/Credentials.hpp" +#include "Application.hpp" +#include "common/Modes.hpp" #include "debug/AssertInGuiThread.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "util/CombinePath.hpp" #include "util/Overloaded.hpp" +#include "util/Variant.hpp" #include #include +#include + +#include #ifndef NO_QTKEYCHAIN # ifdef CMAKE_BUILD -# include "qt5keychain/keychain.h" +# if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +# include "qt6keychain/keychain.h" +# else +# include "qt5keychain/keychain.h" +# endif # else # include "keychain.h" # endif #endif -#include -#include #define FORMAT_NAME \ ([&] { \ @@ -25,152 +33,152 @@ return QString("chatterino:%1:%2").arg(provider).arg(name_); \ })() -namespace chatterino { - namespace { - bool useKeyring() - { + +using namespace chatterino; + +bool useKeyring() +{ #ifdef NO_QTKEYCHAIN - return false; + return false; #endif - if (getPaths()->isPortable()) - { - return false; - } - else - { + if (Modes::instance().isPortable) + { + return false; + } + #ifdef Q_OS_LINUX - return getSettings()->useKeyring; + return getSettings()->useKeyring; #else - return true; + return true; #endif - } - } +} - // Insecure storage: - QString insecurePath() - { - return combinePath(getPaths()->settingsDirectory, "credentials.json"); - } +// Insecure storage: +QString insecurePath() +{ + return combinePath(getIApp()->getPaths().settingsDirectory, + "credentials.json"); +} - QJsonDocument loadInsecure() - { - QFile file(insecurePath()); - file.open(QIODevice::ReadOnly); - return QJsonDocument::fromJson(file.readAll()); - } +QJsonDocument loadInsecure() +{ + QFile file(insecurePath()); + file.open(QIODevice::ReadOnly); + return QJsonDocument::fromJson(file.readAll()); +} - void storeInsecure(const QJsonDocument &doc) - { - QSaveFile file(insecurePath()); - file.open(QIODevice::WriteOnly); - file.write(doc.toJson()); - file.commit(); - } +void storeInsecure(const QJsonDocument &doc) +{ + QSaveFile file(insecurePath()); + file.open(QIODevice::WriteOnly); + file.write(doc.toJson()); + file.commit(); +} - QJsonDocument &insecureInstance() - { - static auto store = loadInsecure(); - return store; - } +QJsonDocument &insecureInstance() +{ + static auto store = loadInsecure(); + return store; +} - void queueInsecureSave() - { - static bool isQueued = false; +void queueInsecureSave() +{ + static bool isQueued = false; - if (!isQueued) - { - isQueued = true; - QTimer::singleShot(200, qApp, [] { - storeInsecure(insecureInstance()); - isQueued = false; - }); - } + if (!isQueued) + { + isQueued = true; + QTimer::singleShot(200, qApp, [] { + storeInsecure(insecureInstance()); + isQueued = false; + }); } +} - // QKeychain runs jobs asyncronously, so we have to assure that set/erase - // jobs gets executed in order. - struct SetJob { - QString name; - QString credential; - }; +// QKeychain runs jobs asyncronously, so we have to assure that set/erase +// jobs gets executed in order. +struct SetJob { + QString name; + QString credential; +}; - struct EraseJob { - QString name; - }; +struct EraseJob { + QString name; +}; - using Job = boost::variant; +using Job = std::variant; - static std::queue &jobQueue() - { - static std::queue jobs; - return jobs; - } +std::queue &jobQueue() +{ + static std::queue jobs; + return jobs; +} - static void runNextJob() - { +void runNextJob() +{ #ifndef NO_QTKEYCHAIN - auto &&queue = jobQueue(); + auto &&queue = jobQueue(); - if (!queue.empty()) - { - // we were gonna use std::visit here but macos is shit - - auto &&item = queue.front(); - - if (item.which() == 0) // set job - { - auto set = boost::get(item); - auto job = new QKeychain::WritePasswordJob("chatterino"); - job->setAutoDelete(true); - job->setKey(set.name); - job->setTextData(set.credential); - QObject::connect(job, &QKeychain::Job::finished, qApp, - [](auto) { - runNextJob(); - }); - job->start(); - } - else // erase job - { - auto erase = boost::get(item); - auto job = new QKeychain::DeletePasswordJob("chatterino"); - job->setAutoDelete(true); - job->setKey(erase.name); - QObject::connect(job, &QKeychain::Job::finished, qApp, - [](auto) { - runNextJob(); - }); - job->start(); - } - - queue.pop(); - } -#endif + if (!queue.empty()) + { + // we were gonna use std::visit here but macos is shit + + auto &&item = queue.front(); + + std::visit( + variant::Overloaded{ + [](const SetJob &set) { + auto *job = new QKeychain::WritePasswordJob("chatterino"); + job->setAutoDelete(true); + job->setKey(set.name); + job->setTextData(set.credential); + QObject::connect(job, &QKeychain::Job::finished, qApp, + [](auto) { + runNextJob(); + }); + job->start(); + }, + [](const EraseJob &erase) { + auto *job = new QKeychain::DeletePasswordJob("chatterino"); + job->setAutoDelete(true); + job->setKey(erase.name); + QObject::connect(job, &QKeychain::Job::finished, qApp, + [](auto) { + runNextJob(); + }); + job->start(); + }, + }, + item); + + queue.pop(); } +#endif +} - static void queueJob(Job &&job) - { - auto &&queue = jobQueue(); +void queueJob(Job &&job) +{ + auto &&queue = jobQueue(); - queue.push(std::move(job)); - if (queue.size() == 1) - { - runNextJob(); - } + queue.push(std::move(job)); + if (queue.size() == 1) + { + runNextJob(); } +} + } // namespace +namespace chatterino { + Credentials &Credentials::instance() { static Credentials creds; return creds; } -Credentials::Credentials() -{ -} - +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) void Credentials::get(const QString &provider, const QString &name_, QObject *receiver, std::function &&onLoaded) @@ -183,7 +191,7 @@ void Credentials::get(const QString &provider, const QString &name_, { #ifndef NO_QTKEYCHAIN // if NO_QTKEYCHAIN is set, then this code is never used either way - auto job = new QKeychain::ReadPasswordJob("chatterino"); + auto *job = new QKeychain::ReadPasswordJob("chatterino"); job->setAutoDelete(true); job->setKey(name); QObject::connect( @@ -197,12 +205,13 @@ void Credentials::get(const QString &provider, const QString &name_, } else { - auto &instance = insecureInstance(); + const auto &instance = insecureInstance(); - onLoaded(instance.object().find(name).value().toString()); + onLoaded(instance[name].toString()); } } +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) void Credentials::set(const QString &provider, const QString &name_, const QString &credential) { @@ -229,6 +238,7 @@ void Credentials::set(const QString &provider, const QString &name_, } } +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) void Credentials::erase(const QString &provider, const QString &name_) { assertInGuiThread(); diff --git a/src/common/Credentials.hpp b/src/common/Credentials.hpp index 0b144f60249..d3bec378aab 100644 --- a/src/common/Credentials.hpp +++ b/src/common/Credentials.hpp @@ -19,7 +19,7 @@ class Credentials void erase(const QString &provider, const QString &name); private: - Credentials(); + Credentials() = default; }; } // namespace chatterino diff --git a/src/common/DownloadManager.cpp b/src/common/DownloadManager.cpp deleted file mode 100644 index f8344c304c3..00000000000 --- a/src/common/DownloadManager.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "DownloadManager.hpp" - -#include "common/QLogging.hpp" -#include "singletons/Paths.hpp" - -#include - -namespace chatterino { - -DownloadManager::DownloadManager(QObject *parent) - : QObject(parent) - , manager_(new QNetworkAccessManager) -{ -} - -DownloadManager::~DownloadManager() -{ - this->manager_->deleteLater(); -} - -void DownloadManager::setFile(QString fileURL, const QString &channelName) -{ - QString saveFilePath; - saveFilePath = - getPaths()->twitchProfileAvatars + "/twitch/" + channelName + ".png"; - QNetworkRequest request; - request.setUrl(QUrl(fileURL)); - this->reply_ = this->manager_->get(request); - - this->file_ = new QFile; - this->file_->setFileName(saveFilePath); - this->file_->open(QIODevice::WriteOnly); - - connect(this->reply_, SIGNAL(downloadProgress(qint64, qint64)), this, - SLOT(onDownloadProgress(qint64, qint64))); - connect(this->manager_, SIGNAL(finished(QNetworkReply *)), this, - SLOT(onFinished(QNetworkReply *))); - connect(this->reply_, SIGNAL(readyRead()), this, SLOT(onReadyRead())); - connect(this->reply_, SIGNAL(finished()), this, SLOT(onReplyFinished())); -} - -void DownloadManager::onDownloadProgress(qint64 bytesRead, qint64 bytesTotal) -{ - qCDebug(chatterinoCommon) - << "Download progress: " << bytesRead << "/" << bytesTotal; -} - -void DownloadManager::onFinished(QNetworkReply *reply) -{ - switch (reply->error()) - { - case QNetworkReply::NoError: { - qCDebug(chatterinoCommon) << "file is downloaded successfully."; - } - break; - default: { - qCDebug(chatterinoCommon) << reply->errorString().toLatin1(); - }; - } - - if (this->file_->isOpen()) - { - this->file_->close(); - this->file_->deleteLater(); - } - emit downloadComplete(); -} - -void DownloadManager::onReadyRead() -{ - this->file_->write(this->reply_->readAll()); -} - -void DownloadManager::onReplyFinished() -{ - if (this->file_->isOpen()) - { - this->file_->close(); - this->file_->deleteLater(); - } -} - -} // namespace chatterino diff --git a/src/common/DownloadManager.hpp b/src/common/DownloadManager.hpp deleted file mode 100644 index b1f6b6fb565..00000000000 --- a/src/common/DownloadManager.hpp +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace chatterino { - -class DownloadManager : public QObject -{ - Q_OBJECT -public: - explicit DownloadManager(QObject *parent = nullptr); - virtual ~DownloadManager(); - void setFile(QString fileURL, const QString &channelName); - -private: - QNetworkAccessManager *manager_; - QNetworkReply *reply_; - QFile *file_; - -private slots: - void onDownloadProgress(qint64, qint64); - void onFinished(QNetworkReply *); - void onReadyRead(); - void onReplyFinished(); - -signals: - void downloadComplete(); -}; - -} // namespace chatterino diff --git a/src/common/Env.cpp b/src/common/Env.cpp index 605e4924799..6a7f5dac3ef 100644 --- a/src/common/Env.cpp +++ b/src/common/Env.cpp @@ -3,6 +3,7 @@ #include "common/QLogging.hpp" #include "util/TypeName.hpp" +#include #include namespace chatterino { @@ -10,16 +11,8 @@ namespace chatterino { namespace { template - void warn(const char *envName, T defaultValue) + void warn(const char *envName, const QString &envString, T defaultValue) { - auto *envString = std::getenv(envName); - if (!envString) - { - // This function is not supposed to be used for non-existant - // environment variables. - return; - } - const auto typeName = QString::fromStdString( std::string(type_name())); @@ -33,54 +26,41 @@ namespace { .arg(defaultValue); } - QString readStringEnv(const char *envName, QString defaultValue) + std::optional readOptionalStringEnv(const char *envName) { - auto envString = std::getenv(envName); - if (envString != nullptr) + auto envString = qEnvironmentVariable(envName); + if (!envString.isEmpty()) { - return QString(envString); + return envString; } - return defaultValue; - } - - boost::optional readOptionalStringEnv(const char *envName) - { - auto envString = std::getenv(envName); - if (envString != nullptr) - { - return QString(envString); - } - - return boost::none; + return std::nullopt; } uint16_t readPortEnv(const char *envName, uint16_t defaultValue) { - auto envString = std::getenv(envName); - if (envString != nullptr) + auto envString = qEnvironmentVariable(envName); + if (!envString.isEmpty()) { - bool ok; - auto val = QString(envString).toUShort(&ok); + bool ok = false; + auto val = envString.toUShort(&ok); if (ok) { return val; } - else - { - warn(envName, defaultValue); - } + + warn(envName, envString, defaultValue); } return defaultValue; } - uint16_t readBoolEnv(const char *envName, bool defaultValue) + bool readBoolEnv(const char *envName, bool defaultValue) { - auto envString = std::getenv(envName); - if (envString != nullptr) + auto envString = qEnvironmentVariable(envName); + if (!envString.isEmpty()) { - return QVariant(QString(envString)).toBool(); + return QVariant(envString).toBool(); } return defaultValue; @@ -90,14 +70,14 @@ namespace { Env::Env() : recentMessagesApiUrl( - readStringEnv("CHATTERINO2_RECENT_MESSAGES_URL", - "https://recent-messages.robotty.de/api/v2/" - "recent-messages/%1")) - , linkResolverUrl(readStringEnv( + qEnvironmentVariable("CHATTERINO2_RECENT_MESSAGES_URL", + "https://recent-messages.robotty.de/api/v2/" + "recent-messages/%1")) + , linkResolverUrl(qEnvironmentVariable( "CHATTERINO2_LINK_RESOLVER_URL", "https://braize.pajlada.com/chatterino/link_resolver/%1")) - , twitchServerHost( - readStringEnv("CHATTERINO2_TWITCH_SERVER_HOST", "irc.chat.twitch.tv")) + , twitchServerHost(qEnvironmentVariable("CHATTERINO2_TWITCH_SERVER_HOST", + "irc.chat.twitch.tv")) , twitchServerPort(readPortEnv("CHATTERINO2_TWITCH_SERVER_PORT", 443)) , twitchServerSecure(readBoolEnv("CHATTERINO2_TWITCH_SERVER_SECURE", true)) , proxyUrl(readOptionalStringEnv("CHATTERINO2_PROXY_URL")) diff --git a/src/common/Env.hpp b/src/common/Env.hpp index 97e5040d863..353a6af483a 100644 --- a/src/common/Env.hpp +++ b/src/common/Env.hpp @@ -1,8 +1,9 @@ #pragma once -#include #include +#include + namespace chatterino { class Env @@ -17,7 +18,7 @@ class Env const QString twitchServerHost; const uint16_t twitchServerPort; const bool twitchServerSecure; - const boost::optional proxyUrl; + const std::optional proxyUrl; }; } // namespace chatterino diff --git a/src/common/FlagsEnum.hpp b/src/common/FlagsEnum.hpp index 5eee938794b..7d1c11c8abb 100644 --- a/src/common/FlagsEnum.hpp +++ b/src/common/FlagsEnum.hpp @@ -42,6 +42,12 @@ class FlagsEnum reinterpret_cast(this->value_) |= static_cast(flag); } + /** Adds the flags from `flags` in this enum. */ + void set(FlagsEnum flags) + { + reinterpret_cast(this->value_) |= static_cast(flags.value_); + } + void unset(T flag) { reinterpret_cast(this->value_) &= ~static_cast(flag); @@ -50,9 +56,13 @@ class FlagsEnum void set(T flag, bool value) { if (value) + { this->set(flag); + } else + { this->unset(flag); + } } bool has(T flag) const @@ -69,6 +79,12 @@ class FlagsEnum return xd; } + FlagsEnum operator|(FlagsEnum rhs) + { + return static_cast(static_cast(this->value_) | + static_cast(rhs.value_)); + } + bool hasAny(FlagsEnum flags) const { return static_cast(this->value_) & static_cast(flags.value_); @@ -85,6 +101,11 @@ class FlagsEnum return !this->hasAny(flags); } + T value() const + { + return this->value_; + } + private: T value_{}; }; diff --git a/src/common/LinkParser.cpp b/src/common/LinkParser.cpp index 1b8c3f14dbf..8ef1dc232b7 100644 --- a/src/common/LinkParser.cpp +++ b/src/common/LinkParser.cpp @@ -1,202 +1,239 @@ +#define QT_NO_CAST_FROM_ASCII // avoids unexpected implicit casts #include "common/LinkParser.hpp" #include -#include -#include #include #include -#include +#include #include -namespace chatterino { namespace { - QSet &tlds() - { - static QSet tlds = [] { - QFile file(":/tlds.txt"); - file.open(QFile::ReadOnly); - QTextStream stream(&file); - stream.setCodec("UTF-8"); - int safetyMax = 20000; - QSet set; +QSet &tlds() +{ + static QSet tlds = [] { + QFile file(QStringLiteral(":/tlds.txt")); + file.open(QFile::ReadOnly); + QTextStream stream(&file); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + // Default encoding of QTextStream is already UTF-8, at least in Qt6 +#else + stream.setCodec("UTF-8"); +#endif + int safetyMax = 20000; - while (!stream.atEnd()) - { - auto line = stream.readLine(); - set.insert(line); + QSet set; - if (safetyMax-- == 0) - break; + while (!stream.atEnd()) + { + auto line = stream.readLine(); + set.insert(line); + + if (safetyMax-- == 0) + { + break; } + } - return set; - }(); - return tlds; - } + return set; + }(); + return tlds; +} - bool isValidHostname(QStringRef &host) - { - int index = host.lastIndexOf('.'); +bool isValidTld(QStringView tld) +{ + return tlds().contains(tld.toString().toLower()); +} - return index != -1 && - tlds().contains(host.mid(index + 1).toString().toLower()); - } +bool isValidIpv4(QStringView host) +{ + // We don't care about the actual value, + // we only want to verify the ip. + + char16_t sectionValue = 0; // 0..256 + uint8_t octetNumber = 0; // 0..4 + uint8_t sectionDigits = 0; // 0..3 + bool lastWasDot = true; - bool isValidIpv4(QStringRef &host) + for (auto c : host) { - static auto exp = QRegularExpression("^\\d{1,3}(?:\\.\\d{1,3}){3}$"); + char16_t current = c.unicode(); + if (current == '.') + { + if (lastWasDot || octetNumber == 3) + { + return false; + } + lastWasDot = true; + octetNumber++; + sectionValue = 0; + sectionDigits = 0; + continue; + } + lastWasDot = false; + + if (current > u'9' || current < u'0') + { + return false; + } - return exp.match(host).hasMatch(); + sectionValue = sectionValue * 10 + (current - u'0'); + sectionDigits++; + if (sectionValue >= 256 || sectionDigits > 3) + { + return false; + } } -#ifdef C_MATCH_IPV6_LINK - bool isValidIpv6(QStringRef &host) + return octetNumber == 3 && !lastWasDot; +} + +/** + * @brief Checks if the string starts with a port number. + * + * The value of the port number isn't checked. A port in this implementation + * can be in the range 0..100'000. + */ +bool startsWithPort(QStringView string) +{ + for (qsizetype i = 0; i < std::min(5, string.length()); i++) { - static auto exp = QRegularExpression("^\\[[a-fA-F0-9:%]+\\]$"); + char16_t c = string[i].unicode(); + if (i >= 1 && (c == u'/' || c == u'?' || c == u'#')) + { + return true; + } - return exp.match(host).hasMatch(); + if (!string[i].isDigit()) + { + return false; + } } -#endif + return true; +} + } // namespace +namespace chatterino { + LinkParser::LinkParser(const QString &unparsedString) { - this->match_ = unparsedString; - + ParsedLink result; // This is not implemented with a regex to increase performance. - // We keep removing parts of the url until there's either nothing left or we fail. - QStringRef l(&unparsedString); - - bool hasHttp = false; + QStringView remaining(unparsedString); + QStringView protocol(remaining); + +#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) + QStringView wholeString(unparsedString); + const auto refFromView = [&](QStringView view) { + return QStringRef(&unparsedString, + static_cast(view.begin() - wholeString.begin()), + static_cast(view.size())); + }; +#endif - // Protocol `https?://` - if (l.startsWith("https://", Qt::CaseInsensitive)) - { - hasHttp = true; - l = l.mid(8); - } - else if (l.startsWith("http://", Qt::CaseInsensitive)) + // Check protocol for https?:// + if (remaining.startsWith(QStringLiteral("http"), Qt::CaseInsensitive) && + remaining.length() >= 4 + 3 + 1) // 'http' + '://' + [any] { - hasHttp = true; - l = l.mid(7); + // optimistic view assuming there's a protocol (http or https) + auto withProto = remaining.mid(4); // 'http' + + if (withProto[0] == QChar(u's') || withProto[0] == QChar(u'S')) + { + withProto = withProto.mid(1); + } + + if (withProto.startsWith(QStringLiteral("://"))) + { + // there's really a protocol => consume it + remaining = withProto.mid(3); +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + result.protocol = {protocol.begin(), remaining.begin()}; +#else + result.protocol = + refFromView({protocol.begin(), remaining.begin()}); +#endif + } } - // Http basic auth `user:password`. - // Not supported for security reasons (misleading links) + // Http basic auth `user:password` isn't supported for security reasons (misleading links) // Host `a.b.c.com` - QStringRef host = l; + QStringView host = remaining; + QStringView rest; bool lastWasDot = true; - bool inIpv6 = false; + int lastDotPos = -1; + int nDots = 0; - for (int i = 0; i < l.size(); i++) + // Extract the host + for (int i = 0; i < remaining.size(); i++) { - if (l[i] == '.') + char16_t currentChar = remaining[i].unicode(); + if (currentChar == u'.') { - if (lastWasDot == true) // no double dots .. - goto error; + if (lastWasDot) // no double dots .. + { + return; + } + lastDotPos = i; lastWasDot = true; + nDots++; } else { lastWasDot = false; } - if (l[i] == ':' && !inIpv6) + // found a port + if (currentChar == u':') { - host = l.mid(0, i); - l = l.mid(i + 1); - goto parsePort; - } - else if (l[i] == '/') - { - host = l.mid(0, i); - l = l.mid(i + 1); - goto parsePath; - } - else if (l[i] == '?') - { - host = l.mid(0, i); - l = l.mid(i + 1); - goto parseQuery; - } - else if (l[i] == '#') - { - host = l.mid(0, i); - l = l.mid(i + 1); - goto parseAnchor; - } + host = remaining.mid(0, i); + rest = remaining.mid(i); + remaining = remaining.mid(i + 1); - // ipv6 - if (l[i] == '[') - { - if (i == 0) - inIpv6 = true; - else - goto error; + if (!startsWithPort(remaining)) + { + return; + } + + break; } - else if (l[i] == ']') + + // we accept everything in the path/query/anchor + if (currentChar == u'/' || currentChar == u'?' || currentChar == u'#') { - inIpv6 = false; + host = remaining.mid(0, i); + rest = remaining.mid(i); + break; } } - if (lastWasDot) - goto error; - else - goto done; - -parsePort: - // Port `:12345` - for (int i = 0; i < std::min(5, l.size()); i++) + if (lastWasDot || lastDotPos <= 0) { - if (l[i] == '/') - goto parsePath; - else if (l[i] == '?') - goto parseQuery; - else if (l[i] == '#') - goto parseAnchor; - - if (!l[i].isDigit()) - goto error; + return; } - goto done; - -parsePath: -parseQuery: -parseAnchor: - // we accept everything in the path/query/anchor - -done: - // check host - this->hasMatch_ = isValidHostname(host) || isValidIpv4(host) -#ifdef C_MATCH_IPV6_LINK - - || (hasHttp && isValidIpv6(host)) -#endif - ; - - if (this->hasMatch_) + // check host/tld + if ((nDots == 3 && isValidIpv4(host)) || + isValidTld(host.mid(lastDotPos + 1))) { - this->match_ = unparsedString; +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + result.host = host; + result.rest = rest; +#else + result.host = refFromView(host); + result.rest = refFromView(rest); +#endif + result.source = unparsedString; + this->result_ = std::move(result); } - - return; - -error: - hasMatch_ = false; -} - -bool LinkParser::hasMatch() const -{ - return this->hasMatch_; } -QString LinkParser::getCaptured() const +const std::optional &LinkParser::result() const { - return this->match_; + return this->result_; } } // namespace chatterino diff --git a/src/common/LinkParser.hpp b/src/common/LinkParser.hpp index 8350f466858..16bfe235e52 100644 --- a/src/common/LinkParser.hpp +++ b/src/common/LinkParser.hpp @@ -2,19 +2,31 @@ #include +#include + namespace chatterino { +struct ParsedLink { +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + using StringView = QStringView; +#else + using StringView = QStringRef; +#endif + StringView protocol; + StringView host; + StringView rest; + QString source; +}; + class LinkParser { public: explicit LinkParser(const QString &unparsedString); - bool hasMatch() const; - QString getCaptured() const; + const std::optional &result() const; private: - bool hasMatch_{false}; - QString match_; + std::optional result_{}; }; } // namespace chatterino diff --git a/src/common/Literals.hpp b/src/common/Literals.hpp new file mode 100644 index 00000000000..b7276d499cf --- /dev/null +++ b/src/common/Literals.hpp @@ -0,0 +1,170 @@ +#pragma once + +#include + +/// This namespace defines the string suffixes _s, _ba, and _L1 used to create Qt types at compile-time. +/// They're easier to use comapred to their corresponding macros. +/// +/// * u"foobar"_s creates a QString (like QStringLiteral). The u prefix is required. +/// +/// * "foobar"_ba creates a QByteArray (like QByteArrayLiteral). +/// +/// * "foobar"_L1 creates a QLatin1String(-View). +namespace chatterino::literals { + +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + +// This makes sure that the backing data never causes allocation after compilation. +// It's essentially the QStringLiteral macro inlined. +// +// From desktop-app/lib_base +// https://github.com/desktop-app/lib_base/blob/f904c60987115a4b514a575b23009ff25de0fafa/base/basic_types.h#L63-L152 +// And qt/qtbase (5.15) +// https://github.com/qt/qtbase/blob/29400a683f96867133b28299c0d0bd6bcf40df35/src/corelib/text/qstringliteral.h#L64-L104 +namespace detail { + // NOLINTBEGIN(modernize-avoid-c-arrays) + // NOLINTBEGIN(cppcoreguidelines-avoid-c-arrays) + + template + struct LiteralResolver { + template + constexpr LiteralResolver(const char16_t (&text)[N], + std::index_sequence /*seq*/) + : utf16Text{text[I]...} + { + } + template + constexpr LiteralResolver(const char (&text)[N], + std::index_sequence /*seq*/) + : latin1Text{text[I]...} + , latin1(true) + { + } + constexpr LiteralResolver(const char16_t (&text)[N]) + : LiteralResolver(text, std::make_index_sequence{}) + { + } + constexpr LiteralResolver(const char (&text)[N]) + : LiteralResolver(text, std::make_index_sequence{}) + { + } + + const char16_t utf16Text[N]{}; + const char latin1Text[N]{}; + size_t length = N; + bool latin1 = false; + }; + + template + struct StaticStringData { + template + constexpr StaticStringData(const char16_t (&text)[N], + std::index_sequence /*seq*/) + : data Q_STATIC_STRING_DATA_HEADER_INITIALIZER(N - 1) + , text{text[I]...} + { + } + QArrayData data; + char16_t text[N]; + + QStringData *pointer() + { + Q_ASSERT(data.ref.isStatic()); + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-static-cast-downcast) + return static_cast(&data); + } + }; + + template + struct StaticByteArrayData { + template + constexpr StaticByteArrayData(const char (&text)[N], + std::index_sequence /*seq*/) + : data Q_STATIC_BYTE_ARRAY_DATA_HEADER_INITIALIZER(N - 1) + , text{text[I]...} + { + } + QByteArrayData data; + char text[N]; + + QByteArrayData *pointer() + { + Q_ASSERT(data.ref.isStatic()); + return &data; + } + }; + + // NOLINTEND(cppcoreguidelines-avoid-c-arrays) + // NOLINTEND(modernize-avoid-c-arrays) + +} // namespace detail + +template +inline QString operator""_s() noexcept +{ + static_assert(R.length > 0); // always has a terminating null + static_assert(!R.latin1, "QString literals must be made up of 16bit " + "characters. Forgot a u\"\"?"); + + static auto literal = detail::StaticStringData( + R.utf16Text, std::make_index_sequence{}); + return QString{QStringDataPtr{literal.pointer()}}; +}; + +template +inline QByteArray operator""_ba() noexcept +{ + static_assert(R.length > 0); // always has a terminating null + static_assert(R.latin1, "QByteArray literals must be made up of 8bit " + "characters. Misplaced u\"\"?"); + + static auto literal = detail::StaticByteArrayData( + R.latin1Text, std::make_index_sequence{}); + return QByteArray{QByteArrayDataPtr{literal.pointer()}}; +}; + +#elif QT_VERSION < QT_VERSION_CHECK(6, 4, 0) + +// The operators were added in 6.4, but their implementation works in any 6.x version. +// +// NOLINTBEGIN(cppcoreguidelines-pro-type-const-cast) +inline QString operator""_s(const char16_t *str, size_t size) noexcept +{ + return QString( + QStringPrivate(nullptr, const_cast(str), qsizetype(size))); +} + +inline QByteArray operator""_ba(const char *str, size_t size) noexcept +{ + return QByteArray( + QByteArrayData(nullptr, const_cast(str), qsizetype(size))); +} +// NOLINTEND(cppcoreguidelines-pro-type-const-cast) + +#else + +inline QString operator""_s(const char16_t *str, size_t size) noexcept +{ + return Qt::Literals::StringLiterals::operator""_s(str, size); +} + +inline QByteArray operator""_ba(const char *str, size_t size) noexcept +{ + return Qt::Literals::StringLiterals::operator""_ba(str, size); +} + +#endif + +constexpr inline QLatin1String operator""_L1(const char *str, + size_t size) noexcept +{ +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + using SizeType = int; +#else + using SizeType = qsizetype; +#endif + + return QLatin1String{str, static_cast(size)}; +} + +} // namespace chatterino::literals diff --git a/src/common/NetworkPrivate.cpp b/src/common/NetworkPrivate.cpp deleted file mode 100644 index 44cb8710202..00000000000 --- a/src/common/NetworkPrivate.cpp +++ /dev/null @@ -1,403 +0,0 @@ -#include "common/NetworkPrivate.hpp" - -#include "common/NetworkManager.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" -#include "common/QLogging.hpp" -#include "debug/AssertInGuiThread.hpp" -#include "singletons/Paths.hpp" -#include "util/DebugCount.hpp" -#include "util/PostToThread.hpp" - -#include -#include -#include -#include - -namespace chatterino { - -NetworkData::NetworkData() - : lifetimeManager_(new QObject) -{ - DebugCount::increase("NetworkData"); -} - -NetworkData::~NetworkData() -{ - this->lifetimeManager_->deleteLater(); - - DebugCount::decrease("NetworkData"); -} - -QString NetworkData::getHash() -{ - static std::mutex mu; - - std::lock_guard lock(mu); - - if (this->hash_.isEmpty()) - { - QByteArray bytes; - - bytes.append(this->request_.url().toString().toUtf8()); - - for (const auto &header : this->request_.rawHeaderList()) - { - bytes.append(header); - } - - QByteArray hashBytes( - QCryptographicHash::hash(bytes, QCryptographicHash::Sha256)); - - this->hash_ = hashBytes.toHex(); - } - - return this->hash_; -} - -void writeToCache(const std::shared_ptr &data, - const QByteArray &bytes) -{ - if (data->cache_) - { - QtConcurrent::run([data, bytes] { - QFile cachedFile(getPaths()->cacheDirectory() + "/" + - data->getHash()); - - if (cachedFile.open(QIODevice::WriteOnly)) - { - cachedFile.write(bytes); - } - }); - } -} - -void loadUncached(const std::shared_ptr &data) -{ - DebugCount::increase("http request started"); - - NetworkRequester requester; - NetworkWorker *worker = new NetworkWorker; - - worker->moveToThread(&NetworkManager::workerThread); - - auto onUrlRequested = [data, worker]() mutable { - if (data->hasTimeout_) - { - data->timer_ = new QTimer(); - data->timer_->setSingleShot(true); - data->timer_->start(data->timeoutMS_); - } - - auto reply = [&]() -> QNetworkReply * { - switch (data->requestType_) - { - case NetworkRequestType::Get: - return NetworkManager::accessManager.get(data->request_); - - case NetworkRequestType::Put: - return NetworkManager::accessManager.put(data->request_, - data->payload_); - - case NetworkRequestType::Delete: - return NetworkManager::accessManager.deleteResource( - data->request_); - - case NetworkRequestType::Post: - if (data->multiPartPayload_) - { - assert(data->payload_.isNull()); - - return NetworkManager::accessManager.post( - data->request_, data->multiPartPayload_); - } - else - { - return NetworkManager::accessManager.post( - data->request_, data->payload_); - } - case NetworkRequestType::Patch: - if (data->multiPartPayload_) - { - assert(data->payload_.isNull()); - - return NetworkManager::accessManager.sendCustomRequest( - data->request_, "PATCH", data->multiPartPayload_); - } - else - { - return NetworkManager::accessManager.sendCustomRequest( - data->request_, "PATCH", data->payload_); - } - } - return nullptr; - }(); - - if (reply == nullptr) - { - qCDebug(chatterinoCommon) << "Unhandled request type"; - return; - } - - if (data->timer_ != nullptr && data->timer_->isActive()) - { - QObject::connect( - data->timer_, &QTimer::timeout, worker, [reply, data]() { - qCDebug(chatterinoCommon) << "Aborted!"; - reply->abort(); - qCDebug(chatterinoHTTP) - << QString("%1 [timed out] %2") - .arg(networkRequestTypes.at( - int(data->requestType_)), - data->request_.url().toString()); - - if (data->onError_) - { - postToThread([data] { - data->onError_(NetworkResult( - {}, NetworkResult::timedoutStatus)); - }); - } - - if (data->finally_) - { - postToThread([data] { - data->finally_(); - }); - } - }); - } - - if (data->onReplyCreated_) - { - data->onReplyCreated_(reply); - } - - auto handleReply = [data, reply]() mutable { - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - - // TODO(pajlada): A reply was received, kill the timeout timer - if (reply->error() != QNetworkReply::NetworkError::NoError) - { - if (reply->error() == - QNetworkReply::NetworkError::OperationCanceledError) - { - // Operation cancelled, most likely timed out - qCDebug(chatterinoHTTP) - << QString("%1 [cancelled] %2") - .arg(networkRequestTypes.at( - int(data->requestType_)), - data->request_.url().toString()); - return; - } - - if (data->onError_) - { - auto status = reply->attribute( - QNetworkRequest::HttpStatusCodeAttribute); - if (data->requestType_ == NetworkRequestType::Get) - { - qCDebug(chatterinoHTTP) - << QString("%1 %2 %3") - .arg(networkRequestTypes.at( - int(data->requestType_)), - QString::number(status.toInt()), - data->request_.url().toString()); - } - else - { - qCDebug(chatterinoHTTP) - << QString("%1 %2 %3 %4") - .arg(networkRequestTypes.at( - int(data->requestType_)), - QString::number(status.toInt()), - data->request_.url().toString(), - QString(data->payload_)); - } - // TODO: Should this always be run on the GUI thread? - postToThread([data, code = status.toInt(), reply] { - data->onError_(NetworkResult(reply->readAll(), code)); - }); - } - - if (data->finally_) - { - postToThread([data] { - data->finally_(); - }); - } - return; - } - - QByteArray bytes = reply->readAll(); - writeToCache(data, bytes); - - auto status = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - - NetworkResult result(bytes, status.toInt()); - - DebugCount::increase("http request success"); - // log("starting {}", data->request_.url().toString()); - if (data->onSuccess_) - { - if (data->executeConcurrently_) - QtConcurrent::run([onSuccess = std::move(data->onSuccess_), - result = std::move(result)] { - onSuccess(result); - }); - else - data->onSuccess_(result); - } - // log("finished {}", data->request_.url().toString()); - - reply->deleteLater(); - - if (data->requestType_ == NetworkRequestType::Get) - { - qCDebug(chatterinoHTTP) - << QString("%1 %2 %3") - .arg(networkRequestTypes.at(int(data->requestType_)), - QString::number(status.toInt()), - data->request_.url().toString()); - } - else - { - qCDebug(chatterinoHTTP) - << QString("%1 %3 %2 %4") - .arg(networkRequestTypes.at(int(data->requestType_)), - data->request_.url().toString(), - QString::number(status.toInt()), - QString(data->payload_)); - } - if (data->finally_) - { - if (data->executeConcurrently_) - QtConcurrent::run([finally = std::move(data->finally_)] { - finally(); - }); - else - data->finally_(); - } - }; - - if (data->timer_ != nullptr) - { - QObject::connect(reply, &QNetworkReply::finished, data->timer_, - &QObject::deleteLater); - } - - QObject::connect( - reply, &QNetworkReply::finished, worker, - [data, handleReply, worker]() mutable { - if (data->executeConcurrently_ || isGuiThread()) - { - handleReply(); - delete worker; - } - else - { - postToThread( - [worker, cb = std::move(handleReply)]() mutable { - cb(); - delete worker; - }); - } - }); - }; - - QObject::connect(&requester, &NetworkRequester::requestUrl, worker, - onUrlRequested); - - emit requester.requestUrl(); -} - -// First tried to load cached, then uncached. -void loadCached(const std::shared_ptr &data) -{ - QFile cachedFile(getPaths()->cacheDirectory() + "/" + data->getHash()); - - if (!cachedFile.exists() || !cachedFile.open(QIODevice::ReadOnly)) - { - // File didn't exist OR File could not be opened - loadUncached(data); - return; - } - else - { - // XXX: check if bytes is empty? - QByteArray bytes = cachedFile.readAll(); - NetworkResult result(bytes, 200); - - qCDebug(chatterinoHTTP) - << QString("%1 [CACHED] 200 %2") - .arg(networkRequestTypes.at(int(data->requestType_)), - data->request_.url().toString()); - if (data->onSuccess_) - { - if (data->executeConcurrently_ || isGuiThread()) - { - // XXX: If outcome is Failure, we should invalidate the cache file - // somehow/somewhere - /*auto outcome =*/ - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - data->onSuccess_(result); - } - else - { - postToThread([data, result]() { - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - - data->onSuccess_(result); - }); - } - } - - if (data->finally_) - { - if (data->executeConcurrently_ || isGuiThread()) - { - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - - data->finally_(); - } - else - { - postToThread([data]() { - if (data->hasCaller_ && !data->caller_.get()) - { - return; - } - - data->finally_(); - }); - } - } - } -} - -void load(const std::shared_ptr &data) -{ - if (data->cache_) - { - QtConcurrent::run(loadCached, data); - } - else - { - loadUncached(data); - } -} - -} // namespace chatterino diff --git a/src/common/NetworkPrivate.hpp b/src/common/NetworkPrivate.hpp deleted file mode 100644 index 03d4a705e82..00000000000 --- a/src/common/NetworkPrivate.hpp +++ /dev/null @@ -1,73 +0,0 @@ -#pragma once - -#include "common/NetworkCommon.hpp" -#include "util/QObjectRef.hpp" - -#include -#include -#include - -#include -#include - -class QNetworkReply; - -namespace chatterino { - -class NetworkResult; - -class NetworkRequester : public QObject -{ - Q_OBJECT - -signals: - void requestUrl(); -}; - -class NetworkWorker : public QObject -{ - Q_OBJECT - -signals: - void doneUrl(); -}; - -struct NetworkData { - NetworkData(); - ~NetworkData(); - - QNetworkRequest request_; - bool hasCaller_{}; - QObjectRef caller_; - bool cache_{}; - bool executeConcurrently_{}; - - NetworkReplyCreatedCallback onReplyCreated_; - NetworkErrorCallback onError_; - NetworkSuccessCallback onSuccess_; - NetworkFinallyCallback finally_; - - NetworkRequestType requestType_ = NetworkRequestType::Get; - - QByteArray payload_; - // lifetime secured by lifetimeManager_ - QHttpMultiPart *multiPartPayload_{}; - - // Timer that tracks the timeout - // By default, there's no explicit timeout for the request - // to enable the timer, the "setTimeout" function needs to be called before - // execute is called - bool hasTimeout_{}; - int timeoutMS_{}; - QTimer *timer_ = nullptr; - QObject *lifetimeManager_; - - QString getHash(); - -private: - QString hash_; -}; - -void load(const std::shared_ptr &data); - -} // namespace chatterino diff --git a/src/common/NetworkRequest.cpp b/src/common/NetworkRequest.cpp deleted file mode 100644 index 6d66b9777b0..00000000000 --- a/src/common/NetworkRequest.cpp +++ /dev/null @@ -1,198 +0,0 @@ -#include "common/NetworkRequest.hpp" - -#include "common/NetworkPrivate.hpp" -#include "common/Outcome.hpp" -#include "common/QLogging.hpp" -#include "common/Version.hpp" -#include "debug/AssertInGuiThread.hpp" -#include "providers/twitch/TwitchCommon.hpp" -#include "singletons/Paths.hpp" -#include "util/DebugCount.hpp" -#include "util/PostToThread.hpp" - -#include -#include -#include - -#include - -namespace chatterino { - -NetworkRequest::NetworkRequest(const std::string &url, - NetworkRequestType requestType) - : data(new NetworkData) -{ - this->data->request_.setUrl(QUrl(QString::fromStdString(url))); - this->data->requestType_ = requestType; - - this->initializeDefaultValues(); -} - -NetworkRequest::NetworkRequest(QUrl url, NetworkRequestType requestType) - : data(new NetworkData) -{ - this->data->request_.setUrl(url); - this->data->requestType_ = requestType; - - this->initializeDefaultValues(); -} - -NetworkRequest::~NetworkRequest() -{ - //assert(!this->data || this->executed_); -} - -NetworkRequest NetworkRequest::type(NetworkRequestType newRequestType) && -{ - this->data->requestType_ = newRequestType; - return std::move(*this); -} - -NetworkRequest NetworkRequest::caller(const QObject *caller) && -{ - if (caller) - { - // Caller must be in gui thread - assert(caller->thread() == qApp->thread()); - - this->data->caller_ = const_cast(caller); - this->data->hasCaller_ = true; - } - return std::move(*this); -} - -NetworkRequest NetworkRequest::onReplyCreated(NetworkReplyCreatedCallback cb) && -{ - this->data->onReplyCreated_ = cb; - return std::move(*this); -} - -NetworkRequest NetworkRequest::onError(NetworkErrorCallback cb) && -{ - this->data->onError_ = cb; - return std::move(*this); -} - -NetworkRequest NetworkRequest::onSuccess(NetworkSuccessCallback cb) && -{ - this->data->onSuccess_ = cb; - return std::move(*this); -} - -NetworkRequest NetworkRequest::finally(NetworkFinallyCallback cb) && -{ - this->data->finally_ = cb; - return std::move(*this); -} - -NetworkRequest NetworkRequest::header(const char *headerName, - const char *value) && -{ - this->data->request_.setRawHeader(headerName, value); - return std::move(*this); -} - -NetworkRequest NetworkRequest::header(const char *headerName, - const QByteArray &value) && -{ - this->data->request_.setRawHeader(headerName, value); - return std::move(*this); -} - -NetworkRequest NetworkRequest::header(const char *headerName, - const QString &value) && -{ - this->data->request_.setRawHeader(headerName, value.toUtf8()); - return std::move(*this); -} - -NetworkRequest NetworkRequest::headerList( - const std::vector> &headers) && -{ - for (const auto &[headerName, headerValue] : headers) - { - this->data->request_.setRawHeader(headerName, headerValue); - } - return std::move(*this); -} - -NetworkRequest NetworkRequest::timeout(int ms) && -{ - this->data->hasTimeout_ = true; - this->data->timeoutMS_ = ms; - return std::move(*this); -} - -NetworkRequest NetworkRequest::concurrent() && -{ - this->data->executeConcurrently_ = true; - return std::move(*this); -} - -NetworkRequest NetworkRequest::authorizeTwitchV5(const QString &clientID, - const QString &oauthToken) && -{ - // TODO: make two overloads, with and without oauth token - auto tmp = std::move(*this) - .header("Client-ID", clientID) - .header("Accept", "application/vnd.twitchtv.v5+json"); - - if (!oauthToken.isEmpty()) - return std::move(tmp).header("Authorization", "OAuth " + oauthToken); - else - return tmp; -} - -NetworkRequest NetworkRequest::multiPart(QHttpMultiPart *payload) && -{ - payload->setParent(this->data->lifetimeManager_); - this->data->multiPartPayload_ = payload; - return std::move(*this); -} - -NetworkRequest NetworkRequest::payload(const QByteArray &payload) && -{ - this->data->payload_ = payload; - return std::move(*this); -} - -NetworkRequest NetworkRequest::cache() && -{ - this->data->cache_ = true; - return std::move(*this); -} - -void NetworkRequest::execute() -{ - this->executed_ = true; - - // Only allow caching for GET request - if (this->data->cache_ && - this->data->requestType_ != NetworkRequestType::Get) - { - qCDebug(chatterinoCommon) << "Can only cache GET requests!"; - this->data->cache_ = false; - } - - // Can not have a caller and be concurrent at the same time. - assert(!(this->data->caller_ && this->data->executeConcurrently_)); - - load(std::move(this->data)); -} - -void NetworkRequest::initializeDefaultValues() -{ - const auto userAgent = QString("chatterino/%1 (%2)") - .arg(CHATTERINO_VERSION, CHATTERINO_GIT_HASH) - .toUtf8(); - - this->data->request_.setRawHeader("User-Agent", userAgent); -} - -// Helper creator functions -NetworkRequest NetworkRequest::twitchRequest(QUrl url) -{ - return NetworkRequest(url).authorizeTwitchV5(getDefaultClientID()); -} - -} // namespace chatterino diff --git a/src/common/NetworkResult.hpp b/src/common/NetworkResult.hpp deleted file mode 100644 index 64baed5ea8d..00000000000 --- a/src/common/NetworkResult.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace chatterino { - -class NetworkResult -{ -public: - NetworkResult(const QByteArray &data, int status); - - /// Parses the result as json and returns the root as an object. - /// Returns empty object if parsing failed. - QJsonObject parseJson() const; - /// Parses the result as json and returns the root as an array. - /// Returns empty object if parsing failed. - QJsonArray parseJsonArray() const; - /// Parses the result as json and returns the document. - rapidjson::Document parseRapidJson() const; - const QByteArray &getData() const; - int status() const; - - static constexpr int timedoutStatus = -2; - -private: - QByteArray data_; - int status_; -}; - -} // namespace chatterino diff --git a/src/common/NullablePtr.hpp b/src/common/NullablePtr.hpp deleted file mode 100644 index 9fa1b9fb640..00000000000 --- a/src/common/NullablePtr.hpp +++ /dev/null @@ -1,73 +0,0 @@ -#pragma once - -#include - -namespace chatterino { - -template -class NullablePtr -{ -public: - NullablePtr() - : element_(nullptr) - { - } - - NullablePtr(T *element) - : element_(element) - { - } - - T *operator->() const - { - assert(this->hasElement()); - - return element_; - } - - typename std::add_lvalue_reference::type operator*() const - { - assert(this->hasElement()); - - return *element_; - } - - T *get() const - { - assert(this->hasElement()); - - return this->element_; - } - - bool isNull() const - { - return this->element_ == nullptr; - } - - bool hasElement() const - { - return this->element_ != nullptr; - } - - operator bool() const - { - return this->hasElement(); - } - - bool operator!() const - { - return !this->hasElement(); - } - - template ::value>> - operator NullablePtr() const - { - return NullablePtr(this->element_); - } - -private: - T *element_; -}; - -} // namespace chatterino diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 473d5e4d740..de4ef056c0c 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -1,6 +1,6 @@ #include "common/QLogging.hpp" -#ifdef DEBUG_OFF +#ifdef NDEBUG static constexpr QtMsgType logThreshold = QtWarningMsg; #else static constexpr QtMsgType logThreshold = QtDebugMsg; @@ -12,6 +12,8 @@ Q_LOGGING_CATEGORY(chatterinoBenchmark, "chatterino.benchmark", logThreshold); Q_LOGGING_CATEGORY(chatterinoBttv, "chatterino.bttv", logThreshold); Q_LOGGING_CATEGORY(chatterinoCache, "chatterino.cache", logThreshold); Q_LOGGING_CATEGORY(chatterinoCommon, "chatterino.common", logThreshold); +Q_LOGGING_CATEGORY(chatterinoCrashhandler, "chatterino.crashhandler", + logThreshold); Q_LOGGING_CATEGORY(chatterinoEmoji, "chatterino.emoji", logThreshold); Q_LOGGING_CATEGORY(chatterinoEnv, "chatterino.env", logThreshold); Q_LOGGING_CATEGORY(chatterinoFfzemotes, "chatterino.ffzemotes", logThreshold); @@ -24,6 +26,7 @@ Q_LOGGING_CATEGORY(chatterinoIrc, "chatterino.irc", logThreshold); Q_LOGGING_CATEGORY(chatterinoIvr, "chatterino.ivr", logThreshold); Q_LOGGING_CATEGORY(chatterinoLiveupdates, "chatterino.liveupdates", logThreshold); +Q_LOGGING_CATEGORY(chatterinoLua, "chatterino.lua", logThreshold); Q_LOGGING_CATEGORY(chatterinoMain, "chatterino.main", logThreshold); Q_LOGGING_CATEGORY(chatterinoMessage, "chatterino.message", logThreshold); Q_LOGGING_CATEGORY(chatterinoNativeMessage, "chatterino.nativemessage", @@ -31,7 +34,7 @@ Q_LOGGING_CATEGORY(chatterinoNativeMessage, "chatterino.nativemessage", Q_LOGGING_CATEGORY(chatterinoNetwork, "chatterino.network", logThreshold); Q_LOGGING_CATEGORY(chatterinoNotification, "chatterino.notification", logThreshold); -Q_LOGGING_CATEGORY(chatterinoNuulsuploader, "chatterino.nuulsuploader", +Q_LOGGING_CATEGORY(chatterinoImageuploader, "chatterino.imageuploader", logThreshold); Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold); Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages", @@ -44,10 +47,14 @@ Q_LOGGING_CATEGORY(chatterinoSound, "chatterino.sound", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); +Q_LOGGING_CATEGORY(chatterinoTheme, "chatterino.theme", logThreshold); Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold); +Q_LOGGING_CATEGORY(chatterinoTwitchLiveController, + "chatterino.twitch.livecontroller", logThreshold); Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold); Q_LOGGING_CATEGORY(chatterinoWebsocket, "chatterino.websocket", logThreshold); Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold); Q_LOGGING_CATEGORY(chatterinoWindowmanager, "chatterino.windowmanager", logThreshold); +Q_LOGGING_CATEGORY(chatterinoXDG, "chatterino.xdg", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 8c8f0d6c49e..36daa0e1e92 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -8,6 +8,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoBenchmark); Q_DECLARE_LOGGING_CATEGORY(chatterinoBttv); Q_DECLARE_LOGGING_CATEGORY(chatterinoCache); Q_DECLARE_LOGGING_CATEGORY(chatterinoCommon); +Q_DECLARE_LOGGING_CATEGORY(chatterinoCrashhandler); Q_DECLARE_LOGGING_CATEGORY(chatterinoEmoji); Q_DECLARE_LOGGING_CATEGORY(chatterinoEnv); Q_DECLARE_LOGGING_CATEGORY(chatterinoFfzemotes); @@ -19,12 +20,13 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoImage); Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc); Q_DECLARE_LOGGING_CATEGORY(chatterinoIvr); Q_DECLARE_LOGGING_CATEGORY(chatterinoLiveupdates); +Q_DECLARE_LOGGING_CATEGORY(chatterinoLua); Q_DECLARE_LOGGING_CATEGORY(chatterinoMain); Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNetwork); Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); -Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader); +Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); @@ -33,9 +35,12 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoSeventvEventAPI); Q_DECLARE_LOGGING_CATEGORY(chatterinoSound); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); +Q_DECLARE_LOGGING_CATEGORY(chatterinoTheme); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); +Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchLiveController); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget); Q_DECLARE_LOGGING_CATEGORY(chatterinoWindowmanager); +Q_DECLARE_LOGGING_CATEGORY(chatterinoXDG); diff --git a/src/common/SignalVector.hpp b/src/common/SignalVector.hpp index 3de5be89914..e0f2a4e6fe3 100644 --- a/src/common/SignalVector.hpp +++ b/src/common/SignalVector.hpp @@ -2,7 +2,6 @@ #include "debug/AssertInGuiThread.hpp" -#include #include #include #include @@ -19,7 +18,7 @@ struct SignalVectorItemEvent { }; template -class SignalVector : boost::noncopyable +class SignalVector { public: pajlada::Signals::Signal> itemInserted; @@ -42,6 +41,12 @@ class SignalVector : boost::noncopyable this->itemCompare_ = std::move(compare); } + SignalVector(const SignalVector &) = delete; + SignalVector &operator=(const SignalVector &) = delete; + + SignalVector(SignalVector &&) = delete; + SignalVector &operator=(SignalVector &&) = delete; + bool isSorted() const { return bool(this->itemCompare_); diff --git a/src/common/SignalVectorModel.hpp b/src/common/SignalVectorModel.hpp index 8cd5eaf6214..bf31dbb00ae 100644 --- a/src/common/SignalVectorModel.hpp +++ b/src/common/SignalVectorModel.hpp @@ -2,12 +2,13 @@ #include "common/SignalVector.hpp" -#include #include #include #include #include +#include + namespace chatterino { template @@ -100,7 +101,7 @@ class SignalVectorModel : public QAbstractTableModel, return this; } - virtual ~SignalVectorModel() + ~SignalVectorModel() override { for (Row &row : this->rows_) { @@ -127,7 +128,8 @@ class SignalVectorModel : public QAbstractTableModel, QVariant data(const QModelIndex &index, int role) const override { - int row = index.row(), column = index.column(); + int row = index.row(); + int column = index.column(); if (row < 0 || column < 0 || row >= this->rows_.size() || column >= this->columnCount_) { @@ -140,7 +142,8 @@ class SignalVectorModel : public QAbstractTableModel, bool setData(const QModelIndex &index, const QVariant &value, int role) override { - int row = index.row(), column = index.column(); + int row = index.row(); + int column = index.column(); if (row < 0 || column < 0 || row >= this->rows_.size() || column >= this->columnCount_) { @@ -166,7 +169,7 @@ class SignalVectorModel : public QAbstractTableModel, assert(this->rows_[row].original); TVectorItem item = this->getItemFromRow( - this->rows_[row].items, this->rows_[row].original.get()); + this->rows_[row].items, this->rows_[row].original.value()); this->vector_->insert(item, vecRow, this); } @@ -262,7 +265,7 @@ class SignalVectorModel : public QAbstractTableModel, TVectorItem item = this->getItemFromRow(this->rows_[sourceRow].items, - this->rows_[sourceRow].original.get()); + this->rows_[sourceRow].original.value()); this->vector_->removeAt(signalVectorRow); this->vector_->insert( item, this->getVectorIndexFromModelIndex(destinationChild)); @@ -305,10 +308,12 @@ class SignalVectorModel : public QAbstractTableModel, for (auto &&x : list) { if (x.row() != list.first().row()) + { return nullptr; + } } - auto data = new QMimeData; + auto *data = new QMimeData; data->setData("chatterino_row_id", QByteArray::number(list[0].row())); return data; } @@ -333,7 +338,7 @@ class SignalVectorModel : public QAbstractTableModel, if (from != to) { - this->moveRow(this->index(from, to), from, parent, to); + this->moveRow(this->index(from, 0), from, parent, to); } // We return false since we remove items ourselves. @@ -417,7 +422,7 @@ class SignalVectorModel : public QAbstractTableModel, struct Row { std::vector items; - boost::optional original; + std::optional original; bool isCustomRow; Row(std::vector _items, bool _isCustomRow = false) diff --git a/src/common/Singleton.hpp b/src/common/Singleton.hpp index 401716d78ff..f94d7152a4c 100644 --- a/src/common/Singleton.hpp +++ b/src/common/Singleton.hpp @@ -1,18 +1,23 @@ #pragma once -#include - namespace chatterino { class Settings; class Paths; -class Singleton : boost::noncopyable +class Singleton { public: + Singleton() = default; virtual ~Singleton() = default; - virtual void initialize(Settings &settings, Paths &paths) + Singleton(const Singleton &) = delete; + Singleton &operator=(const Singleton &) = delete; + + Singleton(Singleton &&) = delete; + Singleton &operator=(Singleton &&) = delete; + + virtual void initialize(Settings &settings, const Paths &paths) { (void)(settings); (void)(paths); diff --git a/src/common/Version.cpp b/src/common/Version.cpp index bbd99e17691..76ef549a0d0 100644 --- a/src/common/Version.cpp +++ b/src/common/Version.cpp @@ -4,27 +4,14 @@ #include -#define UGLYMACROHACK1(s) #s -#define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s) - namespace chatterino { Version::Version() + : version_(CHATTERINO_VERSION) + , commitHash_(QStringLiteral(CHATTERINO_GIT_HASH)) + , isModified_(CHATTERINO_GIT_MODIFIED == 1) + , dateOfBuild_(QStringLiteral(CHATTERINO_CMAKE_GEN_DATE)) { - this->version_ = CHATTERINO_VERSION; - - this->commitHash_ = - QString(FROM_EXTERNAL_DEFINE(CHATTERINO_GIT_HASH)).remove('"'); - -#ifdef CHATTERINO_GIT_MODIFIED - this->isModified_ = true; -#endif - -#ifdef CHATTERINO_CMAKE_GEN_DATE - this->dateOfBuild_ = - QString(FROM_EXTERNAL_DEFINE(CHATTERINO_CMAKE_GEN_DATE)).remove('"'); -#endif - this->fullVersion_ = "Chatterino "; if (Modes::instance().isNightly) { diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 222b656f7b8..5d978b19a88 100644 --- a/src/common/Version.hpp +++ b/src/common/Version.hpp @@ -24,7 +24,7 @@ * - 2.4.0-alpha.2 * - 2.4.0-alpha **/ -#define CHATTERINO_VERSION "2.4.0" +#define CHATTERINO_VERSION "2.4.6" #if defined(Q_OS_WIN) # define CHATTERINO_OS "win" diff --git a/src/common/WindowDescriptors.cpp b/src/common/WindowDescriptors.cpp index 902de665f72..e08069b2cc4 100644 --- a/src/common/WindowDescriptors.cpp +++ b/src/common/WindowDescriptors.cpp @@ -229,4 +229,108 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) return layout; } +void WindowLayout::activateOrAddChannel(ProviderId provider, + const QString &name) +{ + if (provider != ProviderId::Twitch || name.startsWith(u'/') || + name.startsWith(u'$')) + { + qCWarning(chatterinoWindowmanager) + << "Only twitch channels can be set as active"; + return; + } + + auto mainWindow = std::find_if(this->windows_.begin(), this->windows_.end(), + [](const auto &win) { + return win.type_ == WindowType::Main; + }); + + if (mainWindow == this->windows_.end()) + { + this->windows_.emplace_back(WindowDescriptor{ + .type_ = WindowType::Main, + .geometry_ = {-1, -1, -1, -1}, + .tabs_ = + { + TabDescriptor{ + .selected_ = true, + .rootNode_ = SplitNodeDescriptor{{ + .type_ = "twitch", + .channelName_ = name, + }}, + }, + }, + }); + return; + } + + TabDescriptor *bestTab = nullptr; + // The tab score is calculated as follows: + // +2 for every split + // +1 if the desired split has filters + // Thus lower is better and having one split of a channel is preferred over multiple + size_t bestTabScore = std::numeric_limits::max(); + + for (auto &tab : mainWindow->tabs_) + { + tab.selected_ = false; + + if (!tab.rootNode_) + { + continue; + } + + // recursive visitor + struct Visitor { + const QString &spec; + size_t score = 0; + bool hasChannel = false; + + void operator()(const SplitNodeDescriptor &split) + { + this->score += 2; + if (split.channelName_ == this->spec) + { + hasChannel = true; + if (!split.filters_.empty()) + { + this->score += 1; + } + } + } + + void operator()(const ContainerNodeDescriptor &container) + { + for (const auto &item : container.items_) + { + std::visit(*this, item); + } + } + } visitor{name}; + + std::visit(visitor, *tab.rootNode_); + + if (visitor.hasChannel && visitor.score < bestTabScore) + { + bestTab = &tab; + bestTabScore = visitor.score; + } + } + + if (bestTab) + { + bestTab->selected_ = true; + return; + } + + TabDescriptor tab{ + .selected_ = true, + .rootNode_ = SplitNodeDescriptor{{ + .type_ = "twitch", + .channelName_ = name, + }}, + }; + mainWindow->tabs_.emplace_back(tab); +} + } // namespace chatterino diff --git a/src/common/WindowDescriptors.hpp b/src/common/WindowDescriptors.hpp index 820916c365a..b35edf1554e 100644 --- a/src/common/WindowDescriptors.hpp +++ b/src/common/WindowDescriptors.hpp @@ -1,5 +1,7 @@ #pragma once +#include "common/ProviderId.hpp" + #include #include #include @@ -30,7 +32,7 @@ namespace chatterino { enum class WindowType; struct SplitDescriptor { - // Twitch or mentions or watching or whispers or IRC + // Twitch or mentions or watching or live or automod or whispers or IRC QString type_; // Twitch Channel name or IRC channel name @@ -95,12 +97,22 @@ struct WindowDescriptor { class WindowLayout { public: - static WindowLayout loadFromFile(const QString &path); - // A complete window layout has a single emote popup position that is shared among all windows QPoint emotePopupPos_; std::vector windows_; + + /// Selects the split containing the channel specified by @a name for the specified + /// @a provider. Currently, only Twitch is supported as the provider + /// and special channels (such as /mentions) are ignored. + /// + /// Tabs with fewer splits are preferred. + /// Channels without filters are preferred. + /// + /// If no split with the channel exists, a new one is added. + /// If no window exists, a new one is added. + void activateOrAddChannel(ProviderId provider, const QString &name); + static WindowLayout loadFromFile(const QString &path); }; } // namespace chatterino diff --git a/src/common/enums/MessageOverflow.hpp b/src/common/enums/MessageOverflow.hpp new file mode 100644 index 00000000000..28dbd800c0f --- /dev/null +++ b/src/common/enums/MessageOverflow.hpp @@ -0,0 +1,18 @@ +#pragma once + +namespace chatterino { + +// MessageOverflow is used for controlling how to guide the user into not +// sending a message that will be discarded by Twitch +enum MessageOverflow { + // Allow overflowing characters to be inserted into the input box, but highlight them in red + Highlight, + + // Prevent more characters from being inserted into the input box + Prevent, + + // Do nothing + Allow, +}; + +} // namespace chatterino diff --git a/src/common/NetworkCommon.cpp b/src/common/network/NetworkCommon.cpp similarity index 95% rename from src/common/NetworkCommon.cpp rename to src/common/network/NetworkCommon.cpp index 85061241784..0d4df7d760d 100644 --- a/src/common/NetworkCommon.cpp +++ b/src/common/network/NetworkCommon.cpp @@ -1,4 +1,4 @@ -#include "common/NetworkCommon.hpp" +#include "common/network/NetworkCommon.hpp" #include diff --git a/src/common/NetworkCommon.hpp b/src/common/network/NetworkCommon.hpp similarity index 74% rename from src/common/NetworkCommon.hpp rename to src/common/network/NetworkCommon.hpp index 40b034cbd99..a5a44430e95 100644 --- a/src/common/NetworkCommon.hpp +++ b/src/common/network/NetworkCommon.hpp @@ -9,12 +9,10 @@ class QNetworkReply; namespace chatterino { -class Outcome; class NetworkResult; -using NetworkSuccessCallback = std::function; +using NetworkSuccessCallback = std::function; using NetworkErrorCallback = std::function; -using NetworkReplyCreatedCallback = std::function; using NetworkFinallyCallback = std::function; enum class NetworkRequestType { @@ -24,13 +22,6 @@ enum class NetworkRequestType { Delete, Patch, }; -const static std::vector networkRequestTypes{ - "GET", // - "POST", // - "PUT", // - "DELETE", // - "PATCH", // -}; // parseHeaderList takes a list of headers in string form, // where each header pair is separated by semicolons (;) and the header name and value is divided by a colon (:) diff --git a/src/common/NetworkManager.cpp b/src/common/network/NetworkManager.cpp similarity index 90% rename from src/common/NetworkManager.cpp rename to src/common/network/NetworkManager.cpp index 1ff1bf63507..dfc9fe0a068 100644 --- a/src/common/NetworkManager.cpp +++ b/src/common/network/NetworkManager.cpp @@ -1,4 +1,4 @@ -#include "common/NetworkManager.hpp" +#include "common/network/NetworkManager.hpp" #include diff --git a/src/common/NetworkManager.hpp b/src/common/network/NetworkManager.hpp similarity index 100% rename from src/common/NetworkManager.hpp rename to src/common/network/NetworkManager.hpp diff --git a/src/common/network/NetworkPrivate.cpp b/src/common/network/NetworkPrivate.cpp new file mode 100644 index 00000000000..adf46b6f702 --- /dev/null +++ b/src/common/network/NetworkPrivate.cpp @@ -0,0 +1,205 @@ +#include "common/network/NetworkPrivate.hpp" + +#include "Application.hpp" +#include "common/network/NetworkManager.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/network/NetworkTask.hpp" +#include "common/QLogging.hpp" +#include "singletons/Paths.hpp" +#include "util/AbandonObject.hpp" +#include "util/DebugCount.hpp" +#include "util/PostToThread.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef NDEBUG +constexpr qsizetype SLOW_HTTP_THRESHOLD = 30; +#else +constexpr qsizetype SLOW_HTTP_THRESHOLD = 90; +#endif + +using namespace chatterino::network::detail; + +namespace { + +using namespace chatterino; + +void runCallback(bool concurrent, auto &&fn) +{ + if (concurrent) + { + std::ignore = QtConcurrent::run(std::forward(fn)); + } + else + { + runInGuiThread(std::forward(fn)); + } +} + +void loadUncached(std::shared_ptr &&data) +{ + DebugCount::increase("http request started"); + + NetworkRequester requester; + auto *worker = new NetworkTask(std::move(data)); + + worker->moveToThread(&NetworkManager::workerThread); + + QObject::connect(&requester, &NetworkRequester::requestUrl, worker, + &NetworkTask::run); + + emit requester.requestUrl(); +} + +void loadCached(std::shared_ptr &&data) +{ + QFile cachedFile(getIApp()->getPaths().cacheDirectory() + "/" + + data->getHash()); + + if (!cachedFile.exists() || !cachedFile.open(QIODevice::ReadOnly)) + { + loadUncached(std::move(data)); + return; + } + + // XXX: check if bytes is empty? + QByteArray bytes = cachedFile.readAll(); + + qCDebug(chatterinoHTTP).noquote() << data->typeString() << "[CACHED] 200" + << data->request.url().toString(); + + data->emitSuccess( + {NetworkResult::NetworkError::NoError, QVariant(200), bytes}); + data->emitFinally(); +} + +} // namespace + +namespace chatterino { + +NetworkData::NetworkData() +{ + DebugCount::increase("NetworkData"); +} + +NetworkData::~NetworkData() +{ + DebugCount::decrease("NetworkData"); +} + +QString NetworkData::getHash() +{ + if (this->hash_.isEmpty()) + { + QByteArray bytes; + + bytes.append(this->request.url().toString().toUtf8()); + + for (const auto &header : this->request.rawHeaderList()) + { + bytes.append(header); + } + + QByteArray hashBytes( + QCryptographicHash::hash(bytes, QCryptographicHash::Sha256)); + + this->hash_ = hashBytes.toHex(); + } + + return this->hash_; +} + +void NetworkData::emitSuccess(NetworkResult &&result) +{ + if (!this->onSuccess) + { + return; + } + + runCallback(this->executeConcurrently, + [cb = std::move(this->onSuccess), result = std::move(result), + url = this->request.url(), hasCaller = this->hasCaller, + caller = this->caller]() { + if (hasCaller && caller.isNull()) + { + return; + } + + QElapsedTimer timer; + timer.start(); + cb(result); + if (timer.elapsed() > SLOW_HTTP_THRESHOLD) + { + qCWarning(chatterinoHTTP) + << "Slow HTTP success handler for" << url.toString() + << timer.elapsed() + << "ms (threshold:" << SLOW_HTTP_THRESHOLD << "ms)"; + } + }); +} + +void NetworkData::emitError(NetworkResult &&result) +{ + if (!this->onError) + { + return; + } + + runCallback(this->executeConcurrently, + [cb = std::move(this->onError), result = std::move(result), + hasCaller = this->hasCaller, caller = this->caller]() { + if (hasCaller && caller.isNull()) + { + return; + } + + cb(result); + }); +} + +void NetworkData::emitFinally() +{ + if (!this->finally) + { + return; + } + + runCallback(this->executeConcurrently, + [cb = std::move(this->finally), hasCaller = this->hasCaller, + caller = this->caller]() { + if (hasCaller && caller.isNull()) + { + return; + } + + cb(); + }); +} + +QLatin1String NetworkData::typeString() const +{ + auto view = magic_enum::enum_name(this->requestType); + return QLatin1String{view.data(), + static_cast(view.size())}; +} + +void load(std::shared_ptr &&data) +{ + if (data->cache) + { + std::ignore = QtConcurrent::run([data = std::move(data)]() mutable { + loadCached(std::move(data)); + }); + } + else + { + loadUncached(std::move(data)); + } +} + +} // namespace chatterino diff --git a/src/common/network/NetworkPrivate.hpp b/src/common/network/NetworkPrivate.hpp new file mode 100644 index 00000000000..1e169a927f4 --- /dev/null +++ b/src/common/network/NetworkPrivate.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "common/Common.hpp" +#include "common/network/NetworkCommon.hpp" + +#include +#include +#include +#include + +#include +#include + +class QNetworkReply; + +namespace chatterino { + +class NetworkResult; + +class NetworkRequester : public QObject +{ + Q_OBJECT + +signals: + void requestUrl(); +}; + +class NetworkData +{ +public: + NetworkData(); + ~NetworkData(); + NetworkData(const NetworkData &) = delete; + NetworkData(NetworkData &&) = delete; + NetworkData &operator=(const NetworkData &) = delete; + NetworkData &operator=(NetworkData &&) = delete; + + QNetworkRequest request; + bool hasCaller{}; + QPointer caller; + bool cache{}; + bool executeConcurrently{}; + + NetworkSuccessCallback onSuccess; + NetworkErrorCallback onError; + NetworkFinallyCallback finally; + + NetworkRequestType requestType = NetworkRequestType::Get; + + QByteArray payload; + std::unique_ptr multiPartPayload; + + /// By default, there's no explicit timeout for the request. + /// To set a timeout, use NetworkRequest's timeout method + std::optional timeout{}; + + QString getHash(); + + void emitSuccess(NetworkResult &&result); + void emitError(NetworkResult &&result); + void emitFinally(); + + QLatin1String typeString() const; + +private: + QString hash_; +}; + +void load(std::shared_ptr &&data); + +} // namespace chatterino diff --git a/src/common/network/NetworkRequest.cpp b/src/common/network/NetworkRequest.cpp new file mode 100644 index 00000000000..b436216ae06 --- /dev/null +++ b/src/common/network/NetworkRequest.cpp @@ -0,0 +1,210 @@ +#include "common/network/NetworkRequest.hpp" + +#include "common/network/NetworkPrivate.hpp" +#include "common/QLogging.hpp" +#include "common/Version.hpp" + +#include +#include +#include + +#include + +namespace chatterino { + +NetworkRequest::NetworkRequest(const std::string &url, + NetworkRequestType requestType) + : data(new NetworkData) +{ + this->data->request.setUrl(QUrl(QString::fromStdString(url))); + this->data->requestType = requestType; + + this->initializeDefaultValues(); +} + +NetworkRequest::NetworkRequest(const QUrl &url, NetworkRequestType requestType) + : data(new NetworkData) +{ + this->data->request.setUrl(url); + this->data->requestType = requestType; + + this->initializeDefaultValues(); +} + +NetworkRequest::~NetworkRequest() = default; + +NetworkRequest NetworkRequest::type(NetworkRequestType newRequestType) && +{ + this->data->requestType = newRequestType; + return std::move(*this); +} + +NetworkRequest NetworkRequest::caller(const QObject *caller) && +{ + if (caller) + { + // Caller must be in gui thread + assert(caller->thread() == qApp->thread()); + + this->data->caller = const_cast(caller); + this->data->hasCaller = true; + } + return std::move(*this); +} + +NetworkRequest NetworkRequest::onError(NetworkErrorCallback cb) && +{ + this->data->onError = std::move(cb); + return std::move(*this); +} + +NetworkRequest NetworkRequest::onSuccess(NetworkSuccessCallback cb) && +{ + this->data->onSuccess = std::move(cb); + return std::move(*this); +} + +NetworkRequest NetworkRequest::finally(NetworkFinallyCallback cb) && +{ + this->data->finally = std::move(cb); + return std::move(*this); +} + +NetworkRequest NetworkRequest::header(const char *headerName, + const char *value) && +{ + this->data->request.setRawHeader(headerName, value); + return std::move(*this); +} + +NetworkRequest NetworkRequest::header(const char *headerName, + const QByteArray &value) && +{ + this->data->request.setRawHeader(headerName, value); + return std::move(*this); +} + +NetworkRequest NetworkRequest::header(const char *headerName, + const QString &value) && +{ + this->data->request.setRawHeader(headerName, value.toUtf8()); + return std::move(*this); +} + +NetworkRequest NetworkRequest::header(QNetworkRequest::KnownHeaders header, + const QVariant &value) && +{ + this->data->request.setHeader(header, value); + return std::move(*this); +} + +NetworkRequest NetworkRequest::headerList( + const std::vector> &headers) && +{ + for (const auto &[headerName, headerValue] : headers) + { + this->data->request.setRawHeader(headerName, headerValue); + } + return std::move(*this); +} + +NetworkRequest NetworkRequest::timeout(int ms) && +{ + this->data->timeout = std::chrono::milliseconds(ms); + return std::move(*this); +} + +NetworkRequest NetworkRequest::concurrent() && +{ + this->data->executeConcurrently = true; + return std::move(*this); +} + +NetworkRequest NetworkRequest::multiPart(QHttpMultiPart *payload) && +{ + this->data->multiPartPayload = {payload, {}}; + return std::move(*this); +} + +NetworkRequest NetworkRequest::followRedirects(bool on) && +{ + if (on) + { + this->data->request.setAttribute( + QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + } + else + { + this->data->request.setAttribute( + QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::ManualRedirectPolicy); + } + + return std::move(*this); +} + +NetworkRequest NetworkRequest::payload(const QByteArray &payload) && +{ + this->data->payload = payload; + return std::move(*this); +} + +NetworkRequest NetworkRequest::cache() && +{ + this->data->cache = true; + return std::move(*this); +} + +void NetworkRequest::execute() +{ + this->executed_ = true; + + // Only allow caching for GET request + if (this->data->cache && this->data->requestType != NetworkRequestType::Get) + { + qCDebug(chatterinoCommon) << "Can only cache GET requests!"; + this->data->cache = false; + } + + // Can not have a caller and be concurrent at the same time. + assert(!(this->data->caller && this->data->executeConcurrently)); + + load(std::move(this->data)); +} + +void NetworkRequest::initializeDefaultValues() +{ + const auto userAgent = QStringLiteral("chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) + .toUtf8(); + + this->data->request.setRawHeader("User-Agent", userAgent); +} + +NetworkRequest NetworkRequest::json(const QJsonArray &root) && +{ + return std::move(*this).json(QJsonDocument(root)); +} + +NetworkRequest NetworkRequest::json(const QJsonObject &root) && +{ + return std::move(*this).json(QJsonDocument(root)); +} + +NetworkRequest NetworkRequest::json(const QJsonDocument &document) && +{ + return std::move(*this).json(document.toJson(QJsonDocument::Compact)); +} + +NetworkRequest NetworkRequest::json(const QByteArray &payload) && +{ + return std::move(*this) + .payload(payload) + .header(QNetworkRequest::ContentTypeHeader, "application/json") + .header(QNetworkRequest::ContentLengthHeader, payload.length()) + .header("Accept", "application/json"); +} + +} // namespace chatterino diff --git a/src/common/NetworkRequest.hpp b/src/common/network/NetworkRequest.hpp similarity index 71% rename from src/common/NetworkRequest.hpp rename to src/common/network/NetworkRequest.hpp index 384c49f31ad..4da4c7a9e9e 100644 --- a/src/common/NetworkRequest.hpp +++ b/src/common/network/NetworkRequest.hpp @@ -1,14 +1,18 @@ #pragma once -#include "common/NetworkCommon.hpp" +#include "common/network/NetworkCommon.hpp" #include #include +class QJsonArray; +class QJsonObject; +class QJsonDocument; + namespace chatterino { -struct NetworkData; +class NetworkData; class NetworkRequest final { @@ -24,8 +28,8 @@ class NetworkRequest final explicit NetworkRequest( const std::string &url, NetworkRequestType requestType = NetworkRequestType::Get); - explicit NetworkRequest( - QUrl url, NetworkRequestType requestType = NetworkRequestType::Get); + explicit NetworkRequest(const QUrl &url, NetworkRequestType requestType = + NetworkRequestType::Get); // Enable move NetworkRequest(NetworkRequest &&other) = default; @@ -39,7 +43,6 @@ class NetworkRequest final NetworkRequest type(NetworkRequestType newRequestType) &&; - NetworkRequest onReplyCreated(NetworkReplyCreatedCallback cb) &&; NetworkRequest onError(NetworkErrorCallback cb) &&; NetworkRequest onSuccess(NetworkSuccessCallback cb) &&; NetworkRequest finally(NetworkFinallyCallback cb) &&; @@ -54,18 +57,25 @@ class NetworkRequest final NetworkRequest header(const char *headerName, const char *value) &&; NetworkRequest header(const char *headerName, const QByteArray &value) &&; NetworkRequest header(const char *headerName, const QString &value) &&; + NetworkRequest header(QNetworkRequest::KnownHeaders header, + const QVariant &value) &&; NetworkRequest headerList( const std::vector> &headers) &&; NetworkRequest timeout(int ms) &&; NetworkRequest concurrent() &&; - NetworkRequest authorizeTwitchV5(const QString &clientID, - const QString &oauthToken = QString()) &&; NetworkRequest multiPart(QHttpMultiPart *payload) &&; + /** + * This will change `RedirectPolicyAttribute`. + * `QNetworkRequest`'s defaults are used by default (Qt 5: no-follow, Qt 6: follow). + */ + NetworkRequest followRedirects(bool on) &&; + NetworkRequest json(const QJsonObject &root) &&; + NetworkRequest json(const QJsonArray &root) &&; + NetworkRequest json(const QJsonDocument &document) &&; + NetworkRequest json(const QByteArray &payload) &&; void execute(); - static NetworkRequest twitchRequest(QUrl url); - private: void initializeDefaultValues(); }; diff --git a/src/common/NetworkResult.cpp b/src/common/network/NetworkResult.cpp similarity index 62% rename from src/common/NetworkResult.cpp rename to src/common/network/NetworkResult.cpp index 0a5295fdbdd..177d2ae6f97 100644 --- a/src/common/NetworkResult.cpp +++ b/src/common/network/NetworkResult.cpp @@ -1,17 +1,23 @@ -#include "common/NetworkResult.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include +#include #include #include namespace chatterino { -NetworkResult::NetworkResult(const QByteArray &data, int status) - : data_(data) - , status_(status) +NetworkResult::NetworkResult(NetworkError error, const QVariant &httpStatusCode, + QByteArray data) + : data_(std::move(data)) + , error_(error) { + if (httpStatusCode.isValid()) + { + this->status_ = httpStatusCode.toInt(); + } } QJsonObject NetworkResult::parseJson() const @@ -59,9 +65,21 @@ const QByteArray &NetworkResult::getData() const return this->data_; } -int NetworkResult::status() const +QString NetworkResult::formatError() const { - return this->status_; + if (this->status_) + { + return QString::number(*this->status_); + } + + const auto *name = + QMetaEnum::fromType().valueToKey( + this->error_); + if (name == nullptr) + { + return QStringLiteral("unknown error (%1)").arg(this->error_); + } + return name; } } // namespace chatterino diff --git a/src/common/network/NetworkResult.hpp b/src/common/network/NetworkResult.hpp new file mode 100644 index 00000000000..9f0ada784cf --- /dev/null +++ b/src/common/network/NetworkResult.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace chatterino { + +class NetworkResult +{ +public: + using NetworkError = QNetworkReply::NetworkError; + + NetworkResult(NetworkError error, const QVariant &httpStatusCode, + QByteArray data); + + /// Parses the result as json and returns the root as an object. + /// Returns empty object if parsing failed. + QJsonObject parseJson() const; + /// Parses the result as json and returns the root as an array. + /// Returns empty object if parsing failed. + QJsonArray parseJsonArray() const; + /// Parses the result as json and returns the document. + rapidjson::Document parseRapidJson() const; + const QByteArray &getData() const; + + /// The error code of the reply. + /// In case of a successful reply, this will be NoError (0) + NetworkError error() const + { + return this->error_; + } + + /// The HTTP status code if a response was received. + std::optional status() const + { + return this->status_; + } + + /// Formats the error. + /// If a reply is received, returns the HTTP status otherwise, the network error. + QString formatError() const; + +private: + QByteArray data_; + + NetworkError error_; + std::optional status_; +}; + +} // namespace chatterino diff --git a/src/common/network/NetworkTask.cpp b/src/common/network/NetworkTask.cpp new file mode 100644 index 00000000000..7590c8a4673 --- /dev/null +++ b/src/common/network/NetworkTask.cpp @@ -0,0 +1,191 @@ +#include "common/network/NetworkTask.hpp" + +#include "Application.hpp" +#include "common/network/NetworkManager.hpp" +#include "common/network/NetworkPrivate.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/QLogging.hpp" +#include "singletons/Paths.hpp" +#include "util/AbandonObject.hpp" +#include "util/DebugCount.hpp" + +#include +#include +#include + +namespace chatterino::network::detail { + +NetworkTask::NetworkTask(std::shared_ptr &&data) + : data_(std::move(data)) +{ +} + +NetworkTask::~NetworkTask() +{ + if (this->reply_) + { + this->reply_->deleteLater(); + } +} + +void NetworkTask::run() +{ + const auto &timeout = this->data_->timeout; + if (timeout.has_value()) + { + this->timer_ = new QTimer(this); + this->timer_->setSingleShot(true); + this->timer_->start(timeout.value()); + QObject::connect(this->timer_, &QTimer::timeout, this, + &NetworkTask::timeout); + } + + this->reply_ = this->createReply(); + if (!this->reply_) + { + this->deleteLater(); + return; + } + QObject::connect(this->reply_, &QNetworkReply::finished, this, + &NetworkTask::finished); +} + +QNetworkReply *NetworkTask::createReply() +{ + const auto &data = this->data_; + const auto &request = this->data_->request; + auto &accessManager = NetworkManager::accessManager; + switch (this->data_->requestType) + { + case NetworkRequestType::Get: + return accessManager.get(request); + + case NetworkRequestType::Put: + return accessManager.put(request, data->payload); + + case NetworkRequestType::Delete: + return accessManager.deleteResource(data->request); + + case NetworkRequestType::Post: + if (data->multiPartPayload) + { + assert(data->payload.isNull()); + + return accessManager.post(request, + data->multiPartPayload.get()); + } + else + { + return accessManager.post(request, data->payload); + } + case NetworkRequestType::Patch: + if (data->multiPartPayload) + { + assert(data->payload.isNull()); + + return accessManager.sendCustomRequest( + request, "PATCH", data->multiPartPayload.get()); + } + else + { + return NetworkManager::accessManager.sendCustomRequest( + request, "PATCH", data->payload); + } + } + return nullptr; +} + +void NetworkTask::logReply() +{ + auto status = + this->reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute) + .toInt(); + if (this->data_->requestType == NetworkRequestType::Get) + { + qCDebug(chatterinoHTTP).noquote() + << this->data_->typeString() << status + << this->data_->request.url().toString(); + } + else + { + qCDebug(chatterinoHTTP).noquote() + << this->data_->typeString() + << this->data_->request.url().toString() << status + << QString(this->data_->payload); + } +} + +void NetworkTask::writeToCache(const QByteArray &bytes) const +{ + std::ignore = QtConcurrent::run([data = this->data_, bytes] { + QFile cachedFile(getIApp()->getPaths().cacheDirectory() + "/" + + data->getHash()); + + if (cachedFile.open(QIODevice::WriteOnly)) + { + cachedFile.write(bytes); + } + }); +} + +void NetworkTask::timeout() +{ + AbandonObject guard(this); + + // prevent abort() from calling finished() + QObject::disconnect(this->reply_, &QNetworkReply::finished, this, + &NetworkTask::finished); + this->reply_->abort(); + + qCDebug(chatterinoHTTP).noquote() + << this->data_->typeString() << "[timed out]" + << this->data_->request.url().toString(); + + this->data_->emitError({NetworkResult::NetworkError::TimeoutError, {}, {}}); + this->data_->emitFinally(); +} + +void NetworkTask::finished() +{ + AbandonObject guard(this); + + if (this->timer_) + { + this->timer_->stop(); + } + + auto *reply = this->reply_; + auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + + if (reply->error() == QNetworkReply::OperationCanceledError) + { + // Operation cancelled, most likely timed out + qCDebug(chatterinoHTTP).noquote() + << this->data_->typeString() << "[cancelled]" + << this->data_->request.url().toString(); + return; + } + + if (reply->error() != QNetworkReply::NoError) + { + this->logReply(); + this->data_->emitError({reply->error(), status, reply->readAll()}); + this->data_->emitFinally(); + + return; + } + + QByteArray bytes = reply->readAll(); + + if (this->data_->cache) + { + this->writeToCache(bytes); + } + + DebugCount::increase("http request success"); + this->logReply(); + this->data_->emitSuccess({reply->error(), status, bytes}); + this->data_->emitFinally(); +} + +} // namespace chatterino::network::detail diff --git a/src/common/network/NetworkTask.hpp b/src/common/network/NetworkTask.hpp new file mode 100644 index 00000000000..d88d0a11388 --- /dev/null +++ b/src/common/network/NetworkTask.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include + +class QNetworkReply; + +namespace chatterino { + +class NetworkData; + +} // namespace chatterino + +namespace chatterino::network::detail { + +class NetworkTask : public QObject +{ + Q_OBJECT + +public: + NetworkTask(std::shared_ptr &&data); + ~NetworkTask() override; + + NetworkTask(const NetworkTask &) = delete; + NetworkTask(NetworkTask &&) = delete; + NetworkTask &operator=(const NetworkTask &) = delete; + NetworkTask &operator=(NetworkTask &&) = delete; + + // NOLINTNEXTLINE(readability-redundant-access-specifiers) +public slots: + void run(); + +private: + QNetworkReply *createReply(); + + void logReply(); + void writeToCache(const QByteArray &bytes) const; + + std::shared_ptr data_; + QNetworkReply *reply_{}; // parent: default (accessManager) + QTimer *timer_{}; // parent: this + + // NOLINTNEXTLINE(readability-redundant-access-specifiers) +private slots: + void timeout(); + void finished(); +}; + +} // namespace chatterino::network::detail diff --git a/src/controllers/accounts/AccountController.cpp b/src/controllers/accounts/AccountController.cpp index 0ba3bc95974..d0643cdefb8 100644 --- a/src/controllers/accounts/AccountController.cpp +++ b/src/controllers/accounts/AccountController.cpp @@ -1,4 +1,4 @@ -#include "AccountController.hpp" +#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/Account.hpp" #include "controllers/accounts/AccountModel.hpp" @@ -10,22 +10,27 @@ namespace chatterino { AccountController::AccountController() : accounts_(SharedPtrElementLess{}) { - this->twitch.accounts.itemInserted.connect([this](const auto &args) { - this->accounts_.insert(std::dynamic_pointer_cast(args.item)); - }); - - this->twitch.accounts.itemRemoved.connect([this](const auto &args) { - if (args.caller != this) - { - auto &accs = this->twitch.accounts.raw(); - auto it = std::find(accs.begin(), accs.end(), args.item); - assert(it != accs.end()); - - this->accounts_.removeAt(it - accs.begin(), this); - } - }); + // These signal connections can safely be ignored since the twitch object + // will always be destroyed before the AccountController + std::ignore = + this->twitch.accounts.itemInserted.connect([this](const auto &args) { + this->accounts_.insert( + std::dynamic_pointer_cast(args.item)); + }); + + std::ignore = + this->twitch.accounts.itemRemoved.connect([this](const auto &args) { + if (args.caller != this) + { + const auto &accs = this->twitch.accounts.raw(); + auto it = std::find(accs.begin(), accs.end(), args.item); + assert(it != accs.end()); + + this->accounts_.removeAt(it - accs.begin(), this); + } + }); - this->accounts_.itemRemoved.connect([this](const auto &args) { + std::ignore = this->accounts_.itemRemoved.connect([this](const auto &args) { switch (args.item->getProviderId()) { case ProviderId::Twitch: { @@ -42,7 +47,7 @@ AccountController::AccountController() }); } -void AccountController::initialize(Settings &settings, Paths &paths) +void AccountController::initialize(Settings &settings, const Paths &paths) { this->twitch.load(); } diff --git a/src/controllers/accounts/AccountController.hpp b/src/controllers/accounts/AccountController.hpp index a19af9692c1..726719f5cb0 100644 --- a/src/controllers/accounts/AccountController.hpp +++ b/src/controllers/accounts/AccountController.hpp @@ -21,7 +21,7 @@ class AccountController final : public Singleton AccountModel *createModel(QObject *parent); - virtual void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; TwitchAccountManager twitch; diff --git a/src/controllers/accounts/AccountModel.hpp b/src/controllers/accounts/AccountModel.hpp index 2552a00b752..bcc4dbf5d14 100644 --- a/src/controllers/accounts/AccountModel.hpp +++ b/src/controllers/accounts/AccountModel.hpp @@ -17,21 +17,20 @@ class AccountModel : public SignalVectorModel> protected: // turn a vector item into a model row - virtual std::shared_ptr getItemFromRow( + std::shared_ptr getItemFromRow( std::vector &row, const std::shared_ptr &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const std::shared_ptr &item, - std::vector &row) override; + void getRowFromItem(const std::shared_ptr &item, + std::vector &row) override; - virtual int beforeInsert(const std::shared_ptr &item, - std::vector &row, - int proposedIndex) override; + int beforeInsert(const std::shared_ptr &item, + std::vector &row, + int proposedIndex) override; - virtual void afterRemoved(const std::shared_ptr &item, - std::vector &row, - int index) override; + void afterRemoved(const std::shared_ptr &item, + std::vector &row, int index) override; friend class AccountController; diff --git a/src/controllers/commands/Command.hpp b/src/controllers/commands/Command.hpp index 774be6a6234..08438110e3d 100644 --- a/src/controllers/commands/Command.hpp +++ b/src/controllers/commands/Command.hpp @@ -10,7 +10,7 @@ namespace chatterino { struct Command { QString name; QString func; - bool showInMsgContextMenu; + bool showInMsgContextMenu{}; Command() = default; explicit Command(const QString &text); diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 06feedd96be..dd9025fb77f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1,344 +1,53 @@ #include "controllers/commands/CommandController.hpp" #include "Application.hpp" -#include "common/Env.hpp" -#include "common/NetworkResult.hpp" -#include "common/QLogging.hpp" -#include "common/SignalVector.hpp" +#include "common/Channel.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/builtin/chatterino/Debugging.hpp" +#include "controllers/commands/builtin/Misc.hpp" +#include "controllers/commands/builtin/twitch/AddModerator.hpp" +#include "controllers/commands/builtin/twitch/AddVIP.hpp" +#include "controllers/commands/builtin/twitch/Announce.hpp" +#include "controllers/commands/builtin/twitch/Ban.hpp" +#include "controllers/commands/builtin/twitch/Block.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" +#include "controllers/commands/builtin/twitch/Chatters.hpp" +#include "controllers/commands/builtin/twitch/DeleteMessages.hpp" +#include "controllers/commands/builtin/twitch/GetModerators.hpp" +#include "controllers/commands/builtin/twitch/GetVIPs.hpp" +#include "controllers/commands/builtin/twitch/Raid.hpp" +#include "controllers/commands/builtin/twitch/RemoveModerator.hpp" +#include "controllers/commands/builtin/twitch/RemoveVIP.hpp" +#include "controllers/commands/builtin/twitch/SendReply.hpp" +#include "controllers/commands/builtin/twitch/SendWhisper.hpp" +#include "controllers/commands/builtin/twitch/ShieldMode.hpp" +#include "controllers/commands/builtin/twitch/Shoutout.hpp" +#include "controllers/commands/builtin/twitch/StartCommercial.hpp" +#include "controllers/commands/builtin/twitch/Unban.hpp" +#include "controllers/commands/builtin/twitch/UpdateChannel.hpp" +#include "controllers/commands/builtin/twitch/UpdateColor.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" -#include "controllers/userdata/UserDataController.hpp" +#include "controllers/plugins/PluginController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" -#include "messages/MessageElement.hpp" -#include "messages/MessageThread.hpp" -#include "providers/irc/IrcChannel2.hpp" -#include "providers/irc/IrcServer.hpp" -#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchCommon.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" -#include "singletons/Settings.hpp" -#include "singletons/Theme.hpp" -#include "singletons/WindowManager.hpp" -#include "util/Clipboard.hpp" #include "util/CombinePath.hpp" -#include "util/FormatTime.hpp" -#include "util/Helpers.hpp" -#include "util/IncognitoBrowser.hpp" +#include "util/QStringHash.hpp" #include "util/Qt.hpp" -#include "util/StreamerMode.hpp" -#include "util/StreamLink.hpp" -#include "util/Twitch.hpp" -#include "widgets/dialogs/ReplyThreadPopup.hpp" -#include "widgets/dialogs/UserInfoPopup.hpp" -#include "widgets/helper/ChannelView.hpp" -#include "widgets/splits/Split.hpp" -#include "widgets/splits/SplitContainer.hpp" -#include "widgets/Window.hpp" - -#include -#include -#include -#include -#include -namespace { - -using namespace chatterino; - -bool areIRCCommandsStillAvailable() -{ - // 11th of February 2023, 06:00am UTC - const QDateTime migrationTime(QDate(2023, 2, 11), QTime(6, 0), Qt::UTC); - auto now = QDateTime::currentDateTimeUtc(); - return now < migrationTime; -} - -QString useIRCCommand(const QStringList &words) -{ - // Reform the original command - auto originalCommand = words.join(" "); - - // Replace the / with a . to pass it along to TMI - auto newCommand = originalCommand; - newCommand.replace(0, 1, "."); - - qCDebug(chatterinoTwitch) - << "Forwarding command" << originalCommand << "as" << newCommand; - - return newCommand; -} - -void sendWhisperMessage(const QString &text) -{ - // (hemirt) pajlada: "we should not be sending whispers through jtv, but - // rather to your own username" - auto app = getApp(); - QString toSend = text.simplified(); - - app->twitch->sendMessage("jtv", toSend); -} - -bool appendWhisperMessageWordsLocally(const QStringList &words) -{ - auto app = getApp(); - - MessageBuilder b; - - b.emplace(); - b.emplace(app->accounts->twitch.getCurrent()->getUserName(), - MessageElementFlag::Text, MessageColor::Text, - FontStyle::ChatMediumBold); - b.emplace("->", MessageElementFlag::Text, - getApp()->themes->messages.textColors.system); - b.emplace(words[1] + ":", MessageElementFlag::Text, - MessageColor::Text, FontStyle::ChatMediumBold); - - const auto &acc = app->accounts->twitch.getCurrent(); - const auto &accemotes = *acc->accessEmotes(); - const auto &bttvemotes = app->twitch->getBttvEmotes(); - const auto &ffzemotes = app->twitch->getFfzEmotes(); - auto flags = MessageElementFlags(); - auto emote = boost::optional{}; - for (int i = 2; i < words.length(); i++) - { - { // Twitch emote - auto it = accemotes.emotes.find({words[i]}); - if (it != accemotes.emotes.end()) - { - b.emplace(it->second, - MessageElementFlag::TwitchEmote); - continue; - } - } // Twitch emote - - { // bttv/ffz emote - if ((emote = bttvemotes.emote({words[i]}))) - { - flags = MessageElementFlag::BttvEmote; - } - else if ((emote = ffzemotes.emote({words[i]}))) - { - flags = MessageElementFlag::FfzEmote; - } - if (emote) - { - b.emplace(emote.get(), flags); - continue; - } - } // bttv/ffz emote - { // emoji/text - for (auto &variant : app->emotes->emojis.parse(words[i])) - { - constexpr const static struct { - void operator()(EmotePtr emote, MessageBuilder &b) const - { - b.emplace(emote, - MessageElementFlag::EmojiAll); - } - void operator()(const QString &string, - MessageBuilder &b) const - { - auto linkString = b.matchLink(string); - if (linkString.isEmpty()) - { - b.emplace(string, - MessageElementFlag::Text); - } - else - { - b.addLink(string, linkString); - } - } - } visitor; - boost::apply_visitor( - [&b](auto &&arg) { - visitor(arg, b); - }, - variant); - } // emoji/text - } - } - - b->flags.set(MessageFlag::DoNotTriggerNotification); - b->flags.set(MessageFlag::Whisper); - auto messagexD = b.release(); - - app->twitch->whispersChannel->addMessage(messagexD); - - auto overrideFlags = boost::optional(messagexD->flags); - overrideFlags->set(MessageFlag::DoNotLog); - - if (getSettings()->inlineWhispers && - !(getSettings()->streamerModeSuppressInlineWhispers && - isInStreamerMode())) - { - app->twitch->forEachChannel( - [&messagexD, overrideFlags](ChannelPtr _channel) { - _channel->addMessage(messagexD, overrideFlags); - }); - } - - return true; -} - -bool useIrcForWhisperCommand() -{ - switch (getSettings()->helixTimegateWhisper.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return true; - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return true; - } - break; +#include - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - return false; -} +#include -QString runWhisperCommand(const QStringList &words, const ChannelPtr &channel) -{ - if (words.size() < 3) - { - channel->addMessage( - makeSystemMessage("Usage: /w ")); - return ""; - } +namespace { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to send a whisper!")); - return ""; - } - auto target = words.at(1); - stripChannelName(target); - auto message = words.mid(2).join(' '); - if (channel->isTwitchChannel()) - { - // this covers all twitch channels and twitch-like channels - if (useIrcForWhisperCommand()) - { - appendWhisperMessageWordsLocally(words); - sendWhisperMessage(words.join(' ')); - return ""; - } - getHelix()->getUserByName( - target, - [channel, currentUser, target, message, - words](const auto &targetUser) { - getHelix()->sendWhisper( - currentUser->getUserId(), targetUser.id, message, - [words] { - appendWhisperMessageWordsLocally(words); - }, - [channel, target, targetUser](auto error, auto message) { - using Error = HelixWhisperError; - - QString errorMessage = "Failed to send whisper - "; - - switch (error) - { - case Error::NoVerifiedPhone: { - errorMessage += - "Due to Twitch restrictions, you are now " - "required to have a verified phone number " - "to send whispers. You can add a phone " - "number in Twitch settings. " - "https://www.twitch.tv/settings/security"; - }; - break; - - case Error::RecipientBlockedUser: { - errorMessage += - "The recipient doesn't allow whispers " - "from strangers or you directly."; - }; - break; - - case Error::WhisperSelf: { - errorMessage += "You cannot whisper yourself."; - }; - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You may only whisper a maximum of 40 " - "unique recipients per day. Within the " - "per day limit, you may whisper a " - "maximum of 3 whispers per second and " - "a maximum of 100 whispers per minute."; - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Unknown: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel] { - channel->addMessage( - makeSystemMessage("No user matching that username.")); - }); - return ""; - } - // we must be on IRC - auto *ircChannel = dynamic_cast(channel.get()); - if (ircChannel == nullptr) - { - // give up - return ""; - } - auto *server = ircChannel->server(); - server->sendWhisper(target, message); - return ""; -} +using namespace chatterino; using VariableReplacer = std::function; @@ -411,7 +120,8 @@ const std::unordered_map COMMAND_VARS{ [](const auto &altText, const auto &channel, const auto *message) { (void)(channel); //unused (void)(message); //unused - auto uid = getApp()->accounts->twitch.getCurrent()->getUserId(); + auto uid = + getIApp()->getAccounts()->twitch.getCurrent()->getUserId(); return uid.isEmpty() ? altText : uid; }, }, @@ -420,7 +130,8 @@ const std::unordered_map COMMAND_VARS{ [](const auto &altText, const auto &channel, const auto *message) { (void)(channel); //unused (void)(message); //unused - auto name = getApp()->accounts->twitch.getCurrent()->getUserName(); + auto name = + getIApp()->getAccounts()->twitch.getCurrent()->getUserName(); return name.isEmpty() ? altText : name; }, }, @@ -552,7 +263,7 @@ const std::unordered_map COMMAND_VARS{ namespace chatterino { -void CommandController::initialize(Settings &, Paths &paths) +void CommandController::initialize(Settings &, const Paths &paths) { // Update commands map when the vector of commands has been updated auto addFirstMatchToMap = [this](auto args) { @@ -580,8 +291,10 @@ void CommandController::initialize(Settings &, Paths &paths) this->maxSpaces_ = maxSpaces; }; - this->items.itemInserted.connect(addFirstMatchToMap); - this->items.itemRemoved.connect(addFirstMatchToMap); + // We can safely ignore these signal connections since items will be destroyed + // before CommandController + std::ignore = this->items.itemInserted.connect(addFirstMatchToMap); + std::ignore = this->items.itemRemoved.connect(addFirstMatchToMap); // Initialize setting manager for commands.json auto path = combinePath(paths.settingsDirectory, "commands.json"); @@ -597,7 +310,7 @@ void CommandController::initialize(Settings &, Paths &paths) // Update the setting when the vector of commands has been updated (most // likely from the settings dialog) - this->items.delayedItemsChanged.connect([this] { + std::ignore = this->items.delayedItemsChanged.connect([this] { this->commandsSetting_->setValue(this->items.raw()); }); @@ -613,2571 +326,198 @@ void CommandController::initialize(Settings &, Paths &paths) /// Deprecated commands - auto blockLambda = [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /block command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /block ")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to block someone!")); - return ""; - } + this->registerCommand("/ignore", &commands::ignoreUser); - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const HelixUser &targetUser) { - getApp()->accounts->twitch.getCurrent()->blockUser( - targetUser.id, - [channel, target, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You successfully blocked user %1") - .arg(target))); - }, - [channel, target] { - channel->addMessage(makeSystemMessage( - QString("User %1 couldn't be blocked, an unknown " - "error occurred!") - .arg(target))); - }); - }, - [channel, target] { - channel->addMessage( - makeSystemMessage(QString("User %1 couldn't be blocked, no " - "user with that name found!") - .arg(target))); - }); + this->registerCommand("/unignore", &commands::unignoreUser); - return ""; - }; + this->registerCommand("/follow", &commands::follow); - auto unblockLambda = [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unblock command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /unblock ")); - return ""; - } + this->registerCommand("/unfollow", &commands::unfollow); - auto currentUser = getApp()->accounts->twitch.getCurrent(); + /// Supported commands - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to unblock someone!")); - return ""; - } + this->registerCommand("/debug-args", &commands::listArgs); - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const auto &targetUser) { - getApp()->accounts->twitch.getCurrent()->unblockUser( - targetUser.id, - [channel, target, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You successfully unblocked user %1") - .arg(target))); - }, - [channel, target] { - channel->addMessage(makeSystemMessage( - QString("User %1 couldn't be unblocked, an unknown " - "error occurred!") - .arg(target))); - }); - }, - [channel, target] { - channel->addMessage( - makeSystemMessage(QString("User %1 couldn't be unblocked, " - "no user with that name found!") - .arg(target))); - }); + this->registerCommand("/debug-env", &commands::listEnvironmentVariables); - return ""; - }; + this->registerCommand("/uptime", &commands::uptime); - this->registerCommand( - "/ignore", [blockLambda](const auto &words, auto channel) { - channel->addMessage(makeSystemMessage( - "Ignore command has been renamed to /block, please use it from " - "now on as /ignore is going to be removed soon.")); - blockLambda(words, channel); - return ""; - }); - - this->registerCommand( - "/unignore", [unblockLambda](const auto &words, auto channel) { - channel->addMessage(makeSystemMessage( - "Unignore command has been renamed to /unblock, please use it " - "from now on as /unignore is going to be removed soon.")); - unblockLambda(words, channel); - return ""; - }); + this->registerCommand("/block", &commands::blockUser); - this->registerCommand("/follow", [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - return ""; - } - channel->addMessage(makeSystemMessage( - "Twitch has removed the ability to follow users through " - "third-party applications. For more information, see " - "https://github.com/Chatterino/chatterino2/issues/3076")); - return ""; - }); + this->registerCommand("/unblock", &commands::unblockUser); - this->registerCommand("/unfollow", [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - return ""; - } - channel->addMessage(makeSystemMessage( - "Twitch has removed the ability to unfollow users through " - "third-party applications. For more information, see " - "https://github.com/Chatterino/chatterino2/issues/3076")); - return ""; - }); + this->registerCommand("/user", &commands::user); - /// Supported commands + this->registerCommand("/usercard", &commands::openUsercard); - this->registerCommand( - "/debug-args", [](const auto & /*words*/, auto channel) { - QString msg = QApplication::instance()->arguments().join(' '); + this->registerCommand("/requests", &commands::requests); - channel->addMessage(makeSystemMessage(msg)); + this->registerCommand("/lowtrust", &commands::lowtrust); - return ""; - }); + this->registerCommand("/chatters", &commands::chatters); - this->registerCommand("/debug-env", [](const auto & /*words*/, - ChannelPtr channel) { - auto env = Env::get(); + this->registerCommand("/test-chatters", &commands::testChatters); - QStringList debugMessages{ - "recentMessagesApiUrl: " + env.recentMessagesApiUrl, - "linkResolverUrl: " + env.linkResolverUrl, - "twitchServerHost: " + env.twitchServerHost, - "twitchServerPort: " + QString::number(env.twitchServerPort), - "twitchServerSecure: " + QString::number(env.twitchServerSecure), - }; + this->registerCommand("/mods", &commands::getModerators); - for (QString &str : debugMessages) - { - MessageBuilder builder; - builder.emplace(QTime::currentTime()); - builder.emplace(str, MessageElementFlag::Text, - MessageColor::System); - channel->addMessage(builder.release()); - } - return ""; - }); + this->registerCommand("/clip", &commands::clip); - this->registerCommand("/uptime", [](const auto & /*words*/, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /uptime command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/marker", &commands::marker); - const auto &streamStatus = twitchChannel->accessStreamStatus(); + this->registerCommand("/streamlink", &commands::streamlink); - QString messageText = - streamStatus->live ? streamStatus->uptime : "Channel is not live."; + this->registerCommand("/popout", &commands::popout); - channel->addMessage(makeSystemMessage(messageText)); + this->registerCommand("/popup", &commands::popup); - return ""; - }); + this->registerCommand("/clearmessages", &commands::clearmessages); - this->registerCommand("/block", blockLambda); + this->registerCommand("/settitle", &commands::setTitle); - this->registerCommand("/unblock", unblockLambda); + this->registerCommand("/setgame", &commands::setGame); - this->registerCommand("/user", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /user [channel]")); - return ""; - } - QString userName = words[1]; - stripUserName(userName); + this->registerCommand("/openurl", &commands::openURL); - QString channelName = channel->getName(); + this->registerCommand("/raw", &commands::sendRawMessage); - if (words.size() > 2) - { - channelName = words[2]; - stripChannelName(channelName); - } - openTwitchUsercard(channelName, userName); + this->registerCommand("/reply", &commands::sendReply); - return ""; - }); +#ifndef NDEBUG + this->registerCommand("/fakemsg", &commands::injectFakeMessage); +#endif - this->registerCommand("/usercard", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /usercard [channel]")); - return ""; - } + this->registerCommand("/copy", &commands::copyToClipboard); - QString userName = words[1]; - stripUserName(userName); + this->registerCommand("/color", &commands::updateUserColor); - if (words.size() > 2) - { - QString channelName = words[2]; - stripChannelName(channelName); + this->registerCommand("/clear", &commands::deleteAllMessages); - ChannelPtr channelTemp = - getApp()->twitch->getChannelOrEmpty(channelName); + this->registerCommand("/delete", &commands::deleteOneMessage); - if (channelTemp->isEmpty()) - { - channel->addMessage(makeSystemMessage( - "A usercard can only be displayed for a channel that is " - "currently opened in Chatterino.")); - return ""; - } + this->registerCommand("/mod", &commands::addModerator); - channel = channelTemp; - } + this->registerCommand("/unmod", &commands::removeModerator); - // try to link to current split if possible - Split *currentSplit = nullptr; - auto *currentPage = dynamic_cast( - getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); - if (currentPage != nullptr) - { - currentSplit = currentPage->getSelectedSplit(); - } + this->registerCommand("/announce", &commands::sendAnnouncement); - auto differentChannel = - currentSplit != nullptr && currentSplit->getChannel() != channel; - if (differentChannel || currentSplit == nullptr) - { - // not possible to use current split, try searching for one - const auto ¬ebook = - getApp()->windows->getMainWindow().getNotebook(); - auto count = notebook.getPageCount(); - for (int i = 0; i < count; i++) - { - auto *page = notebook.getPageAt(i); - auto *container = dynamic_cast(page); - assert(container != nullptr); - for (auto *split : container->getSplits()) - { - if (split->getChannel() == channel) - { - currentSplit = split; - break; - } - } - } + this->registerCommand("/vip", &commands::addVIP); - // This would have crashed either way. - assert(currentSplit != nullptr && - "something went HORRIBLY wrong with the /usercard " - "command. It couldn't find a split for a channel which " - "should be open."); - } + this->registerCommand("/unvip", &commands::removeVIP); - auto *userPopup = new UserInfoPopup( - getSettings()->autoCloseUserPopup, - static_cast(&(getApp()->windows->getMainWindow())), - currentSplit); - userPopup->setData(userName, channel); - userPopup->move(QCursor::pos()); - userPopup->show(); - return ""; - }); + this->registerCommand("/unban", &commands::unbanUser); + this->registerCommand("/untimeout", &commands::unbanUser); - this->registerCommand("/requests", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); + this->registerCommand("/raid", &commands::startRaid); - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "Usage: /requests [channel]. You can also use the command " - "without arguments in any Twitch channel to open its " - "channel points requests queue. Only the broadcaster and " - "moderators have permission to view the queue.")); - return ""; - } - } + this->registerCommand("/unraid", &commands::cancelRaid); - stripChannelName(target); - QDesktopServices::openUrl( - QUrl(QString("https://www.twitch.tv/popout/%1/reward-queue") - .arg(target))); + this->registerCommand("/emoteonly", &commands::emoteOnly); + this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff); - return ""; - }); + this->registerCommand("/subscribers", &commands::subscribers); + this->registerCommand("/subscribersoff", &commands::subscribersOff); - auto formatChattersError = [](HelixGetChattersError error, - QString message) { - using Error = HelixGetChattersError; + this->registerCommand("/slow", &commands::slow); + this->registerCommand("/slowoff", &commands::slowOff); - QString errorMessage = QString("Failed to get chatter count - "); + this->registerCommand("/followers", &commands::followers); + this->registerCommand("/followersoff", &commands::followersOff); - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; + this->registerCommand("/uniquechat", &commands::uniqueChat); + this->registerCommand("/r9kbeta", &commands::uniqueChat); + this->registerCommand("/uniquechatoff", &commands::uniqueChatOff); + this->registerCommand("/r9kbetaoff", &commands::uniqueChatOff); - case Error::UserMissingScope: { - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + this->registerCommand("/timeout", &commands::sendTimeout); - case Error::UserNotAuthorized: { - errorMessage += "You must have moderator permissions to " - "use this command."; - } - break; + this->registerCommand("/ban", &commands::sendBan); + this->registerCommand("/banid", &commands::sendBanById); - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) + { + this->registerCommand(cmd, &commands::sendWhisper); + } - this->registerCommand( - "/chatters", [formatChattersError](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/vips", &commands::getVIPs); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /chatters command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/commercial", &commands::startCommercial); - // Refresh chatter list via helix api for mods - getHelix()->getChatters( - twitchChannel->roomId(), - getApp()->accounts->twitch.getCurrent()->getUserId(), 1, - [channel](auto result) { - channel->addMessage(makeSystemMessage( - QString("Chatter count: %1") - .arg(localizeNumbers(result.total)))); - }, - [channel, formatChattersError](auto error, auto message) { - auto errorMessage = formatChattersError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); + this->registerCommand("/unstable-set-user-color", + &commands::unstableSetUserClientSideColor); - return ""; - }); + this->registerCommand("/debug-force-image-gc", + &commands::forceImageGarbageCollection); - this->registerCommand("/test-chatters", [formatChattersError]( - const auto & /*words*/, - auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/debug-force-image-unload", + &commands::forceImageUnload); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /test-chatters command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/shield", &commands::shieldModeOn); + this->registerCommand("/shieldoff", &commands::shieldModeOff); - getHelix()->getChatters( - twitchChannel->roomId(), - getApp()->accounts->twitch.getCurrent()->getUserId(), 5000, - [channel, twitchChannel](auto result) { - QStringList entries; - for (const auto &username : result.chatters) - { - entries << username; - } + this->registerCommand("/shoutout", &commands::sendShoutout); - QString prefix = "Chatters "; + this->registerCommand("/c2-set-logging-rules", &commands::setLoggingRules); + this->registerCommand("/c2-theme-autoreload", &commands::toggleThemeReload); +} - if (result.total > 5000) - { - prefix += QString("(5000/%1):").arg(result.total); - } - else - { - prefix += QString("(%1):").arg(result.total); - } +void CommandController::save() +{ + this->sm_->save(); +} - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - prefix, entries, twitchChannel, &builder); +CommandModel *CommandController::createModel(QObject *parent) +{ + CommandModel *model = new CommandModel(parent); + model->initialize(&this->items); - channel->addMessage(builder.release()); - }, - [channel, formatChattersError](auto error, auto message) { - auto errorMessage = formatChattersError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); + return model; +} - return ""; - }); +QString CommandController::execCommand(const QString &textNoEmoji, + ChannelPtr channel, bool dryRun) +{ + QString text = + getIApp()->getEmotes()->getEmojis()->replaceShortCodes(textNoEmoji); + QStringList words = text.split(' ', Qt::SkipEmptyParts); - auto formatModsError = [](HelixGetModeratorsError error, QString message) { - using Error = HelixGetModeratorsError; + if (words.length() == 0) + { + return text; + } - QString errorMessage = QString("Failed to get moderators - "); + QString commandName = words[0]; - switch (error) + { + // check if user command exists + const auto it = this->userCommands_.find(commandName); + if (it != this->userCommands_.end()) { - case Error::Forwarded: { - errorMessage += message; - } - break; + text = getIApp()->getEmotes()->getEmojis()->replaceShortCodes( + this->execCustomCommand(words, it.value(), dryRun, channel)); - case Error::UserMissingScope: { - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + words = text.split(' ', Qt::SkipEmptyParts); - case Error::UserNotAuthorized: { - errorMessage += - "Due to Twitch restrictions, " - "this command can only be used by the broadcaster. " - "To see the list of mods you must use the Twitch website."; + if (words.length() == 0) + { + return text; } - break; - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; + commandName = words[0]; } - return errorMessage; - }; - - this->registerCommand( - "/mods", - [formatModsError](const QStringList &words, auto channel) -> QString { - auto twitchChannel = dynamic_cast(channel.get()); + } - if (twitchChannel == nullptr) + if (!dryRun) + { + // check if command exists + const auto it = this->commands_.find(commandName); + if (it != this->commands_.end()) + { + if (auto *command = std::get_if(&it->second)) { - channel->addMessage(makeSystemMessage( - "The /mods command only works in Twitch Channels")); - return ""; + return (*command)(words, channel); } - - switch (getSettings()->helixTimegateModerators.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - case HelixTimegateOverride::AlwaysUseHelix: { - // Fall through to helix logic - } - break; - } - - getHelix()->getModerators( - twitchChannel->roomId(), 500, - [channel, twitchChannel](auto result) { - // TODO: sort results? - - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - "The moderators of this channel are", result, - twitchChannel, &builder); - channel->addMessage(builder.release()); - }, - [channel, formatModsError](auto error, auto message) { - auto errorMessage = formatModsError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - return ""; - }); - - this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { - if (const auto type = channel->getType(); - type != Channel::Type::Twitch && - type != Channel::Type::TwitchWatching) - { - channel->addMessage(makeSystemMessage( - "The /clip command only works in Twitch Channels")); - return ""; - } - - auto *twitchChannel = dynamic_cast(channel.get()); - - twitchChannel->createClip(); - - return ""; - }); - - this->registerCommand("/marker", [](const QStringList &words, - auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /marker command only works in Twitch channels")); - return ""; - } - - // Avoid Helix calls without Client ID and/or OAuth Token - if (getApp()->accounts->twitch.getCurrent()->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You need to be logged in to create stream markers!")); - return ""; - } - - // Exact same message as in webchat - if (!twitchChannel->isLive()) - { - channel->addMessage(makeSystemMessage( - "You can only add stream markers during live streams. Try " - "again when the channel is live streaming.")); - return ""; - } - - auto arguments = words; - arguments.removeFirst(); - - getHelix()->createStreamMarker( - // Limit for description is 140 characters, webchat just crops description - // if it's >140 characters, so we're doing the same thing - twitchChannel->roomId(), arguments.join(" ").left(140), - [channel, arguments](const HelixStreamMarker &streamMarker) { - channel->addMessage(makeSystemMessage( - QString("Successfully added a stream marker at %1%2") - .arg(formatTime(streamMarker.positionSeconds)) - .arg(streamMarker.description.isEmpty() - ? "" - : QString(": \"%1\"") - .arg(streamMarker.description)))); - }, - [channel](auto error) { - QString errorMessage("Failed to create stream marker - "); - - switch (error) - { - case HelixStreamMarkerError::UserNotAuthorized: { - errorMessage += - "you don't have permission to perform that action."; - } - break; - - case HelixStreamMarkerError::UserNotAuthenticated: { - errorMessage += "you need to re-authenticate."; - } - break; - - // This would most likely happen if the service is down, or if the JSON payload returned has changed format - case HelixStreamMarkerError::Unknown: - default: { - errorMessage += "an unknown error occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - this->registerCommand("/streamlink", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); - - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "/streamlink [channel]. Open specified Twitch channel in " - "streamlink. If no channel argument is specified, open the " - "current Twitch channel instead.")); - return ""; - } - } - - stripChannelName(target); - openStreamlinkForChannel(target); - - return ""; - }); - - this->registerCommand("/popout", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); - - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "Usage: /popout . You can also use the command " - "without arguments in any Twitch channel to open its " - "popout chat.")); - return ""; - } - } - - stripChannelName(target); - QDesktopServices::openUrl( - QUrl(QString("https://www.twitch.tv/popout/%1/chat?popout=") - .arg(target))); - - return ""; - }); - - this->registerCommand("/popup", [](const QStringList &words, - ChannelPtr sourceChannel) { - static const auto *usageMessage = - "Usage: /popup [channel]. Open specified Twitch channel in " - "a new window. If no channel argument is specified, open " - "the currently selected split instead."; - - QString target(words.value(1)); - stripChannelName(target); - - // Popup the current split - if (target.isEmpty()) - { - auto *currentPage = - dynamic_cast(getApp() - ->windows->getMainWindow() - .getNotebook() - .getSelectedPage()); - if (currentPage != nullptr) - { - auto *currentSplit = currentPage->getSelectedSplit(); - if (currentSplit != nullptr) - { - currentSplit->popup(); - - return ""; - } - } - - sourceChannel->addMessage(makeSystemMessage(usageMessage)); - return ""; - } - - // Open channel passed as argument in a popup - auto *app = getApp(); - auto targetChannel = app->twitch->getOrAddChannel(target); - app->windows->openInPopup(targetChannel); - - return ""; - }); - - this->registerCommand("/clearmessages", [](const auto & /*words*/, - ChannelPtr channel) { - auto *currentPage = dynamic_cast( - getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); - - if (auto split = currentPage->getSelectedSplit()) - { - split->getChannelView().clearMessages(); - } - - return ""; - }); - - this->registerCommand("/settitle", [](const QStringList &words, - ChannelPtr channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /settitle ")); - return ""; - } - if (auto twitchChannel = dynamic_cast(channel.get())) - { - auto status = twitchChannel->accessStreamStatus(); - auto title = words.mid(1).join(" "); - getHelix()->updateChannel( - twitchChannel->roomId(), "", "", title, - [channel, title](NetworkResult) { - channel->addMessage(makeSystemMessage( - QString("Updated title to %1").arg(title))); - }, - [channel] { - channel->addMessage( - makeSystemMessage("Title update failed! Are you " - "missing the required scope?")); - }); - } - else - { - channel->addMessage(makeSystemMessage( - "Unable to set title of non-Twitch channel.")); - } - return ""; - }); - - this->registerCommand("/setgame", [](const QStringList &words, - const ChannelPtr channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /setgame ")); - return ""; - } - if (auto twitchChannel = dynamic_cast(channel.get())) - { - const auto gameName = words.mid(1).join(" "); - - getHelix()->searchGames( - gameName, - [channel, twitchChannel, - gameName](const std::vector &games) { - if (games.empty()) - { - channel->addMessage( - makeSystemMessage("Game not found.")); - return; - } - - auto matchedGame = games.at(0); - - if (games.size() > 1) - { - // NOTE: Improvements could be made with 'fuzzy string matching' code here - // attempt to find the best looking game by comparing exactly with lowercase values - for (const auto &game : games) - { - if (game.name.toLower() == gameName.toLower()) - { - matchedGame = game; - break; - } - } - } - - auto status = twitchChannel->accessStreamStatus(); - getHelix()->updateChannel( - twitchChannel->roomId(), matchedGame.id, "", "", - [channel, games, matchedGame](const NetworkResult &) { - channel->addMessage( - makeSystemMessage(QString("Updated game to %1") - .arg(matchedGame.name))); - }, - [channel] { - channel->addMessage(makeSystemMessage( - "Game update failed! Are you " - "missing the required scope?")); - }); - }, - [channel] { - channel->addMessage( - makeSystemMessage("Failed to look up game.")); - }); - } - else - { - channel->addMessage( - makeSystemMessage("Unable to set game of non-Twitch channel.")); - } - return ""; - }); - - this->registerCommand("/openurl", [](const QStringList &words, - const ChannelPtr channel) { - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /openurl ")); - return ""; - } - - QUrl url = QUrl::fromUserInput(words.mid(1).join(" ")); - if (!url.isValid()) - { - channel->addMessage(makeSystemMessage("Invalid URL specified.")); - return ""; - } - - bool res = false; - if (supportsIncognitoLinks() && getSettings()->openLinksIncognito) - { - res = openLinkIncognito(url.toString(QUrl::FullyEncoded)); - } - else - { - res = QDesktopServices::openUrl(url); - } - - if (!res) - { - channel->addMessage(makeSystemMessage("Could not open URL.")); - } - - return ""; - }); - - this->registerCommand( - "/raw", [](const QStringList &words, ChannelPtr channel) -> QString { - if (channel->isTwitchChannel()) - { - getApp()->twitch->sendRawMessage(words.mid(1).join(" ")); - } - else - { - // other code down the road handles this for IRC - return words.join(" "); - } - return ""; - }); - - this->registerCommand( - "/reply", [](const QStringList &words, ChannelPtr channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /reply command only works in Twitch channels")); - return ""; - } - - if (words.size() < 3) - { - channel->addMessage( - makeSystemMessage("Usage: /reply ")); - return ""; - } - - QString username = words[1]; - stripChannelName(username); - - auto snapshot = twitchChannel->getMessageSnapshot(); - for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) - { - const auto &msg = *it; - if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) - { - std::shared_ptr thread; - // found most recent message by user - if (msg->replyThread == nullptr) - { - thread = std::make_shared(msg); - twitchChannel->addReplyThread(thread); - } - else - { - thread = msg->replyThread; - } - - QString reply = words.mid(2).join(" "); - twitchChannel->sendReply(reply, thread->rootId()); - return ""; - } - } - - channel->addMessage( - makeSystemMessage("A message from that user wasn't found")); - - return ""; - }); - -#ifndef NDEBUG - this->registerCommand( - "/fakemsg", - [](const QStringList &words, ChannelPtr channel) -> QString { - if (!channel->isTwitchChannel()) - { - channel->addMessage(makeSystemMessage( - "The /fakemsg command only works in Twitch channels.")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: /fakemsg (raw irc text) - injects raw irc text as " - "if it was a message received from TMI")); - return ""; - } - auto ircText = words.mid(1).join(" "); - getApp()->twitch->addFakeMessage(ircText); - return ""; - }); -#endif - - this->registerCommand( - "/copy", [](const QStringList &words, ChannelPtr channel) -> QString { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /copy - copies provided " - "text to clipboard.")); - return ""; - } - crossPlatformCopy(words.mid(1).join(" ")); - return ""; - }); - - this->registerCommand("/color", [](const QStringList &words, auto channel) { - if (!channel->isTwitchChannel()) - { - channel->addMessage(makeSystemMessage( - "The /color command only works in Twitch channels")); - return ""; - } - auto user = getApp()->accounts->twitch.getCurrent(); - - // Avoid Helix calls without Client ID and/or OAuth Token - if (user->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to use the /color command")); - return ""; - } - - auto colorString = words.value(1); - - if (colorString.isEmpty()) - { - channel->addMessage(makeSystemMessage( - QString("Usage: /color - Color must be one of Twitch's " - "supported colors (%1) or a hex code (#000000) if you " - "have Turbo or Prime.") - .arg(VALID_HELIX_COLORS.join(", ")))); - return ""; - } - - cleanHelixColorName(colorString); - - getHelix()->updateUserChatColor( - user->getUserId(), colorString, - [colorString, channel] { - QString successMessage = - QString("Your color has been changed to %1.") - .arg(colorString); - channel->addMessage(makeSystemMessage(successMessage)); - }, - [colorString, channel](auto error, auto message) { - QString errorMessage = - QString("Failed to change color to %1 - ").arg(colorString); - - switch (error) - { - case HelixUpdateUserChatColorError::UserMissingScope: { - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case HelixUpdateUserChatColorError::InvalidColor: { - errorMessage += QString("Color must be one of Twitch's " - "supported colors (%1) or a " - "hex code (#000000) if you " - "have Turbo or Prime.") - .arg(VALID_HELIX_COLORS.join(", ")); - } - break; - - case HelixUpdateUserChatColorError::Forwarded: { - errorMessage += message + "."; - } - break; - - case HelixUpdateUserChatColorError::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - auto deleteMessages = [](TwitchChannel *twitchChannel, - const QString &messageID) { - const auto *commandName = messageID.isEmpty() ? "/clear" : "/delete"; - - auto user = getApp()->accounts->twitch.getCurrent(); - - // Avoid Helix calls without Client ID and/or OAuth Token - if (user->isAnon()) - { - twitchChannel->addMessage(makeSystemMessage( - QString("You must be logged in to use the %1 command.") - .arg(commandName))); - return ""; - } - - getHelix()->deleteChatMessages( - twitchChannel->roomId(), user->getUserId(), messageID, - []() { - // Success handling, we do nothing: IRC/pubsub-edge will dispatch the correct - // events to update state for us. - }, - [twitchChannel, messageID](auto error, auto message) { - QString errorMessage = - QString("Failed to delete chat messages - "); - - switch (error) - { - case HelixDeleteChatMessagesError::UserMissingScope: { - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case HelixDeleteChatMessagesError::UserNotAuthorized: { - errorMessage += - "you don't have permission to perform that action."; - } - break; - - case HelixDeleteChatMessagesError::MessageUnavailable: { - // Override default message prefix to match with IRC message format - errorMessage = - QString( - "The message %1 does not exist, was deleted, " - "or is too old to be deleted.") - .arg(messageID); - } - break; - - case HelixDeleteChatMessagesError::UserNotAuthenticated: { - errorMessage += "you need to re-authenticate."; - } - break; - - case HelixDeleteChatMessagesError::Forwarded: { - errorMessage += message; - } - break; - - case HelixDeleteChatMessagesError::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - twitchChannel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }; - - this->registerCommand( - "/clear", [deleteMessages](const QStringList &words, auto channel) { - (void)words; // unused - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /clear command only works in Twitch channels")); - return ""; - } - return deleteMessages(twitchChannel, QString()); - }); - - this->registerCommand("/delete", [deleteMessages](const QStringList &words, - auto channel) { - // This is a wrapper over the Helix delete messages endpoint - // We use this to ensure the user gets better error messages for missing or malformed arguments - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /delete command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /delete - Deletes the " - "specified message.")); - return ""; - } - - auto messageID = words.at(1); - auto uuid = QUuid(messageID); - if (uuid.isNull()) - { - // The message id must be a valid UUID - channel->addMessage(makeSystemMessage( - QString("Invalid msg-id: \"%1\"").arg(messageID))); - return ""; - } - - auto msg = channel->findMessage(messageID); - if (msg != nullptr) - { - if (msg->loginName == channel->getName() && - !channel->isBroadcaster()) - { - channel->addMessage(makeSystemMessage( - "You cannot delete the broadcaster's messages unless " - "you are the broadcaster.")); - return ""; - } - } - - return deleteMessages(twitchChannel, messageID); - }); - - this->registerCommand("/mod", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /mod command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/mod \" - Grant moderator status to a " - "user. Use \"/mods\" to list the moderators of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to mod someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->addChannelModerator( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You have added %1 as a moderator of this " - "channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to add channel moderator - "); - - using Error = HelixAddChannelModeratorError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetIsVIP: { - errorMessage += - QString("%1 is currently a VIP, \"/unvip\" " - "them and " - "retry this command.") - .arg(targetUser.displayName); - } - break; - - case Error::TargetAlreadyModded: { - // Equivalent irc error - errorMessage = - QString("%1 is already a moderator of this " - "channel.") - .arg(targetUser.displayName); - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand("/unmod", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unmod command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/unmod \" - Revoke moderator status from a " - "user. Use \"/mods\" to list the moderators of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to unmod someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->removeChannelModerator( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You have removed %1 as a moderator of " - "this channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to remove channel moderator - "); - - using Error = HelixRemoveChannelModeratorError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetNotModded: { - // Equivalent irc error - errorMessage += - QString("%1 is not a moderator of this " - "channel.") - .arg(targetUser.displayName); - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand( - "/announce", [](const QStringList &words, auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "This command can only be used in Twitch channels.")); - return ""; - } - - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: /announce - Call attention to your " - "message with a highlight.")); - return ""; - } - - auto user = getApp()->accounts->twitch.getCurrent(); - if (user->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to use the /announce command")); - return ""; - } - - getHelix()->sendChatAnnouncement( - twitchChannel->roomId(), user->getUserId(), - words.mid(1).join(" "), HelixAnnouncementColor::Primary, - []() { - // do nothing. - }, - [channel](auto error, auto message) { - using Error = HelixSendChatAnnouncementError; - QString errorMessage = - QString("Failed to send announcement - "); - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - return ""; - }); - - this->registerCommand("/vip", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /vip command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/vip \" - Grant VIP status to a user. Use " - "\"/vips\" to list the VIPs of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to VIP someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->addChannelVIP( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString( - "You have added %1 as a VIP of this channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = QString("Failed to add VIP - "); - - using Error = HelixAddChannelVIPError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - // These are actually the IRC equivalents, so we can ditch the prefix - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand("/unvip", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unvip command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/unvip \" - Revoke VIP status from a user. " - "Use \"/vips\" to list the VIPs of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to UnVIP someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->removeChannelVIP( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString( - "You have removed %1 as a VIP of this channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to remove VIP - "); - - using Error = HelixRemoveChannelVIPError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - // These are actually the IRC equivalents, so we can ditch the prefix - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - auto unbanLambda = [](auto words, auto channel) { - auto commandName = words.at(0).toLower(); - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The %1 command only works in Twitch channels") - .arg(commandName))); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - QString("Usage: \"%1 \" - Removes a ban on a user.") - .arg(commandName))); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to unban someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, - target](const auto &targetUser) { - getHelix()->unbanUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, - [] { - // No response for unbans, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - using Error = HelixUnbanUserError; - - QString errorMessage = - QString("Failed to unban user - "); - - switch (error) - { - case Error::ConflictingOperation: { - errorMessage += - "There was a conflicting ban operation on " - "this user. Please try again."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetNotBanned: { - // Equivalent IRC error - errorMessage = - QString( - "%1 is not banned from this channel.") - .arg(targetUser.displayName); - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Unknown: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }; // These changes are from the helix-command-migration/unban-untimeout branch - - this->registerCommand("/unban", [unbanLambda](const QStringList &words, - auto channel) { - return unbanLambda(words, channel); - }); // These changes are from the helix-command-migration/unban-untimeout branch - - this->registerCommand("/untimeout", [unbanLambda](const QStringList &words, - auto channel) { - return unbanLambda(words, channel); - }); // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - // These changes are from the helix-command-migration/unban-untimeout branch - - this->registerCommand( // /raid - "/raid", [](const QStringList &words, auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /raid command only works in Twitch channels")); - return ""; - } - switch (getSettings()->helixTimegateRaid.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/raid \" - Raid a user. " - "Only the broadcaster can start a raid.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to start a raid!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->startRaid( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You started to raid %1.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to start a raid - "); - - using Error = HelixStartRaidError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - errorMessage += - "You must be the broadcaster " - "to start a raid."; - } - break; - - case Error::CantRaidYourself: { - errorMessage += - "A channel cannot raid itself."; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited " - "by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage( - makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); // /raid - - this->registerCommand( // /unraid - "/unraid", [](const QStringList &words, auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unraid command only works in Twitch channels")); - return ""; - } - switch (getSettings()->helixTimegateRaid.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - - if (words.size() != 1) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/unraid\" - Cancel the current raid. " - "Only the broadcaster can cancel the raid.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to cancel the raid!")); - return ""; - } - - getHelix()->cancelRaid( - twitchChannel->roomId(), - [channel] { - channel->addMessage( - makeSystemMessage(QString("You cancelled the raid."))); - }, - [channel](auto error, auto message) { - QString errorMessage = - QString("Failed to cancel the raid - "); - - using Error = HelixCancelRaidError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - errorMessage += "You must be the broadcaster " - "to cancel the raid."; - } - break; - - case Error::NoRaidPending: { - errorMessage += "You don't have an active raid."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); // unraid - - this->registerCommand("/emoteonly", &commands::emoteOnly); - this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff); - - this->registerCommand("/subscribers", &commands::subscribers); - this->registerCommand("/subscribersoff", &commands::subscribersOff); - - this->registerCommand("/slow", &commands::slow); - this->registerCommand("/slowoff", &commands::slowOff); - - this->registerCommand("/followers", &commands::followers); - this->registerCommand("/followersoff", &commands::followersOff); - - this->registerCommand("/uniquechat", &commands::uniqueChat); - this->registerCommand("/r9kbeta", &commands::uniqueChat); - this->registerCommand("/uniquechatoff", &commands::uniqueChatOff); - this->registerCommand("/r9kbetaoff", &commands::uniqueChatOff); - - auto formatBanTimeoutError = - [](const char *operation, HelixBanUserError error, - const QString &message, const QString &userDisplayName) -> QString { - using Error = HelixBanUserError; - - QString errorMessage = QString("Failed to %1 user - ").arg(operation); - - switch (error) - { - case Error::ConflictingOperation: { - errorMessage += "There was a conflicting ban operation on " - "this user. Please try again."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetBanned: { - // Equivalent IRC error - errorMessage += QString("%1 is already banned in this channel.") - .arg(userDisplayName); - } - break; - - case Error::CannotBanUser: { - // We can't provide the identical error as in IRC, - // because we don't have enough information about the user. - // The messages from IRC are formatted like this: - // "You cannot {op} moderator {mod} unless you are the owner of this channel." - // "You cannot {op} the broadcaster." - errorMessage += QString("You cannot %1 %2.") - .arg(operation, userDisplayName); - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; - - this->registerCommand("/timeout", [formatBanTimeoutError]( - const QStringList &words, - auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /timeout command only works in Twitch channels"))); - return ""; - } - const auto *usageStr = - "Usage: \"/timeout [duration][time unit] [reason]\" - " - "Temporarily prevent a user from chatting. Duration (optional, " - "default=10 minutes) must be a positive integer; time unit " - "(optional, default=s) must be one of s, m, h, d, w; maximum " - "duration is 2 weeks. Combinations like 1d2h are also allowed. " - "Reason is optional and will be shown to the target user and other " - "moderators. Use \"/untimeout\" to remove a timeout."; - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to timeout someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - int duration = 10 * 60; // 10min - if (words.size() >= 3) - { - duration = (int)parseDurationToSeconds(words.at(2)); - if (duration <= 0) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } - } - auto reason = words.mid(3).join(' '); - - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, target, duration, reason, - formatBanTimeoutError](const auto &targetUser) { - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, duration, reason, - [] { - // No response for timeouts, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser, formatBanTimeoutError]( - auto error, auto message) { - auto errorMessage = formatBanTimeoutError( - "timeout", error, message, targetUser.displayName); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand("/ban", [formatBanTimeoutError]( - const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /ban command only works in Twitch channels"))); - return ""; - } - - const auto *usageStr = - "Usage: \"/ban [reason]\" - Permanently prevent a user " - "from chatting. Reason is optional and will be shown to the target " - "user and other moderators. Use \"/unban\" to remove a ban."; - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to ban someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - auto reason = words.mid(2).join(' '); - - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, target, reason, - formatBanTimeoutError](const auto &targetUser) { - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, boost::none, reason, - [] { - // No response for bans, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser, formatBanTimeoutError]( - auto error, auto message) { - auto errorMessage = formatBanTimeoutError( - "ban", error, message, targetUser.displayName); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - for (const auto &cmd : TWITCH_WHISPER_COMMANDS) - { - this->registerCommand(cmd, [](const QStringList &words, auto channel) { - return runWhisperCommand(words, channel); - }); - } - - auto formatVIPListError = [](HelixListVIPsError error, - const QString &message) -> QString { - using Error = HelixListVIPsError; - - QString errorMessage = QString("Failed to list VIPs - "); - - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::UserNotBroadcaster: { - errorMessage += - "Due to Twitch restrictions, " - "this command can only be used by the broadcaster. " - "To see the list of VIPs you must use the Twitch website."; - } - break; - - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; - - auto formatStartCommercialError = [](HelixStartCommercialError error, - const QString &message) -> QString { - using Error = HelixStartCommercialError; - - QString errorMessage = "Failed to start commercial - "; - - switch (error) - { - case Error::UserMissingScope: { - errorMessage += "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case Error::TokenMustMatchBroadcaster: { - errorMessage += "Only the broadcaster of the channel can run " - "commercials."; - } - break; - - case Error::BroadcasterNotStreaming: { - errorMessage += "You must be streaming live to run " - "commercials."; - } - break; - - case Error::MissingLengthParameter: { - errorMessage += - "Command must include a desired commercial break " - "length that is greater than zero."; - } - break; - - case Error::Ratelimited: { - errorMessage += "You must wait until your cooldown period " - "expires before you can run another " - "commercial."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - QString("An unknown error has occurred (%1).").arg(message); - } - break; - } - - return errorMessage; - }; - - this->registerCommand( - "/vips", - [formatVIPListError](const QStringList &words, - auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /vips command only works in Twitch channels")); - return ""; - } - - switch (getSettings()->helixTimegateVIPs.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "Due to Twitch restrictions, " // - "this command can only be used by the broadcaster. " - "To see the list of VIPs you must use the " - "Twitch website.")); - return ""; - } - - getHelix()->getChannelVIPs( - twitchChannel->roomId(), - [channel, twitchChannel](const std::vector &vipList) { - if (vipList.empty()) - { - channel->addMessage(makeSystemMessage( - "This channel does not have any VIPs.")); - return; - } - - auto messagePrefix = - QString("The VIPs of this channel are"); - - // TODO: sort results? - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - messagePrefix, vipList, twitchChannel, &builder); - - channel->addMessage(builder.release()); - }, - [channel, formatVIPListError](auto error, auto message) { - auto errorMessage = formatVIPListError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - this->registerCommand( - "/commercial", - [formatStartCommercialError](const QStringList &words, - auto channel) -> QString { - auto *tc = dynamic_cast(channel.get()); - if (tc == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /commercial command only works in Twitch channels")); - return ""; - } - - const auto *usageStr = "Usage: \"/commercial \" - Starts a " - "commercial with the " - "specified duration for the current " - "channel. Valid length options " - "are 30, 60, 90, 120, 150, and 180 seconds."; - - switch (getSettings()->helixTimegateCommercial.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } - - auto user = getApp()->accounts->twitch.getCurrent(); - - // Avoid Helix calls without Client ID and/or OAuth Token - if (user->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to use the /commercial command")); - return ""; - } - - auto broadcasterID = tc->roomId(); - auto length = words.at(1).toInt(); - - getHelix()->startCommercial( - broadcasterID, length, - [channel](auto response) { - channel->addMessage(makeSystemMessage( - QString("Starting %1 second long commercial break. " - "Keep in mind you are still " - "live and not all viewers will receive a " - "commercial. " - "You may run another commercial in %2 seconds.") - .arg(response.length) - .arg(response.retryAfter))); - }, - [channel, formatStartCommercialError](auto error, - auto message) { - auto errorMessage = - formatStartCommercialError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - this->registerCommand("/unstable-set-user-color", [](const auto &ctx) { - if (ctx.twitchChannel == nullptr) - { - ctx.channel->addMessage( - makeSystemMessage("The /unstable-set-user-color command only " - "works in Twitch channels")); - return ""; - } - auto userID = ctx.words.at(1); - if (ctx.words.size() < 2) - { - ctx.channel->addMessage( - makeSystemMessage(QString("Usage: %1 [color]") - .arg(ctx.words.at(0)))); - } - - auto color = ctx.words.value(2); - - getIApp()->getUserData()->setUserColor(userID, color); - - return ""; - }); -} - -void CommandController::save() -{ - this->sm_->save(); -} - -CommandModel *CommandController::createModel(QObject *parent) -{ - CommandModel *model = new CommandModel(parent); - model->initialize(&this->items); - - return model; -} - -QString CommandController::execCommand(const QString &textNoEmoji, - ChannelPtr channel, bool dryRun) -{ - QString text = getApp()->emotes->emojis.replaceShortCodes(textNoEmoji); - QStringList words = text.split(' ', Qt::SkipEmptyParts); - - if (words.length() == 0) - { - return text; - } - - QString commandName = words[0]; - - { - // check if user command exists - const auto it = this->userCommands_.find(commandName); - if (it != this->userCommands_.end()) - { - text = getApp()->emotes->emojis.replaceShortCodes( - this->execCustomCommand(words, it.value(), dryRun, channel)); - - words = text.split(' ', Qt::SkipEmptyParts); - - if (words.length() == 0) - { - return text; - } - - commandName = words[0]; - } - } - - if (!dryRun) - { - // check if command exists - const auto it = this->commands_.find(commandName); - if (it != this->commands_.end()) - { - if (auto *command = std::get_if(&it->second)) - { - return (*command)(words, channel); - } - if (auto *command = - std::get_if(&it->second)) + if (auto *command = + std::get_if(&it->second)) { CommandContext ctx{ words, @@ -3191,7 +531,8 @@ QString CommandController::execCommand(const QString &textNoEmoji, } } - auto maxSpaces = std::min(this->maxSpaces_, words.length() - 1); + // We have checks to ensure words cannot be empty, so this can never wrap around + auto maxSpaces = std::min(this->maxSpaces_, (qsizetype)words.length() - 1); for (int i = 0; i < maxSpaces; ++i) { commandName += ' ' + words[i + 1]; @@ -3213,6 +554,32 @@ QString CommandController::execCommand(const QString &textNoEmoji, return text; } +#ifdef CHATTERINO_HAVE_PLUGINS +bool CommandController::registerPluginCommand(const QString &commandName) +{ + if (this->commands_.contains(commandName)) + { + return false; + } + + this->commands_[commandName] = [commandName](const CommandContext &ctx) { + return getIApp()->getPlugins()->tryExecPluginCommand(commandName, ctx); + }; + this->pluginCommands_.append(commandName); + return true; +} + +bool CommandController::unregisterPluginCommand(const QString &commandName) +{ + if (!this->pluginCommands_.contains(commandName)) + { + return false; + } + this->pluginCommands_.removeAll(commandName); + return this->commands_.erase(commandName) != 0; +} +#endif + void CommandController::registerCommand(const QString &commandName, CommandFunctionVariants commandFunction) { diff --git a/src/controllers/commands/CommandController.hpp b/src/controllers/commands/CommandController.hpp index fcae24249f7..d13197e4fcf 100644 --- a/src/controllers/commands/CommandController.hpp +++ b/src/controllers/commands/CommandController.hpp @@ -33,8 +33,8 @@ class CommandController final : public Singleton bool dryRun); QStringList getDefaultChatterinoCommandList(); - virtual void initialize(Settings &, Paths &paths) override; - virtual void save() override; + void initialize(Settings &, const Paths &paths) override; + void save() override; CommandModel *createModel(QObject *parent); @@ -42,6 +42,15 @@ class CommandController final : public Singleton const QStringList &words, const Command &command, bool dryRun, ChannelPtr channel, const Message *message = nullptr, std::unordered_map context = {}); +#ifdef CHATTERINO_HAVE_PLUGINS + bool registerPluginCommand(const QString &commandName); + bool unregisterPluginCommand(const QString &commandName); + + const QStringList &pluginCommands() + { + return this->pluginCommands_; + } +#endif private: void load(Paths &paths); @@ -62,7 +71,7 @@ class CommandController final : public Singleton // User-created commands QMap userCommands_; - int maxSpaces_ = 0; + qsizetype maxSpaces_ = 0; std::shared_ptr sm_; // Because the setting manager is not initialized until the initialize @@ -73,6 +82,9 @@ class CommandController final : public Singleton commandsSetting_; QStringList defaultChatterinoCommandAutoCompletions_; +#ifdef CHATTERINO_HAVE_PLUGINS + QStringList pluginCommands_; +#endif }; } // namespace chatterino diff --git a/src/controllers/commands/CommandModel.hpp b/src/controllers/commands/CommandModel.hpp index 628a6085902..7dc458b85d1 100644 --- a/src/controllers/commands/CommandModel.hpp +++ b/src/controllers/commands/CommandModel.hpp @@ -22,12 +22,12 @@ class CommandModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual Command getItemFromRow(std::vector &row, - const Command &command) override; + Command getItemFromRow(std::vector &row, + const Command &command) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const Command &item, - std::vector &row) override; + void getRowFromItem(const Command &item, + std::vector &row) override; friend class CommandController; }; diff --git a/src/controllers/commands/builtin/Misc.cpp b/src/controllers/commands/builtin/Misc.cpp new file mode 100644 index 00000000000..e0a9c3ce675 --- /dev/null +++ b/src/controllers/commands/builtin/Misc.cpp @@ -0,0 +1,638 @@ +#include "controllers/commands/builtin/Misc.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/userdata/UserDataController.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" +#include "util/Clipboard.hpp" +#include "util/FormatTime.hpp" +#include "util/IncognitoBrowser.hpp" +#include "util/StreamLink.hpp" +#include "util/Twitch.hpp" +#include "widgets/dialogs/UserInfoPopup.hpp" +#include "widgets/helper/ChannelView.hpp" +#include "widgets/Notebook.hpp" +#include "widgets/splits/Split.hpp" +#include "widgets/splits/SplitContainer.hpp" +#include "widgets/Window.hpp" + +#include +#include +#include + +namespace chatterino::commands { + +QString follow(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + return ""; + } + ctx.channel->addMessage(makeSystemMessage( + "Twitch has removed the ability to follow users through " + "third-party applications. For more information, see " + "https://github.com/Chatterino/chatterino2/issues/3076")); + return ""; +} + +QString unfollow(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + return ""; + } + ctx.channel->addMessage(makeSystemMessage( + "Twitch has removed the ability to unfollow users through " + "third-party applications. For more information, see " + "https://github.com/Chatterino/chatterino2/issues/3076")); + return ""; +} + +QString uptime(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /uptime command only works in Twitch Channels.")); + return ""; + } + + const auto &streamStatus = ctx.twitchChannel->accessStreamStatus(); + + QString messageText = + streamStatus->live ? streamStatus->uptime : "Channel is not live."; + + ctx.channel->addMessage(makeSystemMessage(messageText)); + + return ""; +} + +QString user(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /user [channel]")); + return ""; + } + QString userName = ctx.words[1]; + stripUserName(userName); + + QString channelName = ctx.channel->getName(); + + if (ctx.words.size() > 2) + { + channelName = ctx.words[2]; + stripChannelName(channelName); + } + openTwitchUsercard(channelName, userName); + + return ""; +} + +QString requests(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /requests [channel]. You can also use the command " + "without arguments in any Twitch channel to open its " + "channel points requests queue. Only the broadcaster and " + "moderators have permission to view the queue.")); + return ""; + } + } + + stripChannelName(target); + QDesktopServices::openUrl(QUrl( + QString("https://www.twitch.tv/popout/%1/reward-queue").arg(target))); + + return ""; +} + +QString lowtrust(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /lowtrust [channel]. You can also use the command " + "without arguments in any Twitch channel to open its " + "suspicious user activity feed. Only the broadcaster and " + "moderators have permission to view this feed.")); + return ""; + } + } + + stripChannelName(target); + QDesktopServices::openUrl(QUrl( + QString("https://www.twitch.tv/popout/moderator/%1/low-trust-users") + .arg(target))); + + return ""; +} + +QString clip(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (const auto type = ctx.channel->getType(); + type != Channel::Type::Twitch && type != Channel::Type::TwitchWatching) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clip command only works in Twitch Channels.")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clip command only works in Twitch Channels.")); + return ""; + } + + ctx.twitchChannel->createClip(); + + return ""; +} + +QString marker(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /marker command only works in Twitch channels.")); + return ""; + } + + // Avoid Helix calls without Client ID and/or OAuth Token + if (getIApp()->getAccounts()->twitch.getCurrent()->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You need to be logged in to create stream markers!")); + return ""; + } + + // Exact same message as in webchat + if (!ctx.twitchChannel->isLive()) + { + ctx.channel->addMessage(makeSystemMessage( + "You can only add stream markers during live streams. Try " + "again when the channel is live streaming.")); + return ""; + } + + auto arguments = ctx.words; + arguments.removeFirst(); + + getHelix()->createStreamMarker( + // Limit for description is 140 characters, webchat just crops description + // if it's >140 characters, so we're doing the same thing + ctx.twitchChannel->roomId(), arguments.join(" ").left(140), + [channel{ctx.channel}, + arguments](const HelixStreamMarker &streamMarker) { + channel->addMessage(makeSystemMessage( + QString("Successfully added a stream marker at %1%2") + .arg(formatTime(streamMarker.positionSeconds)) + .arg(streamMarker.description.isEmpty() + ? "" + : QString(": \"%1\"") + .arg(streamMarker.description)))); + }, + [channel{ctx.channel}](auto error) { + QString errorMessage("Failed to create stream marker - "); + + switch (error) + { + case HelixStreamMarkerError::UserNotAuthorized: { + errorMessage += + "you don't have permission to perform that action."; + } + break; + + case HelixStreamMarkerError::UserNotAuthenticated: { + errorMessage += "you need to re-authenticate."; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixStreamMarkerError::Unknown: + default: { + errorMessage += "an unknown error occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +QString streamlink(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "/streamlink [channel]. Open specified Twitch channel in " + "streamlink. If no channel argument is specified, open the " + "current Twitch channel instead.")); + return ""; + } + } + + stripChannelName(target); + openStreamlinkForChannel(target); + + return ""; +} + +QString popout(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /popout . You can also use the command " + "without arguments in any Twitch channel to open its " + "popout chat.")); + return ""; + } + } + + stripChannelName(target); + QDesktopServices::openUrl(QUrl( + QString("https://www.twitch.tv/popout/%1/chat?popout=").arg(target))); + + return ""; +} + +QString popup(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + static const auto *usageMessage = + "Usage: /popup [channel]. Open specified Twitch channel in " + "a new window. If no channel argument is specified, open " + "the currently selected split instead."; + + QString target(ctx.words.value(1)); + stripChannelName(target); + + // Popup the current split + if (target.isEmpty()) + { + auto *currentPage = + dynamic_cast(getIApp() + ->getWindows() + ->getMainWindow() + .getNotebook() + .getSelectedPage()); + if (currentPage != nullptr) + { + auto *currentSplit = currentPage->getSelectedSplit(); + if (currentSplit != nullptr) + { + currentSplit->popup(); + + return ""; + } + } + + ctx.channel->addMessage(makeSystemMessage(usageMessage)); + return ""; + } + + // Open channel passed as argument in a popup + auto *app = getApp(); + auto targetChannel = app->twitch->getOrAddChannel(target); + app->getWindows()->openInPopup(targetChannel); + + return ""; +} + +QString clearmessages(const CommandContext &ctx) +{ + (void)ctx; + + auto *currentPage = dynamic_cast(getIApp() + ->getWindows() + ->getMainWindow() + .getNotebook() + .getSelectedPage()); + + if (auto *split = currentPage->getSelectedSplit()) + { + split->getChannelView().clearMessages(); + } + + return ""; +} + +QString openURL(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /openurl ")); + return ""; + } + + QUrl url = QUrl::fromUserInput(ctx.words.mid(1).join(" ")); + if (!url.isValid()) + { + ctx.channel->addMessage(makeSystemMessage("Invalid URL specified.")); + return ""; + } + + bool res = false; + if (supportsIncognitoLinks() && getSettings()->openLinksIncognito) + { + res = openLinkIncognito(url.toString(QUrl::FullyEncoded)); + } + else + { + res = QDesktopServices::openUrl(url); + } + + if (!res) + { + ctx.channel->addMessage(makeSystemMessage("Could not open URL.")); + } + + return ""; +} + +QString sendRawMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.channel->isTwitchChannel()) + { + getApp()->twitch->sendRawMessage(ctx.words.mid(1).join(" ")); + } + else + { + // other code down the road handles this for IRC + return ctx.words.join(" "); + } + return ""; +} + +QString injectFakeMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (!ctx.channel->isTwitchChannel()) + { + ctx.channel->addMessage(makeSystemMessage( + "The /fakemsg command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /fakemsg (raw irc text) - injects raw irc text as " + "if it was a message received from TMI")); + return ""; + } + + auto ircText = ctx.words.mid(1).join(" "); + getApp()->twitch->addFakeMessage(ircText); + + return ""; +} + +QString copyToClipboard(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /copy - copies provided " + "text to clipboard.")); + return ""; + } + + crossPlatformCopy(ctx.words.mid(1).join(" ")); + return ""; +} + +QString unstableSetUserClientSideColor(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("The /unstable-set-user-color command only " + "works in Twitch channels.")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: %1 [color]").arg(ctx.words.at(0)))); + return ""; + } + + auto userID = ctx.words.at(1); + + auto color = ctx.words.value(2); + + getIApp()->getUserData()->setUserColor(userID, color); + + return ""; +} + +QString openUsercard(const CommandContext &ctx) +{ + auto channel = ctx.channel; + + if (channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: /usercard [channel] or " + "/usercard id: [channel]")); + return ""; + } + + QString userName = ctx.words[1]; + stripUserName(userName); + + if (ctx.words.size() > 2) + { + QString channelName = ctx.words[2]; + stripChannelName(channelName); + + ChannelPtr channelTemp = + getApp()->twitch->getChannelOrEmpty(channelName); + + if (channelTemp->isEmpty()) + { + channel->addMessage(makeSystemMessage( + "A usercard can only be displayed for a channel that is " + "currently opened in Chatterino.")); + return ""; + } + + channel = channelTemp; + } + + // try to link to current split if possible + Split *currentSplit = nullptr; + auto *currentPage = dynamic_cast(getIApp() + ->getWindows() + ->getMainWindow() + .getNotebook() + .getSelectedPage()); + if (currentPage != nullptr) + { + currentSplit = currentPage->getSelectedSplit(); + } + + auto differentChannel = + currentSplit != nullptr && currentSplit->getChannel() != channel; + if (differentChannel || currentSplit == nullptr) + { + // not possible to use current split, try searching for one + const auto ¬ebook = + getIApp()->getWindows()->getMainWindow().getNotebook(); + auto count = notebook.getPageCount(); + for (int i = 0; i < count; i++) + { + auto *page = notebook.getPageAt(i); + auto *container = dynamic_cast(page); + assert(container != nullptr); + for (auto *split : container->getSplits()) + { + if (split->getChannel() == channel) + { + currentSplit = split; + break; + } + } + } + + // This would have crashed either way. + assert(currentSplit != nullptr && + "something went HORRIBLY wrong with the /usercard " + "command. It couldn't find a split for a channel which " + "should be open."); + } + + auto *userPopup = + new UserInfoPopup(getSettings()->autoCloseUserPopup, currentSplit); + userPopup->setData(userName, channel); + userPopup->moveTo(QCursor::pos(), widgets::BoundsChecking::CursorPosition); + userPopup->show(); + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/Misc.hpp b/src/controllers/commands/builtin/Misc.hpp new file mode 100644 index 00000000000..7a8be28c798 --- /dev/null +++ b/src/controllers/commands/builtin/Misc.hpp @@ -0,0 +1,32 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString follow(const CommandContext &ctx); +QString unfollow(const CommandContext &ctx); +QString uptime(const CommandContext &ctx); +QString user(const CommandContext &ctx); +QString requests(const CommandContext &ctx); +QString lowtrust(const CommandContext &ctx); +QString clip(const CommandContext &ctx); +QString marker(const CommandContext &ctx); +QString streamlink(const CommandContext &ctx); +QString popout(const CommandContext &ctx); +QString popup(const CommandContext &ctx); +QString clearmessages(const CommandContext &ctx); +QString openURL(const CommandContext &ctx); +QString sendRawMessage(const CommandContext &ctx); +QString injectFakeMessage(const CommandContext &ctx); +QString copyToClipboard(const CommandContext &ctx); +QString unstableSetUserClientSideColor(const CommandContext &ctx); +QString openUsercard(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp new file mode 100644 index 00000000000..c72f0cde04c --- /dev/null +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -0,0 +1,137 @@ +#include "controllers/commands/builtin/chatterino/Debugging.hpp" + +#include "common/Channel.hpp" +#include "common/Env.hpp" +#include "common/Literals.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Image.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" +#include "singletons/Theme.hpp" +#include "util/PostToThread.hpp" + +#include +#include +#include + +namespace chatterino::commands { + +using namespace literals; + +QString setLoggingRules(const CommandContext &ctx) +{ + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /c2-set-logging-rules . To enable debug logging " + "for all categories from chatterino, use " + "'chatterino.*.debug=true'. For the format on the rules, see " + "https://doc.qt.io/qt-6/" + "qloggingcategory.html#configuring-categories")); + return {}; + } + + auto filterRules = ctx.words.mid(1).join('\n'); + + QLoggingCategory::setFilterRules(filterRules); + + auto message = + QStringLiteral("Updated filter rules to '%1'.").arg(filterRules); + + if (!qgetenv("QT_LOGGING_RULES").isEmpty()) + { + message += QStringLiteral( + " Warning: Logging rules were previously set by the " + "QT_LOGGING_RULES environment variable. This might cause " + "interference - see: " + "https://doc.qt.io/qt-6/qloggingcategory.html#setFilterRules"); + } + + ctx.channel->addMessage(makeSystemMessage(message)); + return {}; +} + +QString toggleThemeReload(const CommandContext &ctx) +{ + if (getTheme()->isAutoReloading()) + { + getTheme()->setAutoReload(false); + ctx.channel->addMessage( + makeSystemMessage(u"Disabled theme auto reloading."_s)); + return {}; + } + + getTheme()->setAutoReload(true); + ctx.channel->addMessage( + makeSystemMessage(u"Auto reloading theme every %1 ms."_s.arg( + Theme::AUTO_RELOAD_INTERVAL_MS))); + return {}; +} + +QString listEnvironmentVariables(const CommandContext &ctx) +{ + const auto &channel = ctx.channel; + if (channel == nullptr) + { + return ""; + } + + auto env = Env::get(); + + QStringList debugMessages{ + "recentMessagesApiUrl: " + env.recentMessagesApiUrl, + "linkResolverUrl: " + env.linkResolverUrl, + "twitchServerHost: " + env.twitchServerHost, + "twitchServerPort: " + QString::number(env.twitchServerPort), + "twitchServerSecure: " + QString::number(env.twitchServerSecure), + }; + + for (QString &str : debugMessages) + { + MessageBuilder builder; + builder.emplace(QTime::currentTime()); + builder.emplace(str, MessageElementFlag::Text, + MessageColor::System); + channel->addMessage(builder.release()); + } + return ""; +} + +QString listArgs(const CommandContext &ctx) +{ + const auto &channel = ctx.channel; + if (channel == nullptr) + { + return ""; + } + + QString msg = QApplication::instance()->arguments().join(' '); + + channel->addMessage(makeSystemMessage(msg)); + + return ""; +} + +QString forceImageGarbageCollection(const CommandContext &ctx) +{ + (void)ctx; + + runInGuiThread([] { + auto &iep = ImageExpirationPool::instance(); + iep.freeOld(); + }); + return ""; +} + +QString forceImageUnload(const CommandContext &ctx) +{ + (void)ctx; + + runInGuiThread([] { + auto &iep = ImageExpirationPool::instance(); + iep.freeAll(); + }); + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.hpp b/src/controllers/commands/builtin/chatterino/Debugging.hpp new file mode 100644 index 00000000000..8d185737009 --- /dev/null +++ b/src/controllers/commands/builtin/chatterino/Debugging.hpp @@ -0,0 +1,25 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString setLoggingRules(const CommandContext &ctx); + +QString toggleThemeReload(const CommandContext &ctx); + +QString listEnvironmentVariables(const CommandContext &ctx); + +QString listArgs(const CommandContext &ctx); + +QString forceImageGarbageCollection(const CommandContext &ctx); + +QString forceImageUnload(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddModerator.cpp b/src/controllers/commands/builtin/twitch/AddModerator.cpp new file mode 100644 index 00000000000..6c88eb0248b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddModerator.cpp @@ -0,0 +1,131 @@ +#include "controllers/commands/builtin/twitch/AddModerator.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString addModerator(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /mod command only works in Twitch channels.")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/mod \" - Grant moderator status to a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to mod someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->addChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have added %1 as a moderator of this " + "channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to add channel moderator - "); + + using Error = HelixAddChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetIsVIP: { + errorMessage += + QString("%1 is currently a VIP, \"/unvip\" " + "them and " + "retry this command.") + .arg(targetUser.displayName); + } + break; + + case Error::TargetAlreadyModded: { + // Equivalent irc error + errorMessage = + QString("%1 is already a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddModerator.hpp b/src/controllers/commands/builtin/twitch/AddModerator.hpp new file mode 100644 index 00000000000..722ad724bb7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddModerator.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /mod +QString addModerator(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddVIP.cpp b/src/controllers/commands/builtin/twitch/AddVIP.cpp new file mode 100644 index 00000000000..ab0ae679e8b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddVIP.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/AddVIP.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString addVIP(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /vip command only works in Twitch channels.")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/vip \" - Grant VIP status to a user. Use " + "\"/vips\" to list the VIPs of this channel.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to VIP someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->addChannelVIP( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have added %1 as a VIP of this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = QString("Failed to add VIP - "); + + using Error = HelixAddChannelVIPError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + // These are actually the IRC equivalents, so we can ditch the prefix + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddVIP.hpp b/src/controllers/commands/builtin/twitch/AddVIP.hpp new file mode 100644 index 00000000000..3d956cc42f4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddVIP.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /vip +QString addVIP(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Announce.cpp b/src/controllers/commands/builtin/twitch/Announce.cpp new file mode 100644 index 00000000000..566c79fe10a --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Announce.cpp @@ -0,0 +1,81 @@ +#include "controllers/commands/builtin/twitch/Announce.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString sendAnnouncement(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "This command can only be used in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /announce - Call attention to your " + "message with a highlight.")); + return ""; + } + + auto user = getIApp()->getAccounts()->twitch.getCurrent(); + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /announce command.")); + return ""; + } + + getHelix()->sendChatAnnouncement( + ctx.twitchChannel->roomId(), user->getUserId(), + ctx.words.mid(1).join(" "), HelixAnnouncementColor::Primary, + []() { + // do nothing. + }, + [channel{ctx.channel}](auto error, auto message) { + using Error = HelixSendChatAnnouncementError; + QString errorMessage = QString("Failed to send announcement - "); + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Announce.hpp b/src/controllers/commands/builtin/twitch/Announce.hpp new file mode 100644 index 00000000000..3904d1a203c --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Announce.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /announce +QString sendAnnouncement(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Ban.cpp b/src/controllers/commands/builtin/twitch/Ban.cpp new file mode 100644 index 00000000000..27b3d5a462d --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Ban.cpp @@ -0,0 +1,310 @@ +#include "controllers/commands/builtin/twitch/Ban.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatBanTimeoutError(const char *operation, HelixBanUserError error, + const QString &message, const QString &userTarget) +{ + using Error = HelixBanUserError; + + QString errorMessage = QString("Failed to %1 user - ").arg(operation); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetBanned: { + // Equivalent IRC error + errorMessage += QString("%1 is already banned in this channel.") + .arg(userTarget); + } + break; + + case Error::CannotBanUser: { + // We can't provide the identical error as in IRC, + // because we don't have enough information about the user. + // The messages from IRC are formatted like this: + // "You cannot {op} moderator {mod} unless you are the owner of this channel." + // "You cannot {op} the broadcaster." + errorMessage += + QString("You cannot %1 %2.").arg(operation, userTarget); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + const QString &reason, const QString &displayName) +{ + getHelix()->banUser( + twitchChannel->roomId(), sourceUserID, targetUserID, std::nullopt, + reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("ban", error, message, displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +void timeoutUserByID(const ChannelPtr &channel, + const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + int duration, const QString &reason, + const QString &displayName) +{ + getHelix()->banUser( + twitchChannel->roomId(), sourceUserID, targetUserID, duration, reason, + [] { + // No response for timeouts, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("timeout", error, message, displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +} // namespace + +namespace chatterino::commands { + +QString sendBan(const CommandContext &ctx) +{ + const auto &words = ctx.words; + const auto &channel = ctx.channel; + const auto *twitchChannel = ctx.twitchChannel; + + if (channel == nullptr) + { + return ""; + } + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /ban command only works in Twitch channels."))); + return ""; + } + + const auto *usageStr = + "Usage: \"/ban [reason]\" - Permanently prevent a user " + "from chatting. Reason is optional and will be shown to the target " + "user and other moderators. Use \"/unban\" to remove a ban."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to ban someone!")); + return ""; + } + + const auto &rawTarget = words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); + auto reason = words.mid(2).join(' '); + + if (!targetUserID.isEmpty()) + { + banUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUserID, reason, targetUserID); + } + else + { + getHelix()->getUserByName( + targetUserName, + [channel, currentUser, twitchChannel, + reason](const auto &targetUser) { + banUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUser.id, reason, targetUser.displayName); + }, + [channel, targetUserName{targetUserName}] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(targetUserName))); + }); + } + + return ""; +} + +QString sendBanById(const CommandContext &ctx) +{ + const auto &words = ctx.words; + const auto &channel = ctx.channel; + const auto *twitchChannel = ctx.twitchChannel; + + if (channel == nullptr) + { + return ""; + } + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /banid command only works in Twitch channels."))); + return ""; + } + + const auto *usageStr = + "Usage: \"/banid [reason]\" - Permanently prevent a user " + "from chatting via their user ID. Reason is optional and will be " + "shown to the target user and other moderators."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to ban someone!")); + return ""; + } + + auto target = words.at(1); + auto reason = words.mid(2).join(' '); + + banUserByID(channel, twitchChannel, currentUser->getUserId(), target, + reason, target); + + return ""; +} + +QString sendTimeout(const CommandContext &ctx) +{ + const auto &words = ctx.words; + const auto &channel = ctx.channel; + const auto *twitchChannel = ctx.twitchChannel; + + if (channel == nullptr) + { + return ""; + } + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /timeout command only works in Twitch channels."))); + return ""; + } + const auto *usageStr = + "Usage: \"/timeout [duration][time unit] [reason]\" - " + "Temporarily prevent a user from chatting. Duration (optional, " + "default=10 minutes) must be a positive integer; time unit " + "(optional, default=s) must be one of s, m, h, d, w; maximum " + "duration is 2 weeks. Combinations like 1d2h are also allowed. " + "Reason is optional and will be shown to the target user and other " + "moderators. Use \"/untimeout\" to remove a timeout."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to timeout someone!")); + return ""; + } + + const auto &rawTarget = words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); + + int duration = 10 * 60; // 10min + if (words.size() >= 3) + { + duration = (int)parseDurationToSeconds(words.at(2)); + if (duration <= 0) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + } + auto reason = words.mid(3).join(' '); + + if (!targetUserID.isEmpty()) + { + timeoutUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUserID, duration, reason, targetUserID); + } + else + { + getHelix()->getUserByName( + targetUserName, + [channel, currentUser, twitchChannel, + targetUserName{targetUserName}, duration, + reason](const auto &targetUser) { + timeoutUserByID(channel, twitchChannel, + currentUser->getUserId(), targetUser.id, + duration, reason, targetUser.displayName); + }, + [channel, targetUserName{targetUserName}] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(targetUserName))); + }); + } + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Ban.hpp b/src/controllers/commands/builtin/twitch/Ban.hpp new file mode 100644 index 00000000000..9ba72491087 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Ban.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /ban +QString sendBan(const CommandContext &ctx); +/// /banid +QString sendBanById(const CommandContext &ctx); + +/// /timeout +QString sendTimeout(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Block.cpp b/src/controllers/commands/builtin/twitch/Block.cpp new file mode 100644 index 00000000000..35bb780d742 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Block.cpp @@ -0,0 +1,166 @@ +#include "controllers/commands/builtin/twitch/Block.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +} // namespace + +namespace chatterino::commands { + +QString blockUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /block command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /block ")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to block someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [currentUser, channel{ctx.channel}, + target](const HelixUser &targetUser) { + getIApp()->getAccounts()->twitch.getCurrent()->blockUser( + targetUser.id, nullptr, + [channel, target, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You successfully blocked user %1") + .arg(target))); + }, + [channel, target] { + channel->addMessage(makeSystemMessage( + QString("User %1 couldn't be blocked, an unknown " + "error occurred!") + .arg(target))); + }); + }, + [channel{ctx.channel}, target] { + channel->addMessage( + makeSystemMessage(QString("User %1 couldn't be blocked, no " + "user with that name found!") + .arg(target))); + }); + + return ""; +} + +QString ignoreUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + ctx.channel->addMessage(makeSystemMessage( + "Ignore command has been renamed to /block, please use it from " + "now on as /ignore is going to be removed soon.")); + + return blockUser(ctx); +} + +QString unblockUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unblock command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /unblock ")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unblock someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [currentUser, channel{ctx.channel}, target](const auto &targetUser) { + getIApp()->getAccounts()->twitch.getCurrent()->unblockUser( + targetUser.id, nullptr, + [channel, target, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You successfully unblocked user %1") + .arg(target))); + }, + [channel, target] { + channel->addMessage(makeSystemMessage( + QString("User %1 couldn't be unblocked, an unknown " + "error occurred!") + .arg(target))); + }); + }, + [channel{ctx.channel}, target] { + channel->addMessage( + makeSystemMessage(QString("User %1 couldn't be unblocked, " + "no user with that name found!") + .arg(target))); + }); + + return ""; +} + +QString unignoreUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + ctx.channel->addMessage(makeSystemMessage( + "Unignore command has been renamed to /unblock, please use it " + "from now on as /unignore is going to be removed soon.")); + return unblockUser(ctx); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Block.hpp b/src/controllers/commands/builtin/twitch/Block.hpp new file mode 100644 index 00000000000..75ea3d0d42a --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Block.hpp @@ -0,0 +1,25 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /block +QString blockUser(const CommandContext &ctx); + +/// /ignore +QString ignoreUser(const CommandContext &ctx); + +/// /unblock +QString unblockUser(const CommandContext &ctx); + +/// /unignore +QString unignoreUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/ChatSettings.cpp b/src/controllers/commands/builtin/twitch/ChatSettings.cpp index ed687261561..9f4bdfea616 100644 --- a/src/controllers/commands/builtin/twitch/ChatSettings.cpp +++ b/src/controllers/commands/builtin/twitch/ChatSettings.cpp @@ -101,7 +101,7 @@ namespace chatterino::commands { QString emoteOnly(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -111,7 +111,7 @@ QString emoteOnly(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /emoteonly command only works in Twitch channels")); + "The /emoteonly command only works in Twitch channels.")); return ""; } @@ -131,7 +131,7 @@ QString emoteOnly(const CommandContext &ctx) QString emoteOnlyOff(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -140,7 +140,7 @@ QString emoteOnlyOff(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /emoteonlyoff command only works in Twitch channels")); + "The /emoteonlyoff command only works in Twitch channels.")); return ""; } @@ -160,7 +160,7 @@ QString emoteOnlyOff(const CommandContext &ctx) QString subscribers(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -170,7 +170,7 @@ QString subscribers(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /subscribers command only works in Twitch channels")); + "The /subscribers command only works in Twitch channels.")); return ""; } @@ -190,7 +190,7 @@ QString subscribers(const CommandContext &ctx) QString subscribersOff(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -200,7 +200,7 @@ QString subscribersOff(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /subscribersoff command only works in Twitch channels")); + "The /subscribersoff command only works in Twitch channels.")); return ""; } @@ -220,7 +220,7 @@ QString subscribersOff(const CommandContext &ctx) QString slow(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -230,7 +230,7 @@ QString slow(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /slow command only works in Twitch channels")); + "The /slow command only works in Twitch channels.")); return ""; } @@ -267,7 +267,7 @@ QString slow(const CommandContext &ctx) QString slowOff(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -277,7 +277,7 @@ QString slowOff(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /slowoff command only works in Twitch channels")); + "The /slowoff command only works in Twitch channels.")); return ""; } @@ -289,7 +289,7 @@ QString slowOff(const CommandContext &ctx) } getHelix()->updateSlowMode(ctx.twitchChannel->roomId(), - currentUser->getUserId(), boost::none, + currentUser->getUserId(), std::nullopt, successCallback, failureCallback(ctx.channel)); return ""; @@ -297,7 +297,7 @@ QString slowOff(const CommandContext &ctx) QString followers(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -307,7 +307,7 @@ QString followers(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /followers command only works in Twitch channels")); + "The /followers command only works in Twitch channels.")); return ""; } @@ -345,7 +345,7 @@ QString followers(const CommandContext &ctx) QString followersOff(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -355,7 +355,7 @@ QString followersOff(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /followersoff command only works in Twitch channels")); + "The /followersoff command only works in Twitch channels.")); return ""; } @@ -367,7 +367,7 @@ QString followersOff(const CommandContext &ctx) } getHelix()->updateFollowerMode( - ctx.twitchChannel->roomId(), currentUser->getUserId(), boost::none, + ctx.twitchChannel->roomId(), currentUser->getUserId(), std::nullopt, successCallback, failureCallback(ctx.channel)); return ""; @@ -375,7 +375,7 @@ QString followersOff(const CommandContext &ctx) QString uniqueChat(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -385,7 +385,7 @@ QString uniqueChat(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /uniquechat command only works in Twitch channels")); + "The /uniquechat command only works in Twitch channels.")); return ""; } @@ -405,7 +405,7 @@ QString uniqueChat(const CommandContext &ctx) QString uniqueChatOff(const CommandContext &ctx) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); @@ -415,7 +415,7 @@ QString uniqueChatOff(const CommandContext &ctx) if (ctx.twitchChannel == nullptr) { ctx.channel->addMessage(makeSystemMessage( - "The /uniquechatoff command only works in Twitch channels")); + "The /uniquechatoff command only works in Twitch channels.")); return ""; } diff --git a/src/controllers/commands/builtin/twitch/Chatters.cpp b/src/controllers/commands/builtin/twitch/Chatters.cpp new file mode 100644 index 00000000000..8f3134961b4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Chatters.cpp @@ -0,0 +1,143 @@ +#include "controllers/commands/builtin/twitch/Chatters.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/Env.hpp" +#include "common/Literals.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/Theme.hpp" + +#include +#include +#include + +namespace { + +using namespace chatterino; + +QString formatChattersError(HelixGetChattersError error, const QString &message) +{ + using Error = HelixGetChattersError; + + QString errorMessage = QString("Failed to get chatter count - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must have moderator permissions to " + "use this command."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString chatters(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /chatters command only works in Twitch Channels.")); + return ""; + } + + // Refresh chatter list via helix api for mods + getHelix()->getChatters( + ctx.twitchChannel->roomId(), + getIApp()->getAccounts()->twitch.getCurrent()->getUserId(), 1, + [channel{ctx.channel}](auto result) { + channel->addMessage( + makeSystemMessage(QString("Chatter count: %1.") + .arg(localizeNumbers(result.total)))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +QString testChatters(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /test-chatters command only works in Twitch Channels.")); + return ""; + } + + getHelix()->getChatters( + ctx.twitchChannel->roomId(), + getIApp()->getAccounts()->twitch.getCurrent()->getUserId(), 5000, + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}](auto result) { + QStringList entries; + for (const auto &username : result.chatters) + { + entries << username; + } + + QString prefix = "Chatters "; + + if (result.total > 5000) + { + prefix += QString("(5000/%1):").arg(result.total); + } + else + { + prefix += QString("(%1):").arg(result.total); + } + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + prefix, entries, twitchChannel, &builder); + + channel->addMessage(builder.release()); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Chatters.hpp b/src/controllers/commands/builtin/twitch/Chatters.hpp new file mode 100644 index 00000000000..25b34bab9cb --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Chatters.hpp @@ -0,0 +1,17 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString chatters(const CommandContext &ctx); + +QString testChatters(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/DeleteMessages.cpp b/src/controllers/commands/builtin/twitch/DeleteMessages.cpp new file mode 100644 index 00000000000..c0947968b23 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/DeleteMessages.cpp @@ -0,0 +1,162 @@ +#include "controllers/commands/builtin/twitch/DeleteMessages.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/network/NetworkResult.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include + +namespace { + +using namespace chatterino; + +QString deleteMessages(TwitchChannel *twitchChannel, const QString &messageID) +{ + const auto *commandName = messageID.isEmpty() ? "/clear" : "/delete"; + + auto user = getIApp()->getAccounts()->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + twitchChannel->addMessage(makeSystemMessage( + QString("You must be logged in to use the %1 command.") + .arg(commandName))); + return ""; + } + + getHelix()->deleteChatMessages( + twitchChannel->roomId(), user->getUserId(), messageID, + []() { + // Success handling, we do nothing: IRC/pubsub-edge will dispatch the correct + // events to update state for us. + }, + [twitchChannel, messageID](auto error, auto message) { + QString errorMessage = QString("Failed to delete chat messages - "); + + switch (error) + { + case HelixDeleteChatMessagesError::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case HelixDeleteChatMessagesError::UserNotAuthorized: { + errorMessage += + "you don't have permission to perform that action."; + } + break; + + case HelixDeleteChatMessagesError::MessageUnavailable: { + // Override default message prefix to match with IRC message format + errorMessage = + QString("The message %1 does not exist, was deleted, " + "or is too old to be deleted.") + .arg(messageID); + } + break; + + case HelixDeleteChatMessagesError::UserNotAuthenticated: { + errorMessage += "you need to re-authenticate."; + } + break; + + case HelixDeleteChatMessagesError::Forwarded: { + errorMessage += message; + } + break; + + case HelixDeleteChatMessagesError::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + twitchChannel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace + +namespace chatterino::commands { + +QString deleteAllMessages(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clear command only works in Twitch channels.")); + return ""; + } + + return deleteMessages(ctx.twitchChannel, QString()); +} + +QString deleteOneMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + // This is a wrapper over the Helix delete messages endpoint + // We use this to ensure the user gets better error messages for missing or malformed arguments + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /delete command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /delete - Deletes the " + "specified message.")); + return ""; + } + + auto messageID = ctx.words.at(1); + auto uuid = QUuid(messageID); + if (uuid.isNull()) + { + // The message id must be a valid UUID + ctx.channel->addMessage(makeSystemMessage( + QString("Invalid msg-id: \"%1\"").arg(messageID))); + return ""; + } + + auto msg = ctx.channel->findMessage(messageID); + if (msg != nullptr) + { + if (msg->loginName == ctx.channel->getName() && + !ctx.channel->isBroadcaster()) + { + ctx.channel->addMessage(makeSystemMessage( + "You cannot delete the broadcaster's messages unless " + "you are the broadcaster.")); + return ""; + } + } + + return deleteMessages(ctx.twitchChannel, messageID); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/DeleteMessages.hpp b/src/controllers/commands/builtin/twitch/DeleteMessages.hpp new file mode 100644 index 00000000000..24daae93083 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/DeleteMessages.hpp @@ -0,0 +1,19 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /clear +QString deleteAllMessages(const CommandContext &ctx); + +/// /delete +QString deleteOneMessage(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetModerators.cpp b/src/controllers/commands/builtin/twitch/GetModerators.cpp new file mode 100644 index 00000000000..6f1908f03f3 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetModerators.cpp @@ -0,0 +1,94 @@ +#include "controllers/commands/builtin/twitch/GetModerators.hpp" + +#include "common/Channel.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +namespace { + +using namespace chatterino; + +QString formatModsError(HelixGetModeratorsError error, const QString &message) +{ + using Error = HelixGetModeratorsError; + + QString errorMessage = QString("Failed to get moderators - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of mods you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString getModerators(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /mods command only works in Twitch Channels.")); + return ""; + } + + getHelix()->getModerators( + ctx.twitchChannel->roomId(), 500, + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}](auto result) { + if (result.empty()) + { + channel->addMessage(makeSystemMessage( + "This channel does not have any moderators.")); + return; + } + + // TODO: sort results? + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + "The moderators of this channel are", result, twitchChannel, + &builder); + channel->addMessage(builder.release()); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatModsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetModerators.hpp b/src/controllers/commands/builtin/twitch/GetModerators.hpp new file mode 100644 index 00000000000..517533246e9 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetModerators.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /mods +QString getModerators(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.cpp b/src/controllers/commands/builtin/twitch/GetVIPs.cpp new file mode 100644 index 00000000000..5db9d2eff8f --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetVIPs.cpp @@ -0,0 +1,124 @@ +#include "controllers/commands/builtin/twitch/GetVIPs.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatGetVIPsError(HelixListVIPsError error, const QString &message) +{ + using Error = HelixListVIPsError; + + QString errorMessage = QString("Failed to list VIPs - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserNotBroadcaster: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString getVIPs(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /vips command only works in Twitch channels.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "Due to Twitch restrictions, " // + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the " + "Twitch website.")); + return ""; + } + + getHelix()->getChannelVIPs( + ctx.twitchChannel->roomId(), + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}]( + const std::vector &vipList) { + if (vipList.empty()) + { + channel->addMessage( + makeSystemMessage("This channel does not have any VIPs.")); + return; + } + + auto messagePrefix = QString("The VIPs of this channel are"); + + // TODO: sort results? + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + messagePrefix, vipList, twitchChannel, &builder); + + channel->addMessage(builder.release()); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatGetVIPsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.hpp b/src/controllers/commands/builtin/twitch/GetVIPs.hpp new file mode 100644 index 00000000000..01853759024 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetVIPs.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /vips +QString getVIPs(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Raid.cpp b/src/controllers/commands/builtin/twitch/Raid.cpp new file mode 100644 index 00000000000..421ab22f415 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Raid.cpp @@ -0,0 +1,220 @@ +#include "controllers/commands/builtin/twitch/Raid.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatStartRaidError(HelixStartRaidError error, const QString &message) +{ + QString errorMessage = QString("Failed to start a raid - "); + + using Error = HelixStartRaidError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must be the broadcaster " + "to start a raid."; + } + break; + + case Error::CantRaidYourself: { + errorMessage += "A channel cannot raid itself."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited " + "by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + return errorMessage; +} + +QString formatCancelRaidError(HelixCancelRaidError error, + const QString &message) +{ + QString errorMessage = QString("Failed to cancel the raid - "); + + using Error = HelixCancelRaidError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must be the broadcaster " + "to cancel the raid."; + } + break; + + case Error::NoRaidPending: { + errorMessage += "You don't have an active raid."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString startRaid(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /raid command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: \"/raid \" - Raid a user. " + "Only the broadcaster can start a raid.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to start a raid!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->startRaid( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage( + makeSystemMessage(QString("You started to raid %1.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + auto errorMessage = formatStartRaidError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +QString cancelRaid(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unraid command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() != 1) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: \"/unraid\" - Cancel the current raid. " + "Only the broadcaster can cancel the raid.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to cancel the raid!")); + return ""; + } + + getHelix()->cancelRaid( + ctx.twitchChannel->roomId(), + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage(QString("You cancelled the raid."))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatCancelRaidError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Raid.hpp b/src/controllers/commands/builtin/twitch/Raid.hpp new file mode 100644 index 00000000000..38d37644ded --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Raid.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /raid +QString startRaid(const CommandContext &ctx); + +/// /unraid +QString cancelRaid(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.cpp b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp new file mode 100644 index 00000000000..ba79c4d33ec --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp @@ -0,0 +1,122 @@ +#include "controllers/commands/builtin/twitch/RemoveModerator.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString removeModerator(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unmod command only works in Twitch channels.")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/unmod \" - Revoke moderator status from a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unmod someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->removeChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have removed %1 as a moderator of " + "this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to remove channel moderator - "); + + using Error = HelixRemoveChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotModded: { + // Equivalent irc error + errorMessage += + QString("%1 is not a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.hpp b/src/controllers/commands/builtin/twitch/RemoveModerator.hpp new file mode 100644 index 00000000000..9b6894dc779 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unmod +QString removeModerator(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.cpp b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp new file mode 100644 index 00000000000..53a1ba2b52c --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/RemoveVIP.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString removeVIP(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unvip command only works in Twitch channels.")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/unvip \" - Revoke VIP status from a user. " + "Use \"/vips\" to list the VIPs of this channel.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to UnVIP someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->removeChannelVIP( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have removed %1 as a VIP of this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = QString("Failed to remove VIP - "); + + using Error = HelixRemoveChannelVIPError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + // These are actually the IRC equivalents, so we can ditch the prefix + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.hpp b/src/controllers/commands/builtin/twitch/RemoveVIP.hpp new file mode 100644 index 00000000000..ec66c62e729 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unvip +QString removeVIP(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendReply.cpp b/src/controllers/commands/builtin/twitch/SendReply.cpp new file mode 100644 index 00000000000..381c66b30a7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendReply.cpp @@ -0,0 +1,63 @@ +#include "controllers/commands/builtin/twitch/SendReply.hpp" + +#include "common/Channel.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageThread.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString sendReply(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /reply command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 3) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /reply ")); + return ""; + } + + QString username = ctx.words[1]; + stripChannelName(username); + + auto snapshot = ctx.twitchChannel->getMessageSnapshot(); + for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) + { + const auto &msg = *it; + if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) + { + // found most recent message by user + if (msg->replyThread == nullptr) + { + // prepare thread if one does not exist + auto thread = std::make_shared(msg); + ctx.twitchChannel->addReplyThread(thread); + } + + QString reply = ctx.words.mid(2).join(" "); + ctx.twitchChannel->sendReply(reply, msg->id); + return ""; + } + } + + ctx.channel->addMessage( + makeSystemMessage("A message from that user wasn't found.")); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendReply.hpp b/src/controllers/commands/builtin/twitch/SendReply.hpp new file mode 100644 index 00000000000..0909ae047c0 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendReply.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendReply(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.cpp b/src/controllers/commands/builtin/twitch/SendWhisper.cpp new file mode 100644 index 00000000000..ffb4ac48c85 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendWhisper.cpp @@ -0,0 +1,262 @@ +#include "controllers/commands/builtin/twitch/SendWhisper.hpp" + +#include "Application.hpp" +#include "common/LinkParser.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/irc/IrcChannel2.hpp" +#include "providers/irc/IrcServer.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatWhisperError(HelixWhisperError error, const QString &message) +{ + using Error = HelixWhisperError; + + QString errorMessage = "Failed to send whisper - "; + + switch (error) + { + case Error::NoVerifiedPhone: { + errorMessage += "Due to Twitch restrictions, you are now " + "required to have a verified phone number " + "to send whispers. You can add a phone " + "number in Twitch settings. " + "https://www.twitch.tv/settings/security"; + }; + break; + + case Error::RecipientBlockedUser: { + errorMessage += "The recipient doesn't allow whispers " + "from strangers or you directly."; + }; + break; + + case Error::WhisperSelf: { + errorMessage += "You cannot whisper yourself."; + }; + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You may only whisper a maximum of 40 " + "unique recipients per day. Within the " + "per day limit, you may whisper a " + "maximum of 3 whispers per second and " + "a maximum of 100 whispers per minute."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + return errorMessage; +} + +bool appendWhisperMessageWordsLocally(const QStringList &words) +{ + auto *app = getApp(); + + MessageBuilder b; + + b.emplace(); + b.emplace( + app->getAccounts()->twitch.getCurrent()->getUserName(), + MessageElementFlag::Text, MessageColor::Text, + FontStyle::ChatMediumBold); + b.emplace("->", MessageElementFlag::Text, + getIApp()->getThemes()->messages.textColors.system); + b.emplace(words[1] + ":", MessageElementFlag::Text, + MessageColor::Text, FontStyle::ChatMediumBold); + + const auto &acc = app->getAccounts()->twitch.getCurrent(); + const auto &accemotes = *acc->accessEmotes(); + const auto *bttvemotes = app->getBttvEmotes(); + const auto *ffzemotes = app->getFfzEmotes(); + auto flags = MessageElementFlags(); + auto emote = std::optional{}; + for (int i = 2; i < words.length(); i++) + { + { // Twitch emote + auto it = accemotes.emotes.find({words[i]}); + if (it != accemotes.emotes.end()) + { + b.emplace(it->second, + MessageElementFlag::TwitchEmote); + continue; + } + } // Twitch emote + + { // bttv/ffz emote + if ((emote = bttvemotes->emote({words[i]}))) + { + flags = MessageElementFlag::BttvEmote; + } + else if ((emote = ffzemotes->emote({words[i]}))) + { + flags = MessageElementFlag::FfzEmote; + } + // TODO: Load 7tv global emotes + if (emote) + { + b.emplace(*emote, flags); + continue; + } + } // bttv/ffz emote + { // emoji/text + for (auto &variant : app->getEmotes()->getEmojis()->parse(words[i])) + { + constexpr const static struct { + void operator()(EmotePtr emote, MessageBuilder &b) const + { + b.emplace(emote, + MessageElementFlag::EmojiAll); + } + void operator()(const QString &string, + MessageBuilder &b) const + { + LinkParser parser(string); + if (parser.result()) + { + b.addLink(*parser.result()); + } + else + { + b.emplace(string, + MessageElementFlag::Text); + } + } + } visitor; + boost::apply_visitor( + [&b](auto &&arg) { + visitor(arg, b); + }, + variant); + } // emoji/text + } + } + + b->flags.set(MessageFlag::DoNotTriggerNotification); + b->flags.set(MessageFlag::Whisper); + auto messagexD = b.release(); + + app->twitch->whispersChannel->addMessage(messagexD); + + auto overrideFlags = std::optional(messagexD->flags); + overrideFlags->set(MessageFlag::DoNotLog); + + if (getSettings()->inlineWhispers && + !(getSettings()->streamerModeSuppressInlineWhispers && + isInStreamerMode())) + { + app->twitch->forEachChannel( + [&messagexD, overrideFlags](ChannelPtr _channel) { + _channel->addMessage(messagexD, overrideFlags); + }); + } + + return true; +} + +} // namespace + +namespace chatterino::commands { + +QString sendWhisper(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 3) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /w ")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to send a whisper!")); + return ""; + } + auto target = ctx.words.at(1); + stripChannelName(target); + auto message = ctx.words.mid(2).join(' '); + if (ctx.channel->isTwitchChannel()) + { + getHelix()->getUserByName( + target, + [channel{ctx.channel}, currentUser, target, message, + words{ctx.words}](const auto &targetUser) { + getHelix()->sendWhisper( + currentUser->getUserId(), targetUser.id, message, + [words] { + appendWhisperMessageWordsLocally(words); + }, + [channel, target, targetUser](auto error, auto message) { + auto errorMessage = formatWhisperError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage("No user matching that username.")); + }); + return ""; + } + + // we must be on IRC + auto *ircChannel = dynamic_cast(ctx.channel.get()); + if (ircChannel == nullptr) + { + // give up + return ""; + } + + auto *server = ircChannel->server(); + server->sendWhisper(target, message); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.hpp b/src/controllers/commands/builtin/twitch/SendWhisper.hpp new file mode 100644 index 00000000000..1e882a93667 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendWhisper.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendWhisper(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/ShieldMode.cpp b/src/controllers/commands/builtin/twitch/ShieldMode.cpp new file mode 100644 index 00000000000..424a9e31a35 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/ShieldMode.cpp @@ -0,0 +1,97 @@ +#include "controllers/commands/builtin/twitch/ShieldMode.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString toggleShieldMode(const CommandContext &ctx, bool isActivating) +{ + const QString command = + isActivating ? QStringLiteral("/shield") : QStringLiteral("/shieldoff"); + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + QStringLiteral("The %1 command only works in Twitch channels.") + .arg(command))); + return {}; + } + + auto user = getIApp()->getAccounts()->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + QStringLiteral("You must be logged in to use the %1 command.") + .arg(command))); + return {}; + } + + getHelix()->updateShieldMode( + ctx.twitchChannel->roomId(), user->getUserId(), isActivating, + [channel = ctx.channel](const auto &res) { + if (!res.isActive) + { + channel->addMessage( + makeSystemMessage("Shield mode was deactivated.")); + return; + } + + channel->addMessage( + makeSystemMessage("Shield mode was activated.")); + }, + [channel = ctx.channel](const auto error, const auto &message) { + using Error = HelixUpdateShieldModeError; + QString errorMessage = "Failed to update shield mode - "; + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::MissingPermission: { + errorMessage += "You must be a moderator of the channel."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).") + .arg(message); + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return {}; +} + +QString shieldModeOn(const CommandContext &ctx) +{ + return toggleShieldMode(ctx, true); +} + +QString shieldModeOff(const CommandContext &ctx) +{ + return toggleShieldMode(ctx, false); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/ShieldMode.hpp b/src/controllers/commands/builtin/twitch/ShieldMode.hpp new file mode 100644 index 00000000000..fad2694e8a4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/ShieldMode.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString shieldModeOn(const CommandContext &ctx); +QString shieldModeOff(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Shoutout.cpp b/src/controllers/commands/builtin/twitch/Shoutout.cpp new file mode 100644 index 00000000000..99c5c50b2fd --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Shoutout.cpp @@ -0,0 +1,114 @@ +#include "controllers/commands/builtin/twitch/Shoutout.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString sendShoutout(const CommandContext &ctx) +{ + auto *twitchChannel = ctx.twitchChannel; + auto channel = ctx.channel; + const auto *words = &ctx.words; + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /shoutout command only works in Twitch channels.")); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to send shoutout.")); + return ""; + } + + if (words->size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: \"/shoutout \" - Sends a " + "shoutout to the specified twitch user")); + return ""; + } + + auto target = words->at(1); + stripChannelName(target); + + using Error = HelixSendShoutoutError; + + getHelix()->getUserByName( + target, + [twitchChannel, channel, currentUser](const auto targetUser) { + getHelix()->sendShoutout( + twitchChannel->roomId(), targetUser.id, + currentUser->getUserId(), + [channel, targetUser]() { + channel->addMessage(makeSystemMessage( + QString("Sent shoutout to %1").arg(targetUser.login))); + }, + [channel](auto error, auto message) { + QString errorMessage = "Failed to send shoutout - "; + + switch (error) + { + case Error::UserNotAuthorized: { + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. " + "Try again in a few seconds."; + } + break; + + case Error::UserIsBroadcaster: { + errorMessage += "The broadcaster may not give " + "themselves a Shoutout."; + } + break; + + case Error::BroadcasterNotLive: { + errorMessage += + "The broadcaster is not streaming live or " + "does not have one or more viewers."; + } + break; + + case Error::Unknown: { + errorMessage += message; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Shoutout.hpp b/src/controllers/commands/builtin/twitch/Shoutout.hpp new file mode 100644 index 00000000000..ffeec03f853 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Shoutout.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendShoutout(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.cpp b/src/controllers/commands/builtin/twitch/StartCommercial.cpp new file mode 100644 index 00000000000..c582ad58e5b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/StartCommercial.cpp @@ -0,0 +1,136 @@ +#include "controllers/commands/builtin/twitch/StartCommercial.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +namespace { + +using namespace chatterino; + +QString formatStartCommercialError(HelixStartCommercialError error, + const QString &message) +{ + using Error = HelixStartCommercialError; + + QString errorMessage = "Failed to start commercial - "; + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::TokenMustMatchBroadcaster: { + errorMessage += "Only the broadcaster of the channel can run " + "commercials."; + } + break; + + case Error::BroadcasterNotStreaming: { + errorMessage += "You must be streaming live to run " + "commercials."; + } + break; + + case Error::MissingLengthParameter: { + errorMessage += "Command must include a desired commercial break " + "length that is greater than zero."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You must wait until your cooldown period " + "expires before you can run another " + "commercial."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).").arg(message); + } + break; + } + + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString startCommercial(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /commercial command only works in Twitch channels.")); + return ""; + } + + const auto *usageStr = "Usage: \"/commercial \" - Starts a " + "commercial with the " + "specified duration for the current " + "channel. Valid length options " + "are 30, 60, 90, 120, 150, and 180 seconds."; + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto user = getIApp()->getAccounts()->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /commercial command.")); + return ""; + } + + auto broadcasterID = ctx.twitchChannel->roomId(); + auto length = ctx.words.at(1).toInt(); + + getHelix()->startCommercial( + broadcasterID, length, + [channel{ctx.channel}](auto response) { + channel->addMessage(makeSystemMessage( + QString("Starting %1 second long commercial break. " + "Keep in mind you are still " + "live and not all viewers will receive a " + "commercial. " + "You may run another commercial in %2 seconds.") + .arg(response.length) + .arg(response.retryAfter))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatStartCommercialError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.hpp b/src/controllers/commands/builtin/twitch/StartCommercial.hpp new file mode 100644 index 00000000000..3b1d550fc98 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/StartCommercial.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /commercial +QString startCommercial(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Unban.cpp b/src/controllers/commands/builtin/twitch/Unban.cpp new file mode 100644 index 00000000000..e88008e8427 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Unban.cpp @@ -0,0 +1,145 @@ +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/builtin/twitch/Ban.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +void unbanUserByID(const ChannelPtr &channel, + const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + const QString &displayName) +{ + getHelix()->unbanUser( + twitchChannel->roomId(), sourceUserID, targetUserID, + [] { + // No response for unbans, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + using Error = HelixUnbanUserError; + + QString errorMessage = QString("Failed to unban user - "); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotBanned: { + // Equivalent IRC error + errorMessage = + QString("%1 is not banned from this channel.") + .arg(displayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +} // namespace + +namespace chatterino::commands { + +QString unbanUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + auto commandName = ctx.words.at(0).toLower(); + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + QString("The %1 command only works in Twitch channels.") + .arg(commandName))); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: \"%1 \" - Removes a ban on a user.") + .arg(commandName))); + return ""; + } + + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unban someone!")); + return ""; + } + + const auto &rawTarget = ctx.words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); + + if (!targetUserID.isEmpty()) + { + unbanUserByID(ctx.channel, ctx.twitchChannel, currentUser->getUserId(), + targetUserID, targetUserID); + } + else + { + getHelix()->getUserByName( + targetUserName, + [channel{ctx.channel}, currentUser, + twitchChannel{ctx.twitchChannel}, + targetUserName{targetUserName}](const auto &targetUser) { + unbanUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUser.id, targetUser.displayName); + }, + [channel{ctx.channel}, targetUserName{targetUserName}] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(targetUserName))); + }); + } + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Unban.hpp b/src/controllers/commands/builtin/twitch/Unban.hpp new file mode 100644 index 00000000000..4c32f09b717 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Unban.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unban +QString unbanUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.cpp b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp new file mode 100644 index 00000000000..bcda86f384d --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp @@ -0,0 +1,121 @@ +#include "controllers/commands/builtin/twitch/UpdateChannel.hpp" + +#include "common/Channel.hpp" +#include "common/network/NetworkResult.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString setTitle(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /settitle ")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("Unable to set title of non-Twitch channel.")); + return ""; + } + + auto status = ctx.twitchChannel->accessStreamStatus(); + auto title = ctx.words.mid(1).join(" "); + getHelix()->updateChannel( + ctx.twitchChannel->roomId(), "", "", title, + [channel{ctx.channel}, title](const auto &result) { + (void)result; + + channel->addMessage( + makeSystemMessage(QString("Updated title to %1").arg(title))); + }, + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage("Title update failed! Are you " + "missing the required scope?")); + }); + + return ""; +} + +QString setGame(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /setgame ")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("Unable to set game of non-Twitch channel.")); + return ""; + } + + const auto gameName = ctx.words.mid(1).join(" "); + + getHelix()->searchGames( + gameName, + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}, + gameName](const std::vector &games) { + if (games.empty()) + { + channel->addMessage(makeSystemMessage("Game not found.")); + return; + } + + auto matchedGame = games.at(0); + + if (games.size() > 1) + { + // NOTE: Improvements could be made with 'fuzzy string matching' code here + // attempt to find the best looking game by comparing exactly with lowercase values + for (const auto &game : games) + { + if (game.name.toLower() == gameName.toLower()) + { + matchedGame = game; + break; + } + } + } + + auto status = twitchChannel->accessStreamStatus(); + getHelix()->updateChannel( + twitchChannel->roomId(), matchedGame.id, "", "", + [channel, games, matchedGame](const NetworkResult &) { + channel->addMessage(makeSystemMessage( + QString("Updated game to %1").arg(matchedGame.name))); + }, + [channel] { + channel->addMessage( + makeSystemMessage("Game update failed! Are you " + "missing the required scope?")); + }); + }, + [channel{ctx.channel}] { + channel->addMessage(makeSystemMessage("Failed to look up game.")); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.hpp b/src/controllers/commands/builtin/twitch/UpdateChannel.hpp new file mode 100644 index 00000000000..2a085b49c0b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString setTitle(const CommandContext &ctx); +QString setGame(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateColor.cpp b/src/controllers/commands/builtin/twitch/UpdateColor.cpp new file mode 100644 index 00000000000..7e48873a2f4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateColor.cpp @@ -0,0 +1,99 @@ +#include "controllers/commands/builtin/twitch/UpdateColor.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString updateUserColor(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (!ctx.channel->isTwitchChannel()) + { + ctx.channel->addMessage(makeSystemMessage( + "The /color command only works in Twitch channels.")); + return ""; + } + auto user = getIApp()->getAccounts()->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /color command.")); + return ""; + } + + auto colorString = ctx.words.value(1); + + if (colorString.isEmpty()) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: /color - Color must be one of Twitch's " + "supported colors (%1) or a hex code (#000000) if you " + "have Turbo or Prime.") + .arg(VALID_HELIX_COLORS.join(", ")))); + return ""; + } + + cleanHelixColorName(colorString); + + getHelix()->updateUserChatColor( + user->getUserId(), colorString, + [colorString, channel{ctx.channel}] { + QString successMessage = + QString("Your color has been changed to %1.").arg(colorString); + channel->addMessage(makeSystemMessage(successMessage)); + }, + [colorString, channel{ctx.channel}](auto error, auto message) { + QString errorMessage = + QString("Failed to change color to %1 - ").arg(colorString); + + switch (error) + { + case HelixUpdateUserChatColorError::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case HelixUpdateUserChatColorError::InvalidColor: { + errorMessage += QString("Color must be one of Twitch's " + "supported colors (%1) or a " + "hex code (#000000) if you " + "have Turbo or Prime.") + .arg(VALID_HELIX_COLORS.join(", ")); + } + break; + + case HelixUpdateUserChatColorError::Forwarded: { + errorMessage += message + "."; + } + break; + + case HelixUpdateUserChatColorError::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateColor.hpp b/src/controllers/commands/builtin/twitch/UpdateColor.hpp new file mode 100644 index 00000000000..c4c3bdaf038 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateColor.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString updateUserColor(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/completion/CompletionModel.cpp b/src/controllers/completion/CompletionModel.cpp new file mode 100644 index 00000000000..ef19688c05c --- /dev/null +++ b/src/controllers/completion/CompletionModel.cpp @@ -0,0 +1,34 @@ +#include "controllers/completion/CompletionModel.hpp" + +#include "controllers/completion/sources/Source.hpp" + +namespace chatterino { + +CompletionModel::CompletionModel(QObject *parent) + : GenericListModel(parent) +{ +} + +void CompletionModel::setSource(std::unique_ptr source) +{ + this->source_ = std::move(source); +} + +bool CompletionModel::hasSource() const +{ + return this->source_ != nullptr; +} + +void CompletionModel::updateResults(const QString &query, size_t maxCount) +{ + if (this->source_) + { + this->source_->update(query); + + // Copy results to this model + this->clear(); + this->source_->addToListModel(*this, maxCount); + } +} + +} // namespace chatterino diff --git a/src/controllers/completion/CompletionModel.hpp b/src/controllers/completion/CompletionModel.hpp new file mode 100644 index 00000000000..f787a965bf0 --- /dev/null +++ b/src/controllers/completion/CompletionModel.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "widgets/listview/GenericListModel.hpp" + +#include +#include + +namespace chatterino { + +namespace completion { + class Source; +} // namespace completion + +/// @brief Represents the kind of completion occurring +enum class CompletionKind { + Emote, + User, +}; + +/// @brief CompletionModel is a GenericListModel intended to provide completion +/// suggestions to an InputCompletionPopup. The popup can determine the appropriate +/// source based on the current input and the user's preferences. +class CompletionModel final : public GenericListModel +{ +public: + explicit CompletionModel(QObject *parent); + + /// @brief Sets the Source for subsequent queries + /// @param source Source to use + void setSource(std::unique_ptr source); + + /// @return Whether the model has a source set + bool hasSource() const; + + /// @brief Updates the model based on the completion query + /// @param query Completion query + /// @param maxCount Maximum number of results. Zero indicates unlimited. + void updateResults(const QString &query, size_t maxCount = 0); + +private: + std::unique_ptr source_{}; +}; + +}; // namespace chatterino diff --git a/src/controllers/completion/TabCompletionModel.cpp b/src/controllers/completion/TabCompletionModel.cpp new file mode 100644 index 00000000000..202cc9ec8cf --- /dev/null +++ b/src/controllers/completion/TabCompletionModel.cpp @@ -0,0 +1,172 @@ +#include "controllers/completion/TabCompletionModel.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/completion/sources/CommandSource.hpp" +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/sources/UnifiedSource.hpp" +#include "controllers/completion/sources/UserSource.hpp" +#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" +#include "controllers/completion/strategies/ClassicUserStrategy.hpp" +#include "controllers/completion/strategies/CommandStrategy.hpp" +#include "controllers/completion/strategies/SmartEmoteStrategy.hpp" +#include "controllers/plugins/LuaUtilities.hpp" +#include "controllers/plugins/Plugin.hpp" +#include "controllers/plugins/PluginController.hpp" +#include "singletons/Settings.hpp" + +namespace chatterino { + +TabCompletionModel::TabCompletionModel(Channel &channel, QObject *parent) + : QStringListModel(parent) + , channel_(channel) +{ +} + +void TabCompletionModel::updateResults(const QString &query, + const QString &fullTextContent, + int cursorPosition, bool isFirstWord) +{ + this->updateSourceFromQuery(query); + + if (this->source_) + { + this->source_->update(query); + + // Copy results to this model + QStringList results; +#ifdef CHATTERINO_HAVE_PLUGINS + // Try plugins first + bool done{}; + std::tie(done, results) = + getIApp()->getPlugins()->updateCustomCompletions( + query, fullTextContent, cursorPosition, isFirstWord); + if (done) + { + this->setStringList(results); + return; + } +#endif + this->source_->addToStringList(results, 0, isFirstWord); + this->setStringList(results); + } +} + +void TabCompletionModel::updateSourceFromQuery(const QString &query) +{ + auto deducedKind = this->deduceSourceKind(query); + if (!deducedKind) + { + // unable to determine what kind of completion is occurring + this->source_ = nullptr; + return; + } + + // Build source for new query + this->source_ = this->buildSource(*deducedKind); +} + +std::optional + TabCompletionModel::deduceSourceKind(const QString &query) const +{ + if (query.length() < 2 || !this->channel_.isTwitchChannel()) + { + return std::nullopt; + } + + // Check for cases where we can definitively say what kind of completion is taking place. + + if (query.startsWith('@')) + { + return SourceKind::User; + } + else if (query.startsWith(':')) + { + return SourceKind::Emote; + } + else if (query.startsWith('/') || query.startsWith('.')) + { + return SourceKind::Command; + } + + // At this point, we note that emotes can be completed without using a : + // Therefore, we must also consider that the user could be completing an emote + // OR a mention depending on their completion settings. + + if (getSettings()->userCompletionOnlyWithAt) + { + // All kinds but user are possible + return SourceKind::EmoteCommand; + } + + // Any kind is possible + return SourceKind::EmoteUserCommand; +} + +std::unique_ptr TabCompletionModel::buildSource( + SourceKind kind) const +{ + switch (kind) + { + case SourceKind::Emote: { + return this->buildEmoteSource(); + } + case SourceKind::User: { + return this->buildUserSource(true); // Completing with @ + } + case SourceKind::Command: { + return this->buildCommandSource(); + } + case SourceKind::EmoteCommand: { + std::vector> sources; + sources.push_back(this->buildEmoteSource()); + sources.push_back(this->buildCommandSource()); + + return std::make_unique( + std::move(sources)); + } + case SourceKind::EmoteUserCommand: { + std::vector> sources; + sources.push_back(this->buildEmoteSource()); + sources.push_back( + this->buildUserSource(false)); // Not completing with @ + sources.push_back(this->buildCommandSource()); + + return std::make_unique( + std::move(sources)); + } + default: + return nullptr; + } +} + +std::unique_ptr TabCompletionModel::buildEmoteSource() const +{ + if (getSettings()->useSmartEmoteCompletion) + { + return std::make_unique( + &this->channel_, + std::make_unique()); + } + + return std::make_unique( + &this->channel_, + std::make_unique()); +} + +std::unique_ptr TabCompletionModel::buildUserSource( + bool prependAt) const +{ + return std::make_unique( + &this->channel_, std::make_unique(), + nullptr, prependAt); +} + +std::unique_ptr TabCompletionModel::buildCommandSource() + const +{ + return std::make_unique( + std::make_unique(true)); +} + +} // namespace chatterino diff --git a/src/controllers/completion/TabCompletionModel.hpp b/src/controllers/completion/TabCompletionModel.hpp new file mode 100644 index 00000000000..56cf313ed31 --- /dev/null +++ b/src/controllers/completion/TabCompletionModel.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "controllers/completion/sources/Source.hpp" + +#include +#include +#include + +#include + +namespace chatterino { + +class Channel; + +/// @brief TabCompletionModel is a QStringListModel intended to provide tab +/// completion to a ResizingTextInput. The model automatically selects a completion +/// source based on the current query before updating the results. +class TabCompletionModel : public QStringListModel +{ +public: + /// @brief Initializes a new TabCompletionModel bound to a Channel. + /// The reference to the Channel must live as long as the TabCompletionModel. + /// @param channel Channel reference + /// @param parent Model parent + explicit TabCompletionModel(Channel &channel, QObject *parent); + + /// @brief Updates the model based on the completion query + /// @param query Completion query + /// @param fullTextContent Full text of the input, used by plugins for contextual completion + /// @param cursorPosition Number of characters behind the cursor from the + /// beginning of fullTextContent, also used by plugins + /// @param isFirstWord Whether the completion is the first word in the input + void updateResults(const QString &query, const QString &fullTextContent, + int cursorPosition, bool isFirstWord = false); + +private: + enum class SourceKind { + // Known to be an emote, i.e. started with : + Emote, + // Known to be a username, i.e. started with @ + User, + // Known to be a command, i.e. started with / or . + Command, + // Emote or command without : or / . + EmoteCommand, + // Emote, user, or command without :, @, / . + EmoteUserCommand + }; + + /// @brief Updates the internal completion source based on the current query. + /// The completion source will only change if the deduced completion kind + /// changes (see deduceSourceKind). + /// @param query Completion query + void updateSourceFromQuery(const QString &query); + + /// @brief Attempts to deduce the source kind from the current query. If the + /// bound Channel is not a TwitchChannel or if the query is too short, no + /// query type will be deduced to prevent completions. + /// @param query Completion query + /// @return An optional SourceKind deduced from the query + std::optional deduceSourceKind(const QString &query) const; + + std::unique_ptr buildSource(SourceKind kind) const; + + std::unique_ptr buildEmoteSource() const; + std::unique_ptr buildUserSource(bool prependAt) const; + std::unique_ptr buildCommandSource() const; + + Channel &channel_; + std::unique_ptr source_{}; +}; + +} // namespace chatterino diff --git a/src/controllers/completion/sources/CommandSource.cpp b/src/controllers/completion/sources/CommandSource.cpp new file mode 100644 index 00000000000..9eedaf622ed --- /dev/null +++ b/src/controllers/completion/sources/CommandSource.cpp @@ -0,0 +1,107 @@ +#include "controllers/completion/sources/CommandSource.hpp" + +#include "Application.hpp" +#include "controllers/commands/Command.hpp" +#include "controllers/commands/CommandController.hpp" +#include "controllers/completion/sources/Helpers.hpp" +#include "providers/twitch/TwitchCommon.hpp" + +namespace chatterino::completion { + +namespace { + + void addCommand(const QString &command, std::vector &out) + { + if (command.startsWith('/') || command.startsWith('.')) + { + out.push_back({ + .name = command.mid(1), + .prefix = command.at(0), + }); + } + else + { + out.push_back({ + .name = command, + .prefix = "", + }); + } + } + +} // namespace + +CommandSource::CommandSource(std::unique_ptr strategy, + ActionCallback callback) + : strategy_(std::move(strategy)) + , callback_(std::move(callback)) +{ + this->initializeItems(); +} + +void CommandSource::update(const QString &query) +{ + this->output_.clear(); + if (this->strategy_) + { + this->strategy_->apply(this->items_, this->output_, query); + } +} + +void CommandSource::addToListModel(GenericListModel &model, + size_t maxCount) const +{ + addVecToListModel(this->output_, model, maxCount, + [this](const CommandItem &command) { + return std::make_unique( + nullptr, command.name, this->callback_); + }); +} + +void CommandSource::addToStringList(QStringList &list, size_t maxCount, + bool /* isFirstWord */) const +{ + addVecToStringList(this->output_, list, maxCount, + [](const CommandItem &command) { + return command.prefix + command.name + " "; + }); +} + +void CommandSource::initializeItems() +{ + std::vector commands; + +#ifdef CHATTERINO_HAVE_PLUGINS + for (const auto &command : getIApp()->getCommands()->pluginCommands()) + { + addCommand(command, commands); + } +#endif + + // Custom Chatterino commands + for (const auto &command : getIApp()->getCommands()->items) + { + addCommand(command.name, commands); + } + + // Default Chatterino commands + auto x = getIApp()->getCommands()->getDefaultChatterinoCommandList(); + for (const auto &command : x) + { + addCommand(command, commands); + } + + // Default Twitch commands + for (const auto &command : TWITCH_DEFAULT_COMMANDS) + { + addCommand(command, commands); + } + + this->items_ = std::move(commands); +} + +const std::vector &CommandSource::output() const +{ + return this->output_; +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/CommandSource.hpp b/src/controllers/completion/sources/CommandSource.hpp new file mode 100644 index 00000000000..7c9e1017beb --- /dev/null +++ b/src/controllers/completion/sources/CommandSource.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +#include + +#include +#include +#include + +namespace chatterino::completion { + +struct CommandItem { + QString name{}; + QString prefix{}; +}; + +class CommandSource : public Source +{ +public: + using ActionCallback = std::function; + using CommandStrategy = Strategy; + + /// @brief Initializes a source for CommandItems. + /// @param strategy Strategy to apply + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + CommandSource(std::unique_ptr strategy, + ActionCallback callback = nullptr); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + + const std::vector &output() const; + +private: + void initializeItems(); + + std::unique_ptr strategy_; + ActionCallback callback_; + + std::vector items_{}; + std::vector output_{}; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/EmoteSource.cpp b/src/controllers/completion/sources/EmoteSource.cpp new file mode 100644 index 00000000000..ded6d72be94 --- /dev/null +++ b/src/controllers/completion/sources/EmoteSource.cpp @@ -0,0 +1,157 @@ +#include "controllers/completion/sources/EmoteSource.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/completion/sources/Helpers.hpp" +#include "providers/bttv/BttvEmotes.hpp" +#include "providers/emoji/Emojis.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Emotes.hpp" + +namespace chatterino::completion { + +namespace { + + void addEmotes(std::vector &out, const EmoteMap &map, + const QString &providerName) + { + for (auto &&emote : map) + { + out.push_back({.emote = emote.second, + .searchName = emote.first.string, + .tabCompletionName = emote.first.string, + .displayName = emote.second->name.string, + .providerName = providerName, + .isEmoji = false}); + } + } + + void addEmojis(std::vector &out, + const std::vector &map) + { + for (const auto &emoji : map) + { + for (auto &&shortCode : emoji->shortCodes) + { + out.push_back( + {.emote = emoji->emote, + .searchName = shortCode, + .tabCompletionName = QStringLiteral(":%1:").arg(shortCode), + .displayName = shortCode, + .providerName = "Emoji", + .isEmoji = true}); + } + }; + } + +} // namespace + +EmoteSource::EmoteSource(const Channel *channel, + std::unique_ptr strategy, + ActionCallback callback) + : strategy_(std::move(strategy)) + , callback_(std::move(callback)) +{ + this->initializeFromChannel(channel); +} + +void EmoteSource::update(const QString &query) +{ + this->output_.clear(); + if (this->strategy_) + { + this->strategy_->apply(this->items_, this->output_, query); + } +} + +void EmoteSource::addToListModel(GenericListModel &model, size_t maxCount) const +{ + addVecToListModel(this->output_, model, maxCount, + [this](const EmoteItem &e) { + return std::make_unique( + e.emote, e.displayName + " - " + e.providerName, + this->callback_); + }); +} + +void EmoteSource::addToStringList(QStringList &list, size_t maxCount, + bool /* isFirstWord */) const +{ + addVecToStringList(this->output_, list, maxCount, [](const EmoteItem &e) { + return e.tabCompletionName + " "; + }); +} + +void EmoteSource::initializeFromChannel(const Channel *channel) +{ + auto *app = getIApp(); + + std::vector emotes; + const auto *tc = dynamic_cast(channel); + // returns true also for special Twitch channels (/live, /mentions, /whispers, etc.) + if (channel->isTwitchChannel()) + { + if (auto user = app->getAccounts()->twitch.getCurrent()) + { + // Twitch Emotes available globally + auto emoteData = user->accessEmotes(); + addEmotes(emotes, emoteData->emotes, "Twitch Emote"); + + // Twitch Emotes available locally + auto localEmoteData = user->accessLocalEmotes(); + if ((tc != nullptr) && + localEmoteData->find(tc->roomId()) != localEmoteData->end()) + { + if (const auto *localEmotes = &localEmoteData->at(tc->roomId())) + { + addEmotes(emotes, *localEmotes, "Local Twitch Emotes"); + } + } + } + + if (tc) + { + // TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define. + if (auto bttv = tc->bttvEmotes()) + { + addEmotes(emotes, *bttv, "Channel BetterTTV"); + } + if (auto ffz = tc->ffzEmotes()) + { + addEmotes(emotes, *ffz, "Channel FrankerFaceZ"); + } + if (auto seventv = tc->seventvEmotes()) + { + addEmotes(emotes, *seventv, "Channel 7TV"); + } + } + + if (auto bttvG = app->getBttvEmotes()->emotes()) + { + addEmotes(emotes, *bttvG, "Global BetterTTV"); + } + if (auto ffzG = app->getFfzEmotes()->emotes()) + { + addEmotes(emotes, *ffzG, "Global FrankerFaceZ"); + } + if (auto seventvG = app->getSeventvEmotes()->globalEmotes()) + { + addEmotes(emotes, *seventvG, "Global 7TV"); + } + } + + addEmojis(emotes, app->getEmotes()->getEmojis()->getEmojis()); + + this->items_ = std::move(emotes); +} + +const std::vector &EmoteSource::output() const +{ + return this->output_; +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/EmoteSource.hpp b/src/controllers/completion/sources/EmoteSource.hpp new file mode 100644 index 00000000000..4f61fbc2b2f --- /dev/null +++ b/src/controllers/completion/sources/EmoteSource.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "common/Channel.hpp" +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/strategies/Strategy.hpp" +#include "messages/Emote.hpp" + +#include + +#include +#include +#include + +namespace chatterino::completion { + +struct EmoteItem { + /// Emote image to show in input popup + EmotePtr emote{}; + /// Name to check completion queries against + QString searchName{}; + /// Name to insert into split input upon tab completing + QString tabCompletionName{}; + /// Display name within input popup + QString displayName{}; + /// Emote provider name for input popup + QString providerName{}; + /// Whether emote is emoji + bool isEmoji{}; +}; + +class EmoteSource : public Source +{ +public: + using ActionCallback = std::function; + using EmoteStrategy = Strategy; + + /// @brief Initializes a source for EmoteItems from the given channel + /// @param channel Channel to initialize emotes from + /// @param strategy Strategy to apply + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + EmoteSource(const Channel *channel, std::unique_ptr strategy, + ActionCallback callback = nullptr); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + + const std::vector &output() const; + +private: + void initializeFromChannel(const Channel *channel); + + std::unique_ptr strategy_; + ActionCallback callback_; + + std::vector items_{}; + std::vector output_{}; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/Helpers.hpp b/src/controllers/completion/sources/Helpers.hpp new file mode 100644 index 00000000000..74198f14a5e --- /dev/null +++ b/src/controllers/completion/sources/Helpers.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "widgets/listview/GenericListModel.hpp" + +#include + +#include + +namespace chatterino::completion { + +namespace { + + size_t sizeWithinLimit(size_t size, size_t limit) + { + if (limit == 0) + { + return size; + } + return std::min(size, limit); + } + +} // namespace + +template +void addVecToListModel(const std::vector &input, GenericListModel &model, + size_t maxCount, Mapper mapper) +{ + const size_t count = sizeWithinLimit(input.size(), maxCount); + model.reserve(model.rowCount() + count); + + for (size_t i = 0; i < count; ++i) + { + model.addItem(mapper(input[i])); + } +} + +template +void addVecToStringList(const std::vector &input, QStringList &list, + size_t maxCount, Mapper mapper) +{ + const size_t count = sizeWithinLimit(input.size(), maxCount); + list.reserve(list.count() + count); + + for (size_t i = 0; i < count; ++i) + { + list.push_back(mapper(input[i])); + } +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/Source.hpp b/src/controllers/completion/sources/Source.hpp new file mode 100644 index 00000000000..b78ce06eda5 --- /dev/null +++ b/src/controllers/completion/sources/Source.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "widgets/listview/GenericListModel.hpp" +#include "widgets/splits/InputCompletionItem.hpp" + +#include + +#include +#include +#include + +namespace chatterino::completion { + +/// @brief A Source represents a source for generating completion suggestions. +/// +/// The source can be queried to update its suggestions and then write the completion +/// suggestions to a GenericListModel or QStringList depending on the consumer's +/// requirements. +/// +/// For example, consider providing emotes for completion. The Source instance +/// initialized with every available emote in the channel (including global +/// emotes). As the user updates their query by typing, the suggestions are +/// refined and the output model is updated. +class Source +{ +public: + virtual ~Source() = default; + + /// @brief Updates the internal completion suggestions for the given query + /// @param query Query to complete against + virtual void update(const QString &query) = 0; + + /// @brief Appends the internal completion suggestions to a GenericListModel + /// @param model GenericListModel to add suggestions to + /// @param maxCount Maximum number of suggestions. Zero indicates unlimited. + virtual void addToListModel(GenericListModel &model, + size_t maxCount = 0) const = 0; + + /// @brief Appends the internal completion suggestions to a QStringList + /// @param list QStringList to add suggestions to + /// @param maxCount Maximum number of suggestions. Zero indicates unlimited. + /// @param isFirstWord Whether the completion is the first word in the input + virtual void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const = 0; +}; + +}; // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UnifiedSource.cpp b/src/controllers/completion/sources/UnifiedSource.cpp new file mode 100644 index 00000000000..a0f462ace74 --- /dev/null +++ b/src/controllers/completion/sources/UnifiedSource.cpp @@ -0,0 +1,77 @@ +#include "controllers/completion/sources/UnifiedSource.hpp" + +namespace chatterino::completion { + +UnifiedSource::UnifiedSource(std::vector> sources) + : sources_(std::move(sources)) +{ +} + +void UnifiedSource::update(const QString &query) +{ + // Update all sources + for (const auto &source : this->sources_) + { + source->update(query); + } +} + +void UnifiedSource::addToListModel(GenericListModel &model, + size_t maxCount) const +{ + if (maxCount == 0) + { + for (const auto &source : this->sources_) + { + source->addToListModel(model, 0); + } + return; + } + + // Make sure to only add maxCount elements in total. + int startingSize = model.rowCount(); + int used = 0; + + for (const auto &source : this->sources_) + { + source->addToListModel(model, maxCount - used); + // Calculate how many items have been added so far + used = model.rowCount() - startingSize; + if (used >= maxCount) + { + // Used up all of limit + break; + } + } +} + +void UnifiedSource::addToStringList(QStringList &list, size_t maxCount, + bool isFirstWord) const +{ + if (maxCount == 0) + { + for (const auto &source : this->sources_) + { + source->addToStringList(list, 0, isFirstWord); + } + return; + } + + // Make sure to only add maxCount elements in total. + int startingSize = list.size(); + int used = 0; + + for (const auto &source : this->sources_) + { + source->addToStringList(list, maxCount - used, isFirstWord); + // Calculate how many items have been added so far + used = list.size() - startingSize; + if (used >= maxCount) + { + // Used up all of limit + break; + } + } +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UnifiedSource.hpp b/src/controllers/completion/sources/UnifiedSource.hpp new file mode 100644 index 00000000000..416a7436456 --- /dev/null +++ b/src/controllers/completion/sources/UnifiedSource.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "common/Channel.hpp" +#include "controllers/completion/sources/CommandSource.hpp" +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/sources/UserSource.hpp" + +#include +#include + +namespace chatterino::completion { + +class UnifiedSource : public Source +{ +public: + /// @brief Initializes a unified completion source. + /// @param sources Vector of sources to unify + UnifiedSource(std::vector> sources); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + +private: + std::vector> sources_; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UserSource.cpp b/src/controllers/completion/sources/UserSource.cpp new file mode 100644 index 00000000000..46b16bcdfd1 --- /dev/null +++ b/src/controllers/completion/sources/UserSource.cpp @@ -0,0 +1,69 @@ +#include "controllers/completion/sources/UserSource.hpp" + +#include "controllers/completion/sources/Helpers.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Settings.hpp" +#include "util/Helpers.hpp" + +namespace chatterino::completion { + +UserSource::UserSource(const Channel *channel, + std::unique_ptr strategy, + ActionCallback callback, bool prependAt) + : strategy_(std::move(strategy)) + , callback_(std::move(callback)) + , prependAt_(prependAt) +{ + this->initializeFromChannel(channel); +} + +void UserSource::update(const QString &query) +{ + this->output_.clear(); + if (this->strategy_) + { + this->strategy_->apply(this->items_, this->output_, query); + } +} + +void UserSource::addToListModel(GenericListModel &model, size_t maxCount) const +{ + addVecToListModel(this->output_, model, maxCount, + [this](const UserItem &user) { + return std::make_unique( + nullptr, user.second, this->callback_); + }); +} + +void UserSource::addToStringList(QStringList &list, size_t maxCount, + bool isFirstWord) const +{ + bool mentionComma = getSettings()->mentionUsersWithComma; + addVecToStringList(this->output_, list, maxCount, + [this, isFirstWord, mentionComma](const UserItem &user) { + const auto userMention = formatUserMention( + user.second, isFirstWord, mentionComma); + QString strTemplate = this->prependAt_ + ? QStringLiteral("@%1 ") + : QStringLiteral("%1 "); + return strTemplate.arg(userMention); + }); +} + +void UserSource::initializeFromChannel(const Channel *channel) +{ + const auto *tc = dynamic_cast(channel); + if (!tc) + { + return; + } + + this->items_ = tc->accessChatters()->all(); +} + +const std::vector &UserSource::output() const +{ + return this->output_; +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/sources/UserSource.hpp b/src/controllers/completion/sources/UserSource.hpp new file mode 100644 index 00000000000..316b33c7b0b --- /dev/null +++ b/src/controllers/completion/sources/UserSource.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "common/Channel.hpp" +#include "controllers/completion/sources/Source.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +#include + +#include +#include +#include +#include + +namespace chatterino::completion { + +using UserItem = std::pair; + +class UserSource : public Source +{ +public: + using ActionCallback = std::function; + using UserStrategy = Strategy; + + /// @brief Initializes a source for UserItems from the given channel. + /// @param channel Channel to initialize users from. Must be a TwitchChannel + /// or completion is a no-op. + /// @param strategy Strategy to apply + /// @param callback ActionCallback to invoke upon InputCompletionItem selection. + /// See InputCompletionItem::action(). Can be nullptr. + /// @param prependAt Whether to prepend @ to string completion suggestions. + UserSource(const Channel *channel, std::unique_ptr strategy, + ActionCallback callback = nullptr, bool prependAt = true); + + void update(const QString &query) override; + void addToListModel(GenericListModel &model, + size_t maxCount = 0) const override; + void addToStringList(QStringList &list, size_t maxCount = 0, + bool isFirstWord = false) const override; + + const std::vector &output() const; + +private: + void initializeFromChannel(const Channel *channel); + + std::unique_ptr strategy_; + ActionCallback callback_; + bool prependAt_; + + std::vector items_{}; + std::vector output_{}; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp b/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp new file mode 100644 index 00000000000..1a427483a7f --- /dev/null +++ b/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp @@ -0,0 +1,85 @@ +#include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" + +#include "singletons/Settings.hpp" +#include "util/Helpers.hpp" + +namespace chatterino::completion { + +void ClassicEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString normalizedQuery = query; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + } + + // First pass: filter by contains match + for (const auto &item : items) + { + if (item.searchName.contains(normalizedQuery, Qt::CaseInsensitive)) + { + output.push_back(item); + } + } + + // Second pass: if there is an exact match, put that emote first + for (size_t i = 1; i < output.size(); i++) + { + auto emoteText = output.at(i).searchName; + + // test for match or match with colon at start for emotes like ":)" + if (emoteText.compare(normalizedQuery, Qt::CaseInsensitive) == 0 || + emoteText.compare(":" + normalizedQuery, Qt::CaseInsensitive) == 0) + { + auto emote = output[i]; + output.erase(output.begin() + int(i)); + output.insert(output.begin(), emote); + break; + } + } +} + +struct CompletionEmoteOrder { + bool operator()(const EmoteItem &a, const EmoteItem &b) const + { + return compareEmoteStrings(a.searchName, b.searchName); + } +}; + +void ClassicTabEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + bool emojiOnly = false; + QString normalizedQuery = query; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + // tab completion with : prefix should do emojis only + emojiOnly = true; + } + + std::set emotes; + + for (const auto &item : items) + { + if (emojiOnly ^ item.isEmoji) + { + continue; + } + + if (startsWithOrContains(item.searchName, normalizedQuery, + Qt::CaseInsensitive, + getSettings()->prefixOnlyEmoteCompletion)) + { + emotes.insert(item); + } + } + + output.reserve(emotes.size()); + output.assign(emotes.begin(), emotes.end()); +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicEmoteStrategy.hpp b/src/controllers/completion/strategies/ClassicEmoteStrategy.hpp new file mode 100644 index 00000000000..d231c8ac142 --- /dev/null +++ b/src/controllers/completion/strategies/ClassicEmoteStrategy.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class ClassicEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +class ClassicTabEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicUserStrategy.cpp b/src/controllers/completion/strategies/ClassicUserStrategy.cpp new file mode 100644 index 00000000000..1f002ed5949 --- /dev/null +++ b/src/controllers/completion/strategies/ClassicUserStrategy.cpp @@ -0,0 +1,23 @@ +#include "controllers/completion/strategies/ClassicUserStrategy.hpp" + +namespace chatterino::completion { + +void ClassicUserStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString lowerQuery = query.toLower(); + if (lowerQuery.startsWith('@')) + { + lowerQuery = lowerQuery.mid(1); + } + + for (const auto &item : items) + { + if (item.first.startsWith(lowerQuery)) + { + output.push_back(item); + } + } +} +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/ClassicUserStrategy.hpp b/src/controllers/completion/strategies/ClassicUserStrategy.hpp new file mode 100644 index 00000000000..7575d127113 --- /dev/null +++ b/src/controllers/completion/strategies/ClassicUserStrategy.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "controllers/completion/sources/UserSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class ClassicUserStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/CommandStrategy.cpp b/src/controllers/completion/strategies/CommandStrategy.cpp new file mode 100644 index 00000000000..9edf4e4054c --- /dev/null +++ b/src/controllers/completion/strategies/CommandStrategy.cpp @@ -0,0 +1,45 @@ +#include "controllers/completion/strategies/CommandStrategy.hpp" + +namespace chatterino::completion { + +QString normalizeQuery(const QString &query) +{ + if (query.startsWith('/') || query.startsWith('.')) + { + return query.mid(1); + } + + return query; +} + +CommandStrategy::CommandStrategy(bool startsWithOnly) + : startsWithOnly_(startsWithOnly) +{ +} + +void CommandStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString normalizedQuery = normalizeQuery(query); + + if (startsWithOnly_) + { + std::copy_if(items.begin(), items.end(), + std::back_insert_iterator(output), + [&normalizedQuery](const CommandItem &item) { + return item.name.startsWith(normalizedQuery, + Qt::CaseInsensitive); + }); + } + else + { + std::copy_if( + items.begin(), items.end(), std::back_insert_iterator(output), + [&normalizedQuery](const CommandItem &item) { + return item.name.contains(normalizedQuery, Qt::CaseInsensitive); + }); + } +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/CommandStrategy.hpp b/src/controllers/completion/strategies/CommandStrategy.hpp new file mode 100644 index 00000000000..4f9ca1266f3 --- /dev/null +++ b/src/controllers/completion/strategies/CommandStrategy.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "controllers/completion/sources/CommandSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class CommandStrategy : public Strategy +{ +public: + CommandStrategy(bool startsWithOnly); + + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; + +private: + bool startsWithOnly_; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/SmartEmoteStrategy.cpp b/src/controllers/completion/strategies/SmartEmoteStrategy.cpp new file mode 100644 index 00000000000..aa6e43127ef --- /dev/null +++ b/src/controllers/completion/strategies/SmartEmoteStrategy.cpp @@ -0,0 +1,204 @@ +#include "controllers/completion/strategies/SmartEmoteStrategy.hpp" + +#include "common/QLogging.hpp" +#include "controllers/completion/sources/EmoteSource.hpp" +#include "singletons/Settings.hpp" +#include "util/Helpers.hpp" + +#include + +#include + +namespace chatterino::completion { +namespace { + /** + * @brief This function calculates the "cost" of the changes that need to + * be done to the query to make it the value. + * + * By default an emote with more differences in character casing from the + * query will get a higher cost, each additional letter also increases cost. + * + * @param prioritizeUpper If set, then differences in casing don't matter, but + * instead the more lowercase letters an emote contains, the higher cost it + * will get. Additional letters also increase the cost in this mode. + * + * @return How different the emote is from query. Values in the range [-10, + * \infty]. + */ + int costOfEmote(const QString &query, const QString &emote, + bool prioritizeUpper) + { + int score = 0; + + if (prioritizeUpper) + { + // We are in case 3, push 'more uppercase' emotes to the top + for (const auto i : emote) + { + score += int(!i.isUpper()); + } + } + else + { + // Push more matching emotes to the top + int len = std::min(emote.size(), query.size()); + for (int i = 0; i < len; i++) + { + // Different casing gets a higher cost score + score += query.at(i).isUpper() ^ emote.at(i).isUpper(); + } + } + // No case differences, put this at the top + if (score == 0) + { + score = -10; + } + + auto diff = emote.size() - query.size(); + if (diff > 0) + { + // Case changes are way less changes to the user compared to adding characters + score += diff * 100; + } + return score; + }; + + // This contains the brains of emote tab completion. Updates output to sorted completions. + // Ensure that the query string is already normalized, that is doesn't have a leading ':' + // matchingFunction is used for testing if the emote should be included in the search. + void completeEmotes( + const std::vector &items, std::vector &output, + const QString &query, bool ignoreColonForCost, + const std::function + &matchingFunction) + { + // Given these emotes: pajaW, PAJAW + // There are a few cases of input: + // 1. "pajaw" expect {pajaW, PAJAW} - no uppercase characters, do regular case insensitive search + // 2. "PA" expect {PAJAW} - uppercase characters, case sensitive search gives results + // 3. "Pajaw" expect {PAJAW, pajaW} - case sensitive search doesn't give results, need to use sorting + // 4. "NOTHING" expect {} - no results + // 5. "nothing" expect {} - same as 4 but first search is case insensitive + + // Check if the query contains any uppercase characters + // This tells us if we're in case 1 or 5 vs all others + bool haveUpper = + std::any_of(query.begin(), query.end(), [](const QChar &c) { + return c.isUpper(); + }); + + // First search, for case 1 it will be case insensitive, + // for cases 2, 3 and 4 it will be case sensitive + for (const auto &item : items) + { + if (matchingFunction( + item, query, + haveUpper ? Qt::CaseSensitive : Qt::CaseInsensitive)) + { + output.push_back(item); + } + } + + // if case 3: then true; false otherwise + bool prioritizeUpper = false; + + // No results from search + if (output.empty()) + { + if (!haveUpper) + { + // Optimisation: First search was case insensitive, but we found nothing + // There is nothing to be found: case 5. + return; + } + // Case sensitive search from case 2 found nothing, therefore we can + // only be in case 3 or 4. + + prioritizeUpper = true; + // Run the search again but this time without case sensitivity + for (const auto &item : items) + { + if (matchingFunction(item, query, Qt::CaseInsensitive)) + { + output.push_back(item); + } + } + if (output.empty()) + { + // The second search found nothing, so don't even try to sort: case 4 + return; + } + } + + std::sort(output.begin(), output.end(), + [query, prioritizeUpper, ignoreColonForCost]( + const EmoteItem &a, const EmoteItem &b) -> bool { + auto tempA = a.searchName; + auto tempB = b.searchName; + if (ignoreColonForCost && tempA.startsWith(":")) + { + tempA = tempA.mid(1); + } + if (ignoreColonForCost && tempB.startsWith(":")) + { + tempB = tempB.mid(1); + } + + auto costA = costOfEmote(query, tempA, prioritizeUpper); + auto costB = costOfEmote(query, tempB, prioritizeUpper); + if (costA == costB) + { + // Case difference and length came up tied for (a, b), break the tie + return QString::compare(tempA, tempB, + Qt::CaseInsensitive) < 0; + } + + return costA < costB; + }); + } +} // namespace + +void SmartEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + QString normalizedQuery = query; + bool ignoreColonForCost = false; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + ignoreColonForCost = true; + } + completeEmotes(items, output, normalizedQuery, ignoreColonForCost, + [](const EmoteItem &left, const QString &right, + Qt::CaseSensitivity caseHandling) { + return left.searchName.contains(right, caseHandling); + }); +} + +void SmartTabEmoteStrategy::apply(const std::vector &items, + std::vector &output, + const QString &query) const +{ + bool emojiOnly = false; + QString normalizedQuery = query; + if (normalizedQuery.startsWith(':')) + { + normalizedQuery = normalizedQuery.mid(1); + // tab completion with : prefix should do emojis only + emojiOnly = true; + } + completeEmotes(items, output, normalizedQuery, false, + [emojiOnly](const EmoteItem &left, const QString &right, + Qt::CaseSensitivity caseHandling) -> bool { + if (emojiOnly ^ left.isEmoji) + { + return false; + } + return startsWithOrContains( + left.searchName, right, caseHandling, + getSettings()->prefixOnlyEmoteCompletion); + }); +} + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/SmartEmoteStrategy.hpp b/src/controllers/completion/strategies/SmartEmoteStrategy.hpp new file mode 100644 index 00000000000..365e106b033 --- /dev/null +++ b/src/controllers/completion/strategies/SmartEmoteStrategy.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/completion/sources/EmoteSource.hpp" +#include "controllers/completion/strategies/Strategy.hpp" + +namespace chatterino::completion { + +class SmartEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +class SmartTabEmoteStrategy : public Strategy +{ + void apply(const std::vector &items, + std::vector &output, + const QString &query) const override; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/completion/strategies/Strategy.hpp b/src/controllers/completion/strategies/Strategy.hpp new file mode 100644 index 00000000000..d1056991113 --- /dev/null +++ b/src/controllers/completion/strategies/Strategy.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +namespace chatterino::completion { + +/// @brief An Strategy implements ordering and filtering of completion items in +/// response to a query. +/// @tparam T Type of items to consider +template +class Strategy +{ +public: + virtual ~Strategy() = default; + + /// @brief Applies the strategy, taking the input items and storing the + /// appropriate output items in the desired order. + /// @param items Input items to consider + /// @param output Output vector for items + /// @param query Completion query + virtual void apply(const std::vector &items, std::vector &output, + const QString &query) const = 0; +}; + +} // namespace chatterino::completion diff --git a/src/controllers/filters/FilterModel.hpp b/src/controllers/filters/FilterModel.hpp index 14c0fd66bcf..3b44c7acbc6 100644 --- a/src/controllers/filters/FilterModel.hpp +++ b/src/controllers/filters/FilterModel.hpp @@ -16,13 +16,12 @@ class FilterModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual FilterRecordPtr getItemFromRow( - std::vector &row, - const FilterRecordPtr &original) override; + FilterRecordPtr getItemFromRow(std::vector &row, + const FilterRecordPtr &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const FilterRecordPtr &item, - std::vector &row) override; + void getRowFromItem(const FilterRecordPtr &item, + std::vector &row) override; }; } // namespace chatterino diff --git a/src/controllers/filters/FilterRecord.cpp b/src/controllers/filters/FilterRecord.cpp index 24bd22f6212..409aa64fa32 100644 --- a/src/controllers/filters/FilterRecord.cpp +++ b/src/controllers/filters/FilterRecord.cpp @@ -1,21 +1,40 @@ #include "controllers/filters/FilterRecord.hpp" +#include "controllers/filters/lang/Filter.hpp" + namespace chatterino { -FilterRecord::FilterRecord(const QString &name, const QString &filter) - : name_(name) - , filter_(filter) - , id_(QUuid::createUuid()) - , parser_(std::make_unique(filter)) +static std::unique_ptr buildFilter(const QString &filterText) +{ + using namespace filters; + auto result = Filter::fromString(filterText); + if (std::holds_alternative(result)) + { + auto filter = + std::make_unique(std::move(std::get(result))); + + if (filter->returnType() != Type::Bool) + { + // Only accept Bool results + return nullptr; + } + + return filter; + } + + return nullptr; +} + +FilterRecord::FilterRecord(QString name, QString filter) + : FilterRecord(std::move(name), std::move(filter), QUuid::createUuid()) { } -FilterRecord::FilterRecord(const QString &name, const QString &filter, - const QUuid &id) - : name_(name) - , filter_(filter) +FilterRecord::FilterRecord(QString name, QString filter, const QUuid &id) + : name_(std::move(name)) + , filterText_(std::move(filter)) , id_(id) - , parser_(std::make_unique(filter)) + , filter_(buildFilter(this->filterText_)) { } @@ -26,7 +45,7 @@ const QString &FilterRecord::getName() const const QString &FilterRecord::getFilter() const { - return this->filter_; + return this->filterText_; } const QUuid &FilterRecord::getId() const @@ -36,12 +55,13 @@ const QUuid &FilterRecord::getId() const bool FilterRecord::valid() const { - return this->parser_->valid(); + return this->filter_ != nullptr; } -bool FilterRecord::filter(const filterparser::ContextMap &context) const +bool FilterRecord::filter(const filters::ContextMap &context) const { - return this->parser_->execute(context); + assert(this->valid()); + return this->filter_->execute(context).toBool(); } bool FilterRecord::operator==(const FilterRecord &other) const diff --git a/src/controllers/filters/FilterRecord.hpp b/src/controllers/filters/FilterRecord.hpp index bb6eeeff327..c5f120040a8 100644 --- a/src/controllers/filters/FilterRecord.hpp +++ b/src/controllers/filters/FilterRecord.hpp @@ -1,6 +1,6 @@ #pragma once -#include "controllers/filters/parser/FilterParser.hpp" +#include "controllers/filters/lang/Filter.hpp" #include "util/RapidjsonHelpers.hpp" #include "util/RapidJsonSerializeQString.hpp" @@ -16,9 +16,9 @@ namespace chatterino { class FilterRecord { public: - FilterRecord(const QString &name, const QString &filter); + FilterRecord(QString name, QString filter); - FilterRecord(const QString &name, const QString &filter, const QUuid &id); + FilterRecord(QString name, QString filter, const QUuid &id); const QString &getName() const; @@ -28,16 +28,16 @@ class FilterRecord bool valid() const; - bool filter(const filterparser::ContextMap &context) const; + bool filter(const filters::ContextMap &context) const; bool operator==(const FilterRecord &other) const; private: - QString name_; - QString filter_; - QUuid id_; + const QString name_; + const QString filterText_; + const QUuid id_; - std::unique_ptr parser_; + const std::unique_ptr filter_; }; using FilterRecordPtr = std::shared_ptr; diff --git a/src/controllers/filters/FilterSet.cpp b/src/controllers/filters/FilterSet.cpp index 8bd20414cf5..a64acdf76d3 100644 --- a/src/controllers/filters/FilterSet.cpp +++ b/src/controllers/filters/FilterSet.cpp @@ -8,22 +8,24 @@ namespace chatterino { FilterSet::FilterSet() { this->listener_ = - getCSettings().filterRecords.delayedItemsChanged.connect([this] { + getSettings()->filterRecords.delayedItemsChanged.connect([this] { this->reloadFilters(); }); } FilterSet::FilterSet(const QList &filterIds) { - auto filters = getCSettings().filterRecords.readOnly(); + auto filters = getSettings()->filterRecords.readOnly(); for (const auto &f : *filters) { if (filterIds.contains(f->getId())) + { this->filters_.insert(f->getId(), f); + } } this->listener_ = - getCSettings().filterRecords.delayedItemsChanged.connect([this] { + getSettings()->filterRecords.delayedItemsChanged.connect([this] { this->reloadFilters(); }); } @@ -36,14 +38,17 @@ FilterSet::~FilterSet() bool FilterSet::filter(const MessagePtr &m, ChannelPtr channel) const { if (this->filters_.size() == 0) + { return true; + } - filterparser::ContextMap context = - filterparser::buildContextMap(m, channel.get()); + filters::ContextMap context = filters::buildContextMap(m, channel.get()); for (const auto &f : this->filters_.values()) { if (!f->valid() || !f->filter(context)) + { return false; + } } return true; @@ -56,7 +61,7 @@ const QList FilterSet::filterIds() const void FilterSet::reloadFilters() { - auto filters = getCSettings().filterRecords.readOnly(); + auto filters = getSettings()->filterRecords.readOnly(); for (const auto &key : this->filters_.keys()) { bool found = false; diff --git a/src/controllers/filters/lang/Filter.cpp b/src/controllers/filters/lang/Filter.cpp new file mode 100644 index 00000000000..7ae61991a90 --- /dev/null +++ b/src/controllers/filters/lang/Filter.cpp @@ -0,0 +1,166 @@ +#include "controllers/filters/lang/Filter.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/filters/lang/FilterParser.hpp" +#include "messages/Message.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" + +namespace chatterino::filters { + +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) +{ + auto watchingChannel = getIApp()->getTwitch()->getWatchingChannel().get(); + + /* + * Looking to add a new identifier to filters? Here's what to do: + * 1. Update validIdentifiersMap in Tokenizer.hpp + * 2. Add the identifier to the list below + * 3. Add the type of the identifier to MESSAGE_TYPING_CONTEXT in Filter.hpp + * 4. Add the value for the identifier to the ContextMap returned by this function + * + * List of identifiers: + * + * author.badges + * author.color + * author.name + * author.no_color + * author.subbed + * author.sub_length + * + * channel.name + * channel.watching + * + * flags.highlighted + * flags.points_redeemed + * flags.sub_message + * flags.system_message + * flags.reward_message + * flags.first_message + * flags.elevated_message + * flags.cheer_message + * flags.whisper + * flags.reply + * flags.automod + * flags.restricted + * flags.monitored + * + * message.content + * message.length + * + */ + + using MessageFlag = chatterino::MessageFlag; + + QStringList badges; + badges.reserve(m->badges.size()); + for (const auto &e : m->badges) + { + badges << e.key_; + } + + bool watching = !watchingChannel->getName().isEmpty() && + watchingChannel->getName().compare( + m->channelName, Qt::CaseInsensitive) == 0; + + bool subscribed = false; + int subLength = 0; + for (const auto &subBadge : {"subscriber", "founder"}) + { + if (!badges.contains(subBadge)) + { + continue; + } + subscribed = true; + if (m->badgeInfos.find(subBadge) != m->badgeInfos.end()) + { + subLength = m->badgeInfos.at(subBadge).toInt(); + } + } + ContextMap vars = { + {"author.badges", std::move(badges)}, + {"author.color", m->usernameColor}, + {"author.name", m->displayName}, + {"author.no_color", !m->usernameColor.isValid()}, + {"author.subbed", subscribed}, + {"author.sub_length", subLength}, + + {"channel.name", m->channelName}, + {"channel.watching", watching}, + + {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, + {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, + {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, + {"flags.system_message", m->flags.has(MessageFlag::System)}, + {"flags.reward_message", + m->flags.has(MessageFlag::RedeemedChannelPointReward)}, + {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, + {"flags.elevated_message", m->flags.has(MessageFlag::ElevatedMessage)}, + {"flags.hype_chat", m->flags.has(MessageFlag::ElevatedMessage)}, + {"flags.cheer_message", m->flags.has(MessageFlag::CheerMessage)}, + {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, + {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, + {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, + {"flags.restricted", m->flags.has(MessageFlag::RestrictedMessage)}, + {"flags.monitored", m->flags.has(MessageFlag::MonitoredMessage)}, + + {"message.content", m->messageText}, + {"message.length", m->messageText.length()}, + }; + { + auto *tc = dynamic_cast(channel); + if (channel && !channel->isEmpty() && tc) + { + vars["channel.live"] = tc->isLive(); + } + else + { + vars["channel.live"] = false; + } + } + return vars; +} + +FilterResult Filter::fromString(const QString &str) +{ + FilterParser parser(str); + + if (parser.valid()) + { + auto exp = parser.release(); + auto typ = parser.returnType(); + return Filter(std::move(exp), typ); + } + + return FilterError{parser.errors().join("\n")}; +} + +Filter::Filter(ExpressionPtr expression, Type returnType) + : expression_(std::move(expression)) + , returnType_(returnType) +{ +} + +Type Filter::returnType() const +{ + return this->returnType_; +} + +QVariant Filter::execute(const ContextMap &context) const +{ + return this->expression_->execute(context); +} + +QString Filter::filterString() const +{ + return this->expression_->filterString(); +} + +QString Filter::debugString(const TypingContext &context) const +{ + return this->expression_->debug(context); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Filter.hpp b/src/controllers/filters/lang/Filter.hpp new file mode 100644 index 00000000000..c8afbd76916 --- /dev/null +++ b/src/controllers/filters/lang/Filter.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include + +#include +#include + +namespace chatterino { + +class Channel; +struct Message; +using MessagePtr = std::shared_ptr; + +} // namespace chatterino + +namespace chatterino::filters { + +// MESSAGE_TYPING_CONTEXT maps filter variables to their expected type at evaluation. +// For example, flags.highlighted is a boolean variable, so it is marked as Type::Bool +// below. These variable types will be used to check whether a filter "makes sense", +// i.e. if all the variables and operators being used have compatible types. +static const QMap MESSAGE_TYPING_CONTEXT = { + {"author.badges", Type::StringList}, + {"author.color", Type::Color}, + {"author.name", Type::String}, + {"author.no_color", Type::Bool}, + {"author.subbed", Type::Bool}, + {"author.sub_length", Type::Int}, + {"channel.name", Type::String}, + {"channel.watching", Type::Bool}, + {"channel.live", Type::Bool}, + {"flags.highlighted", Type::Bool}, + {"flags.points_redeemed", Type::Bool}, + {"flags.sub_message", Type::Bool}, + {"flags.system_message", Type::Bool}, + {"flags.reward_message", Type::Bool}, + {"flags.first_message", Type::Bool}, + {"flags.elevated_message", Type::Bool}, + {"flags.hype_chat", Type::Bool}, + {"flags.cheer_message", Type::Bool}, + {"flags.whisper", Type::Bool}, + {"flags.reply", Type::Bool}, + {"flags.automod", Type::Bool}, + {"flags.restricted", Type::Bool}, + {"flags.monitored", Type::Bool}, + {"message.content", Type::String}, + {"message.length", Type::Int}, +}; + +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); + +class Filter; +struct FilterError { + QString message; +}; + +using FilterResult = std::variant; + +class Filter +{ +public: + static FilterResult fromString(const QString &str); + + Type returnType() const; + QVariant execute(const ContextMap &context) const; + + QString filterString() const; + QString debugString(const TypingContext &context) const; + +private: + Filter(ExpressionPtr expression, Type returnType); + + ExpressionPtr expression_; + Type returnType_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/lang/FilterParser.cpp similarity index 67% rename from src/controllers/filters/parser/FilterParser.cpp rename to src/controllers/filters/lang/FilterParser.cpp index 00c5bd6b76c..dddbd164a5f 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/lang/FilterParser.cpp @@ -1,132 +1,67 @@ -#include "FilterParser.hpp" +#include "controllers/filters/lang/FilterParser.hpp" -#include "Application.hpp" -#include "common/Channel.hpp" -#include "controllers/filters/parser/Types.hpp" -#include "messages/Message.hpp" -#include "providers/twitch/TwitchBadge.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" +#include "controllers/filters/lang/expressions/BinaryOperation.hpp" +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/expressions/ListExpression.hpp" +#include "controllers/filters/lang/expressions/RegexExpression.hpp" +#include "controllers/filters/lang/expressions/UnaryOperation.hpp" +#include "controllers/filters/lang/expressions/ValueExpression.hpp" +#include "controllers/filters/lang/Filter.hpp" +#include "controllers/filters/lang/Types.hpp" -namespace filterparser { +namespace { -ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) +using namespace chatterino::filters; + +QString explainIllType(const IllTyped &ill) { - auto watchingChannel = chatterino::getApp()->twitch->watchingChannel.get(); - - /* Known Identifiers - * - * author.badges - * author.color - * author.name - * author.no_color - * author.subbed - * author.sub_length - * - * channel.name - * channel.watching - * - * flags.highlighted - * flags.points_redeemed - * flags.sub_message - * flags.system_message - * flags.reward_message - * flags.first_message - * flags.elevated_message - * flags.cheer_message - * flags.whisper - * flags.reply - * flags.automod - * - * message.content - * message.length - * - */ - - using MessageFlag = chatterino::MessageFlag; - - QStringList badges; - badges.reserve(m->badges.size()); - for (const auto &e : m->badges) - { - badges << e.key_; - } + return QString("%1\n\nProblem occurred here:\n%2") + .arg(ill.message) + .arg(ill.expr->filterString()); +} - bool watching = !watchingChannel->getName().isEmpty() && - watchingChannel->getName().compare( - m->channelName, Qt::CaseInsensitive) == 0; +} // namespace - bool subscribed = false; - int subLength = 0; - for (const auto &subBadge : {"subscriber", "founder"}) +namespace chatterino::filters { + +FilterParser::FilterParser(const QString &text) + : text_(text) + , tokenizer_(Tokenizer(text)) + , builtExpression_(this->parseExpression(true)) +{ + if (!this->valid_) { - if (!badges.contains(subBadge)) - { - continue; - } - subscribed = true; - if (m->badgeInfos.find(subBadge) != m->badgeInfos.end()) - { - subLength = m->badgeInfos.at(subBadge).toInt(); - } + return; } - ContextMap vars = { - {"author.badges", std::move(badges)}, - {"author.color", m->usernameColor}, - {"author.name", m->displayName}, - {"author.no_color", !m->usernameColor.isValid()}, - {"author.subbed", subscribed}, - {"author.sub_length", subLength}, - - {"channel.name", m->channelName}, - {"channel.watching", watching}, - - {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, - {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, - {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, - {"flags.system_message", m->flags.has(MessageFlag::System)}, - {"flags.reward_message", - m->flags.has(MessageFlag::RedeemedChannelPointReward)}, - {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, - {"flags.elevated_message", m->flags.has(MessageFlag::ElevatedMessage)}, - {"flags.cheer_message", m->flags.has(MessageFlag::CheerMessage)}, - {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, - {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, - {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, - - {"message.content", m->messageText}, - {"message.length", m->messageText.length()}, - }; + + // safety: returnType must not live longer than the parsed expression. See + // comment on IllTyped::expr. + auto returnType = + this->builtExpression_->synthesizeType(MESSAGE_TYPING_CONTEXT); + if (isIllTyped(returnType)) { - using namespace chatterino; - auto *tc = dynamic_cast(channel); - if (channel && !channel->isEmpty() && tc) - { - vars["channel.live"] = tc->isLive(); - } - else - { - vars["channel.live"] = false; - } + this->errorLog(explainIllType(std::get(returnType))); + return; } - return vars; + + this->returnType_ = std::get(returnType).type; } -FilterParser::FilterParser(const QString &text) - : text_(text) - , tokenizer_(Tokenizer(text)) - , builtExpression_(this->parseExpression(true)) +bool FilterParser::valid() const { + return this->valid_; } -bool FilterParser::execute(const ContextMap &context) const +Type FilterParser::returnType() const { - return this->builtExpression_->execute(context).toBool(); + return this->returnType_; } -bool FilterParser::valid() const +ExpressionPtr FilterParser::release() { - return this->valid_; + ExpressionPtr ret; + this->builtExpression_.swap(ret); + return ret; } ExpressionPtr FilterParser::parseExpression(bool top) @@ -379,12 +314,7 @@ const QStringList &FilterParser::errors() const const QString FilterParser::debugString() const { - return this->builtExpression_->debug(); -} - -const QString FilterParser::filterString() const -{ - return this->builtExpression_->filterString(); + return this->builtExpression_->debug(MESSAGE_TYPING_CONTEXT); } -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/FilterParser.hpp b/src/controllers/filters/lang/FilterParser.hpp similarity index 62% rename from src/controllers/filters/parser/FilterParser.hpp rename to src/controllers/filters/lang/FilterParser.hpp index 70037993e70..1fa8fa596fa 100644 --- a/src/controllers/filters/parser/FilterParser.hpp +++ b/src/controllers/filters/lang/FilterParser.hpp @@ -1,28 +1,25 @@ #pragma once -#include "controllers/filters/parser/Tokenizer.hpp" -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" +#include "controllers/filters/lang/Types.hpp" -namespace chatterino { - -class Channel; - -} // namespace chatterino - -namespace filterparser { - -ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); +namespace chatterino::filters { class FilterParser { public: + /** + * Take input text & attempt to parse it into a filter + **/ FilterParser(const QString &text); - bool execute(const ContextMap &context) const; + bool valid() const; + Type returnType() const; + ExpressionPtr release(); const QStringList &errors() const; const QString debugString() const; - const QString filterString() const; private: ExpressionPtr parseExpression(bool top = false); @@ -41,5 +38,7 @@ class FilterParser QString text_; Tokenizer tokenizer_; ExpressionPtr builtExpression_; + Type returnType_ = Type::Bool; }; -} // namespace filterparser + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Tokenizer.cpp b/src/controllers/filters/lang/Tokenizer.cpp similarity index 67% rename from src/controllers/filters/parser/Tokenizer.cpp rename to src/controllers/filters/lang/Tokenizer.cpp index e1bb28bcb2a..f25f1976b07 100644 --- a/src/controllers/filters/parser/Tokenizer.cpp +++ b/src/controllers/filters/lang/Tokenizer.cpp @@ -1,8 +1,79 @@ -#include "controllers/filters/parser/Tokenizer.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" #include "common/QLogging.hpp" -namespace filterparser { +namespace chatterino::filters { + +QString tokenTypeToInfoString(TokenType type) +{ + switch (type) + { + case AND: + return "And"; + case OR: + return "Or"; + case LP: + return ""; + case RP: + return ""; + case LIST_START: + return ""; + case LIST_END: + return ""; + case COMMA: + return ""; + case PLUS: + return "Plus"; + case MINUS: + return "Minus"; + case MULTIPLY: + return "Multiply"; + case DIVIDE: + return "Divide"; + case MOD: + return "Mod"; + case EQ: + return "Eq"; + case NEQ: + return "NotEq"; + case LT: + return "LessThan"; + case GT: + return "GreaterThan"; + case LTE: + return "LessThanEq"; + case GTE: + return "GreaterThanEq"; + case CONTAINS: + return "Contains"; + case STARTS_WITH: + return "StartsWith"; + case ENDS_WITH: + return "EndsWith"; + case MATCH: + return "Match"; + case NOT: + return "Not"; + case STRING: + return ""; + case INT: + return ""; + case IDENTIFIER: + return ""; + case CONTROL_START: + case CONTROL_END: + case BINARY_START: + case BINARY_END: + case UNARY_START: + case UNARY_END: + case MATH_START: + case MATH_END: + case OTHER_START: + case NONE: + default: + return ""; + } +} Tokenizer::Tokenizer(const QString &text) { @@ -34,7 +105,9 @@ QString Tokenizer::current() const QString Tokenizer::preview() const { if (this->hasNext()) + { return this->tokens_.at(this->i_); + } return ""; } @@ -101,51 +174,97 @@ const QStringList Tokenizer::allTokens() TokenType Tokenizer::tokenize(const QString &text) { if (text == "&&") + { return TokenType::AND; + } else if (text == "||") + { return TokenType::OR; + } else if (text == "(") + { return TokenType::LP; + } else if (text == ")") + { return TokenType::RP; + } else if (text == "{") + { return TokenType::LIST_START; + } else if (text == "}") + { return TokenType::LIST_END; + } else if (text == ",") + { return TokenType::COMMA; + } else if (text == "+") + { return TokenType::PLUS; + } else if (text == "-") + { return TokenType::MINUS; + } else if (text == "*") + { return TokenType::MULTIPLY; + } else if (text == "/") + { return TokenType::DIVIDE; + } else if (text == "==") + { return TokenType::EQ; + } else if (text == "!=") + { return TokenType::NEQ; + } else if (text == "%") + { return TokenType::MOD; + } else if (text == "<") + { return TokenType::LT; + } else if (text == ">") + { return TokenType::GT; + } else if (text == "<=") + { return TokenType::LTE; + } else if (text == ">=") + { return TokenType::GTE; + } else if (text == "contains") + { return TokenType::CONTAINS; + } else if (text == "startswith") + { return TokenType::STARTS_WITH; + } else if (text == "endswith") + { return TokenType::ENDS_WITH; + } else if (text == "match") + { return TokenType::MATCH; + } else if (text == "!") + { return TokenType::NOT; + } else { if ((text.startsWith("r\"") || text.startsWith("ri\"")) && @@ -155,14 +274,20 @@ TokenType Tokenizer::tokenize(const QString &text) } if (text.front() == '"' && text.back() == '"') + { return TokenType::STRING; + } if (validIdentifiersMap.keys().contains(text)) + { return TokenType::IDENTIFIER; + } bool flag; if (text.toInt(&flag); flag) + { return TokenType::INT; + } } return TokenType::NONE; @@ -190,4 +315,4 @@ bool Tokenizer::typeIsMathOp(TokenType token) return token > TokenType::MATH_START && token < TokenType::MATH_END; } -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/lang/Tokenizer.hpp similarity index 62% rename from src/controllers/filters/parser/Tokenizer.hpp rename to src/controllers/filters/lang/Tokenizer.hpp index 59f4b9cefff..2fbc5fd9536 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/lang/Tokenizer.hpp @@ -1,12 +1,12 @@ #pragma once -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/Types.hpp" #include #include #include -namespace filterparser { +namespace chatterino::filters { static const QMap validIdentifiersMap = { {"author.badges", "author badges"}, @@ -17,18 +17,23 @@ static const QMap validIdentifiersMap = { {"author.sub_length", "author sub length"}, {"channel.name", "channel name"}, {"channel.watching", "/watching channel?"}, - {"channel.live", "Channel live?"}, + {"channel.live", "channel live?"}, {"flags.highlighted", "highlighted?"}, {"flags.points_redeemed", "redeemed points?"}, {"flags.sub_message", "sub/resub message?"}, {"flags.system_message", "system message?"}, {"flags.reward_message", "channel point reward message?"}, {"flags.first_message", "first message?"}, - {"flags.elevated_message", "elevated message?"}, + {"flags.elevated_message", "hype chat message?"}, + // Ideally these values are unique, because ChannelFilterEditorDialog::ValueSpecifier::expressionText depends on + // std::map layout in Qt 6 and internal implementation in Qt 5. + {"flags.hype_chat", "hype chat message?"}, {"flags.cheer_message", "cheer message?"}, {"flags.whisper", "whisper message?"}, {"flags.reply", "reply message?"}, {"flags.automod", "automod message?"}, + {"flags.restricted", "restricted message?"}, + {"flags.monitored", "monitored message?"}, {"message.content", "message text"}, {"message.length", "message length"}}; @@ -42,6 +47,58 @@ static const QRegularExpression tokenRegex( ); // clang-format on +enum TokenType { + // control + CONTROL_START = 0, + AND = 1, + OR = 2, + LP = 3, + RP = 4, + LIST_START = 5, + LIST_END = 6, + COMMA = 7, + CONTROL_END = 19, + + // binary operator + BINARY_START = 20, + EQ = 21, + NEQ = 22, + LT = 23, + GT = 24, + LTE = 25, + GTE = 26, + CONTAINS = 27, + STARTS_WITH = 28, + ENDS_WITH = 29, + MATCH = 30, + BINARY_END = 49, + + // unary operator + UNARY_START = 50, + NOT = 51, + UNARY_END = 99, + + // math operators + MATH_START = 100, + PLUS = 101, + MINUS = 102, + MULTIPLY = 103, + DIVIDE = 104, + MOD = 105, + MATH_END = 149, + + // other types + OTHER_START = 150, + STRING = 151, + INT = 152, + IDENTIFIER = 153, + REGULAR_EXPRESSION = 154, + + NONE = 200 +}; + +QString tokenTypeToInfoString(TokenType type); + class Tokenizer { public: @@ -74,4 +131,4 @@ class Tokenizer TokenType tokenize(const QString &text); }; -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Types.cpp b/src/controllers/filters/lang/Types.cpp new file mode 100644 index 00000000000..66a715960ad --- /dev/null +++ b/src/controllers/filters/lang/Types.cpp @@ -0,0 +1,101 @@ +#include "controllers/filters/lang/Types.hpp" + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" + +namespace chatterino::filters { + +bool isList(const PossibleType &possibleType) +{ + using T = Type; + if (isIllTyped(possibleType)) + { + return false; + } + + auto typ = std::get(possibleType); + return typ == T::List || typ == T::StringList || + typ == T::MatchingSpecifier; +} + +QString typeToString(Type type) +{ + using T = Type; + switch (type) + { + case T::String: + return "String"; + case T::Int: + return "Int"; + case T::Bool: + return "Bool"; + case T::Color: + return "Color"; + case T::RegularExpression: + return "RegularExpression"; + case T::List: + return "List"; + case T::StringList: + return "StringList"; + case T::MatchingSpecifier: + return "MatchingSpecifier"; + case T::Map: + return "Map"; + default: + return "Unknown"; + } +} + +QString TypeClass::string() const +{ + return typeToString(this->type); +} + +bool TypeClass::operator==(Type t) const +{ + return this->type == t; +} + +bool TypeClass::operator==(const TypeClass &t) const +{ + return this->type == t.type; +} + +bool TypeClass::operator==(const IllTyped &t) const +{ + return false; +} + +bool TypeClass::operator!=(Type t) const +{ + return !this->operator==(t); +} + +bool TypeClass::operator!=(const TypeClass &t) const +{ + return !this->operator==(t); +} + +bool TypeClass::operator!=(const IllTyped &t) const +{ + return true; +} + +QString IllTyped::string() const +{ + return "IllTyped"; +} + +QString possibleTypeToString(const PossibleType &possible) +{ + if (isWellTyped(possible)) + { + return std::get(possible).string(); + } + else + { + return std::get(possible).string(); + } +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Types.hpp b/src/controllers/filters/lang/Types.hpp new file mode 100644 index 00000000000..8debaa6975c --- /dev/null +++ b/src/controllers/filters/lang/Types.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace chatterino::filters { + +class Expression; + +enum class Type { + String, + Int, + Bool, + Color, + RegularExpression, + List, + StringList, // List of only strings + MatchingSpecifier, // 2-element list in {RegularExpression, Int} form + Map +}; + +using ContextMap = QMap; +using TypingContext = QMap; + +QString typeToString(Type type); + +struct IllTyped; + +struct TypeClass { + Type type; + + QString string() const; + + bool operator==(Type t) const; + bool operator==(const TypeClass &t) const; + bool operator==(const IllTyped &t) const; + bool operator!=(Type t) const; + bool operator!=(const TypeClass &t) const; + bool operator!=(const IllTyped &t) const; +}; + +struct IllTyped { + // Important nuance to expr: + // During type synthesis, should an error occur and an IllTyped PossibleType be + // returned, expr is a pointer to an Expression that exists in the Expression + // tree that was parsed. Therefore, you cannot hold on to this pointer longer + // than the Expression tree exists. Be careful! + const Expression *expr; + QString message; + + QString string() const; +}; + +using PossibleType = std::variant; + +inline bool isWellTyped(const PossibleType &possible) +{ + return std::holds_alternative(possible); +} + +inline bool isIllTyped(const PossibleType &possible) +{ + return std::holds_alternative(possible); +} + +QString possibleTypeToString(const PossibleType &possible); + +bool isList(const PossibleType &possibleType); + +inline bool variantIs(const QVariant &a, QMetaType::Type type) +{ + return static_cast(a.type()) == type; +} + +inline bool variantIsNot(const QVariant &a, QMetaType::Type type) +{ + return static_cast(a.type()) != type; +} + +inline bool convertVariantTypes(QVariant &a, QVariant &b, int type) +{ + return a.convert(type) && b.convert(type); +} + +inline bool variantTypesMatch(QVariant &a, QVariant &b, QMetaType::Type type) +{ + return variantIs(a, type) && variantIs(b, type); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Types.cpp b/src/controllers/filters/lang/expressions/BinaryOperation.cpp similarity index 52% rename from src/controllers/filters/parser/Types.cpp rename to src/controllers/filters/lang/expressions/BinaryOperation.cpp index 159e89ce9af..868ad23cddf 100644 --- a/src/controllers/filters/parser/Types.cpp +++ b/src/controllers/filters/lang/expressions/BinaryOperation.cpp @@ -1,214 +1,45 @@ -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/expressions/BinaryOperation.hpp" -namespace filterparser { +#include -bool convertVariantTypes(QVariant &a, QVariant &b, int type) -{ - return a.convert(type) && b.convert(type); -} +namespace { -bool variantTypesMatch(QVariant &a, QVariant &b, QVariant::Type type) +/// Loosely compares `lhs` with `rhs`. +/// This attempts to convert both variants to a common type if they're not equal. +bool looselyCompareVariants(QVariant &lhs, QVariant &rhs) { - return a.type() == type && b.type() == type; -} - -QString tokenTypeToInfoString(TokenType type) -{ - switch (type) +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + // Qt 6 and later don't convert types as much as Qt 5 did when comparing. + // + // Based on QVariant::cmp from Qt 5.15 + // https://github.com/qt/qtbase/blob/29400a683f96867133b28299c0d0bd6bcf40df35/src/corelib/kernel/qvariant.cpp#L4039-L4071 + if (lhs.metaType() != rhs.metaType()) { - case CONTROL_START: - case CONTROL_END: - case BINARY_START: - case BINARY_END: - case UNARY_START: - case UNARY_END: - case MATH_START: - case MATH_END: - case OTHER_START: - case NONE: - return ""; - case AND: - return ""; - case OR: - return ""; - case LP: - return ""; - case RP: - return ""; - case LIST_START: - return ""; - case LIST_END: - return ""; - case COMMA: - return ""; - case PLUS: - return ""; - case MINUS: - return ""; - case MULTIPLY: - return ""; - case DIVIDE: - return ""; - case MOD: - return ""; - case EQ: - return ""; - case NEQ: - return ""; - case LT: - return ""; - case GT: - return ""; - case LTE: - return ""; - case GTE: - return ""; - case CONTAINS: - return ""; - case STARTS_WITH: - return ""; - case ENDS_WITH: - return ""; - case MATCH: - return ""; - case NOT: - return ""; - case STRING: - return ""; - case INT: - return ""; - case IDENTIFIER: - return ""; - default: - return ""; - } -} - -// ValueExpression - -ValueExpression::ValueExpression(QVariant value, TokenType type) - : value_(value) - , type_(type){}; - -QVariant ValueExpression::execute(const ContextMap &context) const -{ - if (this->type_ == TokenType::IDENTIFIER) - { - return context.value(this->value_.toString()); - } - return this->value_; -} - -TokenType ValueExpression::type() -{ - return this->type_; -} - -QString ValueExpression::debug() const -{ - return this->value_.toString(); -} - -QString ValueExpression::filterString() const -{ - switch (this->type_) - { - case INT: - return QString::number(this->value_.toInt()); - case STRING: - return QString("\"%1\"").arg( - this->value_.toString().replace("\"", "\\\"")); - case IDENTIFIER: - return this->value_.toString(); - default: - return ""; - } -} - -// RegexExpression - -RegexExpression::RegexExpression(QString regex, bool caseInsensitive) - : regexString_(regex) - , caseInsensitive_(caseInsensitive) - , regex_(QRegularExpression( - regex, caseInsensitive ? QRegularExpression::CaseInsensitiveOption - : QRegularExpression::NoPatternOption)){}; - -QVariant RegexExpression::execute(const ContextMap &) const -{ - return this->regex_; -} - -QString RegexExpression::debug() const -{ - return this->regexString_; -} - -QString RegexExpression::filterString() const -{ - auto s = this->regexString_; - return QString("%1\"%2\"") - .arg(this->caseInsensitive_ ? "ri" : "r") - .arg(s.replace("\"", "\\\"")); -} - -// ListExpression - -ListExpression::ListExpression(ExpressionList list) - : list_(std::move(list)){}; - -QVariant ListExpression::execute(const ContextMap &context) const -{ - QList results; - bool allStrings = true; - for (const auto &exp : this->list_) - { - auto res = exp->execute(context); - if (allStrings && res.type() != QVariant::Type::String) + if (rhs.canConvert(lhs.metaType())) { - allStrings = false; + if (!rhs.convert(lhs.metaType())) + { + return false; + } } - results.append(res); - } - - // if everything is a string return a QStringList for case-insensitive comparison - if (allStrings) - { - QStringList strings; - strings.reserve(results.size()); - for (const auto &val : results) + else { - strings << val.toString(); + // try the opposite conversion, it might work + qSwap(lhs, rhs); + if (!rhs.convert(lhs.metaType())) + { + return false; + } } - return strings; - } - else - { - return results; } -} +#endif -QString ListExpression::debug() const -{ - QStringList debugs; - for (const auto &exp : this->list_) - { - debugs.append(exp->debug()); - } - return QString("{%1}").arg(debugs.join(", ")); + return lhs == rhs; } -QString ListExpression::filterString() const -{ - QStringList strings; - for (const auto &exp : this->list_) - { - strings.append(QString("(%1)").arg(exp->filterString())); - } - return QString("{%1}").arg(strings.join(", ")); -} +} // namespace -// BinaryOperation +namespace chatterino::filters { BinaryOperation::BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right) @@ -225,7 +56,8 @@ QVariant BinaryOperation::execute(const ContextMap &context) const switch (this->op_) { case PLUS: - if (left.type() == QVariant::Type::String && + if (static_cast(left.type()) == + QMetaType::QString && right.canConvert(QMetaType::QString)) { return left.toString().append(right.toString()); @@ -237,73 +69,93 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return 0; case MINUS: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() - right.toInt(); + } return 0; case MULTIPLY: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() * right.toInt(); + } return 0; case DIVIDE: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() / right.toInt(); + } return 0; case MOD: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() % right.toInt(); + } return 0; case OR: if (convertVariantTypes(left, right, QMetaType::Bool)) + { return left.toBool() || right.toBool(); + } return false; case AND: if (convertVariantTypes(left, right, QMetaType::Bool)) + { return left.toBool() && right.toBool(); + } return false; case EQ: - if (variantTypesMatch(left, right, QVariant::Type::String)) + if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) == 0; } - return left == right; + return looselyCompareVariants(left, right); case NEQ: - if (variantTypesMatch(left, right, QVariant::Type::String)) + if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) != 0; } - return left != right; + return !looselyCompareVariants(left, right); case LT: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() < right.toInt(); + } return false; case GT: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() > right.toInt(); + } return false; case LTE: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() <= right.toInt(); + } return false; case GTE: if (convertVariantTypes(left, right, QMetaType::Int)) + { return left.toInt() >= right.toInt(); + } return false; case CONTAINS: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { return left.toStringList().contains(right.toString(), Qt::CaseInsensitive); } - if (left.type() == QVariant::Type::Map && + if (variantIs(left, QMetaType::QVariantMap) && right.canConvert(QMetaType::QString)) { return left.toMap().contains(right.toString()); } - if (left.type() == QVariant::Type::List) + if (variantIs(left, QMetaType::QVariantList)) { return left.toList().contains(right); } @@ -317,16 +169,16 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case STARTS_WITH: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); return !list.isEmpty() && list.first().compare(right.toString(), - Qt::CaseInsensitive); + Qt::CaseInsensitive) == 0; } - if (left.type() == QVariant::Type::List) + if (variantIs(left, QMetaType::QVariantList)) { return left.toList().startsWith(right); } @@ -341,16 +193,16 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case ENDS_WITH: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); return !list.isEmpty() && list.last().compare(right.toString(), - Qt::CaseInsensitive); + Qt::CaseInsensitive) == 0; } - if (left.type() == QVariant::Type::List) + if (variantIs(left, QMetaType::QVariantList)) { return left.toList().endsWith(right); } @@ -371,34 +223,42 @@ QVariant BinaryOperation::execute(const ContextMap &context) const auto matching = left.toString(); - switch (right.type()) + switch (static_cast(right.type())) { - case QVariant::Type::RegularExpression: { + case QMetaType::QRegularExpression: { return right.toRegularExpression() .match(matching) .hasMatch(); } - case QVariant::Type::List: { + case QMetaType::QVariantList: { auto list = right.toList(); // list must be two items if (list.size() != 2) + { return false; + } // list must be a regular expression and an int - if (list.at(0).type() != - QVariant::Type::RegularExpression || - list.at(1).type() != QVariant::Type::Int) + if (variantIsNot(list.at(0), + QMetaType::QRegularExpression) || + variantIsNot(list.at(1), QMetaType::Int)) + { return false; + } auto match = list.at(0).toRegularExpression().match(matching); - // if matched, return nth capture group. Otherwise, return false + // if matched, return nth capture group. Otherwise, return "" if (match.hasMatch()) + { return match.captured(list.at(1).toInt()); + } else - return false; + { + return ""; + } } default: return false; @@ -409,11 +269,129 @@ QVariant BinaryOperation::execute(const ContextMap &context) const } } -QString BinaryOperation::debug() const +PossibleType BinaryOperation::synthesizeType(const TypingContext &context) const { - return QString("(%1 %2 %3)") - .arg(this->left_->debug(), tokenTypeToInfoString(this->op_), - this->right_->debug()); + auto leftSyn = this->left_->synthesizeType(context); + auto rightSyn = this->right_->synthesizeType(context); + + // Return if either operand is ill-typed + if (isIllTyped(leftSyn)) + { + return leftSyn; + } + else if (isIllTyped(rightSyn)) + { + return rightSyn; + } + + auto left = std::get(leftSyn); + auto right = std::get(rightSyn); + + switch (this->op_) + { + case PLUS: + if (left == Type::String) + { + return TypeClass{Type::String}; // String concatenation + } + else if (left == Type::Int && right == Type::Int) + { + return TypeClass{Type::Int}; + } + + return IllTyped{this, "Can only add Ints or concatenate a String"}; + case MINUS: + case MULTIPLY: + case DIVIDE: + case MOD: + if (left == Type::Int && right == Type::Int) + { + return TypeClass{Type::Int}; + } + + return IllTyped{this, "Can only perform operation with Ints"}; + case OR: + case AND: + if (left == Type::Bool && right == Type::Bool) + { + return TypeClass{Type::Bool}; + } + + return IllTyped{this, + "Can only perform logical operations with Bools"}; + case EQ: + case NEQ: + // equals/not equals always produces a valid output + return TypeClass{Type::Bool}; + case LT: + case GT: + case LTE: + case GTE: + if (left == Type::Int && right == Type::Int) + { + return TypeClass{Type::Bool}; + } + + return IllTyped{this, "Can only perform comparisons with Ints"}; + case STARTS_WITH: + case ENDS_WITH: + if (isList(left)) + { + return TypeClass{Type::Bool}; + } + if (left == Type::String && right == Type::String) + { + return TypeClass{Type::Bool}; + } + + return IllTyped{ + this, + "Can only perform starts/ends with a List or two Strings"}; + case CONTAINS: + if (isList(left) || left == Type::Map) + { + return TypeClass{Type::Bool}; + } + if (left == Type::String && right == Type::String) + { + return TypeClass{Type::Bool}; + } + + return IllTyped{ + this, + "Can only perform contains with a List, a Map, or two Strings"}; + case MATCH: { + if (left != Type::String) + { + return IllTyped{this, + "Left argument of match must be a String"}; + } + + if (right == Type::RegularExpression) + { + return TypeClass{Type::Bool}; + } + if (right == Type::MatchingSpecifier) + { // group capturing + return TypeClass{Type::String}; + } + + return IllTyped{this, "Can only match on a RegularExpression or a " + "MatchingSpecifier"}; + } + default: + return IllTyped{this, "Not implemented"}; + } +} + +QString BinaryOperation::debug(const TypingContext &context) const +{ + return QString("BinaryOp[%1](%2 : %3, %4 : %5)") + .arg(tokenTypeToInfoString(this->op_)) + .arg(this->left_->debug(context)) + .arg(possibleTypeToString(this->left_->synthesizeType(context))) + .arg(this->right_->debug(context)) + .arg(possibleTypeToString(this->right_->synthesizeType(context))); } QString BinaryOperation::filterString() const @@ -456,57 +434,14 @@ QString BinaryOperation::filterString() const case MATCH: return "match"; default: - return QString(); + return ""; } }(); - return QString("(%1) %2 (%3)") + return QString("(%1 %2 %3)") .arg(this->left_->filterString()) .arg(opText) .arg(this->right_->filterString()); } -// UnaryOperation - -UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) - : op_(op) - , right_(std::move(right)) -{ -} - -QVariant UnaryOperation::execute(const ContextMap &context) const -{ - auto right = this->right_->execute(context); - switch (this->op_) - { - case NOT: - if (right.canConvert()) - return !right.toBool(); - return false; - default: - return false; - } -} - -QString UnaryOperation::debug() const -{ - return QString("(%1 %2)").arg(tokenTypeToInfoString(this->op_), - this->right_->debug()); -} - -QString UnaryOperation::filterString() const -{ - const auto opText = [&]() -> QString { - switch (this->op_) - { - case NOT: - return "!"; - default: - return QString(); - } - }(); - - return QString("%1(%2)").arg(opText).arg(this->right_->filterString()); -} - -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/BinaryOperation.hpp b/src/controllers/filters/lang/expressions/BinaryOperation.hpp new file mode 100644 index 00000000000..b42f81bf5ad --- /dev/null +++ b/src/controllers/filters/lang/expressions/BinaryOperation.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class BinaryOperation : public Expression +{ +public: + BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr left_; + ExpressionPtr right_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/Expression.cpp b/src/controllers/filters/lang/expressions/Expression.cpp new file mode 100644 index 00000000000..0c353d8c4ef --- /dev/null +++ b/src/controllers/filters/lang/expressions/Expression.cpp @@ -0,0 +1,5 @@ +#include "controllers/filters/lang/expressions/Expression.hpp" + +namespace chatterino::filters { + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/Expression.hpp b/src/controllers/filters/lang/expressions/Expression.hpp new file mode 100644 index 00000000000..fe4fddac32f --- /dev/null +++ b/src/controllers/filters/lang/expressions/Expression.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "controllers/filters/lang/Tokenizer.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include +#include + +#include +#include + +namespace chatterino::filters { + +class Expression +{ +public: + virtual ~Expression() = default; + + virtual QVariant execute(const ContextMap &context) const = 0; + virtual PossibleType synthesizeType(const TypingContext &context) const = 0; + virtual QString debug(const TypingContext &context) const = 0; + virtual QString filterString() const = 0; +}; + +using ExpressionPtr = std::unique_ptr; +using ExpressionList = std::vector>; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ListExpression.cpp b/src/controllers/filters/lang/expressions/ListExpression.cpp new file mode 100644 index 00000000000..c0bf5ef8b65 --- /dev/null +++ b/src/controllers/filters/lang/expressions/ListExpression.cpp @@ -0,0 +1,94 @@ +#include "controllers/filters/lang/expressions/ListExpression.hpp" + +namespace chatterino::filters { + +ListExpression::ListExpression(ExpressionList &&list) + : list_(std::move(list)){}; + +QVariant ListExpression::execute(const ContextMap &context) const +{ + QList results; + bool allStrings = true; + for (const auto &exp : this->list_) + { + auto res = exp->execute(context); + if (allStrings && variantIsNot(res, QMetaType::QString)) + { + allStrings = false; + } + results.append(res); + } + + // if everything is a string return a QStringList for case-insensitive comparison + if (allStrings) + { + QStringList strings; + strings.reserve(results.size()); + for (const auto &val : results) + { + strings << val.toString(); + } + return strings; + } + + return results; +} + +PossibleType ListExpression::synthesizeType(const TypingContext &context) const +{ + std::vector types; + types.reserve(this->list_.size()); + bool allStrings = true; + for (const auto &exp : this->list_) + { + auto typSyn = exp->synthesizeType(context); + if (isIllTyped(typSyn)) + { + return typSyn; // Ill-typed + } + + auto typ = std::get(typSyn); + + if (typ != Type::String) + { + allStrings = false; + } + + types.push_back(typ); + } + + if (types.size() == 2 && types[0] == Type::RegularExpression && + types[1] == Type::Int) + { + // Specific {RegularExpression, Int} form + return TypeClass{Type::MatchingSpecifier}; + } + + return allStrings ? TypeClass{Type::StringList} : TypeClass{Type::List}; +} + +QString ListExpression::debug(const TypingContext &context) const +{ + QStringList debugs; + for (const auto &exp : this->list_) + { + debugs.append( + QString("%1 : %2") + .arg(exp->debug(context)) + .arg(possibleTypeToString(exp->synthesizeType(context)))); + } + + return QString("List(%1)").arg(debugs.join(", ")); +} + +QString ListExpression::filterString() const +{ + QStringList strings; + for (const auto &exp : this->list_) + { + strings.append(exp->filterString()); + } + return QString("{%1}").arg(strings.join(", ")); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ListExpression.hpp b/src/controllers/filters/lang/expressions/ListExpression.hpp new file mode 100644 index 00000000000..6de6a46eecd --- /dev/null +++ b/src/controllers/filters/lang/expressions/ListExpression.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class ListExpression : public Expression +{ +public: + ListExpression(ExpressionList &&list); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + ExpressionList list_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/RegexExpression.cpp b/src/controllers/filters/lang/expressions/RegexExpression.cpp new file mode 100644 index 00000000000..8481757348c --- /dev/null +++ b/src/controllers/filters/lang/expressions/RegexExpression.cpp @@ -0,0 +1,36 @@ +#include "controllers/filters/lang/expressions/RegexExpression.hpp" + +namespace chatterino::filters { + +RegexExpression::RegexExpression(const QString ®ex, bool caseInsensitive) + : regexString_(regex) + , caseInsensitive_(caseInsensitive) + , regex_(QRegularExpression( + regex, caseInsensitive ? QRegularExpression::CaseInsensitiveOption + : QRegularExpression::NoPatternOption)){}; + +QVariant RegexExpression::execute(const ContextMap & /*context*/) const +{ + return this->regex_; +} + +PossibleType RegexExpression::synthesizeType( + const TypingContext & /*context*/) const +{ + return TypeClass{Type::RegularExpression}; +} + +QString RegexExpression::debug(const TypingContext & /*context*/) const +{ + return QString("RegEx(%1)").arg(this->regexString_); +} + +QString RegexExpression::filterString() const +{ + auto s = this->regexString_; + return QString("%1\"%2\"") + .arg(this->caseInsensitive_ ? "ri" : "r") + .arg(s.replace("\"", "\\\"")); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/RegexExpression.hpp b/src/controllers/filters/lang/expressions/RegexExpression.hpp new file mode 100644 index 00000000000..75fa5a08863 --- /dev/null +++ b/src/controllers/filters/lang/expressions/RegexExpression.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include + +namespace chatterino::filters { + +class RegexExpression : public Expression +{ +public: + RegexExpression(const QString ®ex, bool caseInsensitive); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + QString regexString_; + bool caseInsensitive_; + QRegularExpression regex_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/UnaryOperation.cpp b/src/controllers/filters/lang/expressions/UnaryOperation.cpp new file mode 100644 index 00000000000..11f487aa1f2 --- /dev/null +++ b/src/controllers/filters/lang/expressions/UnaryOperation.cpp @@ -0,0 +1,69 @@ +#include "controllers/filters/lang/expressions/UnaryOperation.hpp" + +namespace chatterino::filters { + +UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) + : op_(op) + , right_(std::move(right)) +{ +} + +QVariant UnaryOperation::execute(const ContextMap &context) const +{ + auto right = this->right_->execute(context); + switch (this->op_) + { + case NOT: + return right.canConvert() && !right.toBool(); + default: + return false; + } +} + +PossibleType UnaryOperation::synthesizeType(const TypingContext &context) const +{ + auto rightSyn = this->right_->synthesizeType(context); + if (isIllTyped(rightSyn)) + { + return rightSyn; + } + + auto right = std::get(rightSyn); + + switch (this->op_) + { + case NOT: + if (right == Type::Bool) + { + return TypeClass{Type::Bool}; + } + return IllTyped{this, "Can only negate boolean values"}; + default: + return IllTyped{this, "Not implemented"}; + } +} + +QString UnaryOperation::debug(const TypingContext &context) const +{ + return QString("UnaryOp[%1](%2 : %3)") + .arg(tokenTypeToInfoString(this->op_)) + .arg(this->right_->debug(context)) + .arg(possibleTypeToString(this->right_->synthesizeType(context))); +} + +QString UnaryOperation::filterString() const +{ + const auto opText = [&]() -> QString { + switch (this->op_) + { + case NOT: + return "!"; + default: + return ""; + } + }(); + + return QString("(%1%2)").arg(opText).arg(this->right_->filterString()); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/UnaryOperation.hpp b/src/controllers/filters/lang/expressions/UnaryOperation.hpp new file mode 100644 index 00000000000..155a78b7119 --- /dev/null +++ b/src/controllers/filters/lang/expressions/UnaryOperation.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class UnaryOperation : public Expression +{ +public: + UnaryOperation(TokenType op, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr right_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ValueExpression.cpp b/src/controllers/filters/lang/expressions/ValueExpression.cpp new file mode 100644 index 00000000000..23244613de3 --- /dev/null +++ b/src/controllers/filters/lang/expressions/ValueExpression.cpp @@ -0,0 +1,70 @@ +#include "controllers/filters/lang/expressions/ValueExpression.hpp" + +#include "controllers/filters/lang/Tokenizer.hpp" + +namespace chatterino::filters { + +ValueExpression::ValueExpression(QVariant value, TokenType type) + : value_(std::move(value)) + , type_(type) +{ +} + +QVariant ValueExpression::execute(const ContextMap &context) const +{ + if (this->type_ == TokenType::IDENTIFIER) + { + return context.value(this->value_.toString()); + } + return this->value_; +} + +PossibleType ValueExpression::synthesizeType(const TypingContext &context) const +{ + switch (this->type_) + { + case TokenType::IDENTIFIER: { + auto it = context.find(this->value_.toString()); + if (it != context.end()) + { + return TypeClass{it.value()}; + } + + return IllTyped{this, "Unbound identifier"}; + } + case TokenType::INT: + return TypeClass{Type::Int}; + case TokenType::STRING: + return TypeClass{Type::String}; + default: + return IllTyped{this, "Invalid value type"}; + } +} + +TokenType ValueExpression::type() +{ + return this->type_; +} + +QString ValueExpression::debug(const TypingContext & /*context*/) const +{ + return QString("Val(%1)").arg(this->value_.toString()); +} + +QString ValueExpression::filterString() const +{ + switch (this->type_) + { + case INT: + return QString::number(this->value_.toInt()); + case STRING: + return QString("\"%1\"").arg( + this->value_.toString().replace("\"", "\\\"")); + case IDENTIFIER: + return this->value_.toString(); + default: + return ""; + } +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ValueExpression.hpp b/src/controllers/filters/lang/expressions/ValueExpression.hpp new file mode 100644 index 00000000000..56cbf80c43b --- /dev/null +++ b/src/controllers/filters/lang/expressions/ValueExpression.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class ValueExpression : public Expression +{ +public: + ValueExpression(QVariant value, TokenType type); + TokenType type(); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + QVariant value_; + TokenType type_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Types.hpp b/src/controllers/filters/parser/Types.hpp deleted file mode 100644 index d6fcd7c9ecb..00000000000 --- a/src/controllers/filters/parser/Types.hpp +++ /dev/null @@ -1,168 +0,0 @@ -#pragma once - -#include - -#include - -namespace chatterino { - -struct Message; - -} - -namespace filterparser { - -using MessagePtr = std::shared_ptr; -using ContextMap = QMap; - -enum TokenType { - // control - CONTROL_START = 0, - AND = 1, - OR = 2, - LP = 3, - RP = 4, - LIST_START = 5, - LIST_END = 6, - COMMA = 7, - CONTROL_END = 19, - - // binary operator - BINARY_START = 20, - EQ = 21, - NEQ = 22, - LT = 23, - GT = 24, - LTE = 25, - GTE = 26, - CONTAINS = 27, - STARTS_WITH = 28, - ENDS_WITH = 29, - MATCH = 30, - BINARY_END = 49, - - // unary operator - UNARY_START = 50, - NOT = 51, - UNARY_END = 99, - - // math operators - MATH_START = 100, - PLUS = 101, - MINUS = 102, - MULTIPLY = 103, - DIVIDE = 104, - MOD = 105, - MATH_END = 149, - - // other types - OTHER_START = 150, - STRING = 151, - INT = 152, - IDENTIFIER = 153, - REGULAR_EXPRESSION = 154, - - NONE = 200 -}; - -bool convertVariantTypes(QVariant &a, QVariant &b, int type); -QString tokenTypeToInfoString(TokenType type); - -class Expression -{ -public: - virtual ~Expression() = default; - - virtual QVariant execute(const ContextMap &) const - { - return false; - } - - virtual QString debug() const - { - return "(false)"; - } - - virtual QString filterString() const - { - return ""; - } -}; - -using ExpressionPtr = std::unique_ptr; - -class ValueExpression : public Expression -{ -public: - ValueExpression(QVariant value, TokenType type); - TokenType type(); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - QVariant value_; - TokenType type_; -}; - -class RegexExpression : public Expression -{ -public: - RegexExpression(QString regex, bool caseInsensitive); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - QString regexString_; - bool caseInsensitive_; - QRegularExpression regex_; -}; - -using ExpressionList = std::vector>; - -class ListExpression : public Expression -{ -public: - ListExpression(ExpressionList list); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - ExpressionList list_; -}; - -class BinaryOperation : public Expression -{ -public: - BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - TokenType op_; - ExpressionPtr left_; - ExpressionPtr right_; -}; - -class UnaryOperation : public Expression -{ -public: - UnaryOperation(TokenType op, ExpressionPtr right); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - TokenType op_; - ExpressionPtr right_; -}; - -} // namespace filterparser diff --git a/src/controllers/highlights/BadgeHighlightModel.cpp b/src/controllers/highlights/BadgeHighlightModel.cpp index ffca5b28914..e1d93c3bc93 100644 --- a/src/controllers/highlights/BadgeHighlightModel.cpp +++ b/src/controllers/highlights/BadgeHighlightModel.cpp @@ -53,7 +53,7 @@ void BadgeHighlightModel::getRowFromItem(const HighlightBadge &item, setFilePathItem(row[Column::SoundPath], item.getSoundUrl()); setColorItem(row[Column::Color], *item.getColor()); - TwitchBadges::instance()->getBadgeIcon( + getIApp()->getTwitchBadges()->getBadgeIcon( item.badgeName(), [item, row](QString /*name*/, const QIconPtr pixmap) { row[Column::Badge]->setData(QVariant(*pixmap), Qt::DecorationRole); }); diff --git a/src/controllers/highlights/BadgeHighlightModel.hpp b/src/controllers/highlights/BadgeHighlightModel.hpp index cf3b8501c76..20600bb67fd 100644 --- a/src/controllers/highlights/BadgeHighlightModel.hpp +++ b/src/controllers/highlights/BadgeHighlightModel.hpp @@ -25,12 +25,11 @@ class BadgeHighlightModel : public SignalVectorModel protected: // vector into model row - virtual HighlightBadge getItemFromRow( - std::vector &row, - const HighlightBadge &original) override; + HighlightBadge getItemFromRow(std::vector &row, + const HighlightBadge &original) override; - virtual void getRowFromItem(const HighlightBadge &item, - std::vector &row) override; + void getRowFromItem(const HighlightBadge &item, + std::vector &row) override; }; } // namespace chatterino diff --git a/src/controllers/highlights/HighlightBadge.hpp b/src/controllers/highlights/HighlightBadge.hpp index d1013c3f045..0be6895de7f 100644 --- a/src/controllers/highlights/HighlightBadge.hpp +++ b/src/controllers/highlights/HighlightBadge.hpp @@ -123,7 +123,9 @@ struct Deserialize { auto _color = QColor(encodedColor); if (!_color.isValid()) + { _color = chatterino::HighlightBadge::FALLBACK_HIGHLIGHT_COLOR; + } return chatterino::HighlightBadge(_name, _displayName, _showInMentions, _hasAlert, _hasSound, _soundUrl, diff --git a/src/controllers/highlights/HighlightBlacklistModel.hpp b/src/controllers/highlights/HighlightBlacklistModel.hpp index 414af461a65..8de3b07ea3a 100644 --- a/src/controllers/highlights/HighlightBlacklistModel.hpp +++ b/src/controllers/highlights/HighlightBlacklistModel.hpp @@ -21,13 +21,13 @@ class HighlightBlacklistModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual HighlightBlacklistUser getItemFromRow( + HighlightBlacklistUser getItemFromRow( std::vector &row, const HighlightBlacklistUser &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const HighlightBlacklistUser &item, - std::vector &row) override; + void getRowFromItem(const HighlightBlacklistUser &item, + std::vector &row) override; }; } // namespace chatterino diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp index bd517863f39..53062d08cae 100644 --- a/src/controllers/highlights/HighlightController.cpp +++ b/src/controllers/highlights/HighlightController.cpp @@ -23,7 +23,7 @@ auto highlightPhraseCheck(const HighlightPhrase &highlight) -> HighlightCheck [highlight](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, const auto &flags, - const auto self) -> boost::optional { + const auto self) -> std::optional { (void)args; // unused (void)badges; // unused (void)senderName; // unused @@ -32,15 +32,15 @@ auto highlightPhraseCheck(const HighlightPhrase &highlight) -> HighlightCheck if (self) { // Phrase checks should ignore highlights from the user - return boost::none; + return std::nullopt; } if (!highlight.isMatch(originalMessage)) { - return boost::none; + return std::nullopt; } - boost::optional highlightSoundUrl; + std::optional highlightSoundUrl; if (highlight.hasCustomSound()) { highlightSoundUrl = highlight.getSoundUrl(); @@ -62,7 +62,7 @@ void rebuildSubscriptionHighlights(Settings &settings, auto highlightSound = settings.enableSubHighlightSound.getValue(); auto highlightAlert = settings.enableSubHighlightTaskbar.getValue(); auto highlightSoundUrlValue = settings.subHighlightSoundUrl.getValue(); - boost::optional highlightSoundUrl; + std::optional highlightSoundUrl; if (!highlightSoundUrlValue.isEmpty()) { highlightSoundUrl = highlightSoundUrlValue; @@ -73,7 +73,7 @@ void rebuildSubscriptionHighlights(Settings &settings, checks.emplace_back(HighlightCheck{ [=](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, const auto &flags, - const auto self) -> boost::optional { + const auto self) -> std::optional { (void)badges; // unused (void)senderName; // unused (void)originalMessage; // unused @@ -82,7 +82,7 @@ void rebuildSubscriptionHighlights(Settings &settings, if (!args.isSubscriptionMessage) { - return boost::none; + return std::nullopt; } auto highlightColor = @@ -108,7 +108,7 @@ void rebuildWhisperHighlights(Settings &settings, auto highlightAlert = settings.enableWhisperHighlightTaskbar.getValue(); auto highlightSoundUrlValue = settings.whisperHighlightSoundUrl.getValue(); - boost::optional highlightSoundUrl; + std::optional highlightSoundUrl; if (!highlightSoundUrlValue.isEmpty()) { highlightSoundUrl = highlightSoundUrlValue; @@ -119,7 +119,7 @@ void rebuildWhisperHighlights(Settings &settings, checks.emplace_back(HighlightCheck{ [=](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, const auto &flags, - const auto self) -> boost::optional { + const auto self) -> std::optional { (void)badges; // unused (void)senderName; // unused (void)originalMessage; // unused @@ -128,7 +128,7 @@ void rebuildWhisperHighlights(Settings &settings, if (!args.isReceivedWhisper) { - return boost::none; + return std::nullopt; } return HighlightResult{ @@ -151,7 +151,7 @@ void rebuildReplyThreadHighlight(Settings &settings, auto highlightAlert = settings.enableThreadHighlightTaskbar.getValue(); auto highlightSoundUrlValue = settings.threadHighlightSoundUrl.getValue(); - boost::optional highlightSoundUrl; + std::optional highlightSoundUrl; if (!highlightSoundUrlValue.isEmpty()) { highlightSoundUrl = highlightSoundUrlValue; @@ -162,8 +162,8 @@ void rebuildReplyThreadHighlight(Settings &settings, [=](const auto & /*args*/, const auto & /*badges*/, const auto & /*senderName*/, const auto & /*originalMessage*/, const auto &flags, - const auto self) -> boost::optional { - if (flags.has(MessageFlag::ParticipatedThread) && !self) + const auto self) -> std::optional { + if (flags.has(MessageFlag::SubscribedThread) && !self) { return HighlightResult{ highlightAlert, @@ -175,7 +175,7 @@ void rebuildReplyThreadHighlight(Settings &settings, }; } - return boost::none; + return std::nullopt; }}); } } @@ -186,7 +186,8 @@ void rebuildMessageHighlights(Settings &settings, auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); QString currentUsername = currentUser->getUserName(); - if (settings.enableSelfHighlight && !currentUsername.isEmpty()) + if (settings.enableSelfHighlight && !currentUsername.isEmpty() && + !currentUser->isAnon()) { HighlightPhrase highlight( currentUsername, settings.showSelfHighlightInMentions, @@ -203,6 +204,41 @@ void rebuildMessageHighlights(Settings &settings, { checks.emplace_back(highlightPhraseCheck(highlight)); } + + if (settings.enableAutomodHighlight) + { + const auto highlightSound = + settings.enableAutomodHighlightSound.getValue(); + const auto highlightAlert = + settings.enableAutomodHighlightTaskbar.getValue(); + const auto highlightSoundUrlValue = + settings.automodHighlightSoundUrl.getValue(); + + checks.emplace_back(HighlightCheck{ + [=](const auto & /*args*/, const auto & /*badges*/, + const auto & /*senderName*/, const auto & /*originalMessage*/, + const auto &flags, + const auto /*self*/) -> std::optional { + if (!flags.has(MessageFlag::AutoModOffendingMessage)) + { + return std::nullopt; + } + + std::optional highlightSoundUrl; + if (!highlightSoundUrlValue.isEmpty()) + { + highlightSoundUrl = highlightSoundUrlValue; + } + + return HighlightResult{ + highlightAlert, // alert + highlightSound, // playSound + highlightSoundUrl, // customSoundUrl + nullptr, // color + false, // showInMentions + }; + }}); + } } void rebuildUserHighlights(Settings &settings, @@ -218,7 +254,7 @@ void rebuildUserHighlights(Settings &settings, [showInMentions]( const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, const auto &flags, - const auto self) -> boost::optional { + const auto self) -> std::optional { (void)args; //unused (void)badges; //unused (void)senderName; //unused @@ -227,7 +263,7 @@ void rebuildUserHighlights(Settings &settings, if (!self) { - return boost::none; + return std::nullopt; } // Highlight color is provided by the ColorProvider and will be updated accordingly @@ -245,7 +281,7 @@ void rebuildUserHighlights(Settings &settings, [highlight](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, const auto &flags, - const auto self) -> boost::optional { + const auto self) -> std::optional { (void)args; // unused (void)badges; // unused (void)originalMessage; // unused @@ -254,10 +290,10 @@ void rebuildUserHighlights(Settings &settings, if (!highlight.isMatch(senderName)) { - return boost::none; + return std::nullopt; } - boost::optional highlightSoundUrl; + std::optional highlightSoundUrl; if (highlight.hasCustomSound()) { highlightSoundUrl = highlight.getSoundUrl(); @@ -285,7 +321,7 @@ void rebuildBadgeHighlights(Settings &settings, [highlight](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, const auto &flags, - const auto self) -> boost::optional { + const auto self) -> std::optional { (void)args; // unused (void)senderName; // unused (void)originalMessage; // unused @@ -296,7 +332,7 @@ void rebuildBadgeHighlights(Settings &settings, { if (highlight.isMatch(badge)) { - boost::optional highlightSoundUrl; + std::optional highlightSoundUrl; if (highlight.hasCustomSound()) { highlightSoundUrl = highlight.getSoundUrl(); @@ -312,7 +348,7 @@ void rebuildBadgeHighlights(Settings &settings, } } - return boost::none; + return std::nullopt; }}); } } @@ -322,7 +358,7 @@ void rebuildBadgeHighlights(Settings &settings, namespace chatterino { HighlightResult::HighlightResult(bool _alert, bool _playSound, - boost::optional _customSoundUrl, + std::optional _customSoundUrl, std::shared_ptr _color, bool _showInMentions) : alert(_alert) @@ -336,7 +372,7 @@ HighlightResult::HighlightResult(bool _alert, bool _playSound, HighlightResult HighlightResult::emptyResult() { return { - false, false, boost::none, nullptr, false, + false, false, std::nullopt, nullptr, false, }; } @@ -394,7 +430,7 @@ std::ostream &operator<<(std::ostream &os, const HighlightResult &result) os << "Alert: " << (result.alert ? "Yes" : "No") << ", " << "Play sound: " << (result.playSound ? "Yes" : "No") << " (" << (result.customSoundUrl - ? result.customSoundUrl.get().toString().toStdString() + ? result.customSoundUrl->toString().toStdString() : "") << ")" << ", " @@ -404,7 +440,8 @@ std::ostream &operator<<(std::ostream &os, const HighlightResult &result) return os; } -void HighlightController::initialize(Settings &settings, Paths & /*paths*/) +void HighlightController::initialize(Settings &settings, + const Paths & /*paths*/) { this->rebuildListener_.addSetting(settings.enableSelfHighlight); this->rebuildListener_.addSetting(settings.enableSelfHighlightSound); @@ -433,6 +470,11 @@ void HighlightController::initialize(Settings &settings, Paths & /*paths*/) this->rebuildListener_.addSetting(settings.threadHighlightSoundUrl); this->rebuildListener_.addSetting(settings.showThreadHighlightInMentions); + this->rebuildListener_.addSetting(settings.enableAutomodHighlight); + this->rebuildListener_.addSetting(settings.enableAutomodHighlightSound); + this->rebuildListener_.addSetting(settings.enableAutomodHighlightTaskbar); + this->rebuildListener_.addSetting(settings.automodHighlightSoundUrl); + this->rebuildListener_.setCB([this, &settings] { qCDebug(chatterinoHighlights) << "Rebuild checks because a setting changed"; @@ -440,7 +482,7 @@ void HighlightController::initialize(Settings &settings, Paths & /*paths*/) }); this->signalHolder_.managedConnect( - getCSettings().highlightedBadges.delayedItemsChanged, + getSettings()->highlightedBadges.delayedItemsChanged, [this, &settings] { qCDebug(chatterinoHighlights) << "Rebuild checks because highlight badges changed"; @@ -448,14 +490,14 @@ void HighlightController::initialize(Settings &settings, Paths & /*paths*/) }); this->signalHolder_.managedConnect( - getCSettings().highlightedUsers.delayedItemsChanged, [this, &settings] { + getSettings()->highlightedUsers.delayedItemsChanged, [this, &settings] { qCDebug(chatterinoHighlights) << "Rebuild checks because highlight users changed"; this->rebuildChecks(settings); }); this->signalHolder_.managedConnect( - getCSettings().highlightedMessages.delayedItemsChanged, + getSettings()->highlightedMessages.delayedItemsChanged, [this, &settings] { qCDebug(chatterinoHighlights) << "Rebuild checks because highlight messages changed"; diff --git a/src/controllers/highlights/HighlightController.hpp b/src/controllers/highlights/HighlightController.hpp index 7e238d81737..e05986a0e8b 100644 --- a/src/controllers/highlights/HighlightController.hpp +++ b/src/controllers/highlights/HighlightController.hpp @@ -4,7 +4,6 @@ #include "common/Singleton.hpp" #include "common/UniqueAccess.hpp" -#include #include #include #include @@ -12,6 +11,7 @@ #include #include +#include #include namespace chatterino { @@ -23,7 +23,7 @@ using MessageFlags = FlagsEnum; struct HighlightResult { HighlightResult(bool _alert, bool _playSound, - boost::optional _customSoundUrl, + std::optional _customSoundUrl, std::shared_ptr _color, bool _showInMentions); /** @@ -46,7 +46,7 @@ struct HighlightResult { * * May only be set if playSound is true **/ - boost::optional customSoundUrl{}; + std::optional customSoundUrl{}; /** * @brief set if highlight should set a background color @@ -76,7 +76,7 @@ struct HighlightResult { }; struct HighlightCheck { - using Checker = std::function( + using Checker = std::function( const MessageParseArgs &args, const std::vector &badges, const QString &senderName, const QString &originalMessage, const MessageFlags &messageFlags, bool self)>; @@ -86,7 +86,7 @@ struct HighlightCheck { class HighlightController final : public Singleton { public: - void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; /** * @brief Checks the given message parameters if it matches our internal checks, and returns a result diff --git a/src/controllers/highlights/HighlightModel.cpp b/src/controllers/highlights/HighlightModel.cpp index 13ff5ec6b71..c83657c86d6 100644 --- a/src/controllers/highlights/HighlightModel.cpp +++ b/src/controllers/highlights/HighlightModel.cpp @@ -98,9 +98,8 @@ void HighlightModel::afterInit() QUrl(getSettings()->whisperHighlightSoundUrl.getValue()); setFilePathItem(whisperRow[Column::SoundPath], whisperSound, false); - // auto whisperColor = ColorProvider::instance().color(ColorType::Whisper); - // setColorItem(whisperRow[Column::Color], *whisperColor, false); - whisperRow[Column::Color]->setFlags(Qt::ItemFlag::NoItemFlags); + auto whisperColor = ColorProvider::instance().color(ColorType::Whisper); + setColorItem(whisperRow[Column::Color], *whisperColor, false); this->insertCustomRow(whisperRow, HighlightRowIndexes::WhisperRow); @@ -178,13 +177,12 @@ void HighlightModel::afterInit() this->insertCustomRow(firstMessageRow, HighlightRowIndexes::FirstMessageRow); - // Highlight settings for elevated messages + // Highlight settings for hype chats std::vector elevatedMessageRow = this->createRow(); setBoolItem(elevatedMessageRow[Column::Pattern], getSettings()->enableElevatedMessageHighlight.getValue(), true, false); - elevatedMessageRow[Column::Pattern]->setData("Elevated Messages", - Qt::DisplayRole); + elevatedMessageRow[Column::Pattern]->setData("Hype Chats", Qt::DisplayRole); elevatedMessageRow[Column::ShowInMentions]->setFlags({}); // setBoolItem(elevatedMessageRow[Column::FlashTaskbar], // getSettings()->enableElevatedMessageHighlightTaskbar.getValue(), @@ -210,7 +208,7 @@ void HighlightModel::afterInit() std::vector threadMessageRow = this->createRow(); setBoolItem(threadMessageRow[Column::Pattern], getSettings()->enableThreadHighlight.getValue(), true, false); - threadMessageRow[Column::Pattern]->setData("Participated Reply Threads", + threadMessageRow[Column::Pattern]->setData("Subscribed Reply Threads", Qt::DisplayRole); setBoolItem(threadMessageRow[Column::ShowInMentions], getSettings()->showThreadHighlightInMentions.getValue(), true, @@ -235,6 +233,30 @@ void HighlightModel::afterInit() this->insertCustomRow(threadMessageRow, HighlightRowIndexes::ThreadMessageRow); + + // Highlight settings for automod caught messages + const std::vector automodRow = this->createRow(); + setBoolItem(automodRow[Column::Pattern], + getSettings()->enableAutomodHighlight.getValue(), true, false); + automodRow[Column::Pattern]->setData("AutoMod Caught Messages", + Qt::DisplayRole); + automodRow[Column::ShowInMentions]->setFlags({}); + setBoolItem(automodRow[Column::FlashTaskbar], + getSettings()->enableAutomodHighlightTaskbar.getValue(), true, + false); + setBoolItem(automodRow[Column::PlaySound], + getSettings()->enableAutomodHighlightSound.getValue(), true, + false); + automodRow[Column::UseRegex]->setFlags({}); + automodRow[Column::CaseSensitive]->setFlags({}); + + const auto automodSound = + QUrl(getSettings()->automodHighlightSoundUrl.getValue()); + setFilePathItem(automodRow[Column::SoundPath], automodSound, false); + + automodRow[Column::Color]->setFlags(Qt::ItemFlag::NoItemFlags); + + this->insertCustomRow(automodRow, HighlightRowIndexes::AutomodRow); } void HighlightModel::customRowSetData(const std::vector &row, @@ -279,6 +301,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableThreadHighlight.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->enableAutomodHighlight.setValue( + value.toBool()); + } } } break; @@ -337,6 +364,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableThreadHighlightTaskbar.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->enableAutomodHighlightTaskbar.setValue( + value.toBool()); + } } } break; @@ -378,6 +410,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableThreadHighlightSound.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->enableAutomodHighlightSound.setValue( + value.toBool()); + } } } break; @@ -413,6 +450,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->threadHighlightSoundUrl.setValue( value.toString()); } + else if (rowIndex == HighlightRowIndexes::AutomodRow) + { + getSettings()->automodHighlightSoundUrl.setValue( + value.toString()); + } } } break; @@ -420,55 +462,52 @@ void HighlightModel::customRowSetData(const std::vector &row, // Custom color if (role == Qt::DecorationRole) { - auto colorName = value.value().name(QColor::HexArgb); + const auto setColor = [&](auto &setting, ColorType ty) { + auto color = value.value(); + setting.setValue(color.name(QColor::HexArgb)); + }; + if (rowIndex == HighlightRowIndexes::SelfHighlightRow) { - getSettings()->selfHighlightColor.setValue(colorName); + setColor(getSettings()->selfHighlightColor, + ColorType::SelfHighlight); + } + else if (rowIndex == HighlightRowIndexes::WhisperRow) + { + setColor(getSettings()->whisperHighlightColor, + ColorType::Whisper); } - // else if (rowIndex == HighlightRowIndexes::WhisperRow) - // { - // getSettings()->whisperHighlightColor.setValue(colorName); - // } else if (rowIndex == HighlightRowIndexes::SubRow) { - getSettings()->subHighlightColor.setValue(colorName); + setColor(getSettings()->subHighlightColor, + ColorType::Subscription); } else if (rowIndex == HighlightRowIndexes::RedeemedRow) { - getSettings()->redeemedHighlightColor.setValue(colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::RedeemedHighlight, - QColor(colorName)); + setColor(getSettings()->redeemedHighlightColor, + ColorType::RedeemedHighlight); } else if (rowIndex == HighlightRowIndexes::FirstMessageRow) { - getSettings()->firstMessageHighlightColor.setValue( - colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::FirstMessageHighlight, - QColor(colorName)); + setColor(getSettings()->firstMessageHighlightColor, + ColorType::FirstMessageHighlight); } else if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) { - getSettings()->elevatedMessageHighlightColor.setValue( - colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::ElevatedMessageHighlight, - QColor(colorName)); + setColor(getSettings()->elevatedMessageHighlightColor, + ColorType::ElevatedMessageHighlight); } else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) { - getSettings()->threadHighlightColor.setValue(colorName); - const_cast(ColorProvider::instance()) - .updateColor(ColorType::ThreadMessageHighlight, - QColor(colorName)); + setColor(getSettings()->threadHighlightColor, + ColorType::ThreadMessageHighlight); } } } break; } - getApp()->windows->forceLayoutChannelViews(); + getIApp()->getWindows()->forceLayoutChannelViews(); } } // namespace chatterino diff --git a/src/controllers/highlights/HighlightModel.hpp b/src/controllers/highlights/HighlightModel.hpp index 4966950a12c..be807d33e54 100644 --- a/src/controllers/highlights/HighlightModel.hpp +++ b/src/controllers/highlights/HighlightModel.hpp @@ -34,6 +34,7 @@ class HighlightModel : public SignalVectorModel FirstMessageRow = 4, ElevatedMessageRow = 5, ThreadMessageRow = 6, + AutomodRow = 7, }; enum UserHighlightRowIndexes { @@ -42,19 +43,18 @@ class HighlightModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual HighlightPhrase getItemFromRow( - std::vector &row, - const HighlightPhrase &original) override; + HighlightPhrase getItemFromRow(std::vector &row, + const HighlightPhrase &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const HighlightPhrase &item, - std::vector &row) override; + void getRowFromItem(const HighlightPhrase &item, + std::vector &row) override; - virtual void afterInit() override; + void afterInit() override; - virtual void customRowSetData(const std::vector &row, - int column, const QVariant &value, int role, - int rowIndex) override; + void customRowSetData(const std::vector &row, int column, + const QVariant &value, int role, + int rowIndex) override; }; } // namespace chatterino diff --git a/src/controllers/highlights/HighlightPhrase.hpp b/src/controllers/highlights/HighlightPhrase.hpp index 56d3499ccb9..d470d35f6a9 100644 --- a/src/controllers/highlights/HighlightPhrase.hpp +++ b/src/controllers/highlights/HighlightPhrase.hpp @@ -164,7 +164,9 @@ struct Deserialize { auto _color = QColor(encodedColor); if (!_color.isValid()) + { _color = chatterino::HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR; + } return chatterino::HighlightPhrase(_pattern, _showInMentions, _hasAlert, _hasSound, _isRegex, diff --git a/src/controllers/highlights/UserHighlightModel.cpp b/src/controllers/highlights/UserHighlightModel.cpp index 15ca70163c1..b28866e6d96 100644 --- a/src/controllers/highlights/UserHighlightModel.cpp +++ b/src/controllers/highlights/UserHighlightModel.cpp @@ -1,7 +1,6 @@ -#include "UserHighlightModel.hpp" +#include "controllers/highlights/UserHighlightModel.hpp" #include "Application.hpp" -#include "controllers/highlights/HighlightModel.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "providers/colors/ColorProvider.hpp" #include "singletons/Settings.hpp" @@ -10,8 +9,6 @@ namespace chatterino { -using Column = HighlightModel::Column; - // commandmodel UserHighlightModel::UserHighlightModel(QObject *parent) : SignalVectorModel(Column::COUNT, parent) @@ -106,17 +103,13 @@ void UserHighlightModel::customRowSetData( // Update the setting with the new value getSettings()->selfMessageHighlightColor.setValue( colorName); - // Update the color provider with the new color to be used for future - const_cast(ColorProvider::instance()) - .updateColor(ColorType::SelfMessageHighlight, - QColor(colorName)); } } } break; } - getApp()->windows->forceLayoutChannelViews(); + getIApp()->getWindows()->forceLayoutChannelViews(); } // row into vector item diff --git a/src/controllers/highlights/UserHighlightModel.hpp b/src/controllers/highlights/UserHighlightModel.hpp index 928d4931dde..e17b8479256 100644 --- a/src/controllers/highlights/UserHighlightModel.hpp +++ b/src/controllers/highlights/UserHighlightModel.hpp @@ -1,6 +1,7 @@ #pragma once #include "common/SignalVectorModel.hpp" +#include "controllers/highlights/HighlightModel.hpp" #include @@ -12,22 +13,23 @@ class HighlightPhrase; class UserHighlightModel : public SignalVectorModel { public: + using Column = HighlightModel::Column; + explicit UserHighlightModel(QObject *parent); protected: // vector into model row - virtual HighlightPhrase getItemFromRow( - std::vector &row, - const HighlightPhrase &original) override; + HighlightPhrase getItemFromRow(std::vector &row, + const HighlightPhrase &original) override; - virtual void getRowFromItem(const HighlightPhrase &item, - std::vector &row) override; + void getRowFromItem(const HighlightPhrase &item, + std::vector &row) override; - virtual void afterInit() override; + void afterInit() override; - virtual void customRowSetData(const std::vector &row, - int column, const QVariant &value, int role, - int rowIndex) override; + void customRowSetData(const std::vector &row, int column, + const QVariant &value, int role, + int rowIndex) override; }; } // namespace chatterino diff --git a/src/controllers/hotkeys/ActionNames.hpp b/src/controllers/hotkeys/ActionNames.hpp index 8d5700ac428..71dff1ddf9b 100644 --- a/src/controllers/hotkeys/ActionNames.hpp +++ b/src/controllers/hotkeys/ActionNames.hpp @@ -5,6 +5,20 @@ #include #include +#include + +inline const std::vector>> + HOTKEY_ARG_ON_OFF_TOGGLE = { + {"Toggle", {}}, + {"Set to on", {"on"}}, + {"Set to off", {"off"}}, +}; + +inline const std::vector>> + HOTKEY_ARG_WITH_OR_WITHOUT_SELECTION = { + {"No", {"withoutSelection"}}, + {"Yes", {"withSelection"}}, +}; namespace chatterino { @@ -13,6 +27,9 @@ struct ActionDefinition { // displayName is the value that would be shown to a user when they edit or create a hotkey for an action QString displayName; + // argumentDescription is a description of the arguments in a format of + // " [optional arg: possible + // values]" QString argumentDescription = ""; // minCountArguments is the minimum amount of arguments the action accepts @@ -21,6 +38,20 @@ struct ActionDefinition { // maxCountArguments is the maximum amount of arguments the action accepts uint8_t maxCountArguments = minCountArguments; + + // possibleArguments is empty or contains all possible argument values, + // it is an ordered mapping from option name (what the user sees) to + // arguments (what the action code will see). + // As std::map does not guarantee order this is a std::vector<...> + std::vector>> possibleArguments = + {}; + + // When possibleArguments are present this should be a string like + // "Direction:" which will be shown before the values from + // possibleArguments in the UI. Otherwise, it should be empty. + QString argumentsPrompt = ""; + // A more detailed description of what argumentsPrompt means + QString argumentsPromptHover = ""; }; using ActionDefinitionMap = std::map; @@ -39,15 +70,22 @@ inline const std::map actionNames{ }}, {"scrollPage", ActionDefinition{ - "Scroll", - "", - 1, + .displayName = "Scroll", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Up", {"up"}}, + {"Down", {"down"}}, + }, + .argumentsPrompt = "Direction:", }}, {"search", ActionDefinition{"Focus search box"}}, {"execModeratorAction", ActionDefinition{ "Usercard: execute moderation action", "", 1}}, + {"pin", ActionDefinition{"Usercard, reply thread: pin window"}}, }}, {HotkeyCategory::Split, { @@ -57,24 +95,44 @@ inline const std::map actionNames{ {"delete", ActionDefinition{"Close"}}, {"focus", ActionDefinition{ - "Focus neighbouring split", - "", - 1, + .displayName = "Focus neighbouring split", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Up", {"up"}}, + {"Down", {"down"}}, + {"Left", {"left"}}, + {"Right", {"right"}}, + }, + .argumentsPrompt = "Direction:", + .argumentsPromptHover = + "Which direction to look for a split to focus?", }}, {"openInBrowser", ActionDefinition{"Open channel in browser"}}, + {"openPlayerInBrowser", + ActionDefinition{"Open stream in browser player"}}, {"openInCustomPlayer", ActionDefinition{"Open stream in custom player"}}, {"openInStreamlink", ActionDefinition{"Open stream in streamlink"}}, {"openModView", ActionDefinition{"Open mod view in browser"}}, - {"openViewerList", ActionDefinition{"Open viewer list"}}, + {"openViewerList", ActionDefinition{"Open chatter list"}}, {"pickFilters", ActionDefinition{"Pick filters"}}, {"reconnect", ActionDefinition{"Reconnect to chat"}}, {"reloadEmotes", ActionDefinition{ - "Reload emotes", - "[channel or subscriber]", - 0, - 1, + .displayName = "Reload emotes", + .argumentDescription = + "[type: channel or subscriber; default: all emotes]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments{ + {"All emotes", {}}, + {"Channel emotes only", {"channel"}}, + {"Subscriber emotes only", {"subscriber"}}, + }, + .argumentsPrompt = "Emote type:", + .argumentsPromptHover = "Which emotes should Chatterino reload", }}, {"runCommand", ActionDefinition{ @@ -84,29 +142,44 @@ inline const std::map actionNames{ }}, {"scrollPage", ActionDefinition{ - "Scroll", - "", - 1, + .displayName = "Scroll", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Up", {"up"}}, + {"Down", {"down"}}, + }, + .argumentsPrompt = "Direction:", + .argumentsPromptHover = + "Which direction do you want to see more messages", }}, {"scrollToBottom", ActionDefinition{"Scroll to the bottom"}}, {"scrollToTop", ActionDefinition{"Scroll to the top"}}, {"setChannelNotification", ActionDefinition{ - "Set channel live notification", - "[on or off. default: toggle]", - 0, - 1, + .displayName = "Set channel live notification", + .argumentDescription = "[on or off. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_ON_OFF_TOGGLE, + .argumentsPrompt = "New value:", + .argumentsPromptHover = "Should the channel live notification be " + "enabled, disabled or toggled", }}, {"setModerationMode", ActionDefinition{ - "Set moderation mode", - "[on or off. default: toggle]", - 0, - 1, + .displayName = "Set moderation mode", + .argumentDescription = "[on or off. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_ON_OFF_TOGGLE, + .argumentsPrompt = "New value:", + .argumentsPromptHover = + "Should the moderation mode be enabled, disabled or toggled", }}, {"showSearch", ActionDefinition{"Search current channel"}}, {"showGlobalSearch", ActionDefinition{"Search all channels"}}, - {"startWatching", ActionDefinition{"Start watching"}}, {"debug", ActionDefinition{"Show debug popup"}}, }}, {HotkeyCategory::SplitInput, @@ -114,21 +187,38 @@ inline const std::map actionNames{ {"clear", ActionDefinition{"Clear message"}}, {"copy", ActionDefinition{ - "Copy", - "", - 1, + .displayName = "Copy", + .argumentDescription = + "", + .minCountArguments = 1, + .possibleArguments{ + {"Automatic", {"auto"}}, + {"Split", {"split"}}, + {"Split Input", {"splitInput"}}, + }, + .argumentsPrompt = "Source of text:", }}, {"cursorToStart", ActionDefinition{ - "To start of message", - "", - 1, + .displayName = "To start of message", + .argumentDescription = + "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_WITH_OR_WITHOUT_SELECTION, + .argumentsPrompt = "Select text from cursor to start:", + // XXX: write a hover for this that doesn't suck }}, {"cursorToEnd", ActionDefinition{ - "To end of message", - "", - 1, + .displayName = "To end of message", + .argumentDescription = + "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments = HOTKEY_ARG_WITH_OR_WITHOUT_SELECTION, + .argumentsPrompt = "Select text from cursor to end:", + // XXX: write a hover for this that doesn't suck }}, {"nextMessage", ActionDefinition{"Choose next sent message"}}, {"openEmotesPopup", ActionDefinition{"Open emotes list"}}, @@ -140,10 +230,16 @@ inline const std::map actionNames{ {"selectWord", ActionDefinition{"Select word"}}, {"sendMessage", ActionDefinition{ - "Send message", - "[keepInput to not clear the text after sending]", - 0, - 1, + .displayName = "Send message", + .argumentDescription = + "[keepInput to not clear the text after sending]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments{ + {"Default behavior", {}}, + {"Keep message in input after sending it", {"keepInput"}}, + }, + .argumentsPrompt = "Behavior:", }}, {"undo", ActionDefinition{"Undo"}}, @@ -163,7 +259,7 @@ inline const std::map actionNames{ {"moveTab", ActionDefinition{ "Move tab", - "", + "", 1, }}, {"newSplit", ActionDefinition{"Create a new split"}}, @@ -172,40 +268,78 @@ inline const std::map actionNames{ {"openTab", ActionDefinition{ "Select tab", - "", + "", 1, }}, {"openQuickSwitcher", ActionDefinition{"Open the quick switcher"}}, {"popup", ActionDefinition{ - "New popup", - "", - 1, + .displayName = "New popup", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"Focused Split", {"split"}}, + {"Entire Tab", {"window"}}, + }, + .argumentsPrompt = "Include:", + .argumentsPromptHover = + "What should be included in the new popup", }}, {"quit", ActionDefinition{"Quit Chatterino"}}, {"removeTab", ActionDefinition{"Remove current tab"}}, {"reopenSplit", ActionDefinition{"Reopen closed split"}}, {"setStreamerMode", ActionDefinition{ - "Set streamer mode", - "[on, off, toggle, or auto. default: toggle]", - 0, - 1, + .displayName = "Set streamer mode", + .argumentDescription = + "[on, off, toggle, or auto. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments = + { + {"Toggle on/off", {}}, + {"Set to on", {"on"}}, + {"Set to off", {"off"}}, + {"Set to automatic", {"auto"}}, + }, + .argumentsPrompt = "New value:", + .argumentsPromptHover = + "Should streamer mode be enabled, disabled, toggled (on/off) " + "or set to auto", }}, {"toggleLocalR9K", ActionDefinition{"Toggle local R9K"}}, {"zoom", ActionDefinition{ - "Zoom in/out", - "", - 1, + .displayName = "Zoom in/out", + .argumentDescription = "Argument:", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments = + { + {"Zoom in", {"in"}}, + {"Zoom out", {"out"}}, + {"Reset zoom", {"reset"}}, + }, + .argumentsPrompt = "Option:", }}, {"setTabVisibility", ActionDefinition{ - "Set tab visibility", - "[on, off, or toggle. default: toggle]", - 0, - 1, - }}}}, + .displayName = "Set tab visibility", + .argumentDescription = "[on, off, toggle, liveOnly, or " + "toggleLiveOnly. default: toggle]", + .minCountArguments = 0, + .maxCountArguments = 1, + .possibleArguments{{"Toggle", {}}, + {"Set to on", {"on"}}, + {"Set to off", {"off"}}, + {"Live only on", {"liveOnly"}}, + {"Live only toggle", {"toggleLiveOnly"}}}, + .argumentsPrompt = "New value:", + .argumentsPromptHover = "Should the tabs be enabled, disabled, " + "toggled, or live-only.", + }}, + }}, }; } // namespace chatterino diff --git a/src/controllers/hotkeys/Hotkey.cpp b/src/controllers/hotkeys/Hotkey.cpp index 99017e08c5a..9a2392d13af 100644 --- a/src/controllers/hotkeys/Hotkey.cpp +++ b/src/controllers/hotkeys/Hotkey.cpp @@ -58,7 +58,7 @@ std::vector Hotkey::arguments() const QString Hotkey::getCategory() const { - return getApp()->hotkeys->categoryDisplayName(this->category_); + return getIApp()->getHotkeys()->categoryDisplayName(this->category_); } Qt::ShortcutContext Hotkey::getContext() const diff --git a/src/controllers/hotkeys/HotkeyController.cpp b/src/controllers/hotkeys/HotkeyController.cpp index df6639756be..4893ee62006 100644 --- a/src/controllers/hotkeys/HotkeyController.cpp +++ b/src/controllers/hotkeys/HotkeyController.cpp @@ -56,7 +56,7 @@ std::vector HotkeyController::shortcutsForCategory( { qCDebug(chatterinoHotkeys) << qPrintable(parent->objectName()) - << "Unimplemeneted hotkey action:" << hotkey->action() << "in " + << "Unimplemented hotkey action:" << hotkey->action() << "in" << hotkey->getCategory(); continue; } @@ -66,7 +66,7 @@ std::vector HotkeyController::shortcutsForCategory( continue; } auto createShortcutFromKeySeq = [&](QKeySequence qs) { - auto s = new QShortcut(qs, parent); + auto *s = new QShortcut(qs, parent); s->setContext(hotkey->getContext()); auto functionPointer = target->second; QObject::connect(s, &QShortcut::activated, parent, @@ -101,7 +101,7 @@ void HotkeyController::save() std::shared_ptr HotkeyController::getHotkeyByName(QString name) { - for (auto &hotkey : this->hotkeys_) + for (const auto &hotkey : this->hotkeys_) { if (hotkey->name() == name) { @@ -115,7 +115,7 @@ int HotkeyController::replaceHotkey(QString oldName, std::shared_ptr newHotkey) { int i = 0; - for (auto &hotkey : this->hotkeys_) + for (const auto &hotkey : this->hotkeys_) { if (hotkey->name() == oldName) { @@ -127,7 +127,7 @@ int HotkeyController::replaceHotkey(QString oldName, return this->hotkeys_.append(newHotkey); } -boost::optional HotkeyController::hotkeyCategoryFromName( +std::optional HotkeyController::hotkeyCategoryFromName( QString categoryName) { for (const auto &[category, data] : this->categories()) @@ -189,8 +189,8 @@ QString HotkeyController::categoryName(HotkeyCategory category) const return categoryData.name; } -const std::map - &HotkeyController::categories() const +const std::map & + HotkeyController::categories() const { return this->hotkeyCategories_; } @@ -500,6 +500,10 @@ void HotkeyController::addDefaults(std::set &addedHotkeys) this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, QKeySequence("Ctrl+U"), "setTabVisibility", {"toggle"}, "toggle tab visibility"); + + this->tryAddDefault(addedHotkeys, HotkeyCategory::Window, + QKeySequence("Ctrl+Shift+L"), "setTabVisibility", + {"toggleLiveOnly"}, "toggle live tabs only"); } } @@ -540,7 +544,7 @@ void HotkeyController::tryAddDefault(std::set &addedHotkeys, void HotkeyController::showHotkeyError(const std::shared_ptr &hotkey, QString warning) { - auto msgBox = new QMessageBox( + auto *msgBox = new QMessageBox( QMessageBox::Icon::Warning, "Hotkey error", QString( "There was an error while executing your hotkey named \"%1\": \n%2") diff --git a/src/controllers/hotkeys/HotkeyController.hpp b/src/controllers/hotkeys/HotkeyController.hpp index 1ea48540033..57187ca8b70 100644 --- a/src/controllers/hotkeys/HotkeyController.hpp +++ b/src/controllers/hotkeys/HotkeyController.hpp @@ -4,7 +4,6 @@ #include "common/Singleton.hpp" #include "controllers/hotkeys/HotkeyCategory.hpp" -#include #include #include @@ -52,8 +51,7 @@ class HotkeyController final : public Singleton * @returns the new index in the SignalVector **/ int replaceHotkey(QString oldName, std::shared_ptr newHotkey); - boost::optional hotkeyCategoryFromName( - QString categoryName); + std::optional hotkeyCategoryFromName(QString categoryName); /** * @brief checks if the hotkey is duplicate @@ -83,8 +81,8 @@ class HotkeyController final : public Singleton /** * @returns a const map with the HotkeyCategory enum as its key, and HotkeyCategoryData as the value. **/ - [[nodiscard]] const std::map - &categories() const; + [[nodiscard]] const std::map & + categories() const; pajlada::Signals::NoArgSignal onItemsUpdated; diff --git a/src/controllers/hotkeys/HotkeyHelpers.cpp b/src/controllers/hotkeys/HotkeyHelpers.cpp index d998d76656a..7be663d6e13 100644 --- a/src/controllers/hotkeys/HotkeyHelpers.cpp +++ b/src/controllers/hotkeys/HotkeyHelpers.cpp @@ -1,7 +1,12 @@ #include "controllers/hotkeys/HotkeyHelpers.hpp" +#include "controllers/hotkeys/ActionNames.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" + #include +#include + namespace chatterino { std::vector parseHotkeyArguments(QString argumentString) @@ -27,4 +32,20 @@ std::vector parseHotkeyArguments(QString argumentString) return arguments; } +std::optional findHotkeyActionDefinition( + HotkeyCategory category, const QString &action) +{ + auto allActions = actionNames.find(category); + if (allActions != actionNames.end()) + { + const auto &actionsMap = allActions->second; + auto definition = actionsMap.find(action); + if (definition != actionsMap.end()) + { + return {definition->second}; + } + } + return {}; +} + } // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyHelpers.hpp b/src/controllers/hotkeys/HotkeyHelpers.hpp index 4e63569fff8..a2218472dea 100644 --- a/src/controllers/hotkeys/HotkeyHelpers.hpp +++ b/src/controllers/hotkeys/HotkeyHelpers.hpp @@ -1,11 +1,16 @@ #pragma once +#include "controllers/hotkeys/ActionNames.hpp" + #include +#include #include namespace chatterino { std::vector parseHotkeyArguments(QString argumentString); +std::optional findHotkeyActionDefinition( + HotkeyCategory category, const QString &action); } // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyModel.hpp b/src/controllers/hotkeys/HotkeyModel.hpp index 98c59ca5eac..8ec659593d3 100644 --- a/src/controllers/hotkeys/HotkeyModel.hpp +++ b/src/controllers/hotkeys/HotkeyModel.hpp @@ -17,21 +17,20 @@ class HotkeyModel : public SignalVectorModel> protected: // turn a vector item into a model row - virtual std::shared_ptr getItemFromRow( + std::shared_ptr getItemFromRow( std::vector &row, const std::shared_ptr &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const std::shared_ptr &item, - std::vector &row) override; + void getRowFromItem(const std::shared_ptr &item, + std::vector &row) override; - virtual int beforeInsert(const std::shared_ptr &item, - std::vector &row, - int proposedIndex) override; + int beforeInsert(const std::shared_ptr &item, + std::vector &row, + int proposedIndex) override; - virtual void afterRemoved(const std::shared_ptr &item, - std::vector &row, - int index) override; + void afterRemoved(const std::shared_ptr &item, + std::vector &row, int index) override; friend class HotkeyController; diff --git a/src/controllers/ignores/IgnoreController.cpp b/src/controllers/ignores/IgnoreController.cpp index e3df12e4755..8d064ca8e27 100644 --- a/src/controllers/ignores/IgnoreController.cpp +++ b/src/controllers/ignores/IgnoreController.cpp @@ -14,7 +14,7 @@ bool isIgnoredMessage(IgnoredMessageParameters &¶ms) if (!params.message.isEmpty()) { // TODO(pajlada): Do we need to check if the phrase is valid first? - auto phrases = getCSettings().ignoredMessages.readOnly(); + auto phrases = getSettings()->ignoredMessages.readOnly(); for (const auto &phrase : *phrases) { if (phrase.isBlock() && phrase.isMatch(params.message)) @@ -32,10 +32,12 @@ bool isIgnoredMessage(IgnoredMessageParameters &¶ms) { auto sourceUserID = params.twitchUserID; - auto blocks = - getApp()->accounts->twitch.getCurrent()->accessBlockedUserIds(); - - if (auto it = blocks->find(sourceUserID); it != blocks->end()) + bool isBlocked = getIApp() + ->getAccounts() + ->twitch.getCurrent() + ->blockedUserIds() + .contains(sourceUserID); + if (isBlocked) { switch (static_cast( getSettings()->showBlockedUsersMessages.getValue())) diff --git a/src/controllers/ignores/IgnoreModel.hpp b/src/controllers/ignores/IgnoreModel.hpp index 915a880bdad..c604f8cd268 100644 --- a/src/controllers/ignores/IgnoreModel.hpp +++ b/src/controllers/ignores/IgnoreModel.hpp @@ -15,12 +15,12 @@ class IgnoreModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual IgnorePhrase getItemFromRow(std::vector &row, - const IgnorePhrase &original) override; + IgnorePhrase getItemFromRow(std::vector &row, + const IgnorePhrase &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const IgnorePhrase &item, - std::vector &row) override; + void getRowFromItem(const IgnorePhrase &item, + std::vector &row) override; }; } // namespace chatterino diff --git a/src/controllers/ignores/IgnorePhrase.cpp b/src/controllers/ignores/IgnorePhrase.cpp index d5f7a301e56..735e8774588 100644 --- a/src/controllers/ignores/IgnorePhrase.cpp +++ b/src/controllers/ignores/IgnorePhrase.cpp @@ -95,7 +95,7 @@ bool IgnorePhrase::containsEmote() const { if (!this->emotesChecked_) { - const auto &accvec = getApp()->accounts->twitch.accounts; + const auto &accvec = getIApp()->getAccounts()->twitch.accounts; for (const auto &acc : accvec) { const auto &accemotes = *acc->accessEmotes(); diff --git a/src/controllers/logging/ChannelLog.cpp b/src/controllers/logging/ChannelLog.cpp index a7d8ea04c04..2c163050be4 100644 --- a/src/controllers/logging/ChannelLog.cpp +++ b/src/controllers/logging/ChannelLog.cpp @@ -9,7 +9,7 @@ ChannelLog::ChannelLog(QString channelName) QString ChannelLog::channelName() const { - return this->channelName_; + return this->channelName_.toLower(); } QString ChannelLog::toString() const diff --git a/src/controllers/moderationactions/ModerationAction.cpp b/src/controllers/moderationactions/ModerationAction.cpp index de26613c2c6..2b3a95b0642 100644 --- a/src/controllers/moderationactions/ModerationAction.cpp +++ b/src/controllers/moderationactions/ModerationAction.cpp @@ -136,18 +136,22 @@ bool ModerationAction::isImage() const return bool(this->image_); } -const boost::optional &ModerationAction::getImage() const +const std::optional &ModerationAction::getImage() const { assertInGuiThread(); if (this->imageToLoad_ != 0) { if (this->imageToLoad_ == 1) + { this->image_ = Image::fromResourcePixmap(getResources().buttons.ban); + } else if (this->imageToLoad_ == 2) + { this->image_ = Image::fromResourcePixmap(getResources().buttons.trashCan); + } } return this->image_; diff --git a/src/controllers/moderationactions/ModerationAction.hpp b/src/controllers/moderationactions/ModerationAction.hpp index 2165a1d6f5a..8fa4c9be8a2 100644 --- a/src/controllers/moderationactions/ModerationAction.hpp +++ b/src/controllers/moderationactions/ModerationAction.hpp @@ -2,11 +2,11 @@ #include "util/RapidjsonHelpers.hpp" -#include #include #include #include +#include namespace chatterino { @@ -21,13 +21,13 @@ class ModerationAction bool operator==(const ModerationAction &other) const; bool isImage() const; - const boost::optional &getImage() const; + const std::optional &getImage() const; const QString &getLine1() const; const QString &getLine2() const; const QString &getAction() const; private: - mutable boost::optional image_; + mutable std::optional image_; QString line1_; QString line2_; QString action_; diff --git a/src/controllers/moderationactions/ModerationActionModel.hpp b/src/controllers/moderationactions/ModerationActionModel.hpp index 065e6f417ad..e8e51db037c 100644 --- a/src/controllers/moderationactions/ModerationActionModel.hpp +++ b/src/controllers/moderationactions/ModerationActionModel.hpp @@ -15,13 +15,12 @@ class ModerationActionModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual ModerationAction getItemFromRow( - std::vector &row, - const ModerationAction &original) override; + ModerationAction getItemFromRow(std::vector &row, + const ModerationAction &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const ModerationAction &item, - std::vector &row) override; + void getRowFromItem(const ModerationAction &item, + std::vector &row) override; friend class HighlightController; }; diff --git a/src/controllers/nicknames/Nickname.hpp b/src/controllers/nicknames/Nickname.hpp index 529f5fa0db9..7343dd393bd 100644 --- a/src/controllers/nicknames/Nickname.hpp +++ b/src/controllers/nicknames/Nickname.hpp @@ -8,6 +8,7 @@ #include #include +#include namespace chatterino { @@ -58,25 +59,25 @@ class Nickname return this->isCaseSensitive_; } - [[nodiscard]] bool match(QString &usernameText) const + [[nodiscard]] std::optional match( + const QString &usernameText) const { if (this->isRegex()) { if (!this->regex_.isValid()) { - return false; + return std::nullopt; } if (this->name().isEmpty()) { - return false; + return std::nullopt; } auto workingCopy = usernameText; workingCopy.replace(this->regex_, this->replace()); if (workingCopy != usernameText) { - usernameText = workingCopy; - return true; + return workingCopy; } } else @@ -85,12 +86,11 @@ class Nickname this->name().compare(usernameText, this->caseSensitivity()); if (res == 0) { - usernameText = this->replace(); - return true; + return this->replace(); } } - return false; + return std::nullopt; } private: diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index 69da63b01f7..af15ea765c1 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -1,11 +1,9 @@ #include "controllers/notifications/NotificationController.hpp" #include "Application.hpp" -#include "common/NetworkRequest.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/notifications/NotificationModel.hpp" -#include "controllers/sound/SoundController.hpp" +#include "controllers/sound/ISoundController.hpp" #include "messages/Message.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -28,7 +26,7 @@ namespace chatterino { -void NotificationController::initialize(Settings &settings, Paths &paths) +void NotificationController::initialize(Settings &settings, const Paths &paths) { this->initialized_ = true; for (const QString &channelName : this->twitchSetting_.getValue()) @@ -36,9 +34,13 @@ void NotificationController::initialize(Settings &settings, Paths &paths) this->channelMap[Platform::Twitch].append(channelName); } - this->channelMap[Platform::Twitch].delayedItemsChanged.connect([this] { - this->twitchSetting_.setValue(this->channelMap[Platform::Twitch].raw()); - }); + // We can safely ignore this signal connection since channelMap will always be destroyed + // before the NotificationController + std::ignore = + this->channelMap[Platform::Twitch].delayedItemsChanged.connect([this] { + this->twitchSetting_.setValue( + this->channelMap[Platform::Twitch].raw()); + }); liveStatusTimer_ = new QTimer(); @@ -103,7 +105,7 @@ void NotificationController::playSound() getSettings()->notificationPathSound.getValue()) : QUrl("qrc:/sounds/ping2.wav"); - getApp()->sound->play(highlightSoundUrl); + getIApp()->getSound()->play(highlightSoundUrl); } NotificationModel *NotificationController::createModel(QObject *parent, @@ -181,20 +183,20 @@ void NotificationController::checkStream(bool live, QString channelName) if (Toasts::isEnabled()) { - getApp()->toasts->sendChannelNotification(channelName, QString(), - Platform::Twitch); + getIApp()->getToasts()->sendChannelNotification(channelName, QString(), + Platform::Twitch); } if (getSettings()->notificationPlaySound && !(isInStreamerMode() && getSettings()->streamerModeSuppressLiveNotifications)) { - getApp()->notifications->playSound(); + getIApp()->getNotifications()->playSound(); } if (getSettings()->notificationFlashTaskbar && !(isInStreamerMode() && getSettings()->streamerModeSuppressLiveNotifications)) { - getApp()->windows->sendAlert(); + getIApp()->getWindows()->sendAlert(); } MessageBuilder builder; TwitchMessageBuilder::liveMessage(channelName, &builder); @@ -206,11 +208,11 @@ void NotificationController::checkStream(bool live, QString channelName) void NotificationController::removeFakeChannel(const QString channelName) { - auto i = std::find(fakeTwitchChannels.begin(), fakeTwitchChannels.end(), - channelName); - if (i != fakeTwitchChannels.end()) + auto it = std::find(fakeTwitchChannels.begin(), fakeTwitchChannels.end(), + channelName); + if (it != fakeTwitchChannels.end()) { - fakeTwitchChannels.erase(i); + fakeTwitchChannels.erase(it); // "delete" old 'CHANNEL is live' message LimitedQueueSnapshot snapshot = getApp()->twitch->liveChannel->getMessageSnapshot(); @@ -223,7 +225,7 @@ void NotificationController::removeFakeChannel(const QString channelName) for (int i = snapshotLength - 1; i >= end; --i) { - auto &s = snapshot[i]; + const auto &s = snapshot[i]; if (s->messageText == liveMessageSearchText) { diff --git a/src/controllers/notifications/NotificationController.hpp b/src/controllers/notifications/NotificationController.hpp index f6cfce6b852..ad70e029f02 100644 --- a/src/controllers/notifications/NotificationController.hpp +++ b/src/controllers/notifications/NotificationController.hpp @@ -20,7 +20,7 @@ enum class Platform : uint8_t { class NotificationController final : public Singleton, private QObject { public: - virtual void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; bool isChannelNotified(const QString &channelName, Platform p); void updateChannelNotification(const QString &channelName, Platform p); diff --git a/src/controllers/notifications/NotificationModel.hpp b/src/controllers/notifications/NotificationModel.hpp index 8ac0e39bd23..dc53e953f39 100644 --- a/src/controllers/notifications/NotificationModel.hpp +++ b/src/controllers/notifications/NotificationModel.hpp @@ -14,12 +14,12 @@ class NotificationModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual QString getItemFromRow(std::vector &row, - const QString &original) override; + QString getItemFromRow(std::vector &row, + const QString &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const QString &item, - std::vector &row) override; + void getRowFromItem(const QString &item, + std::vector &row) override; friend class NotificationController; }; diff --git a/src/controllers/pings/MutedChannelModel.hpp b/src/controllers/pings/MutedChannelModel.hpp index 1cc78c2c3b8..926285057a2 100644 --- a/src/controllers/pings/MutedChannelModel.hpp +++ b/src/controllers/pings/MutedChannelModel.hpp @@ -12,12 +12,12 @@ class MutedChannelModel : public SignalVectorModel protected: // turn a vector item into a model row - virtual QString getItemFromRow(std::vector &row, - const QString &original) override; + QString getItemFromRow(std::vector &row, + const QString &original) override; // turns a row in the model into a vector item - virtual void getRowFromItem(const QString &item, - std::vector &row) override; + void getRowFromItem(const QString &item, + std::vector &row) override; }; } // namespace chatterino diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp new file mode 100644 index 00000000000..5f4c66dd50a --- /dev/null +++ b/src/controllers/plugins/LuaAPI.cpp @@ -0,0 +1,390 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/LuaAPI.hpp" + +# include "Application.hpp" +# include "common/QLogging.hpp" +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "messages/MessageBuilder.hpp" +# include "providers/twitch/TwitchIrcServer.hpp" + +# include +# include +# include +# include +# include +# include +# include + +namespace { +using namespace chatterino; + +void logHelper(lua_State *L, Plugin *pl, QDebug stream, int argc) +{ + stream.noquote(); + stream << "[" + pl->id + ":" + pl->meta.name + "]"; + for (int i = 1; i <= argc; i++) + { + stream << lua::toString(L, i); + } + lua_pop(L, argc); +} + +QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) +{ + auto base = + (QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, + QT_MESSAGELOG_FUNC, chatterinoLua().categoryName())); + + using LogLevel = lua::api::LogLevel; + + switch (lvl) + { + case LogLevel::Debug: + return base.debug(); + case LogLevel::Info: + return base.info(); + case LogLevel::Warning: + return base.warning(); + case LogLevel::Critical: + return base.critical(); + default: + assert(false && "if this happens magic_enum must have failed us"); + return QDebug((QString *)nullptr); + } +} + +} // namespace + +// NOLINTBEGIN(*vararg) +// luaL_error is a c-style vararg function, this makes clang-tidy not dislike it so much +namespace chatterino::lua::api { + +int c2_register_command(lua_State *L) +{ + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + + QString name; + if (!lua::peek(L, &name, 1)) + { + luaL_error(L, "cannot get command name (1st arg of register_command, " + "expected a string)"); + return 0; + } + if (lua_isnoneornil(L, 2)) + { + luaL_error(L, "missing argument for register_command: function " + "\"pointer\""); + return 0; + } + + auto callbackSavedName = QString("c2commandcb-%1").arg(name); + lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str()); + auto ok = pl->registerCommand(name, callbackSavedName); + + // delete both name and callback + lua_pop(L, 2); + + lua::push(L, ok); + return 1; +} + +int c2_register_callback(lua_State *L) +{ + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + EventType evtType{}; + if (!lua::peek(L, &evtType, 1)) + { + luaL_error(L, "cannot get event name (1st arg of register_callback, " + "expected a string)"); + return 0; + } + if (lua_isnoneornil(L, 2)) + { + luaL_error(L, "missing argument for register_callback: function " + "\"pointer\""); + return 0; + } + + auto callbackSavedName = QString("c2cb-%1").arg( + magic_enum::enum_name(evtType).data()); + lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str()); + + lua_pop(L, 2); + + return 0; +} + +int c2_send_msg(lua_State *L) +{ + QString text; + QString channel; + if (lua_gettop(L) != 2) + { + luaL_error(L, "send_msg needs exactly 2 arguments (channel and text)"); + lua::push(L, false); + return 1; + } + if (!lua::pop(L, &text)) + { + luaL_error( + L, "cannot get text (2nd argument of send_msg, expected a string)"); + lua::push(L, false); + return 1; + } + if (!lua::pop(L, &channel)) + { + luaL_error( + L, + "cannot get channel (1st argument of send_msg, expected a string)"); + lua::push(L, false); + return 1; + } + + const auto chn = getApp()->twitch->getChannelOrEmpty(channel); + if (chn->isEmpty()) + { + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + + qCWarning(chatterinoLua) + << "Plugin" << pl->id + << "tried to send a message (using send_msg) to channel" << channel + << "which is not known"; + lua::push(L, false); + return 1; + } + QString message = text; + message = message.replace('\n', ' '); + QString outText = + getIApp()->getCommands()->execCommand(message, chn, false); + chn->sendMessage(outText); + lua::push(L, true); + return 1; +} + +int c2_system_msg(lua_State *L) +{ + if (lua_gettop(L) != 2) + { + luaL_error(L, + "system_msg needs exactly 2 arguments (channel and text)"); + lua::push(L, false); + return 1; + } + QString channel; + QString text; + + if (!lua::pop(L, &text)) + { + luaL_error( + L, + "cannot get text (2nd argument of system_msg, expected a string)"); + lua::push(L, false); + return 1; + } + if (!lua::pop(L, &channel)) + { + luaL_error(L, "cannot get channel (1st argument of system_msg, " + "expected a string)"); + lua::push(L, false); + return 1; + } + + const auto chn = getApp()->twitch->getChannelOrEmpty(channel); + if (chn->isEmpty()) + { + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + qCWarning(chatterinoLua) + << "Plugin" << pl->id + << "tried to show a system message (using system_msg) in channel" + << channel << "which is not known"; + lua::push(L, false); + return 1; + } + chn->addMessage(makeSystemMessage(text)); + lua::push(L, true); + return 1; +} + +int c2_log(lua_State *L) +{ + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "c2_log: internal error: no plugin?"); + return 0; + } + auto logc = lua_gettop(L) - 1; + // This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop + LogLevel lvl{}; + if (!lua::pop(L, &lvl, 1)) + { + luaL_error(L, "Invalid log level, use one from c2.LogLevel."); + return 0; + } + QDebug stream = qdebugStreamForLogLevel(lvl); + logHelper(L, pl, stream, logc); + return 0; +} + +int g_load(lua_State *L) +{ +# ifdef NDEBUG + luaL_error(L, "load() is only usable in debug mode"); + return 0; +# else + auto countArgs = lua_gettop(L); + QByteArray data; + if (lua::peek(L, &data, 1)) + { + auto *utf8 = QTextCodec::codecForName("UTF-8"); + QTextCodec::ConverterState state; + utf8->toUnicode(data.constData(), data.size(), &state); + if (state.invalidChars != 0) + { + luaL_error(L, "invalid utf-8 in load() is not allowed"); + return 0; + } + } + else + { + luaL_error(L, "using reader function in load() is not allowed"); + return 0; + } + + for (int i = 0; i < countArgs; i++) + { + lua_seti(L, LUA_REGISTRYINDEX, i); + } + + // fetch load and call it + lua_getfield(L, LUA_REGISTRYINDEX, "real_load"); + + for (int i = 0; i < countArgs; i++) + { + lua_geti(L, LUA_REGISTRYINDEX, i); + lua_pushnil(L); + lua_seti(L, LUA_REGISTRYINDEX, i); + } + + lua_call(L, countArgs, LUA_MULTRET); + + return lua_gettop(L); +# endif +} + +int loadfile(lua_State *L, const QString &str) +{ + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + return luaL_error(L, "loadfile: internal error: no plugin?"); + } + auto dir = QUrl(pl->loadDirectory().canonicalPath() + "/"); + + if (!dir.isParentOf(str)) + { + // XXX: This intentionally hides the resolved path to not leak it + lua::push( + L, QString("requested module is outside of the plugin directory")); + return 1; + } + QFileInfo info(str); + if (!info.exists()) + { + lua::push(L, QString("no file '%1'").arg(str)); + return 1; + } + + auto temp = str.toStdString(); + const auto *filename = temp.c_str(); + + auto res = luaL_loadfilex(L, filename, "t"); + // Yoinked from checkload lib/lua/src/loadlib.c + if (res == LUA_OK) + { + lua_pushstring(L, filename); + return 2; + } + + return luaL_error(L, "error loading module '%s' from file '%s':\n\t%s", + lua_tostring(L, 1), filename, lua_tostring(L, -1)); +} + +int searcherAbsolute(lua_State *L) +{ + auto name = QString::fromUtf8(luaL_checkstring(L, 1)); + name = name.replace('.', QDir::separator()); + + QString filename; + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + return luaL_error(L, "searcherAbsolute: internal error: no plugin?"); + } + + QFileInfo file(pl->loadDirectory().filePath(name + ".lua")); + return loadfile(L, file.canonicalFilePath()); +} + +int searcherRelative(lua_State *L) +{ + lua_Debug dbg; + lua_getstack(L, 1, &dbg); + lua_getinfo(L, "S", &dbg); + auto currentFile = QString::fromUtf8(dbg.source, dbg.srclen); + if (currentFile.startsWith("@")) + { + currentFile = currentFile.mid(1); + } + if (currentFile == "=[C]" || currentFile == "") + { + lua::push( + L, + QString( + "Unable to load relative to file:caller has no source file")); + return 1; + } + + auto parent = QFileInfo(currentFile).dir(); + + auto name = QString::fromUtf8(luaL_checkstring(L, 1)); + name = name.replace('.', QDir::separator()); + QString filename = + parent.canonicalPath() + QDir::separator() + name + ".lua"; + + return loadfile(L, filename); +} + +int g_print(lua_State *L) +{ + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "c2_print: internal error: no plugin?"); + return 0; + } + auto argc = lua_gettop(L); + // This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop + auto stream = + (QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, + QT_MESSAGELOG_FUNC, chatterinoLua().categoryName()) + .debug()); + logHelper(L, pl, stream, argc); + return 0; +} + +} // namespace chatterino::lua::api +// NOLINTEND(*vararg) +#endif diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp new file mode 100644 index 00000000000..95a998f79c2 --- /dev/null +++ b/src/controllers/plugins/LuaAPI.hpp @@ -0,0 +1,112 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include + +# include + +struct lua_State; +namespace chatterino::lua::api { +// function names in this namespace reflect what's visible inside Lua and follow the lua naming scheme + +// NOLINTBEGIN(readability-identifier-naming) +// Following functions are exposed in c2 table. + +// Comments in this file are special, the docs/plugin-meta.lua file is generated from them +// All multiline comments will be added into that file. See scripts/make_luals_meta.py script for more info. + +/** + * @exposeenum c2.LogLevel + */ +// Represents "calls" to qCDebug, qCInfo ... +enum class LogLevel { Debug, Info, Warning, Critical }; + +/** + * @exposeenum c2.EventType + */ +enum class EventType { + CompletionRequested, +}; + +/** + * @lua@class CommandContext + * @lua@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. + * @lua@field channel_name string The name of the channel the command was executed in. + */ + +/** + * @lua@class CompletionList + */ +struct CompletionList { + /** + * @lua@field values string[] The completions + */ + std::vector values{}; + + /** + * @lua@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. + */ + bool hideOthers{}; +}; + +/** + * Registers a new command called `name` which when executed will call `handler`. + * + * @lua@param name string The name of the command. + * @lua@param handler fun(ctx: CommandContext) The handler to be invoked when the command gets executed. + * @lua@return boolean ok Returns `true` if everything went ok, `false` if a command with this name exists. + * @exposed c2.register_command + */ +int c2_register_command(lua_State *L); + +/** + * Registers a callback to be invoked when completions for a term are requested. + * + * @lua@param type "CompletionRequested" + * @lua@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. + * @exposed c2.register_callback + */ +int c2_register_callback(lua_State *L); + +/** + * Sends a message to `channel` with the specified text. Also executes commands. + * + * **Warning**: It is possible to trigger your own Lua command with this causing a potentially infinite loop. + * + * @lua@param channel string The name of the Twitch channel + * @lua@param text string The text to be sent + * @lua@return boolean ok + * @exposed c2.send_msg + */ +int c2_send_msg(lua_State *L); +/** + * Creates a system message (gray message) and adds it to the Twitch channel specified by `channel`. + * + * @lua@param channel string + * @lua@param text string + * @lua@return boolean ok + * @exposed c2.system_msg + */ +int c2_system_msg(lua_State *L); + +/** + * Writes a message to the Chatterino log. + * + * @lua@param level LogLevel The desired level. + * @lua@param ... any Values to log. Should be convertible to a string with `tostring()`. + * @exposed c2.log + */ +int c2_log(lua_State *L); + +// These ones are global +int g_load(lua_State *L); +int g_print(lua_State *L); +// NOLINTEND(readability-identifier-naming) + +// This is for require() exposed as an element of package.searchers +int searcherAbsolute(lua_State *L); +int searcherRelative(lua_State *L); + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp new file mode 100644 index 00000000000..4807f89f24e --- /dev/null +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -0,0 +1,240 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/LuaUtilities.hpp" + +# include "common/Channel.hpp" +# include "common/QLogging.hpp" +# include "controllers/commands/CommandContext.hpp" +# include "controllers/plugins/LuaAPI.hpp" + +# include +# include + +# include +# include + +namespace chatterino::lua { + +void stackDump(lua_State *L, const QString &tag) +{ + qCDebug(chatterinoLua) << "--------------------"; + auto count = lua_gettop(L); + if (!tag.isEmpty()) + { + qCDebug(chatterinoLua) << "Tag: " << tag; + } + qCDebug(chatterinoLua) << "Count elems: " << count; + for (int i = 1; i <= count; i++) + { + auto typeint = lua_type(L, i); + if (typeint == LUA_TSTRING) + { + QString str; + lua::peek(L, &str, i); + qCDebug(chatterinoLua) + << "At" << i << "is a" << lua_typename(L, typeint) << "(" + << typeint << "): " << str; + } + else if (typeint == LUA_TTABLE) + { + qCDebug(chatterinoLua) + << "At" << i << "is a" << lua_typename(L, typeint) << "(" + << typeint << ")" + << "its length is " << lua_rawlen(L, i); + } + else + { + qCDebug(chatterinoLua) + << "At" << i << "is a" << lua_typename(L, typeint) << "(" + << typeint << ")"; + } + } + qCDebug(chatterinoLua) << "--------------------"; +} + +QString humanErrorText(lua_State *L, int errCode) +{ + QString errName; + switch (errCode) + { + case LUA_OK: + return "ok"; + case LUA_ERRRUN: + errName = "(runtime error)"; + break; + case LUA_ERRMEM: + errName = "(memory error)"; + break; + case LUA_ERRERR: + errName = "(error while handling another error)"; + break; + case LUA_ERRSYNTAX: + errName = "(syntax error)"; + break; + case LUA_YIELD: + errName = "(illegal coroutine yield)"; + break; + case LUA_ERRFILE: + errName = "(file error)"; + break; + case ERROR_BAD_PEEK: + errName = "(unable to convert value to c++)"; + break; + default: + errName = "(unknown error type)"; + } + QString errText; + if (peek(L, &errText)) + { + errName += " " + errText; + } + return errName; +} + +StackIdx pushEmptyArray(lua_State *L, int countArray) +{ + lua_createtable(L, countArray, 0); + return lua_gettop(L); +} + +StackIdx pushEmptyTable(lua_State *L, int countProperties) +{ + lua_createtable(L, 0, countProperties); + return lua_gettop(L); +} + +StackIdx push(lua_State *L, const QString &str) +{ + return lua::push(L, str.toStdString()); +} + +StackIdx push(lua_State *L, const std::string &str) +{ + lua_pushstring(L, str.c_str()); + return lua_gettop(L); +} + +StackIdx push(lua_State *L, const CommandContext &ctx) +{ + StackGuard guard(L, 1); + auto outIdx = pushEmptyTable(L, 2); + + push(L, ctx.words); + lua_setfield(L, outIdx, "words"); + push(L, ctx.channel->getName()); + lua_setfield(L, outIdx, "channel_name"); + + return outIdx; +} + +StackIdx push(lua_State *L, const bool &b) +{ + lua_pushboolean(L, int(b)); + return lua_gettop(L); +} + +StackIdx push(lua_State *L, const int &b) +{ + lua_pushinteger(L, b); + return lua_gettop(L); +} + +bool peek(lua_State *L, bool *out, StackIdx idx) +{ + StackGuard guard(L); + if (!lua_isboolean(L, idx)) + { + return false; + } + + *out = bool(lua_toboolean(L, idx)); + return true; +} + +bool peek(lua_State *L, double *out, StackIdx idx) +{ + StackGuard guard(L); + int ok{0}; + auto v = lua_tonumberx(L, idx, &ok); + if (ok != 0) + { + *out = v; + } + return ok != 0; +} + +bool peek(lua_State *L, QString *out, StackIdx idx) +{ + StackGuard guard(L); + size_t len{0}; + const char *str = lua_tolstring(L, idx, &len); + if (str == nullptr) + { + return false; + } + if (len >= INT_MAX) + { + assert(false && "string longer than INT_MAX, shit's fucked, yo"); + } + *out = QString::fromUtf8(str, int(len)); + return true; +} + +bool peek(lua_State *L, QByteArray *out, StackIdx idx) +{ + StackGuard guard(L); + size_t len{0}; + const char *str = lua_tolstring(L, idx, &len); + if (str == nullptr) + { + return false; + } + if (len >= INT_MAX) + { + assert(false && "string longer than INT_MAX, shit's fucked, yo"); + } + *out = QByteArray(str, int(len)); + return true; +} + +bool peek(lua_State *L, std::string *out, StackIdx idx) +{ + StackGuard guard(L); + size_t len{0}; + const char *str = lua_tolstring(L, idx, &len); + if (str == nullptr) + { + return false; + } + if (len >= INT_MAX) + { + assert(false && "string longer than INT_MAX, shit's fucked, yo"); + } + *out = std::string(str, len); + return true; +} + +bool peek(lua_State *L, api::CompletionList *out, StackIdx idx) +{ + StackGuard guard(L); + int typ = lua_getfield(L, idx, "values"); + if (typ != LUA_TTABLE) + { + lua_pop(L, 1); + return false; + } + if (!lua::pop(L, &out->values, -1)) + { + return false; + } + lua_getfield(L, idx, "hide_others"); + return lua::pop(L, &out->hideOthers); +} + +QString toString(lua_State *L, StackIdx idx) +{ + size_t len{}; + const auto *ptr = luaL_tolstring(L, idx, &len); + return QString::fromUtf8(ptr, int(len)); +} +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp new file mode 100644 index 00000000000..c7bd0270eae --- /dev/null +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -0,0 +1,371 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "common/QLogging.hpp" + +# include +# include +# include +# include + +# include +# include +# include +# include +# include +# include +# include +struct lua_State; +class QJsonObject; +namespace chatterino { +struct CommandContext; +} // namespace chatterino + +namespace chatterino::lua { + +namespace api { + struct CompletionList; +} // namespace api + +constexpr int ERROR_BAD_PEEK = LUA_OK - 1; + +/** + * @brief Dumps the Lua stack into qCDebug(chatterinoLua) + * + * @param tag is a string to let you know which dump is which when browsing logs + */ +void stackDump(lua_State *L, const QString &tag); + +/** + * @brief Converts a lua error code and potentially string on top of the stack into a human readable message + */ +QString humanErrorText(lua_State *L, int errCode); + +/** + * Represents an index into Lua's stack + */ +using StackIdx = int; + +/** + * @brief Creates a table with countArray array properties on the Lua stack + * @return stack index of the newly created table + */ +StackIdx pushEmptyArray(lua_State *L, int countArray); + +/** + * @brief Creates a table with countProperties named properties on the Lua stack + * @return stack index of the newly created table + */ +StackIdx pushEmptyTable(lua_State *L, int countProperties); + +StackIdx push(lua_State *L, const CommandContext &ctx); +StackIdx push(lua_State *L, const QString &str); +StackIdx push(lua_State *L, const std::string &str); +StackIdx push(lua_State *L, const bool &b); +StackIdx push(lua_State *L, const int &b); + +// returns OK? +bool peek(lua_State *L, bool *out, StackIdx idx = -1); +bool peek(lua_State *L, double *out, StackIdx idx = -1); +bool peek(lua_State *L, QString *out, StackIdx idx = -1); +bool peek(lua_State *L, QByteArray *out, StackIdx idx = -1); +bool peek(lua_State *L, std::string *out, StackIdx idx = -1); +bool peek(lua_State *L, api::CompletionList *out, StackIdx idx = -1); + +/** + * @brief Converts Lua object at stack index idx to a string. + */ +QString toString(lua_State *L, StackIdx idx = -1); + +// This object ensures that the stack is of expected size when it is destroyed +class StackGuard +{ + int expected; + lua_State *L; + +public: + /** + * Use this constructor if you expect the stack size to be the same on the + * destruction of the object as its creation + */ + StackGuard(lua_State *L) + : expected(lua_gettop(L)) + , L(L) + { + } + + /** + * Use this if you expect the stack size changing, diff is the expected difference + * Ex: diff=3 means three elements added to the stack + */ + StackGuard(lua_State *L, int diff) + : expected(lua_gettop(L) + diff) + , L(L) + { + } + + ~StackGuard() + { + if (expected < 0) + { + return; + } + int after = lua_gettop(this->L); + if (this->expected != after) + { + stackDump(this->L, "StackGuard check tripped"); + // clang-format off + // clang format likes to insert a new line which means that some builds won't show this message fully + assert(false && "internal error: lua stack was not in an expected state"); + // clang-format on + } + } + + // This object isn't meant to be passed around + StackGuard operator=(StackGuard &) = delete; + StackGuard &operator=(StackGuard &&) = delete; + StackGuard(StackGuard &) = delete; + StackGuard(StackGuard &&) = delete; + + // This function tells the StackGuard that the stack isn't in an expected state but it was handled + void handled() + { + this->expected = -1; + } +}; + +/// TEMPLATES + +template +bool peek(lua_State *L, std::optional *out, StackIdx idx = -1) +{ + if (lua_isnil(L, idx)) + { + *out = std::nullopt; + return true; + } + + *out = T(); + return peek(L, out->operator->(), idx); +} + +template +bool peek(lua_State *L, std::vector *vec, StackIdx idx = -1) +{ + StackGuard guard(L); + + if (!lua_istable(L, idx)) + { + lua::stackDump(L, "!table"); + qCDebug(chatterinoLua) + << "value is not a table, type is" << lua_type(L, idx); + return false; + } + auto len = lua_rawlen(L, idx); + if (len == 0) + { + qCDebug(chatterinoLua) << "value has 0 length"; + return true; + } + if (len > 1'000'000) + { + qCDebug(chatterinoLua) << "value is too long"; + return false; + } + // count like lua + for (int i = 1; i <= len; i++) + { + lua_geti(L, idx, i); + std::optional obj; + if (!lua::peek(L, &obj)) + { + //lua_seti(L, LUA_REGISTRYINDEX, 1); // lazy + qCDebug(chatterinoLua) + << "Failed to convert lua object into c++: at array index " << i + << ":"; + stackDump(L, "bad conversion into string"); + return false; + } + lua_pop(L, 1); + vec->push_back(obj.value()); + } + return true; +} + +/** + * @brief Converts object at stack index idx to enum given by template parameter T + */ +template , bool>::type = true> +bool peek(lua_State *L, T *out, StackIdx idx = -1) +{ + std::string tmp; + if (!lua::peek(L, &tmp, idx)) + { + return false; + } + std::optional opt = magic_enum::enum_cast(tmp); + if (opt.has_value()) + { + *out = opt.value(); + return true; + } + + return false; +} + +/** + * @brief Converts a vector to Lua and pushes it onto the stack. + * + * Needs StackIdx push(lua_State*, T); to work. + * + * @return Stack index of newly created table. + */ +template +StackIdx push(lua_State *L, std::vector vec) +{ + auto out = pushEmptyArray(L, vec.size()); + int i = 1; + for (const auto &el : vec) + { + push(L, el); + lua_seti(L, out, i); + i += 1; + } + return out; +} + +/** + * @brief Converts a QList to Lua and pushes it onto the stack. + * + * Needs StackIdx push(lua_State*, T); to work. + * + * @return Stack index of newly created table. + */ +template +StackIdx push(lua_State *L, QList vec) +{ + auto out = pushEmptyArray(L, vec.size()); + int i = 1; + for (const auto &el : vec) + { + push(L, el); + lua_seti(L, out, i); + i += 1; + } + return out; +} + +/** + * @brief Converts an enum given by T to Lua (into a string) and pushes it onto the stack. + * + * @return Stack index of newly created string. + */ +template >> +StackIdx push(lua_State *L, T inp) +{ + std::string_view name = magic_enum::enum_name(inp); + return lua::push(L, std::string(name)); +} + +/** + * @brief Converts a Lua object into c++ and removes it from the stack. + * + * Relies on bool peek(lua_State*, T*, StackIdx) existing. + */ +template +bool pop(lua_State *L, T *out, StackIdx idx = -1) +{ + StackGuard guard(L, -1); + auto ok = peek(L, out, idx); + if (ok) + { + if (idx < 0) + { + idx = lua_gettop(L) + idx + 1; + } + lua_remove(L, idx); + } + return ok; +} + +/** + * @brief Creates a table mapping enum names to unique values. + * + * Values in this table may change. + * + * @returns stack index of newly created table + */ +template +StackIdx pushEnumTable(lua_State *L) +{ + // std::array + auto values = magic_enum::enum_values(); + StackIdx out = lua::pushEmptyTable(L, values.size()); + for (const T v : values) + { + std::string_view name = magic_enum::enum_name(v); + std::string str(name); + + lua::push(L, str); + lua_setfield(L, out, str.c_str()); + } + return out; +} + +// Represents a Lua function on the stack +template +class CallbackFunction +{ + StackIdx stackIdx_; + lua_State *L; + +public: + CallbackFunction(lua_State *L, StackIdx stackIdx) + : stackIdx_(stackIdx) + , L(L) + { + } + + // this type owns the stackidx, it must not be trivially copiable + CallbackFunction operator=(CallbackFunction &) = delete; + CallbackFunction(CallbackFunction &) = delete; + + // Permit only move + CallbackFunction &operator=(CallbackFunction &&) = default; + CallbackFunction(CallbackFunction &&) = default; + + ~CallbackFunction() + { + lua_remove(L, this->stackIdx_); + } + + std::variant operator()(Args... arguments) + { + lua_pushvalue(this->L, this->stackIdx_); + ( // apparently this calls lua::push() for every Arg + [this, &arguments] { + lua::push(this->L, arguments); + }(), + ...); + + int res = lua_pcall(L, sizeof...(Args), 1, 0); + if (res != LUA_OK) + { + qCDebug(chatterinoLua) << "error is: " << res; + return {res}; + } + + ReturnType val; + if (!lua::pop(L, &val)) + { + return {ERROR_BAD_PEEK}; + } + return {val}; + } +}; + +} // namespace chatterino::lua + +#endif diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp new file mode 100644 index 00000000000..0453c65c193 --- /dev/null +++ b/src/controllers/plugins/Plugin.cpp @@ -0,0 +1,177 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/Plugin.hpp" + +# include "controllers/commands/CommandController.hpp" + +# include +# include +# include +# include + +# include +# include + +namespace chatterino { + +PluginMeta::PluginMeta(const QJsonObject &obj) +{ + auto homepageObj = obj.value("homepage"); + if (homepageObj.isString()) + { + this->homepage = homepageObj.toString(); + } + else if (!homepageObj.isUndefined()) + { + QString type = magic_enum::enum_name(homepageObj.type()).data(); + this->errors.emplace_back( + QString("homepage is defined but is not a string (its type is %1)") + .arg(type)); + } + auto nameObj = obj.value("name"); + if (nameObj.isString()) + { + this->name = nameObj.toString(); + } + else + { + QString type = magic_enum::enum_name(nameObj.type()).data(); + this->errors.emplace_back( + QString("name is not a string (its type is %1)").arg(type)); + } + + auto descrObj = obj.value("description"); + if (descrObj.isString()) + { + this->description = descrObj.toString(); + } + else + { + QString type = magic_enum::enum_name(descrObj.type()).data(); + this->errors.emplace_back( + QString("description is not a string (its type is %1)").arg(type)); + } + + auto authorsObj = obj.value("authors"); + if (authorsObj.isArray()) + { + auto authorsArr = authorsObj.toArray(); + for (int i = 0; i < authorsArr.size(); i++) + { + const auto &t = authorsArr.at(i); + if (!t.isString()) + { + QString type = magic_enum::enum_name(t.type()).data(); + this->errors.push_back( + QString("authors element #%1 is not a string (it is a %2)") + .arg(i) + .arg(type)); + break; + } + this->authors.push_back(t.toString()); + } + } + else + { + QString type = magic_enum::enum_name(authorsObj.type()).data(); + this->errors.emplace_back( + QString("authors is not an array (its type is %1)").arg(type)); + } + + auto licenseObj = obj.value("license"); + if (licenseObj.isString()) + { + this->license = licenseObj.toString(); + } + else + { + QString type = magic_enum::enum_name(licenseObj.type()).data(); + this->errors.emplace_back( + QString("license is not a string (its type is %1)").arg(type)); + } + + auto verObj = obj.value("version"); + if (verObj.isString()) + { + auto v = semver::from_string_noexcept(verObj.toString().toStdString()); + if (v.has_value()) + { + this->version = v.value(); + } + else + { + this->errors.emplace_back("unable to parse version (use semver)"); + this->version = semver::version(0, 0, 0); + } + } + else + { + QString type = magic_enum::enum_name(verObj.type()).data(); + this->errors.emplace_back( + QString("version is not a string (its type is %1)").arg(type)); + this->version = semver::version(0, 0, 0); + } + auto tagsObj = obj.value("tags"); + if (!tagsObj.isUndefined()) + { + if (!tagsObj.isArray()) + { + QString type = magic_enum::enum_name(tagsObj.type()).data(); + this->errors.emplace_back( + QString("tags is not an array (its type is %1)").arg(type)); + return; + } + + auto tagsArr = tagsObj.toArray(); + for (int i = 0; i < tagsArr.size(); i++) + { + const auto &t = tagsArr.at(i); + if (!t.isString()) + { + QString type = magic_enum::enum_name(t.type()).data(); + this->errors.push_back( + QString("tags element #%1 is not a string (its type is %2)") + .arg(i) + .arg(type)); + return; + } + this->tags.push_back(t.toString()); + } + } +} + +bool Plugin::registerCommand(const QString &name, const QString &functionName) +{ + if (this->ownedCommands.find(name) != this->ownedCommands.end()) + { + return false; + } + + auto ok = getIApp()->getCommands()->registerPluginCommand(name); + if (!ok) + { + return false; + } + this->ownedCommands.insert({name, functionName}); + return true; +} + +std::unordered_set Plugin::listRegisteredCommands() +{ + std::unordered_set out; + for (const auto &[name, _] : this->ownedCommands) + { + out.insert(name); + } + return out; +} + +Plugin::~Plugin() +{ + if (this->state_ != nullptr) + { + lua_close(this->state_); + } +} + +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp new file mode 100644 index 00000000000..7d81609e17c --- /dev/null +++ b/src/controllers/plugins/Plugin.hpp @@ -0,0 +1,141 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include "Application.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" + +# include +# include +# include + +# include +# include +# include + +struct lua_State; + +namespace chatterino { + +struct PluginMeta { + // for more info on these fields see docs/plugin-info.schema.json + + // display name of the plugin + QString name; + + // description shown to the user + QString description; + + // plugin authors shown to the user + std::vector authors; + + // license name + QString license; + + // version of the plugin + semver::version version; + + // optionally a homepage link + QString homepage; + + // optionally tags that might help in searching for the plugin + std::vector tags; + + // errors that occurred while parsing info.json + std::vector errors; + + bool isValid() const + { + return this->errors.empty(); + } + + explicit PluginMeta(const QJsonObject &obj); +}; + +class Plugin +{ +public: + QString id; + PluginMeta meta; + + Plugin(QString id, lua_State *state, PluginMeta meta, + const QDir &loadDirectory) + : id(std::move(id)) + , meta(std::move(meta)) + , loadDirectory_(loadDirectory) + , state_(state) + { + } + + ~Plugin(); + + /** + * @brief Perform all necessary tasks to bind a command name to this plugin + * @param name name of the command to create + * @param functionName name of the function that should be called when the command is executed + * @return true if addition succeeded, false otherwise (for example because the command name is already taken) + */ + bool registerCommand(const QString &name, const QString &functionName); + + /** + * @brief Get names of all commands belonging to this plugin + */ + std::unordered_set listRegisteredCommands(); + + const QDir &loadDirectory() const + { + return this->loadDirectory_; + } + + // Note: The CallbackFunction object's destructor will remove the function from the lua stack + using LuaCompletionCallback = + lua::CallbackFunction; + std::optional getCompletionCallback() + { + if (this->state_ == nullptr || !this->error_.isNull()) + { + return {}; + } + // this uses magic enum to help automatic tooling find usages + auto typ = + lua_getfield(this->state_, LUA_REGISTRYINDEX, + QString("c2cb-%1") + .arg(magic_enum::enum_name( + lua::api::EventType::CompletionRequested) + .data()) + .toStdString() + .c_str()); + if (typ != LUA_TFUNCTION) + { + lua_pop(this->state_, 1); + return {}; + } + + // move + return std::make_optional>( + this->state_, lua_gettop(this->state_)); + } + + /** + * If the plugin crashes while evaluating the main file, this function will return the error + */ + QString error() + { + return this->error_; + } + +private: + QDir loadDirectory_; + lua_State *state_; + + QString error_; + + // maps command name -> function name + std::unordered_map ownedCommands; + + friend class PluginController; +}; +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp new file mode 100644 index 00000000000..14d9131c3a5 --- /dev/null +++ b/src/controllers/plugins/PluginController.cpp @@ -0,0 +1,397 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/PluginController.hpp" + +# include "Application.hpp" +# include "common/Args.hpp" +# include "common/QLogging.hpp" +# include "controllers/commands/CommandContext.hpp" +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "messages/MessageBuilder.hpp" +# include "singletons/Paths.hpp" +# include "singletons/Settings.hpp" + +# include +# include +# include +# include + +# include +# include +# include + +namespace chatterino { + +PluginController::PluginController(const Paths &paths_) + : paths(paths_) +{ +} + +void PluginController::initialize(Settings &settings, const Paths &paths) +{ + (void)paths; + + // actuallyInitialize will be called by this connection + settings.pluginsEnabled.connect([this](bool enabled) { + if (enabled) + { + this->loadPlugins(); + } + else + { + // uninitialize plugins + this->plugins_.clear(); + } + }); +} + +void PluginController::loadPlugins() +{ + this->plugins_.clear(); + auto dir = QDir(this->paths.pluginsDirectory); + qCDebug(chatterinoLua) << "Loading plugins in" << dir.path(); + for (const auto &info : + dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) + { + auto pluginDir = QDir(info.absoluteFilePath()); + this->tryLoadFromDir(pluginDir); + } +} + +bool PluginController::tryLoadFromDir(const QDir &pluginDir) +{ + // look for init.lua + auto index = QFileInfo(pluginDir.filePath("init.lua")); + qCDebug(chatterinoLua) << "Looking for init.lua and info.json in" + << pluginDir.path(); + if (!index.exists()) + { + qCWarning(chatterinoLua) + << "Missing init.lua in plugin directory:" << pluginDir.path(); + return false; + } + qCDebug(chatterinoLua) << "Found init.lua, now looking for info.json!"; + auto infojson = QFileInfo(pluginDir.filePath("info.json")); + if (!infojson.exists()) + { + qCWarning(chatterinoLua) + << "Missing info.json in plugin directory" << pluginDir.path(); + return false; + } + QFile infoFile(infojson.absoluteFilePath()); + infoFile.open(QIODevice::ReadOnly); + auto everything = infoFile.readAll(); + auto doc = QJsonDocument::fromJson(everything); + if (!doc.isObject()) + { + qCWarning(chatterinoLua) + << "info.json root is not an object" << pluginDir.path(); + return false; + } + + auto meta = PluginMeta(doc.object()); + if (!meta.isValid()) + { + qCWarning(chatterinoLua) + << "Plugin from" << pluginDir << "is invalid because:"; + for (const auto &why : meta.errors) + { + qCWarning(chatterinoLua) << "- " << why; + } + auto plugin = std::make_unique(pluginDir.dirName(), nullptr, + meta, pluginDir); + this->plugins_.insert({pluginDir.dirName(), std::move(plugin)}); + return false; + } + this->load(index, pluginDir, meta); + return true; +} + +void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, + const QDir &pluginDir) +{ + lua::StackGuard guard(L); + // Stuff to change, remove or hide behind a permission system: + static const std::vector loadedlibs = { + luaL_Reg{LUA_GNAME, luaopen_base}, + // - load - don't allow in release mode + + //luaL_Reg{LUA_COLIBNAME, luaopen_coroutine}, + // - needs special support + luaL_Reg{LUA_TABLIBNAME, luaopen_table}, + // luaL_Reg{LUA_IOLIBNAME, luaopen_io}, + // - explicit fs access, needs wrapper with permissions, no usage ideas yet + // luaL_Reg{LUA_OSLIBNAME, luaopen_os}, + // - fs access + // - environ access + // - exit + luaL_Reg{LUA_STRLIBNAME, luaopen_string}, + luaL_Reg{LUA_MATHLIBNAME, luaopen_math}, + luaL_Reg{LUA_UTF8LIBNAME, luaopen_utf8}, + luaL_Reg{LUA_LOADLIBNAME, luaopen_package}, + }; + // Warning: Do not add debug library to this, it would make the security of + // this a living nightmare due to stuff like registry access + // - Mm2PL + + for (const auto ® : loadedlibs) + { + luaL_requiref(L, reg.name, reg.func, int(true)); + lua_pop(L, 1); + } + + // NOLINTNEXTLINE(*-avoid-c-arrays) + static const luaL_Reg c2Lib[] = { + {"system_msg", lua::api::c2_system_msg}, + {"register_command", lua::api::c2_register_command}, + {"register_callback", lua::api::c2_register_callback}, + {"send_msg", lua::api::c2_send_msg}, + {"log", lua::api::c2_log}, + {nullptr, nullptr}, + }; + lua_pushglobaltable(L); + auto gtable = lua_gettop(L); + + // count of elements in C2LIB + LogLevel + EventType + auto c2libIdx = lua::pushEmptyTable(L, 8); + + luaL_setfuncs(L, c2Lib, 0); + + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "LogLevel"); + + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "EventType"); + + lua_setfield(L, gtable, "c2"); + + // ban functions + // Note: this might not be fully secure? some kind of metatable fuckery might come up? + + // possibly randomize this name at runtime to prevent some attacks? + +# ifndef NDEBUG + lua_getfield(L, gtable, "load"); + lua_setfield(L, LUA_REGISTRYINDEX, "real_load"); +# endif + + // NOLINTNEXTLINE(*-avoid-c-arrays) + static const luaL_Reg replacementFuncs[] = { + {"load", lua::api::g_load}, + {"print", lua::api::g_print}, + {nullptr, nullptr}, + }; + luaL_setfuncs(L, replacementFuncs, 0); + + lua_pushnil(L); + lua_setfield(L, gtable, "loadfile"); + + lua_pushnil(L); + lua_setfield(L, gtable, "dofile"); + + // set up package lib + lua_getfield(L, gtable, "package"); + + auto package = lua_gettop(L); + lua_pushstring(L, ""); + lua_setfield(L, package, "cpath"); + + // we don't use path + lua_pushstring(L, ""); + lua_setfield(L, package, "path"); + + { + lua_getfield(L, gtable, "table"); + auto table = lua_gettop(L); + lua_getfield(L, -1, "remove"); + lua_remove(L, table); + } + auto remove = lua_gettop(L); + + // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload + for (int i = 0; i < 3; i++) + { + lua_pushvalue(L, remove); + lua_getfield(L, package, "searchers"); + lua_pcall(L, 1, 0, 0); + } + lua_pop(L, 1); // get rid of remove + + lua_getfield(L, package, "searchers"); + lua_pushcclosure(L, lua::api::searcherRelative, 0); + lua_seti(L, -2, 2); + + lua::push(L, QString(pluginDir.absolutePath())); + lua_pushcclosure(L, lua::api::searcherAbsolute, 1); + lua_seti(L, -2, 3); + + lua_pop(L, 3); // remove gtable, package, package.searchers +} + +void PluginController::load(const QFileInfo &index, const QDir &pluginDir, + const PluginMeta &meta) +{ + auto pluginName = pluginDir.dirName(); + lua_State *l = luaL_newstate(); + auto plugin = std::make_unique(pluginName, l, meta, pluginDir); + auto *temp = plugin.get(); + this->plugins_.insert({pluginName, std::move(plugin)}); + + if (getApp()->getArgs().safeMode) + { + // This isn't done earlier to ensure the user can disable a misbehaving plugin + qCWarning(chatterinoLua) << "Skipping loading plugin " << meta.name + << " because safe mode is enabled."; + return; + } + PluginController::openLibrariesFor(l, meta, pluginDir); + + if (!PluginController::isPluginEnabled(pluginName) || + !getSettings()->pluginsEnabled) + { + qCDebug(chatterinoLua) << "Skipping loading" << pluginName << "(" + << meta.name << ") because it is disabled"; + return; + } + qCDebug(chatterinoLua) << "Running lua file:" << index; + int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str()); + if (err != 0) + { + temp->error_ = lua::humanErrorText(l, err); + qCWarning(chatterinoLua) + << "Failed to load" << pluginName << "plugin from" << index << ": " + << temp->error_; + return; + } + qCInfo(chatterinoLua) << "Loaded" << pluginName << "plugin from" << index; +} + +bool PluginController::reload(const QString &id) +{ + auto it = this->plugins_.find(id); + if (it == this->plugins_.end()) + { + return false; + } + if (it->second->state_ != nullptr) + { + lua_close(it->second->state_); + it->second->state_ = nullptr; + } + for (const auto &[cmd, _] : it->second->ownedCommands) + { + getIApp()->getCommands()->unregisterPluginCommand(cmd); + } + it->second->ownedCommands.clear(); + QDir loadDir = it->second->loadDirectory_; + this->plugins_.erase(id); + this->tryLoadFromDir(loadDir); + return true; +} + +QString PluginController::tryExecPluginCommand(const QString &commandName, + const CommandContext &ctx) +{ + for (auto &[name, plugin] : this->plugins_) + { + if (auto it = plugin->ownedCommands.find(commandName); + it != plugin->ownedCommands.end()) + { + const auto &funcName = it->second; + + auto *L = plugin->state_; + lua_getfield(L, LUA_REGISTRYINDEX, funcName.toStdString().c_str()); + lua::push(L, ctx); + + auto res = lua_pcall(L, 1, 0, 0); + if (res != LUA_OK) + { + ctx.channel->addMessage(makeSystemMessage( + "Lua error: " + lua::humanErrorText(L, res))); + return ""; + } + return ""; + } + } + qCCritical(chatterinoLua) + << "Something's seriously up, no plugin owns command" << commandName + << "yet a call to execute it came in"; + assert(false && "missing plugin command owner"); + return ""; +} + +bool PluginController::isPluginEnabled(const QString &id) +{ + auto vec = getSettings()->enabledPlugins.getValue(); + auto it = std::find(vec.begin(), vec.end(), id); + return it != vec.end(); +} + +Plugin *PluginController::getPluginByStatePtr(lua_State *L) +{ + for (auto &[name, plugin] : this->plugins_) + { + if (plugin->state_ == L) + { + return plugin.get(); + } + } + return nullptr; +} + +const std::map> &PluginController::plugins() + const +{ + return this->plugins_; +} + +std::pair PluginController::updateCustomCompletions( + const QString &query, const QString &fullTextContent, int cursorPosition, + bool isFirstWord) const +{ + QStringList results; + + for (const auto &[name, pl] : this->plugins()) + { + if (!pl->error().isNull()) + { + continue; + } + + lua::StackGuard guard(pl->state_); + + auto opt = pl->getCompletionCallback(); + if (opt) + { + qCDebug(chatterinoLua) + << "Processing custom completions from plugin" << name; + auto &cb = *opt; + auto errOrList = + cb(query, fullTextContent, cursorPosition, isFirstWord); + if (std::holds_alternative(errOrList)) + { + guard.handled(); + int err = std::get(errOrList); + qCDebug(chatterinoLua) + << "Got error from plugin " << pl->meta.name + << " while refreshing tab completion: " + << lua::humanErrorText(pl->state_, err); + continue; + } + + auto list = std::get(errOrList); + if (list.hideOthers) + { + results = QStringList(list.values.begin(), list.values.end()); + return {true, results}; + } + results += QStringList(list.values.begin(), list.values.end()); + } + } + + return {false, results}; +} + +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp new file mode 100644 index 00000000000..099c40a701e --- /dev/null +++ b/src/controllers/plugins/PluginController.hpp @@ -0,0 +1,78 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "common/Singleton.hpp" +# include "controllers/commands/CommandContext.hpp" +# include "controllers/plugins/Plugin.hpp" + +# include +# include +# include +# include +# include + +# include +# include +# include +# include +# include + +struct lua_State; + +namespace chatterino { + +class Paths; + +class PluginController : public Singleton +{ + const Paths &paths; + +public: + explicit PluginController(const Paths &paths_); + + void initialize(Settings &settings, const Paths &paths) override; + + QString tryExecPluginCommand(const QString &commandName, + const CommandContext &ctx); + + // NOTE: this pointer does not own the Plugin, unique_ptr still owns it + // This is required to be public because of c functions + Plugin *getPluginByStatePtr(lua_State *L); + + // TODO: make a function that iterates plugins that aren't errored/enabled + const std::map> &plugins() const; + + /** + * @brief Reload plugin given by id + * + * @param id This is the unique identifier of the plugin, the name of the directory it is in + */ + bool reload(const QString &id); + + /** + * @brief Checks settings to tell if a plugin named by id is enabled. + * + * It is the callers responsibility to check Settings::pluginsEnabled + */ + static bool isPluginEnabled(const QString &id); + + std::pair updateCustomCompletions( + const QString &query, const QString &fullTextContent, + int cursorPosition, bool isFirstWord) const; + +private: + void loadPlugins(); + void load(const QFileInfo &index, const QDir &pluginDir, + const PluginMeta &meta); + + // This function adds lua standard libraries into the state + static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/, + const QDir &pluginDir); + static void loadChatterinoLib(lua_State *l); + bool tryLoadFromDir(const QDir &pluginDir); + std::map> plugins_; +}; + +} // namespace chatterino +#endif diff --git a/src/controllers/sound/ISoundController.hpp b/src/controllers/sound/ISoundController.hpp new file mode 100644 index 00000000000..ebf7e3425b6 --- /dev/null +++ b/src/controllers/sound/ISoundController.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include + +namespace chatterino { + +class Settings; +class Paths; + +enum class SoundBackend { + Miniaudio, + Null, +}; + +/** + * @brief Handles sound loading & playback + **/ +class ISoundController : public Singleton +{ +public: + ISoundController() = default; + ~ISoundController() override = default; + ISoundController(const ISoundController &) = delete; + ISoundController(ISoundController &&) = delete; + ISoundController &operator=(const ISoundController &) = delete; + ISoundController &operator=(ISoundController &&) = delete; + + // Play a sound from the given url + // If the url points to something that isn't a local file, it will play + // the default sound initialized in the initialize method + // + // This function should not block + virtual void play(const QUrl &sound) = 0; +}; + +} // namespace chatterino diff --git a/src/controllers/sound/MiniaudioBackend.cpp b/src/controllers/sound/MiniaudioBackend.cpp new file mode 100644 index 00000000000..f84ee8991c2 --- /dev/null +++ b/src/controllers/sound/MiniaudioBackend.cpp @@ -0,0 +1,299 @@ +#include "controllers/sound/MiniaudioBackend.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "debug/Benchmark.hpp" +#include "singletons/Paths.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" +#include "widgets/Window.hpp" + +#include + +#define MINIAUDIO_IMPLEMENTATION +#include +#include + +#include +#include + +namespace { + +using namespace chatterino; + +// The duration after which a sound is played we should try to stop the sound engine, hopefully +// returning the handle to idle letting the computer or monitors sleep +constexpr const auto STOP_AFTER_DURATION = std::chrono::seconds(30); + +void miniaudioLogCallback(void *userData, ma_uint32 level, const char *pMessage) +{ + (void)userData; + + QString message{pMessage}; + + switch (level) + { + case MA_LOG_LEVEL_DEBUG: { + qCDebug(chatterinoSound).noquote() + << "ma debug: " << message.trimmed(); + } + break; + case MA_LOG_LEVEL_INFO: { + qCDebug(chatterinoSound).noquote() + << "ma info: " << message.trimmed(); + } + break; + case MA_LOG_LEVEL_WARNING: { + qCWarning(chatterinoSound).noquote() + << "ma warning:" << message.trimmed(); + } + break; + case MA_LOG_LEVEL_ERROR: { + qCWarning(chatterinoSound).noquote() + << "ma error: " << message.trimmed(); + } + break; + default: { + qCWarning(chatterinoSound).noquote() + << "ma unknown:" << message.trimmed(); + } + break; + } +} + +} // namespace + +namespace chatterino { + +// NUM_SOUNDS specifies how many simultaneous default ping sounds & decoders to create +constexpr const auto NUM_SOUNDS = 4; + +void MiniaudioBackend::initialize(Settings &settings, const Paths &paths) +{ + (void)(settings); + (void)(paths); + + boost::asio::post(this->ioContext, [this] { + ma_result result{}; + + // We are leaking this log object on purpose + auto *logger = new ma_log; + + result = ma_log_init(nullptr, logger); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing logger:" << result; + return; + } + + result = ma_log_register_callback( + logger, ma_log_callback_init(miniaudioLogCallback, nullptr)); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error registering logger callback:" << result; + return; + } + + auto contextConfig = ma_context_config_init(); + contextConfig.pLog = logger; + + /// Initialize context + result = + ma_context_init(nullptr, 0, &contextConfig, this->context.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing context:" << result; + return; + } + + /// Load default sound + QFile defaultPingFile(":/sounds/ping2.wav"); + if (!defaultPingFile.open(QIODevice::ReadOnly)) + { + qCWarning(chatterinoSound) << "Error loading default ping sound"; + return; + } + this->defaultPingData = defaultPingFile.readAll(); + + /// Initialize engine + auto engineConfig = ma_engine_config_init(); + engineConfig.pContext = this->context.get(); + engineConfig.noAutoStart = MA_TRUE; + + result = ma_engine_init(&engineConfig, this->engine.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing engine:" << result; + return; + } + + /// Initialize default ping sounds + { + // TODO: Can we optimize this? + BenchmarkGuard b("init sounds"); + + ma_uint32 soundFlags = 0; + // Decode the sound during loading instead of during playback + soundFlags |= MA_SOUND_FLAG_DECODE; + // Disable pitch control (we don't use it, so this saves some performance) + soundFlags |= MA_SOUND_FLAG_NO_PITCH; + // Disable spatialization control, this brings the volume up to "normal levels" + soundFlags |= MA_SOUND_FLAG_NO_SPATIALIZATION; + + auto decoderConfig = + ma_decoder_config_init(ma_format_f32, 0, 48000); + // This must match the encoding format of our default ping sound + decoderConfig.encodingFormat = ma_encoding_format_wav; + + for (auto i = 0; i < NUM_SOUNDS; ++i) + { + auto dec = std::make_unique(); + auto snd = std::make_unique(); + + result = ma_decoder_init_memory( + (void *)this->defaultPingData.data(), + this->defaultPingData.size() * sizeof(char), &decoderConfig, + dec.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error initializing default " + "ping decoder from memory:" + << result; + return; + } + + result = ma_sound_init_from_data_source(this->engine.get(), + dec.get(), soundFlags, + nullptr, snd.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing default sound from data source:" + << result; + return; + } + + this->defaultPingDecoders.emplace_back(std::move(dec)); + this->defaultPingSounds.emplace_back(std::move(snd)); + } + } + + qCInfo(chatterinoSound) << "miniaudio sound system initialized"; + + this->initialized = true; + }); + + this->audioThread = std::make_unique([this] { + this->ioContext.run(); + }); +} + +MiniaudioBackend::MiniaudioBackend() + : context(std::make_unique()) + , engine(std::make_unique()) + , workGuard(boost::asio::make_work_guard(this->ioContext)) + , sleepTimer(this->ioContext) +{ + qCInfo(chatterinoSound) << "Initializing miniaudio sound backend"; +} + +MiniaudioBackend::~MiniaudioBackend() +{ + // NOTE: This destructor is never called because the `runGui` function calls _exit before that happens + // I have manually called the destructor prior to _exit being called to ensure this logic is sound + + boost::asio::post(this->ioContext, [this] { + for (const auto &snd : this->defaultPingSounds) + { + ma_sound_uninit(snd.get()); + } + for (const auto &dec : this->defaultPingDecoders) + { + ma_decoder_uninit(dec.get()); + } + + ma_engine_uninit(this->engine.get()); + ma_context_uninit(this->context.get()); + + this->workGuard.reset(); + }); + + if (this->audioThread->joinable()) + { + this->audioThread->join(); + } + else + { + qCWarning(chatterinoSound) << "Audio thread not joinable"; + } +} + +void MiniaudioBackend::play(const QUrl &sound) +{ + boost::asio::post(this->ioContext, [this, sound] { + static size_t i = 0; + + this->tgPlay.guard(); + + if (!this->initialized) + { + qCWarning(chatterinoSound) << "Can't play sound, sound controller " + "didn't initialize correctly"; + return; + } + + auto result = ma_engine_start(this->engine.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error starting engine " << result; + return; + } + + if (sound.isLocalFile()) + { + auto soundPath = sound.toLocalFile(); + result = ma_engine_play_sound(this->engine.get(), + qPrintable(soundPath), nullptr); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Failed to play sound" << sound + << soundPath << ":" << result; + } + + return; + } + + // Play default sound, loaded from our resources in the constructor + auto &snd = this->defaultPingSounds[++i % NUM_SOUNDS]; + ma_sound_seek_to_pcm_frame(snd.get(), 0); + result = ma_sound_start(snd.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Failed to play default ping" << result; + } + + this->sleepTimer.expires_from_now(STOP_AFTER_DURATION); + this->sleepTimer.async_wait([this](const auto &ec) { + if (ec) + { + // Timer was most likely cancelled + return; + } + + auto result = ma_engine_stop(this->engine.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error stopping miniaudio engine " << result; + return; + } + }); + }); +} + +} // namespace chatterino diff --git a/src/controllers/sound/SoundController.hpp b/src/controllers/sound/MiniaudioBackend.hpp similarity index 72% rename from src/controllers/sound/SoundController.hpp rename to src/controllers/sound/MiniaudioBackend.hpp index 5591b982f55..18ef9ed00e3 100644 --- a/src/controllers/sound/SoundController.hpp +++ b/src/controllers/sound/MiniaudioBackend.hpp @@ -1,8 +1,9 @@ #pragma once -#include "common/Singleton.hpp" +#include "controllers/sound/ISoundController.hpp" #include "util/ThreadGuard.hpp" +#include #include #include #include @@ -19,33 +20,25 @@ struct ma_decoder; namespace chatterino { -class Settings; -class Paths; - /** * @brief Handles sound loading & playback **/ -class SoundController : public Singleton +class MiniaudioBackend : public ISoundController { - SoundController(); - - void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; public: - ~SoundController() override; + MiniaudioBackend(); + ~MiniaudioBackend() override; // Play a sound from the given url // If the url points to something that isn't a local file, it will play // the default sound initialized in the initialize method - void play(const QUrl &sound); + void play(const QUrl &sound) final; private: // Used for selecting & initializing an appropriate sound backend std::unique_ptr context; - // Used for storing & reusing sounds to be played - std::unique_ptr resourceManager; - // The sound device we're playing sound into - std::unique_ptr device; // The engine is a high-level API for playing sounds from paths in a simple & efficient-enough manner std::unique_ptr engine; @@ -62,6 +55,14 @@ class SoundController : public Singleton // Ensures play is only ever called from the same thread ThreadGuard tgPlay; + std::chrono::system_clock::time_point lastSoundPlay; + + boost::asio::io_context ioContext{1}; + boost::asio::executor_work_guard + workGuard; + std::unique_ptr audioThread; + boost::asio::steady_timer sleepTimer; + bool initialized{false}; friend class Application; diff --git a/src/controllers/sound/NullBackend.cpp b/src/controllers/sound/NullBackend.cpp new file mode 100644 index 00000000000..7f1797e2eba --- /dev/null +++ b/src/controllers/sound/NullBackend.cpp @@ -0,0 +1,18 @@ +#include "controllers/sound/NullBackend.hpp" + +#include "common/QLogging.hpp" + +namespace chatterino { + +NullBackend::NullBackend() +{ + qCInfo(chatterinoSound) << "Initializing null sound backend"; +} + +void NullBackend::play(const QUrl &sound) +{ + // Do nothing + qCDebug(chatterinoSound) << "null backend asked to play" << sound; +} + +} // namespace chatterino diff --git a/src/controllers/sound/NullBackend.hpp b/src/controllers/sound/NullBackend.hpp new file mode 100644 index 00000000000..421deff0707 --- /dev/null +++ b/src/controllers/sound/NullBackend.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "controllers/sound/ISoundController.hpp" + +namespace chatterino { + +/** + * @brief This sound backend does nothing + **/ +class NullBackend final : public ISoundController +{ +public: + NullBackend(); + ~NullBackend() override = default; + NullBackend(const NullBackend &) = delete; + NullBackend(NullBackend &&) = delete; + NullBackend &operator=(const NullBackend &) = delete; + NullBackend &operator=(NullBackend &&) = delete; + + // Play a sound from the given url + // If the url points to something that isn't a local file, it will play + // the default sound initialized in the initialize method + void play(const QUrl &sound) final; +}; + +} // namespace chatterino diff --git a/src/controllers/sound/SoundController.cpp b/src/controllers/sound/SoundController.cpp deleted file mode 100644 index 22c71a90c8a..00000000000 --- a/src/controllers/sound/SoundController.cpp +++ /dev/null @@ -1,216 +0,0 @@ -#include "controllers/sound/SoundController.hpp" - -#include "common/QLogging.hpp" -#include "debug/Benchmark.hpp" -#include "singletons/Paths.hpp" -#include "singletons/Settings.hpp" - -#define MINIAUDIO_IMPLEMENTATION -#include - -#include -#include - -namespace chatterino { - -// NUM_SOUNDS specifies how many simultaneous default ping sounds & decoders to create -constexpr const auto NUM_SOUNDS = 4; - -SoundController::SoundController() - : context(std::make_unique()) - , resourceManager(std::make_unique()) - , device(std::make_unique()) - , engine(std::make_unique()) -{ -} - -void SoundController::initialize(Settings &settings, Paths &paths) -{ - (void)(settings); - (void)(paths); - - ma_result result{}; - - /// Initialize context - result = ma_context_init(nullptr, 0, nullptr, this->context.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing context:" << result; - return; - } - - /// Initialize resource manager - auto resourceManagerConfig = ma_resource_manager_config_init(); - resourceManagerConfig.decodedFormat = ma_format_f32; - // Use native channel count - resourceManagerConfig.decodedChannels = 0; - resourceManagerConfig.decodedSampleRate = 48000; - - result = ma_resource_manager_init(&resourceManagerConfig, - this->resourceManager.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error initializing resource manager:" << result; - return; - } - - /// Load default sound - QFile defaultPingFile(":/sounds/ping2.wav"); - if (!defaultPingFile.open(QIODevice::ReadOnly)) - { - qCWarning(chatterinoSound) << "Error loading default ping sound"; - return; - } - this->defaultPingData = defaultPingFile.readAll(); - - /// Initialize a sound device - auto deviceConfig = ma_device_config_init(ma_device_type_playback); - deviceConfig.playback.pDeviceID = nullptr; - deviceConfig.playback.format = this->resourceManager->config.decodedFormat; - deviceConfig.playback.channels = 0; - deviceConfig.pulse.pStreamNamePlayback = "Chatterino MA"; - deviceConfig.sampleRate = this->resourceManager->config.decodedSampleRate; - deviceConfig.dataCallback = ma_engine_data_callback_internal; - deviceConfig.pUserData = this->engine.get(); - - result = - ma_device_init(this->context.get(), &deviceConfig, this->device.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing device:" << result; - return; - } - - result = ma_device_start(this->device.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error starting device:" << result; - return; - } - - /// Initialize engine - auto engineConfig = ma_engine_config_init(); - engineConfig.pResourceManager = this->resourceManager.get(); - engineConfig.pDevice = this->device.get(); - engineConfig.pContext = this->context.get(); - - result = ma_engine_init(&engineConfig, this->engine.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing engine:" << result; - return; - } - - /// Initialize default ping sounds - { - // TODO: Can we optimize this? - BenchmarkGuard b("init sounds"); - - ma_uint32 soundFlags = 0; - // Decode the sound during loading instead of during playback - soundFlags |= MA_SOUND_FLAG_DECODE; - // Disable pitch control (we don't use it, so this saves some performance) - soundFlags |= MA_SOUND_FLAG_NO_PITCH; - // Disable spatialization control, this brings the volume up to "normal levels" - soundFlags |= MA_SOUND_FLAG_NO_SPATIALIZATION; - - auto decoderConfig = ma_decoder_config_init(ma_format_f32, 0, 48000); - // This must match the encoding format of our default ping sound - decoderConfig.encodingFormat = ma_encoding_format_wav; - - for (auto i = 0; i < NUM_SOUNDS; ++i) - { - auto dec = std::make_unique(); - auto snd = std::make_unique(); - - result = ma_decoder_init_memory( - (void *)this->defaultPingData.data(), - this->defaultPingData.size() * sizeof(char), &decoderConfig, - dec.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error initializing default ping decoder from memory:" - << result; - return; - } - - result = ma_sound_init_from_data_source( - this->engine.get(), dec.get(), soundFlags, nullptr, snd.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error initializing default sound from data source:" - << result; - return; - } - - this->defaultPingDecoders.emplace_back(std::move(dec)); - this->defaultPingSounds.emplace_back(std::move(snd)); - } - } - - qCInfo(chatterinoSound) << "miniaudio sound system initialized"; - - this->initialized = true; -} - -SoundController::~SoundController() -{ - // NOTE: This destructor is never called because the `runGui` function calls _exit before that happens - // I have manually called the destructor prior to _exit being called to ensure this logic is sound - - for (const auto &snd : this->defaultPingSounds) - { - ma_sound_uninit(snd.get()); - } - for (const auto &dec : this->defaultPingDecoders) - { - ma_decoder_uninit(dec.get()); - } - - ma_engine_uninit(this->engine.get()); - ma_device_uninit(this->device.get()); - ma_resource_manager_uninit(this->resourceManager.get()); - ma_context_uninit(this->context.get()); -} - -void SoundController::play(const QUrl &sound) -{ - static size_t i = 0; - - this->tgPlay.guard(); - - if (!this->initialized) - { - qCWarning(chatterinoSound) << "Can't play sound, sound controller " - "didn't initialize correctly"; - return; - } - - if (sound.isLocalFile()) - { - auto soundPath = sound.toLocalFile(); - auto result = ma_engine_play_sound(this->engine.get(), - qPrintable(soundPath), nullptr); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Failed to play sound" << sound - << soundPath << ":" << result; - } - - return; - } - - // Play default sound, loaded from our resources in the constructor - auto &snd = this->defaultPingSounds[++i % NUM_SOUNDS]; - ma_sound_seek_to_pcm_frame(snd.get(), 0); - auto result = ma_sound_start(snd.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Failed to play default ping" << result; - } -} - -} // namespace chatterino diff --git a/src/controllers/twitch/LiveController.cpp b/src/controllers/twitch/LiveController.cpp new file mode 100644 index 00000000000..c531ebe6ff9 --- /dev/null +++ b/src/controllers/twitch/LiveController.cpp @@ -0,0 +1,190 @@ +#include "controllers/twitch/LiveController.hpp" + +#include "common/QLogging.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Helpers.hpp" + +#include + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoTwitchLiveController; + +} // namespace + +namespace chatterino { + +TwitchLiveController::TwitchLiveController() +{ + QObject::connect(&this->refreshTimer, &QTimer::timeout, [this] { + this->request(); + }); + this->refreshTimer.start(TwitchLiveController::REFRESH_INTERVAL); + + QObject::connect(&this->immediateRequestTimer, &QTimer::timeout, [this] { + QStringList channelIDs; + + { + std::unique_lock immediateRequestsLock( + this->immediateRequestsMutex); + for (const auto &channelID : this->immediateRequests) + { + channelIDs.append(channelID); + } + this->immediateRequests.clear(); + } + + if (channelIDs.isEmpty()) + { + return; + } + + this->request(channelIDs); + }); + this->immediateRequestTimer.start( + TwitchLiveController::IMMEDIATE_REQUEST_INTERVAL); +} + +void TwitchLiveController::add(const std::shared_ptr &newChannel) +{ + assert(newChannel != nullptr); + + const auto channelID = newChannel->roomId(); + assert(!channelID.isEmpty()); + + { + std::unique_lock lock(this->channelsMutex); + this->channels[channelID] = newChannel; + } + + { + std::unique_lock immediateRequestsLock(this->immediateRequestsMutex); + this->immediateRequests.emplace(channelID); + } +} + +void TwitchLiveController::request(std::optional optChannelIDs) +{ + QStringList channelIDs; + + if (optChannelIDs) + { + channelIDs = *optChannelIDs; + } + else + { + std::shared_lock lock(this->channelsMutex); + + for (const auto &channelList : this->channels) + { + channelIDs.append(channelList.first); + } + } + + if (channelIDs.isEmpty()) + { + return; + } + + auto batches = + splitListIntoBatches(channelIDs, TwitchLiveController::BATCH_SIZE); + + qCDebug(LOG) << "Make" << batches.size() << "requests"; + + for (const auto &batch : batches) + { + // TODO: Explore making this concurrent + getHelix()->fetchStreams( + batch, {}, + [this, batch{batch}](const auto &streams) { + std::unordered_map> results; + + for (const auto &channelID : batch) + { + results[channelID] = std::nullopt; + } + + for (const auto &stream : streams) + { + results[stream.userId] = stream; + } + + QStringList deadChannels; + + { + std::shared_lock lock(this->channelsMutex); + for (const auto &result : results) + { + auto it = this->channels.find(result.first); + if (it != channels.end()) + { + if (auto channel = it->second.lock(); channel) + { + channel->updateStreamStatus(result.second); + } + else + { + deadChannels.append(result.first); + } + } + } + } + + if (!deadChannels.isEmpty()) + { + std::unique_lock lock(this->channelsMutex); + for (const auto &deadChannel : deadChannels) + { + this->channels.erase(deadChannel); + } + } + }, + [] { + qCWarning(LOG) << "Failed stream check request"; + }, + [] {}); + + // TODO: Explore making this concurrent + getHelix()->fetchChannels( + batch, + [this, batch{batch}](const auto &helixChannels) { + QStringList deadChannels; + + { + std::shared_lock lock(this->channelsMutex); + for (const auto &helixChannel : helixChannels) + { + auto it = this->channels.find(helixChannel.userId); + if (it != this->channels.end()) + { + if (auto channel = it->second.lock(); channel) + { + channel->updateStreamTitle(helixChannel.title); + channel->updateDisplayName(helixChannel.name); + } + else + { + deadChannels.append(helixChannel.userId); + } + } + } + } + + if (!deadChannels.isEmpty()) + { + std::unique_lock lock(this->channelsMutex); + for (const auto &deadChannel : deadChannels) + { + this->channels.erase(deadChannel); + } + } + }, + [] { + qCWarning(LOG) << "Failed stream check request"; + }); + } +} + +} // namespace chatterino diff --git a/src/controllers/twitch/LiveController.hpp b/src/controllers/twitch/LiveController.hpp new file mode 100644 index 00000000000..79befd62d93 --- /dev/null +++ b/src/controllers/twitch/LiveController.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include "common/Singleton.hpp" +#include "util/QStringHash.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +class TwitchChannel; + +class ITwitchLiveController +{ +public: + virtual ~ITwitchLiveController() = default; + + virtual void add(const std::shared_ptr &newChannel) = 0; +}; + +class TwitchLiveController : public ITwitchLiveController, public Singleton +{ +public: + // Controls how often all channels have their stream status refreshed + static constexpr std::chrono::seconds REFRESH_INTERVAL{30}; + + // Controls how quickly new channels have their stream status loaded + static constexpr std::chrono::seconds IMMEDIATE_REQUEST_INTERVAL{1}; + + /** + * How many channels to include in a single request + * + * Should not be more than 100 + **/ + static constexpr int BATCH_SIZE{100}; + + TwitchLiveController(); + + // Add a Twitch channel to be queried for live status + // A request is made within a few seconds if this is the first time this channel is added + void add(const std::shared_ptr &newChannel) override; + +private: + /** + * Run batched Helix Channels & Stream requests for channels + * + * If a list of channel IDs is passed to request, we only make a request for those channels + * + * If no list of channels is passed to request (the default behaviour), we make requests for all channels + * in the `channels` map. + **/ + void request(std::optional optChannelIDs = std::nullopt); + + /** + * List of channel IDs pointing to their Twitch Channel + * + * These channels will have their stream status updated every REFRESH_INTERVAL seconds + **/ + std::unordered_map> channels; + std::shared_mutex channelsMutex; + + /** + * List of channels that need an immediate live status update + * + * These channels will have their stream status updated after at most IMMEDIATE_REQUEST_INTERVAL seconds + **/ + std::unordered_set immediateRequests; + std::mutex immediateRequestsMutex; + + /** + * Timer responsible for refreshing `channels` + **/ + QTimer refreshTimer; + + /** + * Timer responsible for refreshing `immediateRequests` + **/ + QTimer immediateRequestTimer; +}; + +} // namespace chatterino diff --git a/src/controllers/userdata/UserData.hpp b/src/controllers/userdata/UserData.hpp index a5b460d7149..37eb79aa86c 100644 --- a/src/controllers/userdata/UserData.hpp +++ b/src/controllers/userdata/UserData.hpp @@ -3,11 +3,12 @@ #include "util/RapidjsonHelpers.hpp" #include "util/RapidJsonSerializeQString.hpp" -#include #include #include #include +#include + namespace chatterino { // UserData defines a set of data that is defined for a unique user @@ -15,7 +16,7 @@ namespace chatterino { // or a user note that should be displayed with the user // Replacement fields should be optional, where none denotes that the field should not be updated for the user struct UserData { - boost::optional color{boost::none}; + std::optional color{std::nullopt}; // TODO: User note? }; diff --git a/src/controllers/userdata/UserDataController.cpp b/src/controllers/userdata/UserDataController.cpp index 001c24b8de7..3f8029cbefa 100644 --- a/src/controllers/userdata/UserDataController.cpp +++ b/src/controllers/userdata/UserDataController.cpp @@ -2,18 +2,18 @@ #include "singletons/Paths.hpp" #include "util/CombinePath.hpp" +#include "util/Helpers.hpp" namespace { using namespace chatterino; -std::shared_ptr initSettingsInstance() +std::shared_ptr initSettingsInstance( + const Paths &paths) { auto sm = std::make_shared(); - auto *paths = getPaths(); - - auto path = combinePath(paths->settingsDirectory, "user-data.json"); + auto path = combinePath(paths.settingsDirectory, "user-data.json"); sm->setPath(path.toUtf8().toStdString()); @@ -29,8 +29,8 @@ std::shared_ptr initSettingsInstance() namespace chatterino { -UserDataController::UserDataController() - : sm(initSettingsInstance()) +UserDataController::UserDataController(const Paths &paths) + : sm(initSettingsInstance(paths)) , setting("/users", this->sm) { this->sm->load(); @@ -42,15 +42,14 @@ void UserDataController::save() this->sm->save(); } -boost::optional UserDataController::getUser( - const QString &userID) const +std::optional UserDataController::getUser(const QString &userID) const { std::shared_lock lock(this->usersMutex); auto it = this->users.find(userID); if (it == this->users.end()) { - return boost::none; + return std::nullopt; } return it->second; @@ -67,8 +66,8 @@ void UserDataController::setUserColor(const QString &userID, { auto c = this->getUsers(); auto it = c.find(userID); - boost::optional finalColor = - boost::make_optional(!colorString.isEmpty(), QColor(colorString)); + std::optional finalColor = + makeConditionedOptional(!colorString.isEmpty(), QColor(colorString)); if (it == c.end()) { if (!finalColor) diff --git a/src/controllers/userdata/UserDataController.hpp b/src/controllers/userdata/UserDataController.hpp index fe9b54b2813..3be1f260adc 100644 --- a/src/controllers/userdata/UserDataController.hpp +++ b/src/controllers/userdata/UserDataController.hpp @@ -7,22 +7,24 @@ #include "util/RapidJsonSerializeQString.hpp" #include "util/serialize/Container.hpp" -#include #include #include #include +#include #include #include namespace chatterino { +class Paths; + class IUserDataController { public: virtual ~IUserDataController() = default; - virtual boost::optional getUser(const QString &userID) const = 0; + virtual std::optional getUser(const QString &userID) const = 0; virtual void setUserColor(const QString &userID, const QString &colorString) = 0; @@ -31,11 +33,11 @@ class IUserDataController class UserDataController : public IUserDataController, public Singleton { public: - UserDataController(); + explicit UserDataController(const Paths &paths); // Get extra data about a user // If the user does not have any extra data, return none - boost::optional getUser(const QString &userID) const override; + std::optional getUser(const QString &userID) const override; // Update or insert extra data for the user's color override void setUserColor(const QString &userID, diff --git a/src/debug/AssertInGuiThread.hpp b/src/debug/AssertInGuiThread.hpp index 97da9fa4e59..4cdf6606a3d 100644 --- a/src/debug/AssertInGuiThread.hpp +++ b/src/debug/AssertInGuiThread.hpp @@ -7,12 +7,12 @@ namespace chatterino { -static bool isGuiThread() +inline bool isGuiThread() { return QCoreApplication::instance()->thread() == QThread::currentThread(); } -static void assertInGuiThread() +inline void assertInGuiThread() { #ifdef _DEBUG assert(isGuiThread()); diff --git a/src/debug/Benchmark.hpp b/src/debug/Benchmark.hpp index 4b0d4767f69..e9b058ba779 100644 --- a/src/debug/Benchmark.hpp +++ b/src/debug/Benchmark.hpp @@ -1,16 +1,22 @@ #pragma once -#include #include #include namespace chatterino { -class BenchmarkGuard : boost::noncopyable +class BenchmarkGuard { public: BenchmarkGuard(const QString &_name); ~BenchmarkGuard(); + + BenchmarkGuard(const BenchmarkGuard &) = delete; + BenchmarkGuard &operator=(const BenchmarkGuard &) = delete; + + BenchmarkGuard(BenchmarkGuard &&) = delete; + BenchmarkGuard &operator=(BenchmarkGuard &&) = delete; + qreal getElapsedMs(); private: diff --git a/src/main.cpp b/src/main.cpp index 303d8ea224a..8717679181b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,13 +4,14 @@ #include "common/Modes.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" -#include "providers/Crashpad.hpp" #include "providers/IvrApi.hpp" #include "providers/NetworkConfigurationProvider.hpp" #include "providers/twitch/api/Helix.hpp" #include "RunGui.hpp" +#include "singletons/CrashHandler.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" +#include "singletons/Updates.hpp" #include "util/AttachToConsole.hpp" #include @@ -24,17 +25,22 @@ using namespace chatterino; int main(int argc, char **argv) { + // TODO: This is a temporary fix (see #4552). +#if defined(Q_OS_WINDOWS) && QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + qputenv("QT_ENABLE_HIGHDPI_SCALING", "0"); +#endif + QApplication a(argc, argv); QCoreApplication::setApplicationName("chatterino"); QCoreApplication::setApplicationVersion(CHATTERINO_VERSION); QCoreApplication::setOrganizationDomain("chatterino.com"); - Paths *paths{}; + std::unique_ptr paths; try { - paths = new Paths; + paths = std::make_unique(); } catch (std::runtime_error &error) { @@ -57,18 +63,18 @@ int main(int argc, char **argv) return 1; } - initArgs(a); + const Args args(a, *paths); #ifdef CHATTERINO_WITH_CRASHPAD - const auto crashpadHandler = installCrashHandler(); + const auto crashpadHandler = installCrashHandler(args, *paths); #endif // run in gui mode or browser extension host mode - if (getArgs().shouldRunBrowserExtensionHost) + if (args.shouldRunBrowserExtensionHost) { runBrowserExtensionHost(); } - else if (getArgs().printVersion) + else if (args.printVersion) { attachToConsole(); @@ -82,11 +88,13 @@ int main(int argc, char **argv) } else { - if (getArgs().verbose) + if (args.verbose) { attachToConsole(); } + Updates updates(*paths); + NetworkConfigurationProvider::applyFromEnv(Env::get()); IvrApi::initialize(); @@ -94,7 +102,7 @@ int main(int argc, char **argv) Settings settings(paths->settingsDirectory); - runGui(a, *paths, settings); + runGui(a, *paths, settings, args, updates); } return 0; } diff --git a/src/messages/Emote.cpp b/src/messages/Emote.cpp index e9dbbf3a04b..6281277fcc3 100644 --- a/src/messages/Emote.cpp +++ b/src/messages/Emote.cpp @@ -20,7 +20,9 @@ EmotePtr cachedOrMakeEmotePtr(Emote &&emote, const EmoteMap &cache) // reuse old shared_ptr if nothing changed auto it = cache.find(emote.name); if (it != cache.end() && *it->second == emote) + { return it->second; + } return std::make_shared(std::move(emote)); } diff --git a/src/messages/Emote.hpp b/src/messages/Emote.hpp index d157abbfcf5..57e4a8e68e3 100644 --- a/src/messages/Emote.hpp +++ b/src/messages/Emote.hpp @@ -3,11 +3,10 @@ #include "common/Aliases.hpp" #include "messages/ImageSet.hpp" -#include - #include #include #include +#include #include namespace chatterino { @@ -17,14 +16,14 @@ struct Emote { ImageSet images; Tooltip tooltip; Url homePage; - bool zeroWidth; + bool zeroWidth{}; EmoteId id; EmoteAuthor author; /** * If this emote is aliased, this contains * the original (base) name of the emote. */ - boost::optional baseName; + std::optional baseName; // FOURTF: no solution yet, to be refactored later const QString &getCopyString() const diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index 952b6146578..d3367047084 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -2,12 +2,16 @@ #include "Application.hpp" #include "common/Common.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "debug/AssertInGuiThread.hpp" #include "debug/Benchmark.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/helper/GifTimer.hpp" +#include "singletons/WindowManager.hpp" +#include "util/DebugCount.hpp" +#include "util/PostToThread.hpp" #include #include @@ -20,13 +24,6 @@ #include #include #include -#ifndef CHATTERINO_TEST -# include "singletons/Emotes.hpp" -#endif -#include "singletons/helper/GifTimer.hpp" -#include "singletons/WindowManager.hpp" -#include "util/DebugCount.hpp" -#include "util/PostToThread.hpp" // Duration between each check of every Image instance const auto IMAGE_POOL_CLEANUP_INTERVAL = std::chrono::minutes(1); @@ -55,12 +52,10 @@ namespace detail { { DebugCount::increase("animated images"); -#ifndef CHATTERINO_TEST this->gifTimerConnection_ = - getApp()->emotes->gifTimer.signal.connect([this] { + getIApp()->getEmotes()->getGIFTimer().signal.connect([this] { this->advance(); }); -#endif } auto totalLength = @@ -75,13 +70,14 @@ namespace detail { } else { -#ifndef CHATTERINO_TEST this->durationOffset_ = std::min( - int(getApp()->emotes->gifTimer.position() % totalLength), + int(getIApp()->getEmotes()->getGIFTimer().position() % + totalLength), 60000); -#endif } this->processOffset(); + DebugCount::increase("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever loaded)", this->memoryUsage()); } Frames::~Frames() @@ -97,10 +93,27 @@ namespace detail { { DebugCount::decrease("animated images"); } + DebugCount::decrease("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever unloaded)", + this->memoryUsage()); this->gifTimerConnection_.disconnect(); } + int64_t Frames::memoryUsage() const + { + int64_t usage = 0; + for (const auto &frame : this->items_) + { + auto sz = frame.image.size(); + auto area = sz.width() * sz.height(); + auto memory = area * frame.image.depth() / 8; + + usage += memory; + } + return usage; + } + void Frames::advance() { this->durationOffset_ += GIF_FRAME_LENGTH; @@ -137,6 +150,9 @@ namespace detail { { DebugCount::decrease("loaded images"); } + DebugCount::decrease("image bytes", this->memoryUsage()); + DebugCount::increase("image bytes (ever unloaded)", + this->memoryUsage()); this->items_.clear(); this->index_ = 0; @@ -154,17 +170,23 @@ namespace detail { return this->items_.size() > 1; } - boost::optional Frames::current() const + std::optional Frames::current() const { - if (this->items_.size() == 0) - return boost::none; + if (this->items_.empty()) + { + return std::nullopt; + } + return this->items_[this->index_].image; } - boost::optional Frames::first() const + std::optional Frames::first() const { - if (this->items_.size() == 0) - return boost::none; + if (this->items_.empty()) + { + return std::nullopt; + } + return this->items_.front().image; } @@ -186,13 +208,15 @@ namespace detail { // https://github.com/SevenTV/chatterino7/issues/46#issuecomment-1010595231 int duration = reader.nextImageDelay(); if (duration <= 10) + { duration = 100; + } duration = std::max(20, duration); frames.push_back(Frame{std::move(image), duration}); } } - if (frames.size() == 0) + if (frames.empty()) { qCDebug(chatterinoImage) << "Error while reading image" << url.string << ": '" @@ -228,9 +252,8 @@ namespace detail { } } -#ifndef CHATTERINO_TEST - getApp()->windows->forceLayoutChannelViews(); -#endif + getIApp()->getWindows()->forceLayoutChannelViews(); + loadedEventQueued = false; } @@ -329,10 +352,8 @@ ImagePtr Image::fromResourcePixmap(const QPixmap &pixmap, qreal scale) { return shared; } - else - { - cache.erase(it); - } + + cache.erase(it); } auto newImage = ImagePtr(new Image(scale)); @@ -401,10 +422,10 @@ bool Image::loaded() const { assertInGuiThread(); - return bool(this->frames_->current()); + return this->frames_->current().has_value(); } -boost::optional Image::pixmapOrLoad() const +std::optional Image::pixmapOrLoad() const { assertInGuiThread(); @@ -455,9 +476,12 @@ int Image::width() const assertInGuiThread(); if (auto pixmap = this->frames_->first()) + { return int(pixmap->width() * this->scale_); - else - return 16; + } + + // No frames loaded, use our default magic width 16 + return 16; } int Image::height() const @@ -465,20 +489,26 @@ int Image::height() const assertInGuiThread(); if (auto pixmap = this->frames_->first()) + { return int(pixmap->height() * this->scale_); - else - return 16; + } + + // No frames loaded, use our default magic height 16 + return 16; } void Image::actuallyLoad() { + auto weak = weakOf(this); NetworkRequest(this->url().string) .concurrent() .cache() - .onSuccess([weak = weakOf(this)](auto result) -> Outcome { + .onSuccess([weak](auto result) { auto shared = weak.lock(); if (!shared) - return Failure; + { + return; + } auto data = result.getData(); @@ -492,14 +522,14 @@ void Image::actuallyLoad() qCDebug(chatterinoImage) << "Error: image cant be read " << shared->url().string; shared->empty_ = true; - return Failure; + return; } const auto size = reader.size(); if (size.isEmpty()) { shared->empty_ = true; - return Failure; + return; } // returns 1 for non-animated formats @@ -509,7 +539,7 @@ void Image::actuallyLoad() << "Error: image has less than 1 frame " << shared->url().string << ": " << reader.errorString(); shared->empty_ = true; - return Failure; + return; } // use "double" to prevent int overflows @@ -520,25 +550,26 @@ void Image::actuallyLoad() qCDebug(chatterinoImage) << "image too large in RAM"; shared->empty_ = true; - return Failure; + return; } auto parsed = detail::readFrames(reader, shared->url()); - postToThread(makeConvertCallback(parsed, [weak](auto &&frames) { - if (auto shared = weak.lock()) - { - shared->frames_ = - std::make_unique(std::move(frames)); - } - })); - - return Success; + postToThread(makeConvertCallback( + parsed, [weak = std::weak_ptr(shared)](auto &&frames) { + if (auto shared = weak.lock()) + { + shared->frames_ = std::make_unique( + std::forward(frames)); + } + })); }) - .onError([weak = weakOf(this)](auto /*result*/) { + .onError([weak](auto /*result*/) { auto shared = weak.lock(); if (!shared) + { return false; + } // fourtf: is this the right thing to do? shared->empty_ = true; @@ -558,8 +589,9 @@ void Image::expireFrames() #ifndef DISABLE_IMAGE_EXPIRATION_POOL ImageExpirationPool::ImageExpirationPool() + : freeTimer_(new QTimer) { - QObject::connect(&this->freeTimer_, &QTimer::timeout, [this] { + QObject::connect(this->freeTimer_, &QTimer::timeout, [this] { if (isGuiThread()) { this->freeOld(); @@ -572,15 +604,22 @@ ImageExpirationPool::ImageExpirationPool() } }); - this->freeTimer_.start( + this->freeTimer_->start( std::chrono::duration_cast( IMAGE_POOL_CLEANUP_INTERVAL)); + + // configure all debug counts used by images + DebugCount::configure("image bytes", DebugCount::Flag::DataSize); + DebugCount::configure("image bytes (ever loaded)", + DebugCount::Flag::DataSize); + DebugCount::configure("image bytes (ever unloaded)", + DebugCount::Flag::DataSize); } ImageExpirationPool &ImageExpirationPool::instance() { - static ImageExpirationPool instance; - return instance; + static auto *instance = new ImageExpirationPool; + return *instance; } void ImageExpirationPool::addImagePtr(ImagePtr imgPtr) @@ -595,14 +634,26 @@ void ImageExpirationPool::removeImagePtr(Image *rawPtr) this->allImages_.erase(rawPtr); } +void ImageExpirationPool::freeAll() +{ + { + std::lock_guard lock(this->mutex_); + for (auto it = this->allImages_.begin(); it != this->allImages_.end();) + { + auto img = it->second.lock(); + img->expireFrames(); + it = this->allImages_.erase(it); + } + } + this->freeOld(); +} + void ImageExpirationPool::freeOld() { std::lock_guard lock(this->mutex_); -# ifndef NDEBUG size_t numExpired = 0; size_t eligible = 0; -# endif auto now = std::chrono::steady_clock::now(); for (auto it = this->allImages_.begin(); it != this->allImages_.end();) @@ -623,17 +674,13 @@ void ImageExpirationPool::freeOld() continue; } -# ifndef NDEBUG ++eligible; -# endif // Check if image has expired and, if so, expire its frame data auto diff = now - img->lastUsed_; if (diff > IMAGE_POOL_IMAGE_LIFETIME) { -# ifndef NDEBUG ++numExpired; -# endif img->expireFrames(); // erase without mutex locking issue it = this->allImages_.erase(it); @@ -647,6 +694,9 @@ void ImageExpirationPool::freeOld() qCDebug(chatterinoImage) << "freed frame data for" << numExpired << "/" << eligible << "eligible images"; # endif + DebugCount::set("last image gc: expired", numExpired); + DebugCount::set("last image gc: eligible", eligible); + DebugCount::set("last image gc: left after gc", this->allImages_.size()); } #endif diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index 5ac7fafe55e..6e1052a8a2e 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -3,8 +3,6 @@ #include "common/Aliases.hpp" #include "common/Common.hpp" -#include -#include #include #include #include @@ -18,14 +16,7 @@ #include #include #include - -#ifdef CHATTERINO_TEST -// When running tests, the ImageExpirationPool destructor can be called before -// all images are deleted, leading to a use-after-free of its mutex. This -// happens despite the lifetime of the ImageExpirationPool being (apparently) -// static. Therefore, just disable it during testing. -# define DISABLE_IMAGE_EXPIRATION_POOL -#endif +#include namespace chatterino { namespace detail { @@ -34,21 +25,28 @@ namespace detail { Image image; int duration; }; - class Frames : boost::noncopyable + class Frames { public: Frames(); Frames(QVector> &&frames); ~Frames(); + Frames(const Frames &) = delete; + Frames &operator=(const Frames &) = delete; + + Frames(Frames &&) = delete; + Frames &operator=(Frames &&) = delete; + void clear(); bool empty() const; bool animated() const; void advance(); - boost::optional current() const; - boost::optional first() const; + std::optional current() const; + std::optional first() const; private: + int64_t memoryUsage() const; void processOffset(); QVector> items_; int index_{0}; @@ -61,7 +59,7 @@ class Image; using ImagePtr = std::shared_ptr; /// This class is thread safe. -class Image : public std::enable_shared_from_this, boost::noncopyable +class Image : public std::enable_shared_from_this { public: // Maximum amount of RAM used by the image in bytes. @@ -69,6 +67,12 @@ class Image : public std::enable_shared_from_this, boost::noncopyable ~Image(); + Image(const Image &) = delete; + Image &operator=(const Image &) = delete; + + Image(Image &&) = delete; + Image &operator=(Image &&) = delete; + static ImagePtr fromUrl(const Url &url, qreal scale = 1); static ImagePtr fromResourcePixmap(const QPixmap &pixmap, qreal scale = 1); static ImagePtr getEmpty(); @@ -76,7 +80,7 @@ class Image : public std::enable_shared_from_this, boost::noncopyable const Url &url() const; bool loaded() const; // either returns the current pixmap, or triggers loading it (lazy loading) - boost::optional pixmapOrLoad() const; + std::optional pixmapOrLoad() const; void load() const; qreal scale() const; bool isEmpty() const; @@ -117,9 +121,7 @@ ImagePtr getEmptyImagePtr(); class ImageExpirationPool { -private: - friend class Image; - +public: ImageExpirationPool(); static ImageExpirationPool &instance(); @@ -134,9 +136,14 @@ class ImageExpirationPool */ void freeOld(); -private: + /* + * Debug function that unloads all images in the pool. This is intended to + * test for possible memory leaks from tracked images. + */ + void freeAll(); + // Timer to periodically run freeOld() - QTimer freeTimer_; + QTimer *freeTimer_; std::map> allImages_; std::mutex mutex_; }; diff --git a/src/messages/ImageSet.cpp b/src/messages/ImageSet.cpp index 09294f7664e..7e25e3f86b6 100644 --- a/src/messages/ImageSet.cpp +++ b/src/messages/ImageSet.cpp @@ -61,16 +61,18 @@ const ImagePtr &ImageSet::getImage3() const const std::shared_ptr &getImagePriv(const ImageSet &set, float scale) { -#ifndef CHATTERINO_TEST scale *= getSettings()->emoteScale; -#endif int quality = 1; if (scale > 2.001f) + { quality = 3; + } else if (scale > 1.001f) + { quality = 2; + } if (!set.getImage3()->isEmpty() && quality == 3) { @@ -94,17 +96,27 @@ const ImagePtr &ImageSet::getImageOrLoaded(float scale) const // prefer other image if selected image is not loaded yet if (result->loaded()) + { return result; + } else if (this->imageX3_ && !this->imageX3_->isEmpty() && this->imageX3_->loaded()) + { return this->imageX3_; + } else if (this->imageX2_ && !this->imageX2_->isEmpty() && this->imageX2_->loaded()) + { return this->imageX2_; + } else if (this->imageX1_->loaded()) + { return this->imageX1_; + } else + { return result; + } } const ImagePtr &ImageSet::getImage(float scale) const diff --git a/src/messages/LimitedQueue.hpp b/src/messages/LimitedQueue.hpp index 8c419e184f2..62fd025278d 100644 --- a/src/messages/LimitedQueue.hpp +++ b/src/messages/LimitedQueue.hpp @@ -3,10 +3,10 @@ #include "messages/LimitedQueueSnapshot.hpp" #include -#include #include #include +#include #include #include @@ -62,13 +62,13 @@ class LimitedQueue * @param[in] index the index of the item to fetch * @return the item at the index if it's populated, or none if it's not */ - [[nodiscard]] boost::optional get(size_t index) const + [[nodiscard]] std::optional get(size_t index) const { std::shared_lock lock(this->mutex_); if (index >= this->buffer_.size()) { - return boost::none; + return std::nullopt; } return this->buffer_[index]; @@ -79,13 +79,13 @@ class LimitedQueue * * @return the item at the front of the queue if it's populated, or none the queue is empty */ - [[nodiscard]] boost::optional first() const + [[nodiscard]] std::optional first() const { std::shared_lock lock(this->mutex_); if (this->buffer_.empty()) { - return boost::none; + return std::nullopt; } return this->buffer_.front(); @@ -96,13 +96,13 @@ class LimitedQueue * * @return the item at the back of the queue if it's populated, or none the queue is empty */ - [[nodiscard]] boost::optional last() const + [[nodiscard]] std::optional last() const { std::shared_lock lock(this->mutex_); if (this->buffer_.empty()) { - return boost::none; + return std::nullopt; } return this->buffer_.back(); @@ -293,14 +293,14 @@ class LimitedQueue * * The contents of the LimitedQueue are iterated over from front to back * until the first element that satisfies `pred(item)`. If no item - * satisfies the predicate, or if the queue is empty, then boost::none + * satisfies the predicate, or if the queue is empty, then std::nullopt * is returned. * * @param[in] pred predicate that will be applied to items - * @return the first item found or boost::none + * @return the first item found or std::nullopt */ template - [[nodiscard]] boost::optional find(Predicate pred) const + [[nodiscard]] std::optional find(Predicate pred) const { std::shared_lock lock(this->mutex_); @@ -312,7 +312,7 @@ class LimitedQueue } } - return boost::none; + return std::nullopt; } /** @@ -320,14 +320,14 @@ class LimitedQueue * * The contents of the LimitedQueue are iterated over from back to front * until the first element that satisfies `pred(item)`. If no item - * satisfies the predicate, or if the queue is empty, then boost::none + * satisfies the predicate, or if the queue is empty, then std::nullopt * is returned. * * @param[in] pred predicate that will be applied to items - * @return the first item found or boost::none + * @return the first item found or std::nullopt */ template - [[nodiscard]] boost::optional rfind(Predicate pred) const + [[nodiscard]] std::optional rfind(Predicate pred) const { std::shared_lock lock(this->mutex_); @@ -339,7 +339,7 @@ class LimitedQueue } } - return boost::none; + return std::nullopt; } private: diff --git a/src/messages/Message.cpp b/src/messages/Message.cpp index cb005a84355..73f8ccde038 100644 --- a/src/messages/Message.cpp +++ b/src/messages/Message.cpp @@ -1,18 +1,11 @@ #include "messages/Message.hpp" -#include "Application.hpp" -#include "MessageElement.hpp" #include "providers/colors/ColorProvider.hpp" -#include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchBadge.hpp" #include "singletons/Settings.hpp" -#include "singletons/Theme.hpp" #include "util/DebugCount.hpp" -#include "util/IrcHelpers.hpp" #include "widgets/helper/ScrollbarHighlight.hpp" -using SBHighlight = chatterino::ScrollbarHighlight; - namespace chatterino { Message::Message() @@ -26,45 +19,57 @@ Message::~Message() DebugCount::decrease("messages"); } -SBHighlight Message::getScrollBarHighlight() const +ScrollbarHighlight Message::getScrollBarHighlight() const { if (this->flags.has(MessageFlag::Highlighted) || this->flags.has(MessageFlag::HighlightedWhisper)) { - return SBHighlight(this->highlightColor); + return { + this->highlightColor, + }; } - else if (this->flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight) + + if (this->flags.has(MessageFlag::Subscription) && + getSettings()->enableSubHighlight) { - return SBHighlight( - ColorProvider::instance().color(ColorType::Subscription)); + return { + ColorProvider::instance().color(ColorType::Subscription), + }; } - else if (this->flags.has(MessageFlag::RedeemedHighlight) || - this->flags.has(MessageFlag::RedeemedChannelPointReward)) + + if (this->flags.has(MessageFlag::RedeemedHighlight) || + this->flags.has(MessageFlag::RedeemedChannelPointReward)) { - return SBHighlight( + return { ColorProvider::instance().color(ColorType::RedeemedHighlight), - SBHighlight::Default, true); + ScrollbarHighlight::Default, + true, + }; } - else if (this->flags.has(MessageFlag::ElevatedMessage)) + + if (this->flags.has(MessageFlag::ElevatedMessage)) { - return SBHighlight(ColorProvider::instance().color( - ColorType::ElevatedMessageHighlight), - SBHighlight::Default, false, false, true); + return { + ColorProvider::instance().color( + ColorType::ElevatedMessageHighlight), + ScrollbarHighlight::Default, + false, + false, + true, + }; } - else if (this->flags.has(MessageFlag::FirstMessage)) + + if (this->flags.has(MessageFlag::FirstMessage)) { - return SBHighlight( + return { ColorProvider::instance().color(ColorType::FirstMessageHighlight), - SBHighlight::Default, false, true); + ScrollbarHighlight::Default, + false, + true, + }; } - return SBHighlight(); + return {}; } -// Static -namespace { - -} // namespace - } // namespace chatterino diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index bea40a1b1e3..82de23fe674 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -3,7 +3,6 @@ #include "common/FlagsEnum.hpp" #include "util/QStringHash.hpp" -#include #include #include @@ -46,18 +45,33 @@ enum class MessageFlag : int64_t { FirstMessage = (1LL << 23), ReplyMessage = (1LL << 24), ElevatedMessage = (1LL << 25), - ParticipatedThread = (1LL << 26), + SubscribedThread = (1LL << 26), CheerMessage = (1LL << 27), LiveUpdatesAdd = (1LL << 28), LiveUpdatesRemove = (1LL << 29), LiveUpdatesUpdate = (1LL << 30), + /// The message caught by AutoMod containing the user who sent the message & its contents + AutoModOffendingMessage = (1LL << 31), + LowTrustUsers = (1LL << 32), + /// The message is sent by a user marked as restricted with Twitch's "Low Trust"/"Suspicious User" feature + RestrictedMessage = (1LL << 33), + /// The message is sent by a user marked as monitor with Twitch's "Low Trust"/"Suspicious User" feature + MonitoredMessage = (1LL << 34), }; using MessageFlags = FlagsEnum; -struct Message : boost::noncopyable { +struct Message; +using MessagePtr = std::shared_ptr; +struct Message { Message(); ~Message(); + Message(const Message &) = delete; + Message &operator=(const Message &) = delete; + + Message(Message &&) = delete; + Message &operator=(Message &&) = delete; + // Making this a mutable means that we can update a messages flags, // while still keeping Message constant. This means that a message's flag // can be updated without the renderer being made aware, which might be bad. @@ -83,12 +97,11 @@ struct Message : boost::noncopyable { // the reply thread will be cleaned up by the TwitchChannel. // The root of the thread does not have replyThread set. std::shared_ptr replyThread; + MessagePtr replyParent; uint32_t count = 1; std::vector> elements; ScrollbarHighlight getScrollBarHighlight() const; }; -using MessagePtr = std::shared_ptr; - } // namespace chatterino diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index d97b12e24a9..d1465cef083 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -6,6 +6,7 @@ #include "controllers/accounts/AccountController.hpp" #include "messages/Image.hpp" #include "messages/Message.hpp" +#include "messages/MessageColor.hpp" #include "messages/MessageElement.hpp" #include "providers/LinkResolver.hpp" #include "providers/twitch/PubSubActions.hpp" @@ -77,152 +78,6 @@ MessagePtr makeSystemMessage(const QString &text, const QTime &time) return MessageBuilder(systemMessage, text, time).release(); } -EmotePtr makeAutoModBadge() -{ - return std::make_shared(Emote{ - EmoteName{}, - ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, - Tooltip{"AutoMod"}, - Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); -} - -MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action) -{ - auto builder = MessageBuilder(); - QString text("AutoMod: "); - - builder.emplace(); - builder.message().flags.set(MessageFlag::PubSub); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - builder.emplace( - "AutoMod:", MessageElementFlag::NonBoldUsername, - MessageColor(QColor("blue"))); - switch (action.type) - { - case AutomodInfoAction::OnHold: { - QString info("Hey! Your message is being checked " - "by mods and has not been sent."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - case AutomodInfoAction::Denied: { - QString info("Mods have removed your message."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - case AutomodInfoAction::Approved: { - QString info("Mods have accepted your message."); - text += info; - builder.emplace(info, MessageElementFlag::Text, - MessageColor::Text); - } - break; - } - - builder.message().flags.set(MessageFlag::AutoMod); - builder.message().messageText = text; - builder.message().searchText = text; - - auto message = builder.release(); - - return message; -} - -std::pair makeAutomodMessage( - const AutomodAction &action) -{ - MessageBuilder builder, builder2; - - // - // Builder for AutoMod message with explanation - builder.message().loginName = "automod"; - builder.message().flags.set(MessageFlag::PubSub); - builder.message().flags.set(MessageFlag::Timeout); - builder.message().flags.set(MessageFlag::AutoMod); - - // AutoMod shield badge - builder.emplace(makeAutoModBadge(), - MessageElementFlag::BadgeChannelAuthority); - // AutoMod "username" - builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, - MessageColor(QColor("blue")), - FontStyle::ChatMediumBold); - builder.emplace( - "AutoMod:", MessageElementFlag::NonBoldUsername, - MessageColor(QColor("blue"))); - // AutoMod header message - builder.emplace( - ("Held a message for reason: " + action.reason + - ". Allow will post it in chat. "), - MessageElementFlag::Text, MessageColor::Text); - // Allow link button - builder - .emplace("Allow", MessageElementFlag::Text, - MessageColor(QColor("green")), - FontStyle::ChatMediumBold) - ->setLink({Link::AutoModAllow, action.msgID}); - // Deny link button - builder - .emplace(" Deny", MessageElementFlag::Text, - MessageColor(QColor("red")), - FontStyle::ChatMediumBold) - ->setLink({Link::AutoModDeny, action.msgID}); - // ID of message caught by AutoMod - // builder.emplace(action.msgID, MessageElementFlag::Text, - // MessageColor::Text); - auto text1 = - QString("AutoMod: Held a message for reason: %1. Allow will post " - "it in chat. Allow Deny") - .arg(action.reason); - builder.message().messageText = text1; - builder.message().searchText = text1; - - auto message1 = builder.release(); - - // - // Builder for offender's message - builder2.emplace(); - builder2.emplace(); - builder2.message().loginName = action.target.login; - builder2.message().flags.set(MessageFlag::PubSub); - builder2.message().flags.set(MessageFlag::Timeout); - builder2.message().flags.set(MessageFlag::AutoMod); - - // sender username - builder2 - .emplace( - action.target.displayName + ":", MessageElementFlag::BoldUsername, - MessageColor(action.target.color), FontStyle::ChatMediumBold) - ->setLink({Link::UserInfo, action.target.login}); - builder2 - .emplace(action.target.displayName + ":", - MessageElementFlag::NonBoldUsername, - MessageColor(action.target.color)) - ->setLink({Link::UserInfo, action.target.login}); - // sender's message caught by AutoMod - builder2.emplace(action.message, MessageElementFlag::Text, - MessageColor::Text); - auto text2 = - QString("%1: %2").arg(action.target.displayName, action.message); - builder2.message().messageText = text2; - builder2.message().searchText = text2; - - auto message2 = builder2.release(); - - return std::make_pair(message1, message2); -} - MessageBuilder::MessageBuilder() : message_(std::make_shared()) { @@ -240,10 +95,10 @@ MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text, text.split(QRegularExpression("\\s"), Qt::SkipEmptyParts); for (const auto &word : textFragments) { - const auto linkString = this->matchLink(word); - if (!linkString.isEmpty()) + LinkParser parser(word); + if (parser.result()) { - this->addLink(word, linkString); + this->addLink(*parser.result()); continue; } @@ -349,7 +204,7 @@ MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username, MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count) : MessageBuilder() { - auto current = getApp()->accounts->twitch.getCurrent(); + auto current = getIApp()->getAccounts()->twitch.getCurrent(); this->emplace(); this->message().flags.set(MessageFlag::System); @@ -400,7 +255,8 @@ MessageBuilder::MessageBuilder(const BanAction &action, uint32_t count) this->emplaceSystemTextAndUpdate("banned", text); if (action.reason.isEmpty()) { - this->emplaceSystemTextAndUpdate(action.target.login, text) + this->emplaceSystemTextAndUpdate(action.target.login + ".", + text) ->setLink({Link::UserInfo, action.target.login}); } else @@ -460,7 +316,7 @@ MessageBuilder::MessageBuilder(const UnbanAction &action) ->setLink({Link::UserInfo, action.source.login}); this->emplaceSystemTextAndUpdate( action.wasBan() ? "unbanned" : "untimedout", text); - this->emplaceSystemTextAndUpdate(action.target.login, text) + this->emplaceSystemTextAndUpdate(action.target.login + ".", text) ->setLink({Link::UserInfo, action.target.login}); this->message().messageText = text; @@ -660,6 +516,58 @@ MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag /*unused*/, this->message().flags.set(MessageFlag::DoNotTriggerNotification); } +MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/, + const QString &imageLink, + const QString &deletionLink, + size_t imagesStillQueued, size_t secondsLeft) + : MessageBuilder() +{ + this->message().flags.set(MessageFlag::System); + this->message().flags.set(MessageFlag::DoNotTriggerNotification); + + this->emplace(); + + using MEF = MessageElementFlag; + auto addText = [this](QString text, MessageElementFlags mefs = MEF::Text, + MessageColor color = + MessageColor::System) -> TextElement * { + this->message().searchText += text; + this->message().messageText += text; + return this->emplace(text, mefs, color); + }; + + addText("Your image has been uploaded to"); + + // ASSUMPTION: the user gave this uploader configuration to the program + // therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains. + // This also ensures that the LinkResolver doesn't get these links. + addText(imageLink, {MEF::OriginalLink, MEF::LowercaseLink}, + MessageColor::Link) + ->setLink({Link::Url, imageLink}) + ->setTrailingSpace(false); + + if (!deletionLink.isEmpty()) + { + addText("(Deletion link:"); + addText(deletionLink, {MEF::OriginalLink, MEF::LowercaseLink}, + MessageColor::Link) + ->setLink({Link::Url, deletionLink}) + ->setTrailingSpace(false); + addText(")")->setTrailingSpace(false); + } + addText("."); + + if (imagesStillQueued == 0) + { + return; + } + + addText(QString("%1 left. Please wait until all of them are uploaded. " + "About %2 seconds left.") + .arg(imagesStillQueued) + .arg(secondsLeft)); +} + Message *MessageBuilder::operator->() { return this->message_.get(); @@ -687,60 +595,53 @@ void MessageBuilder::append(std::unique_ptr element) this->message().elements.push_back(std::move(element)); } -QString MessageBuilder::matchLink(const QString &string) +bool MessageBuilder::isEmpty() const { - LinkParser linkParser(string); - - static QRegularExpression httpRegex( - "\\bhttps?://", QRegularExpression::CaseInsensitiveOption); - static QRegularExpression ftpRegex( - "\\bftps?://", QRegularExpression::CaseInsensitiveOption); - static QRegularExpression spotifyRegex( - "\\bspotify:", QRegularExpression::CaseInsensitiveOption); - - if (!linkParser.hasMatch()) - { - return QString(); - } + return this->message_->elements.empty(); +} - QString captured = linkParser.getCaptured(); +MessageElement &MessageBuilder::back() +{ + assert(!this->isEmpty()); + return *this->message().elements.back(); +} - if (!captured.contains(httpRegex) && !captured.contains(ftpRegex) && - !captured.contains(spotifyRegex)) - { - captured.insert(0, "http://"); - } +std::unique_ptr MessageBuilder::releaseBack() +{ + assert(!this->isEmpty()); - return captured; + auto ptr = std::move(this->message().elements.back()); + this->message().elements.pop_back(); + return ptr; } -void MessageBuilder::addLink(const QString &origLink, - const QString &matchedLink) +void MessageBuilder::addLink(const ParsedLink &parsedLink) { - static QRegularExpression domainRegex( - R"(^(?:(?:ftp|http)s?:\/\/)?([^\/]+)(?:\/.*)?$)", - QRegularExpression::CaseInsensitiveOption); - QString lowercaseLinkString; - auto match = domainRegex.match(origLink); - if (match.isValid()) + QString origLink = parsedLink.source; + QString matchedLink; + + if (parsedLink.protocol.isNull()) { - lowercaseLinkString = origLink.mid(0, match.capturedStart(1)) + - match.captured(1).toLower() + - origLink.mid(match.capturedEnd(1)); + matchedLink = QStringLiteral("http://") + parsedLink.source; } else { - lowercaseLinkString = origLink; + lowercaseLinkString += parsedLink.protocol; + matchedLink = parsedLink.source; } + + lowercaseLinkString += parsedLink.host.toString().toLower(); + lowercaseLinkString += parsedLink.rest; + auto linkElement = Link(Link::Url, matchedLink); auto textColor = MessageColor(MessageColor::Link); - auto linkMELowercase = + auto *linkMELowercase = this->emplace(lowercaseLinkString, MessageElementFlag::LowercaseLink, textColor) ->setLink(linkElement); - auto linkMEOriginal = + auto *linkMEOriginal = this->emplace(origLink, MessageElementFlag::OriginalLink, textColor) ->setLink(linkElement); @@ -781,7 +682,8 @@ void MessageBuilder::addIrcMessageText(const QString &text) auto words = text.split(' '); MessageColor defaultColorType = MessageColor::Text; - const auto &defaultColor = defaultColorType.getColor(*getApp()->themes); + const auto &defaultColor = + defaultColorType.getColor(*getIApp()->getThemes()); QColor textColor = defaultColor; int fg = -1; int bg = -1; @@ -796,12 +698,10 @@ void MessageBuilder::addIrcMessageText(const QString &text) auto string = QString(word); // Actually just text - auto linkString = this->matchLink(string); - auto link = Link(); - - if (!linkString.isEmpty()) + LinkParser parser(string); + if (parser.result()) { - this->addLink(string, linkString); + this->addLink(*parser.result()); continue; } @@ -827,7 +727,7 @@ void MessageBuilder::addIrcMessageText(const QString &text) if (fg >= 0 && fg <= 98) { textColor = IRC_COLORS[fg]; - getApp()->themes->normalizeColor(textColor); + getIApp()->getThemes()->normalizeColor(textColor); } else { @@ -867,7 +767,7 @@ void MessageBuilder::addIrcMessageText(const QString &text) if (fg >= 0 && fg <= 98) { textColor = IRC_COLORS[fg]; - getApp()->themes->normalizeColor(textColor); + getIApp()->getThemes()->normalizeColor(textColor); } else { @@ -889,28 +789,24 @@ void MessageBuilder::addTextOrEmoji(const QString &string_) auto string = QString(string_); // Actually just text - auto linkString = this->matchLink(string); - auto link = Link(); + LinkParser linkParser(string); + if (linkParser.result()) + { + this->addLink(*linkParser.result()); + return; + } auto &&textColor = this->textColor_; - if (linkString.isEmpty()) + if (string.startsWith('@')) { - if (string.startsWith('@')) - { - this->emplace(string, MessageElementFlag::BoldUsername, - textColor, FontStyle::ChatMediumBold); - this->emplace( - string, MessageElementFlag::NonBoldUsername, textColor); - } - else - { - this->emplace(string, MessageElementFlag::Text, - textColor); - } + this->emplace(string, MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold); + this->emplace(string, MessageElementFlag::NonBoldUsername, + textColor); } else { - this->addLink(string, linkString); + this->emplace(string, MessageElementFlag::Text, textColor); } } @@ -918,7 +814,7 @@ void MessageBuilder::addIrcWord(const QString &text, const QColor &color, bool addSpace) { this->textColor_ = color; - for (auto &variant : getApp()->emotes->emojis.parse(text)) + for (auto &variant : getIApp()->getEmotes()->getEmojis()->parse(text)) { boost::apply_visitor( [&](auto &&arg) { diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 96ab6658691..c7277997a27 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -23,6 +23,8 @@ class TextElement; struct Emote; using EmotePtr = std::shared_ptr; +struct ParsedLink; + struct SystemMessageTag { }; struct TimeoutMessageTag { @@ -35,6 +37,9 @@ struct LiveUpdatesAddEmoteMessageTag { }; struct LiveUpdatesUpdateEmoteSetMessageTag { }; +struct ImageUploaderResultTag { +}; + const SystemMessageTag systemMessage{}; const TimeoutMessageTag timeoutMessage{}; const LiveUpdatesUpdateEmoteMessageTag liveUpdatesUpdateEmoteMessage{}; @@ -42,11 +47,12 @@ const LiveUpdatesRemoveEmoteMessageTag liveUpdatesRemoveEmoteMessage{}; const LiveUpdatesAddEmoteMessageTag liveUpdatesAddEmoteMessage{}; const LiveUpdatesUpdateEmoteSetMessageTag liveUpdatesUpdateEmoteSetMessage{}; +// This signifies that you want to construct a message containing the result of +// a successful image upload. +const ImageUploaderResultTag imageUploaderResultMessage{}; + MessagePtr makeSystemMessage(const QString &text); MessagePtr makeSystemMessage(const QString &text, const QTime &time); -std::pair makeAutomodMessage( - const AutomodAction &action); -MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); struct MessageParseArgs { bool disablePingSounds = false; @@ -86,6 +92,16 @@ class MessageBuilder MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag, const QString &platform, const QString &actor, const QString &emoteSetName); + /** + * "Your image has been uploaded to %1[ (Deletion link: %2)]." + * or "Your image has been uploaded to %1 %2. %3 left. " + * "Please wait until all of them are uploaded. " + * "About %4 seconds left." + */ + MessageBuilder(ImageUploaderResultTag, const QString &imageLink, + const QString &deletionLink, size_t imagesStillQueued = 0, + size_t secondsLeft = 0); + virtual ~MessageBuilder() = default; Message *operator->(); @@ -94,8 +110,7 @@ class MessageBuilder std::weak_ptr weakOf(); void append(std::unique_ptr element); - QString matchLink(const QString &string); - void addLink(const QString &origLink, const QString &matchedLink); + void addLink(const ParsedLink &parsedLink); /** * Adds the text, applies irc colors, adds links, @@ -123,6 +138,10 @@ class MessageBuilder virtual void addTextOrEmoji(EmotePtr emote); virtual void addTextOrEmoji(const QString &value); + bool isEmpty() const; + MessageElement &back(); + std::unique_ptr releaseBack(); + MessageColor textColor_ = MessageColor::Text; private: diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index ea76b591868..28027ba61ca 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -12,9 +12,28 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "util/DebugCount.hpp" +#include "util/Variant.hpp" namespace chatterino { +namespace { + + // Computes the bounding box for the given vector of images + QSize getBoundingBoxSize(const std::vector &images) + { + int width = 0; + int height = 0; + for (const auto &img : images) + { + width = std::max(width, img->width()); + height = std::max(height, img->height()); + } + + return QSize(width, height); + } + +} // namespace + MessageElement::MessageElement(MessageElementFlags flags) : flags_(flags) { @@ -92,6 +111,11 @@ MessageElementFlags MessageElement::getFlags() const return this->flags_; } +void MessageElement::addFlags(MessageElementFlags flags) +{ + this->flags_.set(flags); +} + MessageElement *MessageElement::updateLink() { this->linkChanged.invoke(); @@ -188,7 +212,9 @@ void EmoteElement::addToContainer(MessageLayoutContainer &container, auto image = this->emote_->images.getImageOrLoaded(container.getScale()); if (image->isEmpty()) + { return; + } auto emoteScale = getSettings()->emoteScale.getValue(); @@ -216,6 +242,170 @@ MessageLayoutElement *EmoteElement::makeImageLayoutElement( return new ImageLayoutElement(*this, image, size); } +LayeredEmoteElement::LayeredEmoteElement( + std::vector &&emotes, MessageElementFlags flags, + const MessageColor &textElementColor) + : MessageElement(flags) + , emotes_(std::move(emotes)) + , textElementColor_(textElementColor) +{ + this->updateTooltips(); +} + +void LayeredEmoteElement::addEmoteLayer(const LayeredEmoteElement::Emote &emote) +{ + this->emotes_.push_back(emote); + this->updateTooltips(); +} + +void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + if (flags.has(MessageElementFlag::EmoteImages)) + { + auto images = this->getLoadedImages(container.getScale()); + if (images.empty()) + { + return; + } + + auto emoteScale = getSettings()->emoteScale.getValue(); + float overallScale = emoteScale * container.getScale(); + + auto largestSize = getBoundingBoxSize(images) * overallScale; + std::vector individualSizes; + individualSizes.reserve(this->emotes_.size()); + for (auto img : images) + { + individualSizes.push_back(QSize(img->width(), img->height()) * + overallScale); + } + + container.addElement(this->makeImageLayoutElement( + images, individualSizes, largestSize) + ->setLink(this->getLink())); + } + else + { + if (this->textElement_) + { + this->textElement_->addToContainer(container, + MessageElementFlag::Misc); + } + } + } +} + +std::vector LayeredEmoteElement::getLoadedImages(float scale) +{ + std::vector res; + res.reserve(this->emotes_.size()); + + for (const auto &emote : this->emotes_) + { + auto image = emote.ptr->images.getImageOrLoaded(scale); + if (image->isEmpty()) + { + continue; + } + res.push_back(image); + } + return res; +} + +MessageLayoutElement *LayeredEmoteElement::makeImageLayoutElement( + const std::vector &images, const std::vector &sizes, + QSize largestSize) +{ + return new LayeredImageLayoutElement(*this, images, sizes, largestSize); +} + +void LayeredEmoteElement::updateTooltips() +{ + if (!this->emotes_.empty()) + { + QString copyStr = this->getCopyString(); + this->textElement_.reset(new TextElement( + copyStr, MessageElementFlag::Misc, this->textElementColor_)); + this->setTooltip(copyStr); + } + + std::vector result; + result.reserve(this->emotes_.size()); + + for (const auto &emote : this->emotes_) + { + result.push_back(emote.ptr->tooltip.string); + } + + this->emoteTooltips_ = std::move(result); +} + +const std::vector &LayeredEmoteElement::getEmoteTooltips() const +{ + return this->emoteTooltips_; +} + +QString LayeredEmoteElement::getCleanCopyString() const +{ + QString result; + for (size_t i = 0; i < this->emotes_.size(); ++i) + { + if (i != 0) + { + result += " "; + } + result += TwitchEmotes::cleanUpEmoteCode( + this->emotes_[i].ptr->getCopyString()); + } + return result; +} + +QString LayeredEmoteElement::getCopyString() const +{ + QString result; + for (size_t i = 0; i < this->emotes_.size(); ++i) + { + if (i != 0) + { + result += " "; + } + result += this->emotes_[i].ptr->getCopyString(); + } + return result; +} + +const std::vector &LayeredEmoteElement::getEmotes() + const +{ + return this->emotes_; +} + +std::vector LayeredEmoteElement::getUniqueEmotes() + const +{ + // Functor for std::copy_if that keeps track of seen elements + struct NotDuplicate { + bool operator()(const Emote &element) + { + return seen.insert(element.ptr).second; + } + + private: + std::set seen; + }; + + // Get unique emotes while maintaining relative layering order + NotDuplicate dup; + std::vector unique; + std::copy_if(this->emotes_.begin(), this->emotes_.end(), + std::back_insert_iterator(unique), dup); + + return unique; +} + // BADGE BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) : MessageElement(flags) @@ -232,7 +422,9 @@ void BadgeElement::addToContainer(MessageLayoutContainer &container, auto image = this->emote_->images.getImageOrLoaded(container.getScale()); if (image->isEmpty()) + { return; + } auto size = QSize(int(container.getScale() * image->width()), int(container.getScale() * image->height())); @@ -249,7 +441,7 @@ EmotePtr BadgeElement::getEmote() const MessageLayoutElement *BadgeElement::makeImageLayoutElement( const ImagePtr &image, const QSize &size) { - auto element = + auto *element = (new ImageLayoutElement(*this, image, size))->setLink(this->getLink()); return element; @@ -267,9 +459,9 @@ MessageLayoutElement *ModBadgeElement::makeImageLayoutElement( { static const QColor modBadgeBackgroundColor("#34AE0A"); - auto element = (new ImageWithBackgroundLayoutElement( - *this, image, size, modBadgeBackgroundColor)) - ->setLink(this->getLink()); + auto *element = (new ImageWithBackgroundLayoutElement( + *this, image, size, modBadgeBackgroundColor)) + ->setLink(this->getLink()); return element; } @@ -284,7 +476,7 @@ VipBadgeElement::VipBadgeElement(const EmotePtr &data, MessageLayoutElement *VipBadgeElement::makeImageLayoutElement( const ImagePtr &image, const QSize &size) { - auto element = + auto *element = (new ImageLayoutElement(*this, image, size))->setLink(this->getLink()); return element; @@ -301,7 +493,7 @@ FfzBadgeElement::FfzBadgeElement(const EmotePtr &data, MessageLayoutElement *FfzBadgeElement::makeImageLayoutElement( const ImagePtr &image, const QSize &size) { - auto element = + auto *element = (new ImageWithBackgroundLayoutElement(*this, image, size, this->color)) ->setLink(this->getLink()); @@ -325,24 +517,24 @@ TextElement::TextElement(const QString &text, MessageElementFlags flags, void TextElement::addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) { - auto app = getApp(); + auto *app = getApp(); if (flags.hasAny(this->getFlags())) { QFontMetrics metrics = - app->fonts->getFontMetrics(this->style_, container.getScale()); + app->getFonts()->getFontMetrics(this->style_, container.getScale()); for (Word &word : this->words_) { auto getTextLayoutElement = [&](QString text, int width, bool hasTrailingSpace) { - auto color = this->color_.getColor(*app->themes); - app->themes->normalizeColor(color); + auto color = this->color_.getColor(*app->getThemes()); + app->getThemes()->normalizeColor(color); - auto e = (new TextLayoutElement( - *this, text, QSize(width, metrics.height()), - color, this->style_, container.getScale())) - ->setLink(this->getLink()); + auto *e = (new TextLayoutElement( + *this, text, QSize(width, metrics.height()), + color, this->style_, container.getScale())) + ->setLink(this->getLink()); e->setTrailingSpace(hasTrailingSpace); e->setText(text); @@ -408,14 +600,18 @@ void TextElement::addToContainer(MessageLayoutContainer &container, width = charWidth; if (isSurrogate) + { i++; + } continue; } width += charWidth; if (isSurrogate) + { i++; + } } //add the final piece of wrapped text container.addElementNoLineBreak(getTextLayoutElement( @@ -441,22 +637,22 @@ SingleLineTextElement::SingleLineTextElement(const QString &text, void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) { - auto app = getApp(); + auto *app = getApp(); if (flags.hasAny(this->getFlags())) { QFontMetrics metrics = - app->fonts->getFontMetrics(this->style_, container.getScale()); + app->getFonts()->getFontMetrics(this->style_, container.getScale()); auto getTextLayoutElement = [&](QString text, int width, bool hasTrailingSpace) { - auto color = this->color_.getColor(*app->themes); - app->themes->normalizeColor(color); + auto color = this->color_.getColor(*app->getThemes()); + app->getThemes()->normalizeColor(color); - auto e = (new TextLayoutElement( - *this, text, QSize(width, metrics.height()), color, - this->style_, container.getScale())) - ->setLink(this->getLink()); + auto *e = (new TextLayoutElement( + *this, text, QSize(width, metrics.height()), color, + this->style_, container.getScale())) + ->setLink(this->getLink()); e->setTrailingSpace(hasTrailingSpace); e->setText(text); @@ -476,83 +672,67 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, QString currentText; container.first = FirstWord::Neutral; + + bool firstIteration = true; for (Word &word : this->words_) { - auto parsedWords = app->emotes->emojis.parse(word.text); - if (parsedWords.size() == 0) + if (firstIteration) { - continue; // sanity check + firstIteration = false; } - - auto &parsedWord = parsedWords[0]; - if (parsedWord.type() == typeid(QString)) + else { - int nextWidth = - metrics.horizontalAdvance(currentText + word.text); + currentText += ' '; + } - // see if the text fits in the current line - if (container.fitsInLine(nextWidth)) + for (const auto &parsedWord : + app->getEmotes()->getEmojis()->parse(word.text)) + { + if (parsedWord.type() == typeid(QString)) { - currentText += (word.text + " "); + currentText += boost::get(parsedWord); + QString prev = + currentText; // only increments the ref-count + currentText = + metrics.elidedText(currentText, Qt::ElideRight, + container.remainingWidth()); + if (currentText != prev) + { + break; + } } - else + else if (parsedWord.type() == typeid(EmotePtr)) { - // word overflows, try minimum truncation - bool cutSuccess = false; - for (size_t cut = 1; cut < word.text.length(); ++cut) + auto emote = boost::get(parsedWord); + auto image = + emote->images.getImageOrLoaded(container.getScale()); + if (!image->isEmpty()) { - // Cut off n characters and append the ellipsis. - // Try removing characters one by one until the word fits. - QString truncatedWord = - word.text.chopped(cut) + ellipsis; - int newSize = metrics.horizontalAdvance(currentText + - truncatedWord); - if (container.fitsInLine(newSize)) - { - currentText += (truncatedWord); + auto emoteScale = getSettings()->emoteScale.getValue(); - cutSuccess = true; + int currentWidth = + metrics.horizontalAdvance(currentText); + auto emoteSize = + QSize(image->width(), image->height()) * + (emoteScale * container.getScale()); + + if (!container.fitsInLine(currentWidth + + emoteSize.width())) + { + currentText += ellipsis; break; } - } - - if (!cutSuccess) - { - // We weren't able to show any part of the current word, so - // just append the ellipsis. - currentText += ellipsis; - } - - break; - } - } - else if (parsedWord.type() == typeid(EmotePtr)) - { - auto emote = boost::get(parsedWord); - auto image = - emote->images.getImageOrLoaded(container.getScale()); - if (!image->isEmpty()) - { - auto emoteScale = getSettings()->emoteScale.getValue(); - int currentWidth = metrics.horizontalAdvance(currentText); - auto emoteSize = QSize(image->width(), image->height()) * - (emoteScale * container.getScale()); + // Add currently pending text to container, then add the emote after. + container.addElementNoLineBreak(getTextLayoutElement( + currentText, currentWidth, false)); + currentText.clear(); - if (!container.fitsInLine(currentWidth + emoteSize.width())) - { - currentText += ellipsis; - break; + container.addElementNoLineBreak( + (new ImageLayoutElement(*this, image, emoteSize)) + ->setLink(this->getLink()) + ->setTrailingSpace(false)); } - - // Add currently pending text to container, then add the emote after. - container.addElementNoLineBreak( - getTextLayoutElement(currentText, currentWidth, false)); - currentText.clear(); - - container.addElementNoLineBreak( - (new ImageLayoutElement(*this, image, emoteSize)) - ->setLink(this->getLink())); } } } @@ -560,9 +740,6 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, // Add the last of the pending message text to the container. if (!currentText.isEmpty()) { - // Remove trailing space. - currentText = currentText.trimmed(); - int width = metrics.horizontalAdvance(currentText); container.addElementNoLineBreak( getTextLayoutElement(currentText, width, false)); @@ -619,13 +796,13 @@ void TwitchModerationElement::addToContainer(MessageLayoutContainer &container, { QSize size(int(container.getScale() * 16), int(container.getScale() * 16)); - auto actions = getCSettings().moderationActions.readOnly(); + auto actions = getSettings()->moderationActions.readOnly(); for (const auto &action : *actions) { if (auto image = action.getImage()) { container.addElement( - (new ImageLayoutElement(*this, image.get(), size)) + (new ImageLayoutElement(*this, *image, size)) ->setLink(Link(Link::UserAction, action.getAction()))); } else @@ -669,7 +846,9 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container, const auto &image = this->images_.getImageOrLoaded(container.getScale()); if (image->isEmpty()) + { return; + } auto size = QSize(image->width() * container.getScale(), image->height() * container.getScale()); diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 0ce8443fcac..c35f9616e25 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -6,7 +6,6 @@ #include "messages/MessageColor.hpp" #include "singletons/Fonts.hpp" -#include #include #include #include @@ -141,9 +140,7 @@ enum class MessageElementFlag : int64_t { LowercaseLink = (1LL << 29), OriginalLink = (1LL << 30), - // ZeroWidthEmotes are emotes that are supposed to overlay over any pre-existing emotes - // e.g. BTTV's SoSnowy during christmas season or 7TV's RainTime - ZeroWidthEmote = (1LL << 31), + // Unused: (1LL << 31) // for elements of the message reply RepliedMessage = (1LL << 32), @@ -160,7 +157,7 @@ enum class MessageElementFlag : int64_t { }; using MessageElementFlags = FlagsEnum; -class MessageElement : boost::noncopyable +class MessageElement { public: enum UpdateFlags : char { @@ -175,6 +172,12 @@ class MessageElement : boost::noncopyable virtual ~MessageElement(); + MessageElement(const MessageElement &) = delete; + MessageElement &operator=(const MessageElement &) = delete; + + MessageElement(MessageElement &&) = delete; + MessageElement &operator=(MessageElement &&) = delete; + MessageElement *setLink(const Link &link); MessageElement *setText(const QString &text); MessageElement *setTooltip(const QString &tooltip); @@ -189,6 +192,7 @@ class MessageElement : boost::noncopyable const Link &getLink() const; bool hasTrailingSpace() const; MessageElementFlags getFlags() const; + void addFlags(MessageElementFlags flags); MessageElement *updateLink(); virtual void addToContainer(MessageLayoutContainer &container, @@ -205,7 +209,7 @@ class MessageElement : boost::noncopyable Link link_; QString tooltip_; ImagePtr thumbnail_; - ThumbnailType thumbnailType_; + ThumbnailType thumbnailType_{}; MessageElementFlags flags_; }; @@ -321,6 +325,48 @@ class EmoteElement : public MessageElement EmotePtr emote_; }; +// A LayeredEmoteElement represents multiple Emotes layered on top of each other. +// This class takes care of rendering animated and non-animated emotes in the +// correct order and aligning them in the right way. +class LayeredEmoteElement : public MessageElement +{ +public: + struct Emote { + EmotePtr ptr; + MessageElementFlags flags; + }; + + LayeredEmoteElement( + std::vector &&emotes, MessageElementFlags flags, + const MessageColor &textElementColor = MessageColor::Text); + + void addEmoteLayer(const Emote &emote); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + + // Returns a concatenation of each emote layer's cleaned copy string + QString getCleanCopyString() const; + const std::vector &getEmotes() const; + std::vector getUniqueEmotes() const; + const std::vector &getEmoteTooltips() const; + +private: + MessageLayoutElement *makeImageLayoutElement( + const std::vector &image, const std::vector &sizes, + QSize largestSize); + + QString getCopyString() const; + void updateTooltips(); + std::vector getLoadedImages(float scale); + + std::vector emotes_; + std::vector emoteTooltips_; + + std::unique_ptr textElement_; + MessageColor textElementColor_; +}; + class BadgeElement : public MessageElement { public: diff --git a/src/messages/MessageThread.cpp b/src/messages/MessageThread.cpp index 0ffdc3f1806..e1227ab09e0 100644 --- a/src/messages/MessageThread.cpp +++ b/src/messages/MessageThread.cpp @@ -1,4 +1,4 @@ -#include "MessageThread.hpp" +#include "messages/MessageThread.hpp" #include "messages/Message.hpp" #include "util/DebugCount.hpp" @@ -58,14 +58,26 @@ size_t MessageThread::liveCount( return count; } -bool MessageThread::participated() const +void MessageThread::markSubscribed() { - return this->participated_; + if (this->subscription_ == Subscription::Subscribed) + { + return; + } + + this->subscription_ = Subscription::Subscribed; + this->subscriptionUpdated(); } -void MessageThread::markParticipated() +void MessageThread::markUnsubscribed() { - this->participated_ = true; + if (this->subscription_ == Subscription::Unsubscribed) + { + return; + } + + this->subscription_ = Subscription::Unsubscribed; + this->subscriptionUpdated(); } } // namespace chatterino diff --git a/src/messages/MessageThread.hpp b/src/messages/MessageThread.hpp index ae0d24794b6..442db46a67d 100644 --- a/src/messages/MessageThread.hpp +++ b/src/messages/MessageThread.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -11,6 +12,12 @@ struct Message; class MessageThread { public: + enum class Subscription : uint8_t { + None, + Subscribed, + Unsubscribed, + }; + MessageThread(std::shared_ptr rootMessage); ~MessageThread(); @@ -23,9 +30,22 @@ class MessageThread /// Returns the number of live reply references size_t liveCount(const std::shared_ptr &exclude) const; - bool participated() const; + bool subscribed() const + { + return this->subscription_ == Subscription::Subscribed; + } + + /// Returns true if and only if the user manually unsubscribed from the thread + /// @see #markUnsubscribed() + bool unsubscribed() const + { + return this->subscription_ == Subscription::Unsubscribed; + } - void markParticipated(); + /// Subscribe to this thread. + void markSubscribed(); + /// Unsubscribe from this thread. + void markUnsubscribed(); const QString &rootId() const { @@ -42,11 +62,14 @@ class MessageThread return replies_; } + boost::signals2::signal subscriptionUpdated; + private: const QString rootMessageId_; const std::shared_ptr rootMessage_; std::vector> replies_; - bool participated_ = false; + + Subscription subscription_ = Subscription::None; }; } // namespace chatterino diff --git a/src/messages/Selection.hpp b/src/messages/Selection.hpp index f16e6ce29b8..b04ad167368 100644 --- a/src/messages/Selection.hpp +++ b/src/messages/Selection.hpp @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -7,12 +9,12 @@ namespace chatterino { struct SelectionItem { - uint32_t messageIndex{0}; - uint32_t charIndex{0}; + size_t messageIndex{0}; + size_t charIndex{0}; SelectionItem() = default; - SelectionItem(uint32_t _messageIndex, uint32_t _charIndex) + SelectionItem(size_t _messageIndex, size_t _charIndex) : messageIndex(_messageIndex) , charIndex(_charIndex) { @@ -37,7 +39,7 @@ struct SelectionItem { bool operator!=(const SelectionItem &b) const { - return this->operator==(b); + return !this->operator==(b); } }; @@ -61,6 +63,23 @@ struct Selection { } } + bool operator==(const Selection &b) const + { + return this->start == b.start && this->end == b.end; + } + + bool operator!=(const Selection &b) const + { + return !this->operator==(b); + } + + //union of both selections + Selection operator|(const Selection &b) const + { + return {std::min(this->selectionMin, b.selectionMin), + std::max(this->selectionMax, b.selectionMax)}; + } + bool isEmpty() const { return this->start == this->end; @@ -73,11 +92,12 @@ struct Selection { } // Shift all message selection indices `offset` back - void shiftMessageIndex(uint32_t offset) + void shiftMessageIndex(size_t offset) { if (offset > this->selectionMin.messageIndex) { this->selectionMin.messageIndex = 0; + this->selectionMin.charIndex = 0; } else { @@ -87,6 +107,7 @@ struct Selection { if (offset > this->selectionMax.messageIndex) { this->selectionMax.messageIndex = 0; + this->selectionMax.charIndex = 0; } else { @@ -96,6 +117,7 @@ struct Selection { if (offset > this->start.messageIndex) { this->start.messageIndex = 0; + this->start.charIndex = 0; } else { @@ -105,6 +127,7 @@ struct Selection { if (offset > this->end.messageIndex) { this->end.messageIndex = 0; + this->end.charIndex = 0; } else { @@ -112,15 +135,4 @@ struct Selection { } } }; - -struct DoubleClickSelection { - uint32_t originalStart{0}; - uint32_t originalEnd{0}; - uint32_t origMessageIndex{0}; - bool selectingLeft{false}; - bool selectingRight{false}; - SelectionItem origStartItem; - SelectionItem origEndItem; -}; - } // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 9d0fda90c41..850df978bad 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -6,7 +6,7 @@ #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/nicknames/Nickname.hpp" -#include "controllers/sound/SoundController.hpp" +#include "controllers/sound/ISoundController.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchBadge.hpp" @@ -18,6 +18,8 @@ #include +#include + namespace { using namespace chatterino; @@ -147,7 +149,7 @@ void SharedMessageBuilder::parseUsername() void SharedMessageBuilder::parseHighlights() { - if (getCSettings().isBlacklistedUser(this->ircMessage->nick())) + if (getSettings()->isBlacklistedUser(this->ircMessage->nick())) { // Do nothing. We ignore highlights from this user. return; @@ -170,18 +172,10 @@ void SharedMessageBuilder::parseHighlights() this->highlightAlert_ = highlightResult.alert; this->highlightSound_ = highlightResult.playSound; + this->highlightSoundCustomUrl_ = highlightResult.customSoundUrl; this->message().highlightColor = highlightResult.color; - if (highlightResult.customSoundUrl) - { - this->highlightSoundUrl_ = highlightResult.customSoundUrl.get(); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - if (highlightResult.showInMentions) { this->message().flags.set(MessageFlag::ShowInMentions); @@ -199,6 +193,15 @@ void SharedMessageBuilder::appendChannelName() } void SharedMessageBuilder::triggerHighlights() +{ + SharedMessageBuilder::triggerHighlights( + this->channel->getName(), this->highlightSound_, + this->highlightSoundCustomUrl_, this->highlightAlert_); +} + +void SharedMessageBuilder::triggerHighlights( + const QString &channelName, bool playSound, + const std::optional &customSoundUrl, bool windowAlert) { if (isInStreamerMode() && getSettings()->streamerModeMuteMentions) { @@ -206,31 +209,40 @@ void SharedMessageBuilder::triggerHighlights() return; } - if (getCSettings().isMutedChannel(this->channel->getName())) + if (getSettings()->isMutedChannel(channelName)) { // Do nothing. Pings are muted in this channel. return; } - bool hasFocus = (QApplication::focusWidget() != nullptr); - bool resolveFocus = !hasFocus || getSettings()->highlightAlwaysPlaySound; + const bool hasFocus = (QApplication::focusWidget() != nullptr); + const bool resolveFocus = + !hasFocus || getSettings()->highlightAlwaysPlaySound; - if (this->highlightSound_ && resolveFocus) + if (playSound && resolveFocus) { - getApp()->sound->play(this->highlightSoundUrl_); + // TODO(C++23): optional or_else + QUrl soundUrl; + if (customSoundUrl) + { + soundUrl = *customSoundUrl; + } + else + { + soundUrl = getFallbackHighlightSound(); + } + getIApp()->getSound()->play(soundUrl); } - if (this->highlightAlert_) + if (windowAlert) { - getApp()->windows->sendAlert(); + getIApp()->getWindows()->sendAlert(); } } QString SharedMessageBuilder::stylizeUsername(const QString &username, const Message &message) { - auto app = getApp(); - const QString &localizedName = message.localizedName; bool hasLocalizedName = !localizedName.isEmpty(); @@ -270,16 +282,12 @@ QString SharedMessageBuilder::stylizeUsername(const QString &username, break; } - auto nicknames = getCSettings().nicknames.readOnly(); - - for (const auto &nickname : *nicknames) + if (auto nicknameText = getSettings()->matchNickname(usernameText)) { - if (nickname.match(usernameText)) - { - break; - } + usernameText = *nicknameText; } return usernameText; } + } // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp index 5dce1ad9f95..a8a2b7df4eb 100644 --- a/src/messages/SharedMessageBuilder.hpp +++ b/src/messages/SharedMessageBuilder.hpp @@ -8,6 +8,8 @@ #include #include +#include + namespace chatterino { class Badge; @@ -52,11 +54,15 @@ class SharedMessageBuilder : public MessageBuilder virtual Outcome tryAppendEmote(const EmoteName &name) { + (void)name; return Failure; } // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function virtual void parseHighlights(); + static void triggerHighlights(const QString &channelName, bool playSound, + const std::optional &customSoundUrl, + bool windowAlert); void appendChannelName(); @@ -72,8 +78,7 @@ class SharedMessageBuilder : public MessageBuilder bool highlightAlert_ = false; bool highlightSound_ = false; - - QUrl highlightSoundUrl_; + std::optional highlightSoundCustomUrl_{}; }; } // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index d00d092e409..bd80a5dc0ba 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -1,16 +1,14 @@ #include "messages/layouts/MessageLayout.hpp" #include "Application.hpp" -#include "debug/Benchmark.hpp" #include "messages/layouts/MessageLayoutContainer.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "messages/Selection.hpp" #include "providers/colors/ColorProvider.hpp" -#include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" -#include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/DebugCount.hpp" #include "util/StreamerMode.hpp" @@ -44,7 +42,6 @@ namespace { MessageLayout::MessageLayout(MessagePtr message) : message_(std::move(message)) - , container_(std::make_shared()) { DebugCount::increase("message layout"); } @@ -67,22 +64,21 @@ const MessagePtr &MessageLayout::getMessagePtr() const // Height int MessageLayout::getHeight() const { - return container_->getHeight(); + return this->container_.getHeight(); } int MessageLayout::getWidth() const { - return this->container_->getWidth(); + return this->container_.getWidth(); } // Layout // return true if redraw is required -bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) +bool MessageLayout::layout(int width, float scale, MessageElementFlags flags, + bool shouldInvalidateBuffer) { // BenchmarkGuard benchmark("MessageLayout::layout()"); - auto app = getApp(); - bool layoutRequired = false; // check if width changed @@ -91,11 +87,12 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) this->currentLayoutWidth_ = width; // check if layout state changed - if (this->layoutState_ != app->windows->getGeneration()) + const auto layoutGeneration = getIApp()->getWindows()->getGeneration(); + if (this->layoutState_ != layoutGeneration) { layoutRequired = true; this->flags.set(MessageLayoutFlag::RequiresBufferUpdate); - this->layoutState_ = app->windows->getGeneration(); + this->layoutState_ = layoutGeneration; } // check if work mask changed @@ -112,12 +109,17 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) if (!layoutRequired) { + if (shouldInvalidateBuffer) + { + this->invalidateBuffer(); + return true; + } return false; } - int oldHeight = this->container_->getHeight(); + int oldHeight = this->container_.getHeight(); this->actuallyLayout(width, flags); - if (widthChanged || this->container_->getHeight() != oldHeight) + if (widthChanged || this->container_.getHeight() != oldHeight) { this->deleteBuffer(); } @@ -128,7 +130,10 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) { +#ifdef FOURTF this->layoutCount_++; +#endif + auto messageFlags = this->message_->flags; if (this->flags.has(MessageLayoutFlag::Expanded) || @@ -143,7 +148,7 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) bool hideSimilar = getSettings()->hideSimilar; bool hideReplies = !flags.has(MessageElementFlag::RepliedMessage); - this->container_->begin(width, this->scale_, messageFlags); + this->container_.beginLayout(width, this->scale_, messageFlags); for (const auto &element : this->message_->elements) { @@ -176,195 +181,199 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) continue; } - element->addToContainer(*this->container_, flags); + element->addToContainer(this->container_, flags); } - if (this->height_ != this->container_->getHeight()) + if (this->height_ != this->container_.getHeight()) { this->deleteBuffer(); } - this->container_->end(); - this->height_ = this->container_->getHeight(); + this->container_.endLayout(); + this->height_ = this->container_.getHeight(); // collapsed state this->flags.unset(MessageLayoutFlag::Collapsed); - if (this->container_->isCollapsed()) + if (this->container_.isCollapsed()) { this->flags.set(MessageLayoutFlag::Collapsed); } } // Painting -void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, - Selection &selection, bool isLastReadMessage, - bool isWindowFocused, bool isMentions) +MessagePaintResult MessageLayout::paint(const MessagePaintContext &ctx) { - auto app = getApp(); - QPixmap *pixmap = this->buffer_.get(); - - // create new buffer if required - if (!pixmap) - { -#if defined(Q_OS_MACOS) || defined(Q_OS_LINUX) - pixmap = new QPixmap(int(width * painter.device()->devicePixelRatioF()), - int(container_->getHeight() * - painter.device()->devicePixelRatioF())); - pixmap->setDevicePixelRatio(painter.device()->devicePixelRatioF()); -#else - pixmap = - new QPixmap(width, std::max(16, this->container_->getHeight())); -#endif + MessagePaintResult result; - this->buffer_ = std::shared_ptr(pixmap); - this->bufferValid_ = false; - DebugCount::increase("message drawing buffers"); - } + QPixmap *pixmap = this->ensureBuffer(ctx.painter, ctx.canvasWidth); - if (!this->bufferValid_ || !selection.isEmpty()) + if (!this->bufferValid_) { - this->updateBuffer(pixmap, messageIndex, selection); + this->updateBuffer(pixmap, ctx); } // draw on buffer - painter.drawPixmap(0, y, *pixmap); - // painter.drawPixmap(0, y, this->container.width, - // this->container.getHeight(), *pixmap); + ctx.painter.drawPixmap(0, ctx.y, *pixmap); // draw gif emotes - this->container_->paintAnimatedElements(painter, y); + result.hasAnimatedElements = + this->container_.paintAnimatedElements(ctx.painter, ctx.y); // draw disabled if (this->message_->flags.has(MessageFlag::Disabled)) { - painter.fillRect(0, y, pixmap->width(), pixmap->height(), - app->themes->messages.disabled); - // painter.fillRect(0, y, pixmap->width(), pixmap->height(), - // QBrush(QColor(64, 64, 64, 64))); + ctx.painter.fillRect(0, ctx.y, pixmap->width(), pixmap->height(), + ctx.messageColors.disabled); } if (this->message_->flags.has(MessageFlag::RecentMessage)) { - painter.fillRect(0, y, pixmap->width(), pixmap->height(), - app->themes->messages.disabled); + ctx.painter.fillRect(0, ctx.y, pixmap->width(), pixmap->height(), + ctx.messageColors.disabled); } - if (!isMentions && + if (!ctx.isMentions && (this->message_->flags.has(MessageFlag::RedeemedChannelPointReward) || this->message_->flags.has(MessageFlag::RedeemedHighlight)) && - getSettings()->enableRedeemedHighlight.getValue()) + ctx.preferences.enableRedeemedHighlight) { - painter.fillRect( - 0, y, this->scale_ * 4, pixmap->height(), + ctx.painter.fillRect( + 0, ctx.y, int(this->scale_ * 4), pixmap->height(), *ColorProvider::instance().color(ColorType::RedeemedHighlight)); } // draw selection - if (!selection.isEmpty()) + if (!ctx.selection.isEmpty()) { - this->container_->paintSelection(painter, messageIndex, selection, y); + this->container_.paintSelection(ctx.painter, ctx.messageIndex, + ctx.selection, ctx.y); } // draw message seperation line - if (getSettings()->separateMessages.getValue()) + if (ctx.preferences.separateMessages) { - painter.fillRect(0, y, this->container_->getWidth() + 64, 1, - app->themes->splits.messageSeperator); + ctx.painter.fillRect(0, ctx.y, this->container_.getWidth() + 64, 1, + ctx.messageColors.messageSeperator); } // draw last read message line - if (isLastReadMessage) + if (ctx.isLastReadMessage) { QColor color; - if (getSettings()->lastMessageColor != QStringLiteral("")) + if (ctx.preferences.lastMessageColor.isValid()) { - color = QColor(getSettings()->lastMessageColor.getValue()); + color = ctx.preferences.lastMessageColor; } else { - color = - isWindowFocused - ? app->themes->tabs.selected.backgrounds.regular.color() - : app->themes->tabs.selected.backgrounds.unfocused.color(); + color = ctx.isWindowFocused + ? ctx.messageColors.focusedLastMessageLine + : ctx.messageColors.unfocusedLastMessageLine; } - QBrush brush(color, static_cast( - getSettings()->lastMessagePattern.getValue())); + QBrush brush(color, ctx.preferences.lastMessagePattern); - painter.fillRect(0, y + this->container_->getHeight() - 1, - pixmap->width(), 1, brush); + ctx.painter.fillRect(0, ctx.y + this->container_.getHeight() - 1, + pixmap->width(), 1, brush); } this->bufferValid_ = true; + + return result; } -void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, - Selection & /*selection*/) +QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width) +{ + if (this->buffer_ != nullptr) + { + return this->buffer_.get(); + } + + // Create new buffer +#if defined(Q_OS_MACOS) || defined(Q_OS_LINUX) + this->buffer_ = std::make_unique( + int(width * painter.device()->devicePixelRatioF()), + int(this->container_.getHeight() * + painter.device()->devicePixelRatioF())); + this->buffer_->setDevicePixelRatio(painter.device()->devicePixelRatioF()); +#else + this->buffer_ = std::make_unique( + width, std::max(16, this->container_.getHeight())); +#endif + + this->bufferValid_ = false; + DebugCount::increase("message drawing buffers"); + return this->buffer_.get(); +} + +void MessageLayout::updateBuffer(QPixmap *buffer, + const MessagePaintContext &ctx) { if (buffer->isNull()) + { return; - - auto app = getApp(); - auto settings = getSettings(); + } QPainter painter(buffer); painter.setRenderHint(QPainter::SmoothPixmapTransform); // draw background - QColor backgroundColor = [this, &app] { - if (getSettings()->alternateMessages.getValue() && + QColor backgroundColor = [&] { + if (ctx.preferences.alternateMessages && this->flags.has(MessageLayoutFlag::AlternateBackground)) { - return app->themes->messages.backgrounds.alternate; - } - else - { - return app->themes->messages.backgrounds.regular; + return ctx.messageColors.alternate; } + + return ctx.messageColors.regular; }(); if (this->message_->flags.has(MessageFlag::ElevatedMessage) && - getSettings()->enableElevatedMessageHighlight.getValue()) + ctx.preferences.enableElevatedMessageHighlight) { - backgroundColor = blendColors(backgroundColor, - *ColorProvider::instance().color( - ColorType::ElevatedMessageHighlight)); + backgroundColor = blendColors( + backgroundColor, + *ctx.colorProvider.color(ColorType::ElevatedMessageHighlight)); } else if (this->message_->flags.has(MessageFlag::FirstMessage) && - getSettings()->enableFirstMessageHighlight.getValue()) + ctx.preferences.enableFirstMessageHighlight) { backgroundColor = blendColors( backgroundColor, - *ColorProvider::instance().color(ColorType::FirstMessageHighlight)); + *ctx.colorProvider.color(ColorType::FirstMessageHighlight)); } else if ((this->message_->flags.has(MessageFlag::Highlighted) || this->message_->flags.has(MessageFlag::HighlightedWhisper)) && !this->flags.has(MessageLayoutFlag::IgnoreHighlights)) { - // Blend highlight color with usual background color - backgroundColor = - blendColors(backgroundColor, *this->message_->highlightColor); + assert(this->message_->highlightColor); + if (this->message_->highlightColor) + { + // Blend highlight color with usual background color + backgroundColor = + blendColors(backgroundColor, *this->message_->highlightColor); + } } else if (this->message_->flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight) + ctx.preferences.enableSubHighlight) { // Blend highlight color with usual background color backgroundColor = blendColors( - backgroundColor, - *ColorProvider::instance().color(ColorType::Subscription)); + backgroundColor, *ctx.colorProvider.color(ColorType::Subscription)); } else if ((this->message_->flags.has(MessageFlag::RedeemedHighlight) || this->message_->flags.has( MessageFlag::RedeemedChannelPointReward)) && - settings->enableRedeemedHighlight.getValue()) + ctx.preferences.enableRedeemedHighlight) { // Blend highlight color with usual background color - backgroundColor = blendColors( - backgroundColor, - *ColorProvider::instance().color(ColorType::RedeemedHighlight)); + backgroundColor = + blendColors(backgroundColor, + *ctx.colorProvider.color(ColorType::RedeemedHighlight)); } - else if (this->message_->flags.has(MessageFlag::AutoMod)) + else if (this->message_->flags.has(MessageFlag::AutoMod) || + this->message_->flags.has(MessageFlag::LowTrustUsers)) { backgroundColor = QColor("#404040"); } @@ -376,7 +385,7 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, painter.fillRect(buffer->rect(), backgroundColor); // draw message - this->container_->paintElements(painter); + this->container_.paintElements(painter, ctx); #ifdef FOURTF // debug @@ -387,7 +396,7 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, QTextOption option; option.setAlignment(Qt::AlignRight | Qt::AlignTop); - painter.drawText(QRectF(1, 1, this->container_->getWidth() - 3, 1000), + painter.drawText(QRectF(1, 1, this->container_.getWidth() - 3, 1000), QString::number(this->layoutCount_) + ", " + QString::number(++this->bufferUpdatedCount_), option); @@ -414,7 +423,7 @@ void MessageLayout::deleteCache() this->deleteBuffer(); #ifdef XD - this->container_->clear(); + this->container_.clear(); #endif } @@ -427,28 +436,28 @@ void MessageLayout::deleteCache() const MessageLayoutElement *MessageLayout::getElementAt(QPoint point) { // go through all words and return the first one that contains the point. - return this->container_->getElementAt(point); + return this->container_.getElementAt(point); } -int MessageLayout::getLastCharacterIndex() const +size_t MessageLayout::getLastCharacterIndex() const { - return this->container_->getLastCharacterIndex(); + return this->container_.getLastCharacterIndex(); } -int MessageLayout::getFirstMessageCharacterIndex() const +size_t MessageLayout::getFirstMessageCharacterIndex() const { - return this->container_->getFirstMessageCharacterIndex(); + return this->container_.getFirstMessageCharacterIndex(); } -int MessageLayout::getSelectionIndex(QPoint position) +size_t MessageLayout::getSelectionIndex(QPoint position) const { - return this->container_->getSelectionIndex(position); + return this->container_.getSelectionIndex(position); } void MessageLayout::addSelectionText(QString &str, uint32_t from, uint32_t to, CopyMode copymode) { - this->container_->addSelectionText(str, from, to, copymode); + this->container_.addSelectionText(str, from, to, copymode); } bool MessageLayout::isReplyable() const diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 5bbf437e465..8a177227fec 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -2,8 +2,8 @@ #include "common/Common.hpp" #include "common/FlagsEnum.hpp" +#include "messages/layouts/MessageLayoutContainer.hpp" -#include #include #include @@ -17,6 +17,7 @@ using MessagePtr = std::shared_ptr; struct Selection; struct MessageLayoutContainer; class MessageLayoutElement; +struct MessagePaintContext; enum class MessageElementFlag : int64_t; using MessageElementFlags = FlagsEnum; @@ -31,12 +32,22 @@ enum class MessageLayoutFlag : uint8_t { }; using MessageLayoutFlags = FlagsEnum; -class MessageLayout : boost::noncopyable +struct MessagePaintResult { + bool hasAnimatedElements = false; +}; + +class MessageLayout { public: MessageLayout(MessagePtr message_); ~MessageLayout(); + MessageLayout(const MessageLayout &) = delete; + MessageLayout &operator=(const MessageLayout &) = delete; + + MessageLayout(MessageLayout &&) = delete; + MessageLayout &operator=(MessageLayout &&) = delete; + const Message *getMessage(); const MessagePtr &getMessagePtr() const; @@ -45,21 +56,38 @@ class MessageLayout : boost::noncopyable MessageLayoutFlags flags; - bool layout(int width, float scale_, MessageElementFlags flags); + bool layout(int width, float scale_, MessageElementFlags flags, + bool shouldInvalidateBuffer); // Painting - void paint(QPainter &painter, int width, int y, int messageIndex, - Selection &selection, bool isLastReadMessage, - bool isWindowFocused, bool isMentions); + MessagePaintResult paint(const MessagePaintContext &ctx); void invalidateBuffer(); void deleteBuffer(); void deleteCache(); - // Elements + /** + * Returns a raw pointer to the element at the given point + * + * If no element is found at the given point, this returns a null pointer + */ const MessageLayoutElement *getElementAt(QPoint point); - int getLastCharacterIndex() const; - int getFirstMessageCharacterIndex() const; - int getSelectionIndex(QPoint position); + + /** + * Get the index of the last character in this message's container + * This is the sum of all the characters in `elements_` + */ + size_t getLastCharacterIndex() const; + + /** + * Get the index of the first visible character in this message's container + * This is not always 0 in case there elements that are skipped + */ + size_t getFirstMessageCharacterIndex() const; + + /** + * Get the character index at the given position, in the context of selections + */ + size_t getSelectionIndex(QPoint position) const; void addSelectionText(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX, CopyMode copymode = CopyMode::Everything); @@ -69,27 +97,30 @@ class MessageLayout : boost::noncopyable bool isReplyable() const; private: + // methods + void actuallyLayout(int width, MessageElementFlags flags); + void updateBuffer(QPixmap *buffer, const MessagePaintContext &ctx); + + // Create new buffer if required, returning the buffer + QPixmap *ensureBuffer(QPainter &painter, int width); + // variables MessagePtr message_; - std::shared_ptr container_; - std::shared_ptr buffer_{}; + MessageLayoutContainer container_; + std::unique_ptr buffer_{}; bool bufferValid_ = false; int height_ = 0; - int currentLayoutWidth_ = -1; int layoutState_ = -1; float scale_ = -1; - unsigned int layoutCount_ = 0; - unsigned int bufferUpdatedCount_ = 0; - MessageElementFlags currentWordFlags_; - int collapsedHeight_ = 32; - - // methods - void actuallyLayout(int width, MessageElementFlags flags); - void updateBuffer(QPixmap *pixmap, int messageIndex, Selection &selection); +#ifdef FOURTF + // Debug counters + unsigned int layoutCount_ = 0; + unsigned int bufferUpdatedCount_ = 0; +#endif }; using MessageLayoutPtr = std::shared_ptr; diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 28050782a0c..17b9b795d4b 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -1,6 +1,7 @@ #include "MessageLayoutContainer.hpp" #include "Application.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" @@ -11,38 +12,42 @@ #include "util/Helpers.hpp" #include +#include #include +#include + #define COMPACT_EMOTES_OFFSET 4 #define MAX_UNCOLLAPSED_LINES \ (getSettings()->collpseMessagesMinLines.getValue()) -namespace chatterino { +namespace { -int MessageLayoutContainer::getHeight() const -{ - return this->height_; -} +constexpr const QMargins MARGIN{8, 4, 8, 4}; -int MessageLayoutContainer::getWidth() const -{ - return this->width_; -} +} // namespace -float MessageLayoutContainer::getScale() const -{ - return this->scale_; -} +namespace chatterino { -// methods -void MessageLayoutContainer::begin(int width, float scale, MessageFlags flags) +void MessageLayoutContainer::beginLayout(int width, float scale, + MessageFlags flags) { - this->clear(); + this->elements_.clear(); + this->lines_.clear(); + + this->line_ = 0; + this->currentX_ = 0; + this->currentY_ = 0; + this->lineStart_ = 0; + this->lineHeight_ = 0; + this->charIndex_ = 0; + this->width_ = width; + this->height_ = 0; this->scale_ = scale; this->flags_ = flags; auto mediumFontMetrics = - getApp()->fonts->getFontMetrics(FontStyle::ChatMedium, scale); + getIApp()->getFonts()->getFontMetrics(FontStyle::ChatMedium, scale); this->textLineHeight_ = mediumFontMetrics.height(); this->spaceWidth_ = mediumFontMetrics.horizontalAdvance(' '); this->dotdotdotWidth_ = mediumFontMetrics.horizontalAdvance("..."); @@ -51,649 +56,856 @@ void MessageLayoutContainer::begin(int width, float scale, MessageFlags flags) this->wasPrevReversed_ = false; } -void MessageLayoutContainer::clear() +void MessageLayoutContainer::endLayout() { - this->elements_.clear(); - this->lines_.clear(); + if (!this->canAddElements()) + { + static TextElement dotdotdot("...", MessageElementFlag::Collapsed, + MessageColor::Link); + static QString dotdotdotText("..."); - this->height_ = 0; - this->line_ = 0; - this->currentX_ = 0; - this->currentY_ = 0; - this->lineStart_ = 0; - this->lineHeight_ = 0; - this->charIndex_ = 0; + auto *element = new TextLayoutElement( + dotdotdot, dotdotdotText, + QSize(this->dotdotdotWidth_, this->textLineHeight_), + QColor("#00D80A"), FontStyle::ChatMediumBold, this->scale_); + + if (this->first == FirstWord::RTL) + { + // Shift all elements in the next line to the left + for (auto i = this->lines_.back().startIndex; + i < this->elements_.size(); i++) + { + QPoint prevPos = this->elements_[i]->getRect().topLeft(); + this->elements_[i]->setPosition( + QPoint(prevPos.x() + this->dotdotdotWidth_, prevPos.y())); + } + } + this->addElement(element, true, -2); + this->isCollapsed_ = true; + } + + if (!this->atStartOfLine()) + { + this->breakLine(); + } + + this->height_ += this->lineHeight_; + + if (!this->lines_.empty()) + { + this->lines_[0].rect.setTop(-100000); + this->lines_.back().rect.setBottom(100000); + this->lines_.back().endIndex = this->elements_.size(); + this->lines_.back().endCharIndex = this->charIndex_; + } + + if (!this->elements_.empty()) + { + this->elements_.back()->setTrailingSpace(false); + } } void MessageLayoutContainer::addElement(MessageLayoutElement *element) { - bool isZeroWidth = - element->getFlags().has(MessageElementFlag::ZeroWidthEmote); - - if (!isZeroWidth && !this->fitsInLine(element->getRect().width())) + if (!this->fitsInLine(element->getRect().width())) { this->breakLine(); } - this->_addElement(element); + this->addElement(element, false, -2); } void MessageLayoutContainer::addElementNoLineBreak( MessageLayoutElement *element) { - this->_addElement(element); -} - -bool MessageLayoutContainer::canAddElements() const -{ - return this->canAddMessages_; + this->addElement(element, false, -2); } -void MessageLayoutContainer::_addElement(MessageLayoutElement *element, - bool forceAdd, int prevIndex) +void MessageLayoutContainer::breakLine() { - if (!this->canAddElements() && !forceAdd) + if (this->containsRTL) { - delete element; - return; - } - - bool isRTLMode = this->first == FirstWord::RTL && prevIndex != -2; - bool isAddingMode = prevIndex == -2; - - // This lambda contains the logic for when to step one 'space width' back for compact x emotes - auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool { - if (prevIndex == -1 || this->elements_.empty()) + for (int i = 0; i < this->elements_.size(); i++) { - // No previous element found - return false; + if (this->elements_[i]->getFlags().has( + MessageElementFlag::Username)) + { + this->reorderRTL(i + 1); + break; + } } + } - const auto &lastElement = prevIndex == -2 ? this->elements_.back() - : this->elements_[prevIndex]; + int xOffset = 0; - if (!lastElement) - { - return false; - } + if (this->flags_.has(MessageFlag::Centered) && this->elements_.size() > 0) + { + const int marginOffset = int(MARGIN.left() * this->scale_) + + int(MARGIN.right() * this->scale_); + xOffset = (width_ - marginOffset - + this->elements_.at(this->elements_.size() - 1) + ->getRect() + .right()) / + 2; + } - if (!lastElement->hasTrailingSpace()) - { - // Last element did not have a trailing space, so we don't need to do anything. - return false; - } + for (size_t i = lineStart_; i < this->elements_.size(); i++) + { + MessageLayoutElement *element = this->elements_.at(i).get(); - if (lastElement->getLine() != this->line_) + bool isCompactEmote = + !this->flags_.has(MessageFlag::DisableCompactEmotes) && + element->getCreator().getFlags().has( + MessageElementFlag::EmoteImages); + + int yExtra = 0; + if (isCompactEmote) { - // Last element was not on the same line as us - return false; + yExtra = (COMPACT_EMOTES_OFFSET / 2) * this->scale_; } - // Returns true if the last element was an emote image - return lastElement->getFlags().has(MessageElementFlag::EmoteImages); - }; - - if (element->getText().isRightToLeft()) - { - this->containsRTL = true; + element->setPosition( + QPoint(element->getRect().x() + xOffset + + int(MARGIN.left() * this->scale_), + element->getRect().y() + this->lineHeight_ + yExtra)); } - // check the first non-neutral word to see if we should render RTL or LTR - if (isAddingMode && this->first == FirstWord::Neutral && - element->getFlags().has(MessageElementFlag::Text) && - !element->getFlags().has(MessageElementFlag::RepliedMessage)) + if (!this->lines_.empty()) { - if (element->getText().isRightToLeft()) - { - this->first = FirstWord::RTL; - } - else if (!isNeutral(element->getText())) - { - this->first = FirstWord::LTR; - } + this->lines_.back().endIndex = this->lineStart_; + this->lines_.back().endCharIndex = this->charIndex_; } + this->lines_.push_back({ + .startIndex = lineStart_, + .endIndex = 0, + .startCharIndex = this->charIndex_, + .endCharIndex = 0, + .rect = QRect(-100000, this->currentY_, 200000, lineHeight_), + }); - // top margin - if (this->elements_.size() == 0) + for (auto i = this->lineStart_; i < this->elements_.size(); i++) { - this->currentY_ = int(this->margin.top * this->scale_); + this->charIndex_ += this->elements_[i]->getSelectionIndexCount(); } - int elementLineHeight = element->getRect().height(); - - // compact emote offset - bool isCompactEmote = - !this->flags_.has(MessageFlag::DisableCompactEmotes) && - element->getCreator().getFlags().has(MessageElementFlag::EmoteImages); + this->lineStart_ = this->elements_.size(); + // this->currentX = (int)(this->scale * 8); - if (isCompactEmote) + if (this->canCollapse() && this->line_ + 1 >= MAX_UNCOLLAPSED_LINES) { - elementLineHeight -= COMPACT_EMOTES_OFFSET * this->scale_; + this->canAddMessages_ = false; + return; } - // update line height - this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight); + this->currentX_ = 0; + this->currentY_ += this->lineHeight_; + this->height_ = this->currentY_ + int(MARGIN.bottom() * this->scale_); + this->lineHeight_ = 0; + this->line_++; +} - auto xOffset = 0; - bool isZeroWidthEmote = element->getCreator().getFlags().has( - MessageElementFlag::ZeroWidthEmote); +void MessageLayoutContainer::paintElements(QPainter &painter, + const MessagePaintContext &ctx) const +{ +#ifdef FOURTF + static constexpr std::array lineColors{ + QColor{255, 0, 0, 60}, // RED + QColor{0, 255, 0, 60}, // GREEN + QColor{0, 0, 255, 60}, // BLUE + QColor{255, 0, 255, 60}, // PINk + QColor{0, 255, 255, 60}, // CYAN + }; - if (isZeroWidthEmote && !isRTLMode) + int lineNum = 0; + for (const auto &line : this->lines_) { - xOffset -= element->getRect().width() + this->spaceWidth_; + const auto &color = lineColors[lineNum++ % 5]; + painter.fillRect(line.rect, color); } +#endif - auto yOffset = 0; - - if (element->getCreator().getFlags().has( - MessageElementFlag::ChannelPointReward) && - element->getCreator().getFlags().hasNone( - {MessageElementFlag::TwitchEmoteImage})) + for (const auto &element : this->elements_) { - yOffset -= (this->margin.top * this->scale_); +#ifdef FOURTF + painter.setPen(QColor(0, 255, 0)); + painter.drawRect(element->getRect()); +#endif + + element->paint(painter, ctx.messageColors); } +} - if (getSettings()->removeSpacesBetweenEmotes && - element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && - !isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes()) +bool MessageLayoutContainer::paintAnimatedElements(QPainter &painter, + int yOffset) const +{ + bool anyAnimatedElement = false; + for (const auto &element : this->elements_) { - // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote - if (isRTLMode) - { - this->currentX_ += this->spaceWidth_; - } - else - { - this->currentX_ -= this->spaceWidth_; - } + anyAnimatedElement |= element->paintAnimated(painter, yOffset); } + return anyAnimatedElement; +} - if (isRTLMode) +void MessageLayoutContainer::paintSelection(QPainter &painter, + const size_t messageIndex, + const Selection &selection, + const int yOffset) const +{ + if (selection.selectionMin.messageIndex > messageIndex || + selection.selectionMax.messageIndex < messageIndex) { - // shift by width since we are calculating according to top right in RTL mode - // but setPosition wants top left - xOffset -= element->getRect().width(); + // This message is not part of the selection, don't draw anything + return; } - // set move element - element->setPosition( - QPoint(this->currentX_ + xOffset, - this->currentY_ - element->getRect().height() + yOffset)); - - element->setLine(this->line_); + const auto selectionColor = getTheme()->messages.selection; - // add element - if (isAddingMode) + if (selection.selectionMin.messageIndex < messageIndex && + selection.selectionMax.messageIndex > messageIndex) { - this->elements_.push_back( - std::unique_ptr(element)); - } + // The selection fully covers this message + // Paint all lines completely - // set current x - if (!isZeroWidthEmote) - { - if (isRTLMode) + for (const Line &line : this->lines_) { - this->currentX_ -= element->getRect().width(); - } - else - { - this->currentX_ += element->getRect().width(); + // Fully paint a selection rectangle over all lines + auto left = this->elements_[line.startIndex]->getRect().left(); + auto right = this->elements_[line.endIndex - 1]->getRect().right(); + this->paintSelectionRect(painter, line, left, right, yOffset, + selectionColor); } + + return; } - if (element->hasTrailingSpace()) + size_t lineIndex = 0; + + if (selection.selectionMin.messageIndex == messageIndex) { - if (isRTLMode) - { - this->currentX_ -= this->spaceWidth_; - } - else + auto oLineIndex = this->paintSelectionStart(painter, messageIndex, + selection, yOffset); + + if (!oLineIndex) { - this->currentX_ += this->spaceWidth_; + // There's no more selection to be drawn in this message + return; } + + // There's further selection to be painted in this message + lineIndex = *oLineIndex; } + + // Paint the selection starting at lineIndex + this->paintSelectionEnd(painter, lineIndex, selection, yOffset); } -void MessageLayoutContainer::reorderRTL(int firstTextIndex) +void MessageLayoutContainer::addSelectionText(QString &str, uint32_t from, + uint32_t to, + CopyMode copymode) const { - if (this->elements_.empty()) - { - return; - } - - int startIndex = static_cast(this->lineStart_); - int endIndex = static_cast(this->elements_.size()) - 1; + uint32_t index = 0; + bool first = true; - if (firstTextIndex >= endIndex || startIndex >= this->elements_.size()) + for (const auto &element : this->elements_) { - return; - } - startIndex = std::max(startIndex, firstTextIndex); - - std::vector correctSequence; - std::stack swappedSequence; - - // we reverse a sequence of words if it's opposite to the text direction - // the second condition below covers the possible three cases: - // 1 - if we are in RTL mode (first non-neutral word is RTL) - // we render RTL, reversing LTR sequences, - // 2 - if we are in LTR mode (first non-neutral word is LTR or all words are neutral) - // we render LTR, reversing RTL sequences - // 3 - neutral words follow previous words, we reverse a neutral word when the previous word was reversed + if (copymode != CopyMode::Everything && + element->getCreator().getFlags().has( + MessageElementFlag::RepliedMessage)) + { + // Don't include the message being replied to + continue; + } - // the first condition checks if a neutral word is treated as a RTL word - // this is used later to add U+202B (RTL embedding) character signal to - // fix punctuation marks and mixing embedding LTR in an RTL word - // this can happen in two cases: - // 1 - in RTL mode, the previous word should be RTL (i.e. not reversed) - // 2 - in LTR mode, the previous word should be RTL (i.e. reversed) - for (int i = startIndex; i <= endIndex; i++) - { - if (isNeutral(this->elements_[i]->getText()) && - ((this->first == FirstWord::RTL && !this->wasPrevReversed_) || - (this->first == FirstWord::LTR && this->wasPrevReversed_))) + if (copymode == CopyMode::OnlyTextAndEmotes) { - this->elements_[i]->reversedNeutral = true; + if (element->getCreator().getFlags().hasAny({ + MessageElementFlag::Timestamp, + MessageElementFlag::Username, + MessageElementFlag::Badges, + MessageElementFlag::ChannelName, + })) + { + continue; + } } - if (((this->elements_[i]->getText().isRightToLeft() != - (this->first == FirstWord::RTL)) && - !isNeutral(this->elements_[i]->getText())) || - (isNeutral(this->elements_[i]->getText()) && - this->wasPrevReversed_)) + + auto indexCount = element->getSelectionIndexCount(); + + if (first) { - swappedSequence.push(i); - this->wasPrevReversed_ = true; + if (index + indexCount > from) + { + element->addCopyTextToString(str, from - index, to - index); + first = false; + + if (index + indexCount >= to) + { + break; + } + } } else { - while (!swappedSequence.empty()) + if (index + indexCount >= to) { - correctSequence.push_back(swappedSequence.top()); - swappedSequence.pop(); + element->addCopyTextToString(str, 0, to - index); + break; } - correctSequence.push_back(i); - this->wasPrevReversed_ = false; + + element->addCopyTextToString(str); } + + index += indexCount; } - while (!swappedSequence.empty()) +} + +MessageLayoutElement *MessageLayoutContainer::getElementAt(QPoint point) const +{ + for (const auto &element : this->elements_) { - correctSequence.push_back(swappedSequence.top()); - swappedSequence.pop(); + if (element->getRect().contains(point)) + { + return element.get(); + } } - // render right to left if we are in RTL mode, otherwise LTR - if (this->first == FirstWord::RTL) + return nullptr; +} + +size_t MessageLayoutContainer::getSelectionIndex(QPoint point) const +{ + if (this->elements_.empty()) { - this->currentX_ = this->elements_[endIndex]->getRect().right(); + return 0; } - else + + auto line = this->lines_.begin(); + + for (; line != this->lines_.end(); line++) { - this->currentX_ = this->elements_[startIndex]->getRect().left(); + if (line->rect.contains(point)) + { + break; + } } - // manually do the first call with -1 as previous index - if (this->canAddElements()) + + auto lineStart = line == this->lines_.end() ? this->lines_.back().startIndex + : line->startIndex; + if (line != this->lines_.end()) { - this->_addElement(this->elements_[correctSequence[0]].get(), false, -1); + line++; } + auto lineEnd = + line == this->lines_.end() ? this->elements_.size() : line->startIndex; - for (int i = 1; i < correctSequence.size() && this->canAddElements(); i++) + size_t index = 0; + + for (auto i = 0; i < lineEnd; i++) { - this->_addElement(this->elements_[correctSequence[i]].get(), false, - correctSequence[i - 1]); + auto &&element = this->elements_[i]; + + // end of line + if (i == lineEnd) + { + break; + } + + // before line + if (i < lineStart) + { + index += element->getSelectionIndexCount(); + continue; + } + + // this is the word + auto rightMargin = element->hasTrailingSpace() ? this->spaceWidth_ : 0; + + if (point.x() <= element->getRect().right() + rightMargin) + { + index += element->getMouseOverIndex(point); + break; + } + + index += element->getSelectionIndexCount(); } + + return index; } -void MessageLayoutContainer::breakLine() +size_t MessageLayoutContainer::getFirstMessageCharacterIndex() const { - if (this->containsRTL) + static const FlagsEnum skippedFlags{ + MessageElementFlag::RepliedMessage, MessageElementFlag::Timestamp, + MessageElementFlag::ModeratorTools, MessageElementFlag::Badges, + MessageElementFlag::Username, + }; + + // Get the index of the first character of the real message + size_t index = 0; + for (const auto &element : this->elements_) { - for (int i = 0; i < this->elements_.size(); i++) + if (element->getFlags().hasAny(skippedFlags)) { - if (this->elements_[i]->getFlags().has( - MessageElementFlag::Username)) - { - this->reorderRTL(i + 1); - break; - } + index += element->getSelectionIndexCount(); + } + else + { + break; } } + return index; +} - int xOffset = 0; - - if (this->flags_.has(MessageFlag::Centered) && this->elements_.size() > 0) +size_t MessageLayoutContainer::getLastCharacterIndex() const +{ + if (this->lines_.empty()) { - const int marginOffset = int(this->margin.left * this->scale_) + - int(this->margin.right * this->scale_); - xOffset = (width_ - marginOffset - - this->elements_.at(this->elements_.size() - 1) - ->getRect() - .right()) / - 2; + return 0; } - for (size_t i = lineStart_; i < this->elements_.size(); i++) + return this->lines_.back().endCharIndex; +} + +int MessageLayoutContainer::getWidth() const +{ + return this->width_; +} + +int MessageLayoutContainer::getHeight() const +{ + return this->height_; +} + +float MessageLayoutContainer::getScale() const +{ + return this->scale_; +} + +bool MessageLayoutContainer::isCollapsed() const +{ + return this->isCollapsed_; +} + +bool MessageLayoutContainer::atStartOfLine() const +{ + return this->lineStart_ == this->elements_.size(); +} + +bool MessageLayoutContainer::fitsInLine(int width) const +{ + return width <= this->remainingWidth(); +} + +int MessageLayoutContainer::remainingWidth() const +{ + return (this->width_ - int(MARGIN.left() * this->scale_) - + int(MARGIN.right() * this->scale_) - + (this->line_ + 1 == MAX_UNCOLLAPSED_LINES ? this->dotdotdotWidth_ + : 0)) - + this->currentX_; +} + +void MessageLayoutContainer::addElement(MessageLayoutElement *element, + const bool forceAdd, + const int prevIndex) +{ + if (!this->canAddElements() && !forceAdd) { - MessageLayoutElement *element = this->elements_.at(i).get(); + delete element; + return; + } - bool isCompactEmote = - !this->flags_.has(MessageFlag::DisableCompactEmotes) && - element->getCreator().getFlags().has( - MessageElementFlag::EmoteImages); + bool isRTLMode = this->first == FirstWord::RTL && prevIndex != -2; + bool isAddingMode = prevIndex == -2; - int yExtra = 0; - if (isCompactEmote) + // This lambda contains the logic for when to step one 'space width' back for compact x emotes + auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool { + if (prevIndex == -1 || this->elements_.empty()) { - yExtra = (COMPACT_EMOTES_OFFSET / 2) * this->scale_; + // No previous element found + return false; } - element->setPosition( - QPoint(element->getRect().x() + xOffset + - int(this->margin.left * this->scale_), - element->getRect().y() + this->lineHeight_ + yExtra)); + const auto &lastElement = prevIndex == -2 ? this->elements_.back() + : this->elements_[prevIndex]; + + if (!lastElement) + { + return false; + } + + if (!lastElement->hasTrailingSpace()) + { + // Last element did not have a trailing space, so we don't need to do anything. + return false; + } + + if (lastElement->getLine() != this->line_) + { + // Last element was not on the same line as us + return false; + } + + // Returns true if the last element was an emote image + return lastElement->getFlags().has(MessageElementFlag::EmoteImages); + }; + + if (element->getText().isRightToLeft()) + { + this->containsRTL = true; } - if (this->lines_.size() != 0) + // check the first non-neutral word to see if we should render RTL or LTR + if (isAddingMode && this->first == FirstWord::Neutral && + element->getFlags().has(MessageElementFlag::Text) && + !element->getFlags().has(MessageElementFlag::RepliedMessage)) { - this->lines_.back().endIndex = this->lineStart_; - this->lines_.back().endCharIndex = this->charIndex_; + if (element->getText().isRightToLeft()) + { + this->first = FirstWord::RTL; + } + else if (!isNeutral(element->getText())) + { + this->first = FirstWord::LTR; + } } - this->lines_.push_back( - {(int)lineStart_, 0, this->charIndex_, 0, - QRect(-100000, this->currentY_, 200000, lineHeight_)}); - for (int i = this->lineStart_; i < this->elements_.size(); i++) + // top margin + if (this->elements_.empty()) { - this->charIndex_ += this->elements_[i]->getSelectionIndexCount(); + this->currentY_ = int(MARGIN.top() * this->scale_); } - this->lineStart_ = this->elements_.size(); - // this->currentX = (int)(this->scale * 8); + int elementLineHeight = element->getRect().height(); - if (this->canCollapse() && line_ + 1 >= MAX_UNCOLLAPSED_LINES) + // compact emote offset + bool isCompactEmote = + !this->flags_.has(MessageFlag::DisableCompactEmotes) && + element->getCreator().getFlags().has(MessageElementFlag::EmoteImages); + + if (isCompactEmote) { - this->canAddMessages_ = false; - return; + elementLineHeight -= COMPACT_EMOTES_OFFSET * this->scale_; } - this->currentX_ = 0; - this->currentY_ += this->lineHeight_; - this->height_ = this->currentY_ + int(this->margin.bottom * this->scale_); - this->lineHeight_ = 0; - this->line_++; -} + // update line height + this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight); -bool MessageLayoutContainer::atStartOfLine() -{ - return this->lineStart_ == this->elements_.size(); + auto xOffset = 0; + auto yOffset = 0; + + if (element->getCreator().getFlags().has( + MessageElementFlag::ChannelPointReward) && + element->getCreator().getFlags().hasNone( + {MessageElementFlag::TwitchEmoteImage})) + { + yOffset -= (MARGIN.top() * this->scale_); + } + + if (getSettings()->removeSpacesBetweenEmotes && + element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && + shouldRemoveSpaceBetweenEmotes()) + { + // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote + if (isRTLMode) + { + this->currentX_ += this->spaceWidth_; + } + else + { + this->currentX_ -= this->spaceWidth_; + } + } + + if (isRTLMode) + { + // shift by width since we are calculating according to top right in RTL mode + // but setPosition wants top left + xOffset -= element->getRect().width(); + } + + // set move element + element->setPosition( + QPoint(this->currentX_ + xOffset, + this->currentY_ - element->getRect().height() + yOffset)); + + element->setLine(this->line_); + + // add element + if (isAddingMode) + { + this->elements_.push_back( + std::unique_ptr(element)); + } + + // set current x + if (isRTLMode) + { + this->currentX_ -= element->getRect().width(); + } + else + { + this->currentX_ += element->getRect().width(); + } + + if (element->hasTrailingSpace()) + { + if (isRTLMode) + { + this->currentX_ -= this->spaceWidth_; + } + else + { + this->currentX_ += this->spaceWidth_; + } + } } -bool MessageLayoutContainer::fitsInLine(int _width) +void MessageLayoutContainer::reorderRTL(int firstTextIndex) { - return this->currentX_ + _width <= - (this->width_ - int(this->margin.left * this->scale_) - - int(this->margin.right * this->scale_) - - (this->line_ + 1 == MAX_UNCOLLAPSED_LINES ? this->dotdotdotWidth_ - : 0)); -} + if (this->elements_.empty()) + { + return; + } + + int startIndex = static_cast(this->lineStart_); + int endIndex = static_cast(this->elements_.size()) - 1; + + if (firstTextIndex >= endIndex || startIndex >= this->elements_.size()) + { + return; + } + startIndex = std::max(startIndex, firstTextIndex); + + std::vector correctSequence; + std::stack swappedSequence; + + // we reverse a sequence of words if it's opposite to the text direction + // the second condition below covers the possible three cases: + // 1 - if we are in RTL mode (first non-neutral word is RTL) + // we render RTL, reversing LTR sequences, + // 2 - if we are in LTR mode (first non-neutral word is LTR or all words are neutral) + // we render LTR, reversing RTL sequences + // 3 - neutral words follow previous words, we reverse a neutral word when the previous word was reversed -void MessageLayoutContainer::end() -{ - if (!this->canAddElements()) + // the first condition checks if a neutral word is treated as a RTL word + // this is used later to add U+202B (RTL embedding) character signal to + // fix punctuation marks and mixing embedding LTR in an RTL word + // this can happen in two cases: + // 1 - in RTL mode, the previous word should be RTL (i.e. not reversed) + // 2 - in LTR mode, the previous word should be RTL (i.e. reversed) + for (int i = startIndex; i <= endIndex; i++) { - static TextElement dotdotdot("...", MessageElementFlag::Collapsed, - MessageColor::Link); - static QString dotdotdotText("..."); + auto &element = this->elements_[i]; - auto *element = new TextLayoutElement( - dotdotdot, dotdotdotText, - QSize(this->dotdotdotWidth_, this->textLineHeight_), - QColor("#00D80A"), FontStyle::ChatMediumBold, this->scale_); + const auto neutral = isNeutral(element->getText()); + const auto neutralOrUsername = + neutral || + element->getFlags().hasAny({MessageElementFlag::BoldUsername, + MessageElementFlag::NonBoldUsername}); - if (this->first == FirstWord::RTL) + if (neutral && + ((this->first == FirstWord::RTL && !this->wasPrevReversed_) || + (this->first == FirstWord::LTR && this->wasPrevReversed_))) { - // Shift all elements in the next line to the left - for (int i = this->lines_.back().startIndex; - i < this->elements_.size(); i++) + element->reversedNeutral = true; + } + if (((element->getText().isRightToLeft() != + (this->first == FirstWord::RTL)) && + !neutralOrUsername) || + (neutralOrUsername && this->wasPrevReversed_)) + { + swappedSequence.push(i); + this->wasPrevReversed_ = true; + } + else + { + while (!swappedSequence.empty()) { - QPoint prevPos = this->elements_[i]->getRect().topLeft(); - this->elements_[i]->setPosition( - QPoint(prevPos.x() + this->dotdotdotWidth_, prevPos.y())); + correctSequence.push_back(swappedSequence.top()); + swappedSequence.pop(); } + correctSequence.push_back(i); + this->wasPrevReversed_ = false; } - this->_addElement(element, true); - this->isCollapsed_ = true; } - - if (!this->atStartOfLine()) + while (!swappedSequence.empty()) { - this->breakLine(); + correctSequence.push_back(swappedSequence.top()); + swappedSequence.pop(); } - this->height_ += this->lineHeight_; - - if (this->lines_.size() != 0) + // render right to left if we are in RTL mode, otherwise LTR + if (this->first == FirstWord::RTL) { - this->lines_[0].rect.setTop(-100000); - this->lines_.back().rect.setBottom(100000); - this->lines_.back().endIndex = this->elements_.size(); - this->lines_.back().endCharIndex = this->charIndex_; + this->currentX_ = this->elements_[endIndex]->getRect().right(); } -} - -bool MessageLayoutContainer::canCollapse() -{ - return getSettings()->collpseMessagesMinLines.getValue() > 0 && - this->flags_.has(MessageFlag::Collapsed); -} - -bool MessageLayoutContainer::isCollapsed() -{ - return this->isCollapsed_; -} - -MessageLayoutElement *MessageLayoutContainer::getElementAt(QPoint point) -{ - for (std::unique_ptr &element : this->elements_) + else { - if (element->getRect().contains(point)) - { - return element.get(); - } + this->currentX_ = this->elements_[startIndex]->getRect().left(); } - - return nullptr; -} - -// painting -void MessageLayoutContainer::paintElements(QPainter &painter) -{ - for (const std::unique_ptr &element : this->elements_) + // manually do the first call with -1 as previous index + if (this->canAddElements()) { -#ifdef FOURTF - painter.setPen(QColor(0, 255, 0)); - painter.drawRect(element->getRect()); -#endif + this->addElement(this->elements_[correctSequence[0]].get(), false, -1); + } - element->paint(painter); + for (int i = 1; i < correctSequence.size() && this->canAddElements(); i++) + { + this->addElement(this->elements_[correctSequence[i]].get(), false, + correctSequence[i - 1]); } } -void MessageLayoutContainer::paintAnimatedElements(QPainter &painter, - int yOffset) +void MessageLayoutContainer::paintSelectionRect(QPainter &painter, + const Line &line, + const int left, const int right, + const int yOffset, + const QColor &color) const { - for (const std::unique_ptr &element : this->elements_) - { - element->paintAnimated(painter, yOffset); - } + QRect rect = line.rect; + + rect.setTop(std::max(0, rect.top()) + yOffset); + rect.setBottom(std::min(this->height_, rect.bottom()) + yOffset); + rect.setLeft(left); + rect.setRight(right); + + painter.fillRect(rect, color); } -void MessageLayoutContainer::paintSelection(QPainter &painter, int messageIndex, - Selection &selection, int yOffset) +std::optional MessageLayoutContainer::paintSelectionStart( + QPainter &painter, const size_t messageIndex, const Selection &selection, + const int yOffset) const { - auto app = getApp(); - QColor selectionColor = app->themes->messages.selection; + const auto selectionColor = getTheme()->messages.selection; - // don't draw anything - if (selection.selectionMin.messageIndex > messageIndex || - selection.selectionMax.messageIndex < messageIndex) + // The selection starts in this message + for (size_t lineIndex = 0; lineIndex < this->lines_.size(); lineIndex++) { - return; - } + const Line &line = this->lines_[lineIndex]; - // fully selected - if (selection.selectionMin.messageIndex < messageIndex && - selection.selectionMax.messageIndex > messageIndex) - { - for (Line &line : this->lines_) + // Selection doesn't start in this line + if (selection.selectionMin.charIndex >= line.endCharIndex) { - QRect rect = line.rect; - - rect.setTop(std::max(0, rect.top()) + yOffset); - rect.setBottom(std::min(this->height_, rect.bottom()) + yOffset); - rect.setLeft(this->elements_[line.startIndex]->getRect().left()); - rect.setRight( - this->elements_[line.endIndex - 1]->getRect().right()); - - painter.fillRect(rect, selectionColor); + continue; } - return; - } - int lineIndex = 0; - int index = 0; - - // start in this message - if (selection.selectionMin.messageIndex == messageIndex) - { - for (; lineIndex < this->lines_.size(); lineIndex++) + if (selection.selectionMin.charIndex == line.endCharIndex - 1) { - Line &line = this->lines_[lineIndex]; - index = line.startCharIndex; + // Selection starts at the trailing newline + // NOTE: Should this be included in the selection? Right now this is + // painted since it's included in the copy action, but if it's trimmed we + // should stop painting this + auto right = this->elements_[line.endIndex - 1]->getRect().right(); + this->paintSelectionRect(painter, line, right, right, yOffset, + selectionColor); + return std::nullopt; + } - bool returnAfter = false; - bool breakAfter = false; - int x = this->elements_[line.startIndex]->getRect().left(); - int r = this->elements_[line.endIndex - 1]->getRect().right(); + int x = this->elements_[line.startIndex]->getRect().left(); + int r = this->elements_[line.endIndex - 1]->getRect().right(); - if (line.endCharIndex <= selection.selectionMin.charIndex) + auto index = line.startCharIndex; + for (auto i = line.startIndex; i < line.endIndex; i++) + { + auto indexCount = this->elements_[i]->getSelectionIndexCount(); + if (index + indexCount <= selection.selectionMin.charIndex) { + index += indexCount; continue; } - for (int i = line.startIndex; i < line.endIndex; i++) - { - int c = this->elements_[i]->getSelectionIndexCount(); + x = this->elements_[i]->getXFromIndex( + selection.selectionMin.charIndex - index); - if (index + c > selection.selectionMin.charIndex) + if (selection.selectionMax.messageIndex == messageIndex && + selection.selectionMax.charIndex < line.endCharIndex) + { + // The selection ends in the same line it started + index = line.startCharIndex; + for (auto elementIdx = line.startIndex; + elementIdx < line.endIndex; elementIdx++) { - x = this->elements_[i]->getXFromIndex( - selection.selectionMin.charIndex - index); + auto c = + this->elements_[elementIdx]->getSelectionIndexCount(); - // ends in same line - if (selection.selectionMax.messageIndex == messageIndex && - line.endCharIndex > - /*=*/selection.selectionMax.charIndex) + if (index + c > selection.selectionMax.charIndex) { - returnAfter = true; - index = line.startCharIndex; - for (int i = line.startIndex; i < line.endIndex; i++) - { - int c = - this->elements_[i]->getSelectionIndexCount(); - - if (index + c > selection.selectionMax.charIndex) - { - r = this->elements_[i]->getXFromIndex( - selection.selectionMax.charIndex - index); - break; - } - index += c; - } + r = this->elements_[elementIdx]->getXFromIndex( + selection.selectionMax.charIndex - index); + break; } - // ends in same line end + index += c; + } - if (selection.selectionMax.messageIndex != messageIndex) - { - int lineIndex2 = lineIndex + 1; - for (; lineIndex2 < this->lines_.size(); lineIndex2++) - { - Line &line2 = this->lines_[lineIndex2]; - QRect rect = line2.rect; - - rect.setTop(std::max(0, rect.top()) + yOffset); - rect.setBottom( - std::min(this->height_, rect.bottom()) + - yOffset); - rect.setLeft(this->elements_[line2.startIndex] - ->getRect() - .left()); - rect.setRight(this->elements_[line2.endIndex - 1] - ->getRect() - .right()); - - painter.fillRect(rect, selectionColor); - } - returnAfter = true; - } - else - { - lineIndex++; - breakAfter = true; - } + this->paintSelectionRect(painter, line, x, r, yOffset, + selectionColor); - break; - } - index += c; + return std::nullopt; } - QRect rect = line.rect; - - rect.setTop(std::max(0, rect.top()) + yOffset); - rect.setBottom(std::min(this->height_, rect.bottom()) + yOffset); - rect.setLeft(x); - rect.setRight(r); + // doesn't end in this message -> paint the following lines of this message + if (selection.selectionMax.messageIndex != messageIndex) + { + // The selection does not end in this message + for (size_t lineIndex2 = lineIndex + 1; + lineIndex2 < this->lines_.size(); lineIndex2++) + { + const auto &line2 = this->lines_[lineIndex2]; + auto left = + this->elements_[line2.startIndex]->getRect().left(); + auto right = + this->elements_[line2.endIndex - 1]->getRect().right(); + + this->paintSelectionRect(painter, line2, left, right, + yOffset, selectionColor); + } - painter.fillRect(rect, selectionColor); + this->paintSelectionRect(painter, line, x, r, yOffset, + selectionColor); - if (returnAfter) - { - return; + return std::nullopt; } - if (breakAfter) - { - break; - } + // The selection starts in this line, but ends in some next line or message + this->paintSelectionRect(painter, line, x, r, yOffset, + selectionColor); + + return {++lineIndex}; } } - // start in this message + return std::nullopt; +} + +void MessageLayoutContainer::paintSelectionEnd(QPainter &painter, + size_t lineIndex, + const Selection &selection, + const int yOffset) const +{ + const auto selectionColor = getTheme()->messages.selection; + // [2] selection contains or ends in this message (starts before our message or line) for (; lineIndex < this->lines_.size(); lineIndex++) { - Line &line = this->lines_[lineIndex]; - index = line.startCharIndex; + const Line &line = this->lines_[lineIndex]; + size_t index = line.startCharIndex; - // just draw the garbage - if (line.endCharIndex < /*=*/selection.selectionMax.charIndex) + // the whole line is included + if (line.endCharIndex < selection.selectionMax.charIndex) { - QRect rect = line.rect; - - rect.setTop(std::max(0, rect.top()) + yOffset); - rect.setBottom(std::min(this->height_, rect.bottom()) + yOffset); - rect.setLeft(this->elements_[line.startIndex]->getRect().left()); - rect.setRight( - this->elements_[line.endIndex - 1]->getRect().right()); - - painter.fillRect(rect, selectionColor); + auto left = this->elements_[line.startIndex]->getRect().left(); + auto right = this->elements_[line.endIndex - 1]->getRect().right(); + this->paintSelectionRect(painter, line, left, right, yOffset, + selectionColor); continue; } + // find the right end of the selection int r = this->elements_[line.endIndex - 1]->getRect().right(); - for (int i = line.startIndex; i < line.endIndex; i++) + for (auto i = line.startIndex; i < line.endIndex; i++) { - int c = this->elements_[i]->getSelectionIndexCount(); + size_t c = this->elements_[i]->getSelectionIndexCount(); if (index + c > selection.selectionMax.charIndex) { @@ -705,169 +917,23 @@ void MessageLayoutContainer::paintSelection(QPainter &painter, int messageIndex, index += c; } - QRect rect = line.rect; - - rect.setTop(std::max(0, rect.top()) + yOffset); - rect.setBottom(std::min(this->height_, rect.bottom()) + yOffset); - rect.setLeft(this->elements_[line.startIndex]->getRect().left()); - rect.setRight(r); - - painter.fillRect(rect, selectionColor); - break; - } -} - -// selection -int MessageLayoutContainer::getSelectionIndex(QPoint point) -{ - if (this->elements_.size() == 0) - { - return 0; - } - - auto line = this->lines_.begin(); - - for (; line != this->lines_.end(); line++) - { - if (line->rect.contains(point)) - { - break; - } - } - - int lineStart = line == this->lines_.end() ? this->lines_.back().startIndex - : line->startIndex; - if (line != this->lines_.end()) - { - line++; - } - int lineEnd = - line == this->lines_.end() ? this->elements_.size() : line->startIndex; - - int index = 0; - - for (int i = 0; i < lineEnd; i++) - { - auto &&element = this->elements_[i]; - - // end of line - if (i == lineEnd) - { - break; - } - - // before line - if (i < lineStart) - { - index += element->getSelectionIndexCount(); - continue; - } - - // this is the word - auto rightMargin = element->hasTrailingSpace() ? this->spaceWidth_ : 0; - - if (point.x() <= element->getRect().right() + rightMargin) - { - index += element->getMouseOverIndex(point); - break; - } - - index += element->getSelectionIndexCount(); - } - - return index; -} + auto left = this->elements_[line.startIndex]->getRect().left(); + this->paintSelectionRect(painter, line, left, r, yOffset, + selectionColor); -// fourtf: no idea if this is acurate LOL -int MessageLayoutContainer::getLastCharacterIndex() const -{ - if (this->lines_.size() == 0) - { - return 0; + return; } - return this->lines_.back().endCharIndex; } -int MessageLayoutContainer::getFirstMessageCharacterIndex() const +bool MessageLayoutContainer::canAddElements() const { - static FlagsEnum skippedFlags; - skippedFlags.set(MessageElementFlag::RepliedMessage); - skippedFlags.set(MessageElementFlag::Timestamp); - skippedFlags.set(MessageElementFlag::Badges); - skippedFlags.set(MessageElementFlag::Username); - - // Get the index of the first character of the real message - int index = 0; - for (auto &element : this->elements_) - { - if (element->getFlags().hasAny(skippedFlags)) - { - index += element->getSelectionIndexCount(); - } - else - { - break; - } - } - return index; + return this->canAddMessages_; } -void MessageLayoutContainer::addSelectionText(QString &str, uint32_t from, - uint32_t to, CopyMode copymode) +bool MessageLayoutContainer::canCollapse() const { - uint32_t index = 0; - bool first = true; - - for (auto &element : this->elements_) - { - if (copymode != CopyMode::Everything && - element->getCreator().getFlags().has( - MessageElementFlag::RepliedMessage)) - { - // Don't include the message being replied to - continue; - } - - if (copymode == CopyMode::OnlyTextAndEmotes) - { - if (element->getCreator().getFlags().hasAny( - {MessageElementFlag::Timestamp, - MessageElementFlag::Username, MessageElementFlag::Badges})) - { - continue; - } - } - - auto indexCount = element->getSelectionIndexCount(); - - if (first) - { - if (index + indexCount > from) - { - element->addCopyTextToString(str, from - index, to - index); - first = false; - - if (index + indexCount > to) - { - break; - } - } - } - else - { - if (index + indexCount > to) - { - element->addCopyTextToString(str, 0, to - index); - break; - } - else - { - element->addCopyTextToString(str); - } - } - - index += indexCount; - } + return getSettings()->collpseMessagesMinLines.getValue() > 0 && + this->flags_.has(MessageFlag::Collapsed); } } // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index 41bb0d941c8..be765da85d4 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -7,6 +7,7 @@ #include #include +#include #include class QPainter; @@ -18,87 +19,176 @@ enum class FirstWord { Neutral, RTL, LTR }; using MessageFlags = FlagsEnum; class MessageLayoutElement; struct Selection; - -struct Margin { - int top; - int right; - int bottom; - int left; - - Margin() - : Margin(0) - { - } - - Margin(int value) - : Margin(value, value, value, value) - { - } - - Margin(int _top, int _right, int _bottom, int _left) - : top(_top) - , right(_right) - , bottom(_bottom) - , left(_left) - { - } -}; +struct MessagePaintContext; struct MessageLayoutContainer { MessageLayoutContainer() = default; FirstWord first = FirstWord::Neutral; - bool containsRTL = false; - int getHeight() const; - int getWidth() const; - float getScale() const; + /** + * Begin the layout process of this message + * + * This will reset all line calculations, and will be considered incomplete + * until the accompanying end function has been called + */ + void beginLayout(int width_, float scale_, MessageFlags flags_); - // methods - void begin(int width_, float scale_, MessageFlags flags_); - void end(); + /** + * Finish the layout process of this message + */ + void endLayout(); - void clear(); - bool canAddElements() const; + /** + * Add the given `element` to this message. + * + * This will also prepend a line break if the element + * does not fit in the current line + */ void addElement(MessageLayoutElement *element); + + /** + * Add the given `element` to this message + */ void addElementNoLineBreak(MessageLayoutElement *element); + + /** + * Break the current line + */ void breakLine(); - bool atStartOfLine(); - bool fitsInLine(int width_); - // this method is called when a message has an RTL word - // we need to reorder the words to be shown properly - // however we don't we to reorder non-text elements like badges, timestamps, username - // firstTextIndex is the index of the first text element that we need to start the reordering from - void reorderRTL(int firstTextIndex); - MessageLayoutElement *getElementAt(QPoint point); - - // painting - void paintElements(QPainter &painter); - void paintAnimatedElements(QPainter &painter, int yOffset); - void paintSelection(QPainter &painter, int messageIndex, - Selection &selection, int yOffset); - - // selection - int getSelectionIndex(QPoint point); - int getLastCharacterIndex() const; - int getFirstMessageCharacterIndex() const; + + /** + * Paint the elements in this message + */ + void paintElements(QPainter &painter, const MessagePaintContext &ctx) const; + + /** + * Paint the animated elements in this message + * @returns true if this container contains at least one animated element + */ + bool paintAnimatedElements(QPainter &painter, int yOffset) const; + + /** + * Paint the selection for this container + * This container contains one or more message elements + * + * @param painter The painter we draw everything to + * @param messageIndex This container's message index in the context of + * the layout we're being painted in + * @param selection The selection we need to paint + * @param yOffset The extra offset added to Y for everything that's painted + */ + void paintSelection(QPainter &painter, size_t messageIndex, + const Selection &selection, int yOffset) const; + + /** + * Add text from this message into the `str` parameter + * + * @param[out] str The string where we append our selected text to + * @param from The character index from which we collecting our selected text + * @param to The character index where we stop collecting our selected text + * @param copymode Decides what from the message gets added to the selected text + */ void addSelectionText(QString &str, uint32_t from, uint32_t to, - CopyMode copymode); + CopyMode copymode) const; + + /** + * Returns a raw pointer to the element at the given point + * + * If no element is found at the given point, this returns a null pointer + */ + MessageLayoutElement *getElementAt(QPoint point) const; - bool isCollapsed(); + /** + * Get the character index at the given point, in the context of selections + */ + size_t getSelectionIndex(QPoint point) const; + + /** + * Get the index of the first visible character in this message + * + * This can be non-zero if there are elements in this message that are skipped + */ + size_t getFirstMessageCharacterIndex() const; + + /** + * Get the index of the last character in this message + * This is the sum of all the characters in `elements_` + */ + size_t getLastCharacterIndex() const; + + /** + * Returns the width of this message + */ + int getWidth() const; + + /** + * Returns the height of this message + */ + int getHeight() const; + + /** + * Returns the scale of this message + */ + float getScale() const; + + /** + * Returns true if this message is collapsed + */ + bool isCollapsed() const; + + /** + * Return true if we are at the start of a new line + */ + bool atStartOfLine() const; + + /** + * Check whether an additional `width` would fit in the current line + * + * Returns true if it does fit, false if not + */ + bool fitsInLine(int width) const; + + /** + * Returns the remaining width of this line until we will need to start a new line + */ + int remainingWidth() const; private: struct Line { - int startIndex; - int endIndex; - int startCharIndex; - int endCharIndex; + /** + * The index of the first message element on this line + * Points into `elements_` + */ + size_t startIndex{}; + + /** + * The index of the last message element on this line + * Points into `elements_` + */ + size_t endIndex{}; + + /** + * In the context of selections, the index of the first character on this line + * The first line's startCharIndex will always be 0 + */ + size_t startCharIndex{}; + + /** + * In the context of selections, the index of the last character on this line + * The last line's startCharIndex will always be the sum of all characters in this message + */ + size_t endCharIndex{}; + + /** + * The rectangle that covers all elements on this line + * This rectangle will always take up 100% of the view's width + */ QRect rect; }; - // helpers /* - _addElement is called at two stages. first stage is the normal one where we want to add message layout elements to the container. + addElement is called at two stages. first stage is the normal one where we want to add message layout elements to the container. If we detect an RTL word in the message, reorderRTL will be called, which is the second stage, where we call _addElement again for each layout element, but in the correct order this time, without adding the elemnt to the this->element_ vector. Due to compact emote logic, we need the previous element to check if we should change the spacing or not. @@ -107,21 +197,76 @@ struct MessageLayoutContainer { In stage one we don't need that and we pass -2 to indicate stage one (i.e. adding mode) In stage two, we pass -1 for the first element, and the index of the oredered privous element for the rest. */ - void _addElement(MessageLayoutElement *element, bool forceAdd = false, - int prevIndex = -2); - bool canCollapse(); + void addElement(MessageLayoutElement *element, bool forceAdd, + int prevIndex); + + // this method is called when a message has an RTL word + // we need to reorder the words to be shown properly + // however we don't we to reorder non-text elements like badges, timestamps, username + // firstTextIndex is the index of the first text element that we need to start the reordering from + void reorderRTL(int firstTextIndex); + + /** + * Paint a selection rectangle over the given line + * + * @param painter The painter we draw everything to + * @param line The line whose rect we use as the base top & bottom of the rect to paint + * @param left The left coordinates of the rect to paint + * @param right The right coordinates of the rect to paint + * @param yOffset Extra offset for line's top & bottom + * @param color Color of the selection + **/ + void paintSelectionRect(QPainter &painter, const Line &line, int left, + int right, int yOffset, const QColor &color) const; + + /** + * Paint the selection start + * + * Returns a line index if this message should also paint the selection end + */ + std::optional paintSelectionStart(QPainter &painter, + size_t messageIndex, + const Selection &selection, + int yOffset) const; - const Margin margin = {4, 8, 4, 8}; + /** + * Paint the selection end + * + * @param lineIndex The index of the line to start painting at + */ + void paintSelectionEnd(QPainter &painter, size_t lineIndex, + const Selection &selection, int yOffset) const; + + /** + * canAddElements returns true if it's possible to add more elements to this message + */ + bool canAddElements() const; + + /** + * Return true if this message can collapse + * + * TODO: comment this better :-) + */ + bool canCollapse() const; // variables - float scale_ = 1.f; + float scale_ = 1.F; int width_ = 0; MessageFlags flags_{}; - int line_ = 0; + /** + * line_ is the current line index we are adding + * This is not the number of lines this message contains, since this will stop + * incrementing if the message is collapsed + */ + size_t line_{}; int height_ = 0; int currentX_ = 0; int currentY_ = 0; - int charIndex_ = 0; + /** + * charIndex_ is the selection-contexted index of where we currently are in our message + * At the end, this will always be equal to the sum of `elements_` getSelectionIndexCount() + */ + size_t charIndex_ = 0; size_t lineStart_ = 0; int lineHeight_ = 0; int spaceWidth_ = 4; @@ -131,7 +276,19 @@ struct MessageLayoutContainer { bool isCollapsed_ = false; bool wasPrevReversed_ = false; + /** + * containsRTL indicates whether or not any of the text in this message + * contains any right-to-left characters (e.g. arabic) + */ + bool containsRTL = false; + std::vector> elements_; + + /** + * A list of lines covering this message + * A message that spans 3 lines in a view will have 3 elements in lines_ + * These lines hold no relation to the elements that are in this + */ std::vector lines_; }; diff --git a/src/messages/layouts/MessageLayoutContext.cpp b/src/messages/layouts/MessageLayoutContext.cpp new file mode 100644 index 00000000000..98c963919d5 --- /dev/null +++ b/src/messages/layouts/MessageLayoutContext.cpp @@ -0,0 +1,88 @@ +#include "messages/layouts/MessageLayoutContext.hpp" + +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" + +namespace chatterino { + +void MessageColors::applyTheme(Theme *theme) +{ + this->regular = theme->messages.backgrounds.regular; + this->alternate = theme->messages.backgrounds.alternate; + + this->disabled = theme->messages.disabled; + this->selection = theme->messages.selection; + this->system = theme->messages.textColors.system; + + this->messageSeperator = theme->splits.messageSeperator; + + this->focusedLastMessageLine = theme->tabs.selected.backgrounds.regular; + this->unfocusedLastMessageLine = theme->tabs.selected.backgrounds.unfocused; +} + +void MessagePreferences::connectSettings(Settings *settings, + pajlada::Signals::SignalHolder &holder) +{ + settings->enableRedeemedHighlight.connect( + [this](const auto &newValue) { + this->enableRedeemedHighlight = newValue; + }, + holder); + + settings->enableElevatedMessageHighlight.connect( + [this](const auto &newValue) { + this->enableElevatedMessageHighlight = newValue; + }, + holder); + + settings->enableFirstMessageHighlight.connect( + [this](const auto &newValue) { + this->enableFirstMessageHighlight = newValue; + }, + holder); + + settings->enableSubHighlight.connect( + [this](const auto &newValue) { + this->enableSubHighlight = newValue; + }, + holder); + + settings->enableAutomodHighlight.connect( + [this](const auto &newValue) { + this->enableAutomodHighlight = newValue; + }, + holder); + + settings->alternateMessages.connect( + [this](const auto &newValue) { + this->alternateMessages = newValue; + }, + holder); + + settings->separateMessages.connect( + [this](const auto &newValue) { + this->separateMessages = newValue; + }, + holder); + + settings->lastMessageColor.connect( + [this](const auto &newValue) { + if (newValue.isEmpty()) + { + this->lastMessageColor = QColor(); + } + else + { + this->lastMessageColor = QColor(newValue); + } + }, + holder); + + settings->lastMessagePattern.connect( + [this](const auto &newValue) { + this->lastMessagePattern = static_cast(newValue); + }, + holder); +} + +} // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutContext.hpp b/src/messages/layouts/MessageLayoutContext.hpp new file mode 100644 index 00000000000..d8f08ab3abf --- /dev/null +++ b/src/messages/layouts/MessageLayoutContext.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +namespace pajlada::Signals { +class SignalHolder; +} // namespace pajlada::Signals + +namespace chatterino { + +class ColorProvider; +class Theme; +class Settings; +struct Selection; + +// TODO: Figure out if this could be a subset of Theme instead (e.g. Theme::MessageColors) +struct MessageColors { + QColor regular; + QColor alternate; + QColor disabled; + QColor selection; + QColor system; + + QColor messageSeperator; + + QColor focusedLastMessageLine; + QColor unfocusedLastMessageLine; + + void applyTheme(Theme *theme); +}; + +// TODO: Explore if we can let settings own this +struct MessagePreferences { + QColor lastMessageColor; + Qt::BrushStyle lastMessagePattern{}; + + bool enableRedeemedHighlight{}; + bool enableElevatedMessageHighlight{}; + bool enableFirstMessageHighlight{}; + bool enableSubHighlight{}; + bool enableAutomodHighlight{}; + + bool alternateMessages{}; + bool separateMessages{}; + + void connectSettings(Settings *settings, + pajlada::Signals::SignalHolder &holder); +}; + +struct MessagePaintContext { + QPainter &painter; + const Selection &selection; + const ColorProvider &colorProvider; + const MessageColors &messageColors; + const MessagePreferences &preferences; + + // width of the area we have to draw on + const int canvasWidth{}; + // whether the painting should be treated as if this view's window is focused + const bool isWindowFocused{}; + // whether the painting should be treated as if this view is the special mentions view + const bool isMentions{}; + + // y coordinate we're currently painting at + int y{}; + + // Index of the message that is currently being painted + // This index refers to the snapshot being used in the painting + size_t messageIndex{}; + + bool isLastReadMessage{}; +}; + +} // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index ab75c16dbac..7bf4a47fd93 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -3,9 +3,9 @@ #include "Application.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchEmotes.hpp" -#include "singletons/Theme.hpp" #include "util/DebugCount.hpp" #include @@ -15,6 +15,14 @@ namespace { const QChar RTL_EMBED(0x202B); + +void alignRectBottomCenter(QRectF &rect, const QRectF &reference) +{ + QPointF newCenter(reference.center().x(), + reference.bottom() - (rect.height() / 2.0)); + rect.moveCenter(newCenter); +} + } // namespace namespace chatterino { @@ -52,12 +60,12 @@ bool MessageLayoutElement::hasTrailingSpace() const return this->trailingSpace; } -int MessageLayoutElement::getLine() const +size_t MessageLayoutElement::getLine() const { return this->line_; } -void MessageLayoutElement::setLine(int line) +void MessageLayoutElement::setLine(size_t line) { this->line_ = line; } @@ -117,19 +125,20 @@ void ImageLayoutElement::addCopyTextToString(QString &str, uint32_t from, { str += emoteElement->getEmote()->getCopyString(); str = TwitchEmotes::cleanUpEmoteCode(str); - if (this->hasTrailingSpace()) + if (this->hasTrailingSpace() && to >= 2) { - str += " "; + str += ' '; } } } -int ImageLayoutElement::getSelectionIndexCount() const +size_t ImageLayoutElement::getSelectionIndexCount() const { return this->trailingSpace ? 2 : 1; } -void ImageLayoutElement::paint(QPainter &painter) +void ImageLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { if (this->image_ == nullptr) { @@ -144,11 +153,11 @@ void ImageLayoutElement::paint(QPainter &painter) } } -void ImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool ImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) { if (this->image_ == nullptr) { - return; + return false; } if (this->image_->animated()) @@ -158,8 +167,10 @@ void ImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) auto rect = this->getRect(); rect.moveTop(rect.y() + yOffset); painter.drawPixmap(QRectF(rect), *pixmap, QRectF()); + return true; } } + return false; } int ImageLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -167,7 +178,136 @@ int ImageLayoutElement::getMouseOverIndex(const QPoint &abs) const return 0; } -int ImageLayoutElement::getXFromIndex(int index) +int ImageLayoutElement::getXFromIndex(size_t index) +{ + if (index <= 0) + { + return this->getRect().left(); + } + else if (index == 1) + { + // fourtf: remove space width + return this->getRect().right(); + } + else + { + return this->getRect().right(); + } +} + +// +// LAYERED IMAGE +// + +LayeredImageLayoutElement::LayeredImageLayoutElement( + MessageElement &creator, std::vector images, + std::vector sizes, QSize largestSize) + : MessageLayoutElement(creator, largestSize) + , images_(std::move(images)) + , sizes_(std::move(sizes)) +{ + assert(this->images_.size() == this->sizes_.size()); + this->trailingSpace = creator.hasTrailingSpace(); +} + +void LayeredImageLayoutElement::addCopyTextToString(QString &str, uint32_t from, + uint32_t to) const +{ + const auto *layeredEmoteElement = + dynamic_cast(&this->getCreator()); + if (layeredEmoteElement) + { + // cleaning is taken care in call + str += layeredEmoteElement->getCleanCopyString(); + if (this->hasTrailingSpace() && to >= 2) + { + str += ' '; + } + } +} + +size_t LayeredImageLayoutElement::getSelectionIndexCount() const +{ + return this->trailingSpace ? 2 : 1; +} + +void LayeredImageLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) +{ + auto fullRect = QRectF(this->getRect()); + + for (size_t i = 0; i < this->images_.size(); ++i) + { + auto &img = this->images_[i]; + if (img == nullptr) + { + continue; + } + + auto pixmap = img->pixmapOrLoad(); + if (img->animated()) + { + // As soon as we see an animated emote layer, we can stop rendering + // the static emotes. The paintAnimated function will render any + // static emotes layered on top of the first seen animated emote. + return; + } + + if (pixmap) + { + // Matching the web chat behavior, we center the emote within the overall + // binding box. E.g. small overlay emotes like cvMask will sit in the direct + // center of even wide emotes. + auto &size = this->sizes_[i]; + QRectF destRect(0, 0, size.width(), size.height()); + alignRectBottomCenter(destRect, fullRect); + + painter.drawPixmap(destRect, *pixmap, QRectF()); + } + } +} + +bool LayeredImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) +{ + auto fullRect = QRectF(this->getRect()); + fullRect.moveTop(fullRect.y() + yOffset); + bool animatedFlag = false; + + for (size_t i = 0; i < this->images_.size(); ++i) + { + auto &img = this->images_[i]; + if (img == nullptr) + { + continue; + } + + // If we have a static emote layered on top of an animated emote, we need + // to render the static emote again after animating anything below it. + if (img->animated() || animatedFlag) + { + if (auto pixmap = img->pixmapOrLoad()) + { + // Matching the web chat behavior, we center the emote within the overall + // binding box. E.g. small overlay emotes like cvMask will sit in the direct + // center of even wide emotes. + auto &size = this->sizes_[i]; + QRectF destRect(0, 0, size.width(), size.height()); + alignRectBottomCenter(destRect, fullRect); + + painter.drawPixmap(destRect, *pixmap, QRectF()); + animatedFlag = true; + } + } + } + return animatedFlag; +} + +int LayeredImageLayoutElement::getMouseOverIndex(const QPoint &abs) const +{ + return 0; +} + +int LayeredImageLayoutElement::getXFromIndex(size_t index) { if (index <= 0) { @@ -194,7 +334,8 @@ ImageWithBackgroundLayoutElement::ImageWithBackgroundLayoutElement( { } -void ImageWithBackgroundLayoutElement::paint(QPainter &painter) +void ImageWithBackgroundLayoutElement::paint( + QPainter &painter, const MessageColors & /*messageColors*/) { if (this->image_ == nullptr) { @@ -225,7 +366,8 @@ ImageWithCircleBackgroundLayoutElement::ImageWithCircleBackgroundLayoutElement( { } -void ImageWithCircleBackgroundLayoutElement::paint(QPainter &painter) +void ImageWithCircleBackgroundLayoutElement::paint( + QPainter &painter, const MessageColors & /*messageColors*/) { if (this->image_ == nullptr) { @@ -277,20 +419,21 @@ void TextLayoutElement::addCopyTextToString(QString &str, uint32_t from, { str += this->getText().mid(from, to - from); - if (this->hasTrailingSpace()) + if (this->hasTrailingSpace() && to > this->getText().length()) { - str += " "; + str += ' '; } } -int TextLayoutElement::getSelectionIndexCount() const +size_t TextLayoutElement::getSelectionIndexCount() const { return this->getText().length() + (this->trailingSpace ? 1 : 0); } -void TextLayoutElement::paint(QPainter &painter) +void TextLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { - auto app = getApp(); + auto *app = getApp(); QString text = this->getText(); if (text.isRightToLeft() || this->reversedNeutral) { @@ -299,15 +442,16 @@ void TextLayoutElement::paint(QPainter &painter) painter.setPen(this->color_); - painter.setFont(app->fonts->getFont(this->style_, this->scale_)); + painter.setFont(app->getFonts()->getFont(this->style_, this->scale_)); painter.drawText( QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), text, QTextOption(Qt::AlignLeft | Qt::AlignTop)); } -void TextLayoutElement::paintAnimated(QPainter &, int) +bool TextLayoutElement::paintAnimated(QPainter & /*painter*/, int /*yOffset*/) { + return false; } int TextLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -317,9 +461,9 @@ int TextLayoutElement::getMouseOverIndex(const QPoint &abs) const return 0; } - auto app = getApp(); + auto *app = getApp(); - auto metrics = app->fonts->getFontMetrics(this->style_, this->scale_); + auto metrics = app->getFonts()->getFontMetrics(this->style_, this->scale_); auto x = this->getRect().left(); for (auto i = 0; i < this->getText().size(); i++) @@ -349,18 +493,18 @@ int TextLayoutElement::getMouseOverIndex(const QPoint &abs) const return this->getSelectionIndexCount() - (this->hasTrailingSpace() ? 1 : 0); } -int TextLayoutElement::getXFromIndex(int index) +int TextLayoutElement::getXFromIndex(size_t index) { - auto app = getApp(); + auto *app = getApp(); QFontMetrics metrics = - app->fonts->getFontMetrics(this->style_, this->scale_); + app->getFonts()->getFontMetrics(this->style_, this->scale_); if (index <= 0) { return this->getRect().left(); } - else if (index < this->getText().size()) + else if (index < static_cast(this->getText().size())) { int x = 0; for (int i = 0; i < index; i++) @@ -392,18 +536,19 @@ void TextIconLayoutElement::addCopyTextToString(QString &str, uint32_t from, { } -int TextIconLayoutElement::getSelectionIndexCount() const +size_t TextIconLayoutElement::getSelectionIndexCount() const { return this->trailingSpace ? 2 : 1; } -void TextIconLayoutElement::paint(QPainter &painter) +void TextIconLayoutElement::paint(QPainter &painter, + const MessageColors &messageColors) { - auto app = getApp(); + auto *app = getApp(); - QFont font = app->fonts->getFont(FontStyle::Tiny, this->scale); + QFont font = app->getFonts()->getFont(FontStyle::Tiny, this->scale); - painter.setPen(app->themes->messages.textColors.system); + painter.setPen(messageColors.system); painter.setFont(font); QTextOption option; @@ -426,8 +571,10 @@ void TextIconLayoutElement::paint(QPainter &painter) } } -void TextIconLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool TextIconLayoutElement::paintAnimated(QPainter & /*painter*/, + int /*yOffset*/) { + return false; } int TextIconLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -435,7 +582,7 @@ int TextIconLayoutElement::getMouseOverIndex(const QPoint &abs) const return 0; } -int TextIconLayoutElement::getXFromIndex(int index) +int TextIconLayoutElement::getXFromIndex(size_t index) { if (index <= 0) { @@ -463,7 +610,8 @@ ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator, { } -void ReplyCurveLayoutElement::paint(QPainter &painter) +void ReplyCurveLayoutElement::paint(QPainter &painter, + const MessageColors & /*messageColors*/) { QRectF paintRect(this->getRect()); QPainterPath path; @@ -498,8 +646,10 @@ void ReplyCurveLayoutElement::paint(QPainter &painter) painter.drawPath(path); } -void ReplyCurveLayoutElement::paintAnimated(QPainter &painter, int yOffset) +bool ReplyCurveLayoutElement::paintAnimated(QPainter & /*painter*/, + int /*yOffset*/) { + return false; } int ReplyCurveLayoutElement::getMouseOverIndex(const QPoint &abs) const @@ -507,7 +657,7 @@ int ReplyCurveLayoutElement::getMouseOverIndex(const QPoint &abs) const return 0; } -int ReplyCurveLayoutElement::getXFromIndex(int index) +int ReplyCurveLayoutElement::getXFromIndex(size_t index) { if (index <= 0) { @@ -522,7 +672,7 @@ void ReplyCurveLayoutElement::addCopyTextToString(QString &str, uint32_t from, { } -int ReplyCurveLayoutElement::getSelectionIndexCount() const +size_t ReplyCurveLayoutElement::getSelectionIndexCount() const { return 1; } diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 86e662e54ad..894a5829d94 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -3,7 +3,6 @@ #include "common/FlagsEnum.hpp" #include "messages/Link.hpp" -#include #include #include #include @@ -21,21 +20,28 @@ class Image; using ImagePtr = std::shared_ptr; enum class FontStyle : uint8_t; enum class MessageElementFlag : int64_t; +struct MessageColors; -class MessageLayoutElement : boost::noncopyable +class MessageLayoutElement { public: MessageLayoutElement(MessageElement &creator_, const QSize &size); virtual ~MessageLayoutElement(); + MessageLayoutElement(const MessageLayoutElement &) = delete; + MessageLayoutElement &operator=(const MessageLayoutElement &) = delete; + + MessageLayoutElement(MessageLayoutElement &&) = delete; + MessageLayoutElement &operator=(MessageLayoutElement &&) = delete; + bool reversedNeutral = false; const QRect &getRect() const; MessageElement &getCreator() const; void setPosition(QPoint point); bool hasTrailingSpace() const; - int getLine() const; - void setLine(int line); + size_t getLine() const; + void setLine(size_t line); MessageLayoutElement *setTrailingSpace(bool value); MessageLayoutElement *setLink(const Link &link_); @@ -43,11 +49,13 @@ class MessageLayoutElement : boost::noncopyable virtual void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const = 0; - virtual int getSelectionIndexCount() const = 0; - virtual void paint(QPainter &painter) = 0; - virtual void paintAnimated(QPainter &painter, int yOffset) = 0; + virtual size_t getSelectionIndexCount() const = 0; + virtual void paint(QPainter &painter, + const MessageColors &messageColors) = 0; + /// @returns true if anything was painted + virtual bool paintAnimated(QPainter &painter, int yOffset) = 0; virtual int getMouseOverIndex(const QPoint &abs) const = 0; - virtual int getXFromIndex(int index) = 0; + virtual int getXFromIndex(size_t index) = 0; const Link &getLink() const; const QString &getText() const; @@ -61,7 +69,10 @@ class MessageLayoutElement : boost::noncopyable QRect rect_; Link link_; MessageElement &creator_; - int line_{}; + /** + * The line of the container this element is laid out at + */ + size_t line_{}; }; // IMAGE @@ -74,15 +85,35 @@ class ImageLayoutElement : public MessageLayoutElement protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; - int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; - void paintAnimated(QPainter &painter, int yOffset) override; + size_t getSelectionIndexCount() const override; + void paint(QPainter &painter, const MessageColors &messageColors) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; - int getXFromIndex(int index) override; + int getXFromIndex(size_t index) override; ImagePtr image_; }; +class LayeredImageLayoutElement : public MessageLayoutElement +{ +public: + LayeredImageLayoutElement(MessageElement &creator, + std::vector images, + std::vector sizes, QSize largestSize); + +protected: + void addCopyTextToString(QString &str, uint32_t from = 0, + uint32_t to = UINT32_MAX) const override; + size_t getSelectionIndexCount() const override; + void paint(QPainter &painter, const MessageColors &messageColors) override; + bool paintAnimated(QPainter &painter, int yOffset) override; + int getMouseOverIndex(const QPoint &abs) const override; + int getXFromIndex(size_t index) override; + + std::vector images_; + std::vector sizes_; +}; + class ImageWithBackgroundLayoutElement : public ImageLayoutElement { public: @@ -90,7 +121,7 @@ class ImageWithBackgroundLayoutElement : public ImageLayoutElement const QSize &size, QColor color); protected: - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; private: QColor color_; @@ -105,7 +136,7 @@ class ImageWithCircleBackgroundLayoutElement : public ImageLayoutElement int padding); protected: - void paint(QPainter &painter) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; private: const QColor color_; @@ -126,11 +157,11 @@ class TextLayoutElement : public MessageLayoutElement protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; - int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; - void paintAnimated(QPainter &painter, int yOffset) override; + size_t getSelectionIndexCount() const override; + void paint(QPainter &painter, const MessageColors &messageColors) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; - int getXFromIndex(int index) override; + int getXFromIndex(size_t index) override; QColor color_; FontStyle style_; @@ -150,11 +181,11 @@ class TextIconLayoutElement : public MessageLayoutElement protected: void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; - int getSelectionIndexCount() const override; - void paint(QPainter &painter) override; - void paintAnimated(QPainter &painter, int yOffset) override; + size_t getSelectionIndexCount() const override; + void paint(QPainter &painter, const MessageColors &messageColors) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; - int getXFromIndex(int index) override; + int getXFromIndex(size_t index) override; private: float scale; @@ -169,13 +200,13 @@ class ReplyCurveLayoutElement : public MessageLayoutElement float radius, float neededMargin); protected: - void paint(QPainter &painter) override; - void paintAnimated(QPainter &painter, int yOffset) override; + void paint(QPainter &painter, const MessageColors &messageColors) override; + bool paintAnimated(QPainter &painter, int yOffset) override; int getMouseOverIndex(const QPoint &abs) const override; - int getXFromIndex(int index) override; + int getXFromIndex(size_t index) override; void addCopyTextToString(QString &str, uint32_t from = 0, uint32_t to = UINT32_MAX) const override; - int getSelectionIndexCount() const override; + size_t getSelectionIndexCount() const override; private: const QPen pen_; diff --git a/src/messages/search/LinkPredicate.cpp b/src/messages/search/LinkPredicate.cpp index be400feb7f6..69442069c9a 100644 --- a/src/messages/search/LinkPredicate.cpp +++ b/src/messages/search/LinkPredicate.cpp @@ -15,8 +15,10 @@ bool LinkPredicate::appliesToImpl(const Message &message) { for (const auto &word : message.messageText.split(' ', Qt::SkipEmptyParts)) { - if (LinkParser(word).hasMatch()) + if (LinkParser(word).result()) + { return true; + } } return false; diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index 9bcee9843fb..76e32de7209 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -35,7 +35,7 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate) { this->flags_.set(MessageFlag::FirstMessage); } - else if (flag == "elevated-msg") + else if (flag == "elevated-msg" || flag == "hype-chat") { this->flags_.set(MessageFlag::ElevatedMessage); } @@ -52,6 +52,14 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate) { this->flags_.set(MessageFlag::ReplyMessage); } + else if (flag == "restricted") + { + this->flags_.set(MessageFlag::RestrictedMessage); + } + else if (flag == "monitored") + { + this->flags_.set(MessageFlag::MonitoredMessage); + } } } diff --git a/src/providers/Crashpad.cpp b/src/providers/Crashpad.cpp deleted file mode 100644 index 4c2fb9760f8..00000000000 --- a/src/providers/Crashpad.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#ifdef CHATTERINO_WITH_CRASHPAD -# include "providers/Crashpad.hpp" - -# include "common/QLogging.hpp" -# include "singletons/Paths.hpp" - -# include -# include -# include - -# include -# include - -namespace { - -/// The name of the crashpad handler executable. -/// This varies across platforms -# if defined(Q_OS_UNIX) -const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler"); -# elif defined(Q_OS_WINDOWS) -const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler.exe"); -# else -# error Unsupported platform -# endif - -/// Converts a QString into the platform string representation. -# if defined(Q_OS_UNIX) -std::string nativeString(const QString &s) -{ - return s.toStdString(); -} -# elif defined(Q_OS_WINDOWS) -std::wstring nativeString(const QString &s) -{ - return s.toStdWString(); -} -# else -# error Unsupported platform -# endif - -} // namespace - -namespace chatterino { - -std::unique_ptr installCrashHandler() -{ - // Currently, the following directory layout is assumed: - // [applicationDirPath] - // │ - // ├─chatterino - // │ - // ╰─[crashpad] - // │ - // ╰─crashpad_handler - // TODO: The location of the binary might vary across platforms - auto crashpadBinDir = QDir(QApplication::applicationDirPath()); - - if (!crashpadBinDir.cd("crashpad")) - { - qCDebug(chatterinoApp) << "Cannot find crashpad directory"; - return nullptr; - } - if (!crashpadBinDir.exists(CRASHPAD_EXECUTABLE_NAME)) - { - qCDebug(chatterinoApp) << "Cannot find crashpad handler executable"; - return nullptr; - } - - const auto handlerPath = base::FilePath(nativeString( - crashpadBinDir.absoluteFilePath(CRASHPAD_EXECUTABLE_NAME))); - - // Argument passed in --database - // > Crash reports are written to this database, and if uploads are enabled, - // uploaded from this database to a crash report collection server. - const auto databaseDir = - base::FilePath(nativeString(getPaths()->crashdumpDirectory)); - - auto client = std::make_unique(); - - // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md - // for documentation on available options. - if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, {}, true, - false)) - { - qCDebug(chatterinoApp) << "Failed to start crashpad handler"; - return nullptr; - } - - qCDebug(chatterinoApp) << "Started crashpad handler"; - return client; -} - -} // namespace chatterino - -#endif diff --git a/src/providers/Crashpad.hpp b/src/providers/Crashpad.hpp deleted file mode 100644 index d15f3fcb705..00000000000 --- a/src/providers/Crashpad.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#ifdef CHATTERINO_WITH_CRASHPAD -# include - -# include - -namespace chatterino { - -std::unique_ptr installCrashHandler(); - -} // namespace chatterino - -#endif diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index 26b4088e5eb..9991661b7b8 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -1,7 +1,6 @@ #include "IvrApi.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include @@ -18,16 +17,14 @@ void IvrApi::getSubage(QString userName, QString channelName, this->makeRequest( QString("twitch/subage/%1/%2").arg(userName).arg(channelName), {}) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); successCallback(root); - - return Success; }) .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) - << "Failed IVR API Call!" << result.status() + << "Failed IVR API Call!" << result.formatError() << QString(result.getData()); failureCallback(); }) @@ -42,16 +39,14 @@ void IvrApi::getBulkEmoteSets(QString emoteSetList, urlQuery.addQueryItem("set_id", emoteSetList); this->makeRequest("twitch/emotes/sets", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJsonArray(); successCallback(root); - - return Success; }) .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) - << "Failed IVR API Call!" << result.status() + << "Failed IVR API Call!" << result.formatError() << QString(result.getData()); failureCallback(); }) diff --git a/src/providers/IvrApi.hpp b/src/providers/IvrApi.hpp index 9aca1454381..f8cc72b76a1 100644 --- a/src/providers/IvrApi.hpp +++ b/src/providers/IvrApi.hpp @@ -1,9 +1,8 @@ #pragma once -#include "common/NetworkRequest.hpp" +#include "common/network/NetworkRequest.hpp" #include "providers/twitch/TwitchEmotes.hpp" -#include #include #include @@ -74,7 +73,7 @@ struct IvrEmote { } }; -class IvrApi final : boost::noncopyable +class IvrApi final { public: // https://api.ivr.fi/v2/docs/static/index.html#/Twitch/get_twitch_subage__user___channel_ @@ -89,6 +88,14 @@ class IvrApi final : boost::noncopyable static void initialize(); + IvrApi() = default; + + IvrApi(const IvrApi &) = delete; + IvrApi &operator=(const IvrApi &) = delete; + + IvrApi(IvrApi &&) = delete; + IvrApi &operator=(IvrApi &&) = delete; + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); }; diff --git a/src/providers/LinkResolver.cpp b/src/providers/LinkResolver.cpp index 320fe569c4d..56b325a5e5b 100644 --- a/src/providers/LinkResolver.cpp +++ b/src/providers/LinkResolver.cpp @@ -1,9 +1,8 @@ #include "providers/LinkResolver.hpp" #include "common/Env.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "messages/Image.hpp" #include "messages/Link.hpp" #include "singletons/Settings.hpp" @@ -27,8 +26,7 @@ void LinkResolver::getLinkInfo( QUrl::toPercentEncoding(url, "", "/:")))) .caller(caller) .timeout(30000) - .onSuccess([successCallback, - url](NetworkResult result) mutable -> Outcome { + .onSuccess([successCallback, url](NetworkResult result) mutable { auto root = result.parseJson(); auto statusCode = root.value("status").toInt(); QString response; @@ -54,8 +52,6 @@ void LinkResolver::getLinkInfo( } successCallback(QUrl::fromPercentEncoding(response.toUtf8()), Link(Link::Url, linkString), thumbnail); - - return Success; }) .onError([successCallback, url](auto /*result*/) { successCallback("No link info found", Link(Link::Url, url), diff --git a/src/providers/NetworkConfigurationProvider.cpp b/src/providers/NetworkConfigurationProvider.cpp index 57291ac2519..dca88e8a6cd 100644 --- a/src/providers/NetworkConfigurationProvider.cpp +++ b/src/providers/NetworkConfigurationProvider.cpp @@ -69,7 +69,7 @@ void NetworkConfigurationProvider::applyFromEnv(const Env &env) { if (env.proxyUrl) { - applyProxy(env.proxyUrl.get()); + applyProxy(*env.proxyUrl); } } diff --git a/src/providers/RecentMessagesApi.cpp b/src/providers/RecentMessagesApi.cpp deleted file mode 100644 index dad429d3c69..00000000000 --- a/src/providers/RecentMessagesApi.cpp +++ /dev/null @@ -1,235 +0,0 @@ -#include "providers/RecentMessagesApi.hpp" - -#include "common/Channel.hpp" -#include "common/Common.hpp" -#include "common/Env.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/QLogging.hpp" -#include "messages/Message.hpp" -#include "providers/twitch/IrcMessageHandler.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" -#include "singletons/Settings.hpp" -#include "util/FormatTime.hpp" -#include "util/PostToThread.hpp" - -#include -#include -#include -#include -#include - -namespace chatterino { - -namespace { - - // convertClearchatToNotice takes a Communi::IrcMessage that is a CLEARCHAT - // command and converts it to a readable NOTICE message. This has - // historically been done in the Recent Messages API, but this functionality - // has been moved to Chatterino instead. - auto convertClearchatToNotice(Communi::IrcMessage *message) - { - auto channelName = message->parameter(0); - QString noticeMessage{}; - if (message->tags().contains("target-user-id")) - { - auto target = message->parameter(1); - - if (message->tags().contains("ban-duration")) - { - // User was timed out - noticeMessage = - QString("%1 has been timed out for %2.") - .arg(target) - .arg(formatTime( - message->tag("ban-duration").toString())); - } - else - { - // User was permanently banned - noticeMessage = - QString("%1 has been permanently banned.").arg(target); - } - } - else - { - // Chat was cleared - noticeMessage = "Chat has been cleared by a moderator."; - } - - // rebuild the raw IRC message so we can convert it back to an ircmessage again! - // this could probably be done in a smarter way - - auto s = QString(":tmi.twitch.tv NOTICE %1 :%2") - .arg(channelName) - .arg(noticeMessage); - - auto newMessage = Communi::IrcMessage::fromData(s.toUtf8(), nullptr); - newMessage->setTags(message->tags()); - - return newMessage; - } - - // Parse the IRC messages returned in JSON form into Communi messages - std::vector parseRecentMessages( - const QJsonObject &jsonRoot) - { - QJsonArray jsonMessages = jsonRoot.value("messages").toArray(); - std::vector messages; - - if (jsonMessages.empty()) - return messages; - - for (const auto jsonMessage : jsonMessages) - { - auto content = jsonMessage.toString(); - - // For explanation of why this exists, see src/providers/twitch/TwitchChannel.hpp, - // where these constants are defined - content.replace(COMBINED_FIXER, ZERO_WIDTH_JOINER); - - auto message = - Communi::IrcMessage::fromData(content.toUtf8(), nullptr); - - if (message->command() == "CLEARCHAT") - { - message = convertClearchatToNotice(message); - } - - messages.emplace_back(std::move(message)); - } - - return messages; - } - - // Build Communi messages retrieved from the recent messages API into - // proper chatterino messages. - std::vector buildRecentMessages( - std::vector &messages, Channel *channel) - { - auto &handler = IrcMessageHandler::instance(); - std::vector allBuiltMessages; - - for (auto message : messages) - { - if (message->tags().contains("rm-received-ts")) - { - QDate msgDate = - QDateTime::fromMSecsSinceEpoch( - message->tags().value("rm-received-ts").toLongLong()) - .date(); - - // Check if we need to insert a message stating that a new day began - if (msgDate != channel->lastDate_) - { - channel->lastDate_ = msgDate; - auto msg = makeSystemMessage( - QLocale().toString(msgDate, QLocale::LongFormat), - QTime(0, 0)); - msg->flags.set(MessageFlag::RecentMessage); - allBuiltMessages.emplace_back(msg); - } - } - - auto builtMessages = handler.parseMessageWithReply( - channel, message, allBuiltMessages); - - for (auto builtMessage : builtMessages) - { - builtMessage->flags.set(MessageFlag::RecentMessage); - allBuiltMessages.emplace_back(builtMessage); - } - } - - return allBuiltMessages; - } - - // Returns the URL to be used for querying the Recent Messages API for the - // given channel. - QUrl constructRecentMessagesUrl(const QString &name) - { - QUrl url(Env::get().recentMessagesApiUrl.arg(name)); - QUrlQuery urlQuery(url); - if (!urlQuery.hasQueryItem("limit")) - { - urlQuery.addQueryItem( - "limit", - QString::number(getSettings()->twitchMessageHistoryLimit)); - } - url.setQuery(urlQuery); - return url; - } - -} // namespace - -void RecentMessagesApi::loadRecentMessages(const QString &channelName, - std::weak_ptr channelPtr, - ResultCallback onLoaded, - ErrorCallback onError) -{ - qCDebug(chatterinoRecentMessages) - << "Loading recent messages for" << channelName; - - QUrl url = constructRecentMessagesUrl(channelName); - - NetworkRequest(url) - .onSuccess([channelPtr, onLoaded](NetworkResult result) -> Outcome { - auto shared = channelPtr.lock(); - if (!shared) - return Failure; - - qCDebug(chatterinoRecentMessages) - << "Successfully loaded recent messages for" - << shared->getName(); - - auto root = result.parseJson(); - auto parsedMessages = parseRecentMessages(root); - - // build the Communi messages into chatterino messages - auto builtMessages = - buildRecentMessages(parsedMessages, shared.get()); - - postToThread([shared = std::move(shared), root = std::move(root), - messages = std::move(builtMessages), - onLoaded]() mutable { - // Notify user about a possible gap in logs if it returned some messages - // but isn't currently joined to a channel - if (QString errorCode = root.value("error_code").toString(); - !errorCode.isEmpty()) - { - qCDebug(chatterinoRecentMessages) - << QString("Got error from API: error_code=%1, " - "channel=%2") - .arg(errorCode, shared->getName()); - if (errorCode == "channel_not_joined" && !messages.empty()) - { - shared->addMessage(makeSystemMessage( - "Message history service recovering, there may " - "be gaps in the message history.")); - } - } - - onLoaded(messages); - }); - - return Success; - }) - .onError([channelPtr, onError](NetworkResult result) { - auto shared = channelPtr.lock(); - if (!shared) - return; - - qCDebug(chatterinoRecentMessages) - << "Failed to load recent messages for" << shared->getName(); - - shared->addMessage(makeSystemMessage( - QString("Message history service unavailable (Error %1)") - .arg(result.status()))); - - onError(); - }) - .execute(); -} - -} // namespace chatterino diff --git a/src/providers/RecentMessagesApi.hpp b/src/providers/RecentMessagesApi.hpp deleted file mode 100644 index 30137d5d200..00000000000 --- a/src/providers/RecentMessagesApi.hpp +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include "ForwardDecl.hpp" - -#include - -#include -#include -#include - -namespace chatterino { - -class RecentMessagesApi -{ -public: - using ResultCallback = std::function &)>; - using ErrorCallback = std::function; - - /** - * @brief Loads recent messages for a channel using the Recent Messages API - * - * @param channelName Name of Twitch channel - * @param channelPtr Weak pointer to Channel to use to build messages - * @param onLoaded Callback taking the built messages as a const std::vector & - * @param onError Callback called when the network request fails - */ - static void loadRecentMessages(const QString &channelName, - std::weak_ptr channelPtr, - ResultCallback onLoaded, - ErrorCallback onError); -}; - -} // namespace chatterino diff --git a/src/providers/bttv/BttvEmotes.cpp b/src/providers/bttv/BttvEmotes.cpp index f214d4177f9..d9e694afb3a 100644 --- a/src/providers/bttv/BttvEmotes.cpp +++ b/src/providers/bttv/BttvEmotes.cpp @@ -1,7 +1,8 @@ #include "providers/bttv/BttvEmotes.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" @@ -141,30 +142,32 @@ namespace { return anyModifications; } - std::pair parseChannelEmotes( - const QJsonObject &jsonRoot, const QString &channelDisplayName) - { - auto emotes = EmoteMap(); +} // namespace - auto innerParse = [&jsonRoot, &emotes, - &channelDisplayName](const char *key) { - auto jsonEmotes = jsonRoot.value(key).toArray(); - for (auto jsonEmote_ : jsonEmotes) - { - auto emote = createChannelEmote(channelDisplayName, - jsonEmote_.toObject()); +using namespace bttv::detail; - emotes[emote.name] = - cachedOrMake(std::move(emote.emote), emote.id); - } - }; +EmoteMap bttv::detail::parseChannelEmotes(const QJsonObject &jsonRoot, + const QString &channelDisplayName) +{ + auto emotes = EmoteMap(); - innerParse("channelEmotes"); - innerParse("sharedEmotes"); + auto innerParse = [&jsonRoot, &emotes, + &channelDisplayName](const char *key) { + auto jsonEmotes = jsonRoot.value(key).toArray(); + for (auto jsonEmote_ : jsonEmotes) + { + auto emote = + createChannelEmote(channelDisplayName, jsonEmote_.toObject()); - return {Success, std::move(emotes)}; - } -} // namespace + emotes[emote.name] = cachedOrMake(std::move(emote.emote), emote.id); + } + }; + + innerParse("channelEmotes"); + innerParse("sharedEmotes"); + + return emotes; +} // // BttvEmotes @@ -179,13 +182,16 @@ std::shared_ptr BttvEmotes::emotes() const return this->global_.get(); } -boost::optional BttvEmotes::emote(const EmoteName &name) const +std::optional BttvEmotes::emote(const EmoteName &name) const { auto emotes = this->global_.get(); auto it = emotes->find(name); if (it == emotes->end()) - return boost::none; + { + return std::nullopt; + } + return it->second; } @@ -193,23 +199,29 @@ void BttvEmotes::loadEmotes() { if (!Settings::instance().enableBTTVGlobalEmotes) { - this->global_.set(EMPTY_EMOTE_MAP); + this->setEmotes(EMPTY_EMOTE_MAP); return; } NetworkRequest(QString(globalEmoteApiUrl)) .timeout(30000) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { auto emotes = this->global_.get(); auto pair = parseGlobalEmotes(result.parseJsonArray(), *emotes); if (pair.first) - this->global_.set( + { + this->setEmotes( std::make_shared(std::move(pair.second))); - return pair.first; + } }) .execute(); } +void BttvEmotes::setEmotes(std::shared_ptr emotes) +{ + this->global_.set(std::move(emotes)); +} + void BttvEmotes::loadChannel(std::weak_ptr channel, const QString &channelId, const QString &channelDisplayName, @@ -219,15 +231,12 @@ void BttvEmotes::loadChannel(std::weak_ptr channel, NetworkRequest(QString(bttvChannelEmoteApiUrl) + channelId) .timeout(20000) .onSuccess([callback = std::move(callback), channel, channelDisplayName, - manualRefresh](auto result) -> Outcome { - auto pair = + manualRefresh](auto result) { + auto emotes = parseChannelEmotes(result.parseJson(), channelDisplayName); - bool hasEmotes = false; - if (pair.first) - { - hasEmotes = !pair.second.empty(); - callback(std::move(pair.second)); - } + bool hasEmotes = !emotes.empty(); + callback(std::move(emotes)); + if (auto shared = channel.lock(); manualRefresh) { if (hasEmotes) @@ -241,36 +250,34 @@ void BttvEmotes::loadChannel(std::weak_ptr channel, makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - return pair.first; }) .onError([channelId, channel, manualRefresh](auto result) { auto shared = channel.lock(); if (!shared) + { return; + } + if (result.status() == 404) { // User does not have any BTTV emotes if (manualRefresh) + { shared->addMessage( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); - } - else if (result.status() == NetworkResult::timedoutStatus) - { - // TODO: Auto retry in case of a timeout, with a delay - qCWarning(chatterinoBttv) - << "Fetching BTTV emotes for channel" << channelId - << "failed due to timeout"; - shared->addMessage(makeSystemMessage( - "Failed to fetch BetterTTV channel emotes. (timed out)")); + } } else { + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); qCWarning(chatterinoBttv) << "Error fetching BTTV emotes for channel" << channelId - << ", error" << result.status(); - shared->addMessage( - makeSystemMessage("Failed to fetch BetterTTV channel " - "emotes. (unknown error)")); + << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch BetterTTV channel " + "emotes. (Error: %1)") + .arg(errorString))); } }) .execute(); @@ -292,7 +299,7 @@ EmotePtr BttvEmotes::addEmote( return emote; } -boost::optional> BttvEmotes::updateEmote( +std::optional> BttvEmotes::updateEmote( const QString &channelDisplayName, Atomic> &channelEmoteMap, const BttvLiveUpdateEmoteUpdateAddMessage &message) @@ -306,7 +313,7 @@ boost::optional> BttvEmotes::updateEmote( { // We already copied the map at this point and are now discarding the copy. // This is fine, because this case should be really rare. - return boost::none; + return std::nullopt; } auto oldEmotePtr = it->second; // copy the existing emote, to not change the original one @@ -317,7 +324,7 @@ boost::optional> BttvEmotes::updateEmote( if (!updateChannelEmote(emote, channelDisplayName, message.jsonEmote)) { // The emote wasn't actually updated - return boost::none; + return std::nullopt; } auto name = emote.name; @@ -328,7 +335,7 @@ boost::optional> BttvEmotes::updateEmote( return std::make_pair(oldEmotePtr, emotePtr); } -boost::optional BttvEmotes::removeEmote( +std::optional BttvEmotes::removeEmote( Atomic> &channelEmoteMap, const BttvLiveUpdateEmoteRemoveMessage &message) { @@ -339,7 +346,7 @@ boost::optional BttvEmotes::removeEmote( { // We already copied the map at this point and are now discarding the copy. // This is fine, because this case should be really rare. - return boost::none; + return std::nullopt; } auto emote = it->second; updatedMap.erase(it); diff --git a/src/providers/bttv/BttvEmotes.hpp b/src/providers/bttv/BttvEmotes.hpp index bca2d4b656f..163f9728f94 100644 --- a/src/providers/bttv/BttvEmotes.hpp +++ b/src/providers/bttv/BttvEmotes.hpp @@ -3,9 +3,10 @@ #include "common/Aliases.hpp" #include "common/Atomic.hpp" -#include +#include #include +#include namespace chatterino { @@ -16,6 +17,13 @@ class Channel; struct BttvLiveUpdateEmoteUpdateAddMessage; struct BttvLiveUpdateEmoteRemoveMessage; +namespace bttv::detail { + + EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot, + const QString &channelDisplayName); + +} // namespace bttv::detail + class BttvEmotes final { static constexpr const char *globalEmoteApiUrl = @@ -27,8 +35,9 @@ class BttvEmotes final BttvEmotes(); std::shared_ptr emotes() const; - boost::optional emote(const EmoteName &name) const; + std::optional emote(const EmoteName &name) const; void loadEmotes(); + void setEmotes(std::shared_ptr emotes); static void loadChannel(std::weak_ptr channel, const QString &channelId, const QString &channelDisplayName, @@ -54,7 +63,7 @@ class BttvEmotes final * * @return pair if any emote was updated. */ - static boost::optional> updateEmote( + static std::optional> updateEmote( const QString &channelDisplayName, Atomic> &channelEmoteMap, const BttvLiveUpdateEmoteUpdateAddMessage &message); @@ -66,7 +75,7 @@ class BttvEmotes final * * @return The removed emote if any emote was removed. */ - static boost::optional removeEmote( + static std::optional removeEmote( Atomic> &channelEmoteMap, const BttvLiveUpdateEmoteRemoveMessage &message); diff --git a/src/providers/bttv/BttvLiveUpdates.cpp b/src/providers/bttv/BttvLiveUpdates.cpp index 1a075229796..f9128b5c792 100644 --- a/src/providers/bttv/BttvLiveUpdates.cpp +++ b/src/providers/bttv/BttvLiveUpdates.cpp @@ -83,6 +83,10 @@ void BttvLiveUpdates::onMessage( this->signals_.emoteRemoved.invoke(message); } + else if (eventType == "lookup_user") + { + // ignored + } else { qCDebug(chatterinoBttv) << "Unhandled event:" << json; diff --git a/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp b/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp index cce72635724..12b27a349a6 100644 --- a/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp +++ b/src/providers/bttv/liveupdates/BttvLiveUpdateSubscription.cpp @@ -1,5 +1,6 @@ #include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp" +#include #include namespace chatterino { diff --git a/src/providers/chatterino/ChatterinoBadges.cpp b/src/providers/chatterino/ChatterinoBadges.cpp index f0ccec3af0c..cb5e7e472ba 100644 --- a/src/providers/chatterino/ChatterinoBadges.cpp +++ b/src/providers/chatterino/ChatterinoBadges.cpp @@ -1,27 +1,22 @@ -#include "ChatterinoBadges.hpp" +#include "providers/chatterino/ChatterinoBadges.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "messages/Emote.hpp" #include #include #include -#include #include namespace chatterino { -void ChatterinoBadges::initialize(Settings &settings, Paths &paths) -{ - this->loadChatterinoBadges(); -} ChatterinoBadges::ChatterinoBadges() { + this->loadChatterinoBadges(); } -boost::optional ChatterinoBadges::getBadge(const UserId &id) +std::optional ChatterinoBadges::getBadge(const UserId &id) { std::shared_lock lock(this->mutex_); @@ -30,7 +25,7 @@ boost::optional ChatterinoBadges::getBadge(const UserId &id) { return emotes[it->second]; } - return boost::none; + return std::nullopt; } void ChatterinoBadges::loadChatterinoBadges() @@ -39,21 +34,25 @@ void ChatterinoBadges::loadChatterinoBadges() NetworkRequest(url) .concurrent() - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { auto jsonRoot = result.parseJson(); std::unique_lock lock(this->mutex_); int index = 0; - for (const auto &jsonBadge_ : jsonRoot.value("badges").toArray()) + for (const auto &jsonBadgeValue : + jsonRoot.value("badges").toArray()) { - auto jsonBadge = jsonBadge_.toObject(); + auto jsonBadge = jsonBadgeValue.toObject(); auto emote = Emote{ - EmoteName{}, - ImageSet{Url{jsonBadge.value("image1").toString()}, - Url{jsonBadge.value("image2").toString()}, - Url{jsonBadge.value("image3").toString()}}, - Tooltip{jsonBadge.value("tooltip").toString()}, Url{}}; + .name = EmoteName{}, + .images = + ImageSet{Url{jsonBadge.value("image1").toString()}, + Url{jsonBadge.value("image2").toString()}, + Url{jsonBadge.value("image3").toString()}}, + .tooltip = Tooltip{jsonBadge.value("tooltip").toString()}, + .homePage = Url{}, + }; emotes.push_back( std::make_shared(std::move(emote))); @@ -64,8 +63,6 @@ void ChatterinoBadges::loadChatterinoBadges() } ++index; } - - return Success; }) .execute(); } diff --git a/src/providers/chatterino/ChatterinoBadges.hpp b/src/providers/chatterino/ChatterinoBadges.hpp index 179c55a4501..d1afcfd5b3c 100644 --- a/src/providers/chatterino/ChatterinoBadges.hpp +++ b/src/providers/chatterino/ChatterinoBadges.hpp @@ -1,12 +1,10 @@ #pragma once #include "common/Aliases.hpp" -#include "common/Singleton.hpp" #include "util/QStringHash.hpp" -#include - #include +#include #include #include #include @@ -16,20 +14,48 @@ namespace chatterino { struct Emote; using EmotePtr = std::shared_ptr; -class ChatterinoBadges : public Singleton +class IChatterinoBadges +{ +public: + IChatterinoBadges() = default; + virtual ~IChatterinoBadges() = default; + + IChatterinoBadges(const IChatterinoBadges &) = delete; + IChatterinoBadges(IChatterinoBadges &&) = delete; + IChatterinoBadges &operator=(const IChatterinoBadges &) = delete; + IChatterinoBadges &operator=(IChatterinoBadges &&) = delete; + + virtual std::optional getBadge(const UserId &id) = 0; +}; + +class ChatterinoBadges : public IChatterinoBadges { public: - virtual void initialize(Settings &settings, Paths &paths) override; + /** + * Makes a network request to load Chatterino user badges + */ ChatterinoBadges(); - boost::optional getBadge(const UserId &id); + /** + * Returns the Chatterino badge for the given user + */ + std::optional getBadge(const UserId &id) override; private: void loadChatterinoBadges(); std::shared_mutex mutex_; + /** + * Maps Twitch user IDs to their badge index + * Guarded by mutex_ + */ std::unordered_map badgeMap; + + /** + * Keeps a list of badges. + * Indexes in here are referred to by badgeMap + */ std::vector emotes; }; diff --git a/src/providers/colors/ColorProvider.cpp b/src/providers/colors/ColorProvider.cpp index 5f47c2f68c2..72039a1f150 100644 --- a/src/providers/colors/ColorProvider.cpp +++ b/src/providers/colors/ColorProvider.cpp @@ -1,8 +1,10 @@ #include "providers/colors/ColorProvider.hpp" +#include "common/QLogging.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "singletons/Settings.hpp" -#include "singletons/Theme.hpp" + +#include namespace chatterino { @@ -13,24 +15,16 @@ const ColorProvider &ColorProvider::instance() } ColorProvider::ColorProvider() - : typeColorMap_() - , defaultColors_() { this->initTypeColorMap(); this->initDefaultColors(); } -const std::shared_ptr ColorProvider::color(ColorType type) const +std::shared_ptr ColorProvider::color(ColorType type) const { return this->typeColorMap_.at(type); } -void ColorProvider::updateColor(ColorType type, QColor color) -{ - auto colorPtr = this->typeColorMap_.at(type); - *colorPtr = std::move(color); -} - QSet ColorProvider::recentColors() const { QSet retVal; @@ -39,12 +33,12 @@ QSet ColorProvider::recentColors() const * Currently, only colors used in highlight phrases are considered. This * may change at any point in the future. */ - for (auto phrase : getSettings()->highlightedMessages) + for (const auto &phrase : getSettings()->highlightedMessages) { retVal.insert(*phrase.getColor()); } - for (auto userHl : getSettings()->highlightedUsers) + for (const auto &userHl : getSettings()->highlightedUsers) { retVal.insert(*userHl.getColor()); } @@ -66,117 +60,74 @@ void ColorProvider::initTypeColorMap() { // Read settings for custom highlight colors and save them in map. // If no custom values can be found, set up default values instead. - - QString customColor = getSettings()->selfHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert( - {ColorType::SelfHighlight, std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::SelfHighlight, - std::make_shared( - HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR)}); - } - - customColor = getSettings()->selfMessageHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert({ColorType::SelfMessageHighlight, - std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::SelfMessageHighlight, - std::make_shared( - HighlightPhrase::FALLBACK_SELF_MESSAGE_HIGHLIGHT_COLOR)}); - } - - customColor = getSettings()->subHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert( - {ColorType::Subscription, std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::Subscription, - std::make_shared(HighlightPhrase::FALLBACK_SUB_COLOR)}); - } - - customColor = getSettings()->whisperHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert( - {ColorType::Whisper, std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::Whisper, - std::make_shared( - HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR)}); - } - - customColor = getSettings()->redeemedHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert({ColorType::RedeemedHighlight, - std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::RedeemedHighlight, - std::make_shared( - HighlightPhrase::FALLBACK_REDEEMED_HIGHLIGHT_COLOR)}); - } - - customColor = getSettings()->firstMessageHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert({ColorType::FirstMessageHighlight, - std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::FirstMessageHighlight, - std::make_shared( - HighlightPhrase::FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR)}); - } - - customColor = getSettings()->elevatedMessageHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert({ColorType::ElevatedMessageHighlight, - std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::ElevatedMessageHighlight, - std::make_shared( - HighlightPhrase::FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR)}); - } - - customColor = getSettings()->threadHighlightColor; - if (QColor(customColor).isValid()) - { - this->typeColorMap_.insert({ColorType::ThreadMessageHighlight, - std::make_shared(customColor)}); - } - else - { - this->typeColorMap_.insert( - {ColorType::ThreadMessageHighlight, - std::make_shared( - HighlightPhrase::FALLBACK_THREAD_HIGHLIGHT_COLOR)}); - } + // Set up a signal to the respective setting for updating the color when it's changed + auto initColor = [this](ColorType colorType, QStringSetting &setting, + QColor fallbackColor) { + const auto &colorString = setting.getValue(); + QColor color(colorString); + if (color.isValid()) + { + this->typeColorMap_.insert({ + colorType, + std::make_shared(color), + }); + } + else + { + this->typeColorMap_.insert({ + colorType, + std::make_shared(fallbackColor), + }); + } + + setting.connect( + [this, colorType](const auto &colorString) { + QColor color(colorString); + if (color.isValid()) + { + // Update color based on the update from the setting + *this->typeColorMap_.at(colorType) = color; + } + else + { + qCWarning(chatterinoCommon) + << "Updated" + << static_cast>( + colorType) + << "to invalid color" << colorString; + } + }, + false); + }; + + initColor(ColorType::SelfHighlight, getSettings()->selfHighlightColor, + HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR); + + initColor(ColorType::SelfMessageHighlight, + getSettings()->selfMessageHighlightColor, + HighlightPhrase::FALLBACK_SELF_MESSAGE_HIGHLIGHT_COLOR); + + initColor(ColorType::Subscription, getSettings()->subHighlightColor, + HighlightPhrase::FALLBACK_SUB_COLOR); + + initColor(ColorType::Whisper, getSettings()->whisperHighlightColor, + HighlightPhrase::FALLBACK_HIGHLIGHT_COLOR); + + initColor(ColorType::RedeemedHighlight, + getSettings()->redeemedHighlightColor, + HighlightPhrase::FALLBACK_REDEEMED_HIGHLIGHT_COLOR); + + initColor(ColorType::FirstMessageHighlight, + getSettings()->firstMessageHighlightColor, + HighlightPhrase::FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR); + + initColor(ColorType::ElevatedMessageHighlight, + getSettings()->elevatedMessageHighlightColor, + HighlightPhrase::FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR); + + initColor(ColorType::ThreadMessageHighlight, + getSettings()->threadHighlightColor, + HighlightPhrase::FALLBACK_THREAD_HIGHLIGHT_COLOR); } void ColorProvider::initDefaultColors() diff --git a/src/providers/colors/ColorProvider.hpp b/src/providers/colors/ColorProvider.hpp index 12745371d1e..dfd9b4b838c 100644 --- a/src/providers/colors/ColorProvider.hpp +++ b/src/providers/colors/ColorProvider.hpp @@ -36,9 +36,7 @@ class ColorProvider * of already parsed predefined (self highlights, subscriptions, * and whispers) highlights. */ - const std::shared_ptr color(ColorType type) const; - - void updateColor(ColorType type, QColor color); + std::shared_ptr color(ColorType type) const; /** * @brief Return a set of recently used colors used anywhere in Chatterino. diff --git a/src/providers/emoji/Emojis.cpp b/src/providers/emoji/Emojis.cpp index 1727f0bf302..c99e31d4762 100644 --- a/src/providers/emoji/Emojis.cpp +++ b/src/providers/emoji/Emojis.cpp @@ -12,125 +12,152 @@ #include #include -#include +#include #include -namespace chatterino { namespace { - auto toneNames = std::map{ - {"1F3FB", "tone1"}, {"1F3FC", "tone2"}, {"1F3FD", "tone3"}, - {"1F3FE", "tone4"}, {"1F3FF", "tone5"}, - }; +using namespace chatterino; - void parseEmoji(const std::shared_ptr &emojiData, - const rapidjson::Value &unparsedEmoji, - QString shortCode = QString()) - { - std::array unicodeBytes; +const std::map TONE_NAMES{ + {"1F3FB", "tone1"}, {"1F3FC", "tone2"}, {"1F3FD", "tone3"}, + {"1F3FE", "tone4"}, {"1F3FF", "tone5"}, +}; - struct { - bool apple; - bool google; - bool twitter; - bool facebook; - } capabilities; +void parseEmoji(const std::shared_ptr &emojiData, + const rapidjson::Value &unparsedEmoji, + const QString &shortCode = {}) +{ + std::vector unicodeBytes{}; - if (!shortCode.isEmpty()) - { - emojiData->shortCodes.push_back(shortCode); - } - else + struct { + bool apple; + bool google; + bool twitter; + bool facebook; + } capabilities{}; + + if (!shortCode.isEmpty()) + { + emojiData->shortCodes.push_back(shortCode); + } + else + { + // Load short codes from the suggested short_names + const auto &shortNames = unparsedEmoji["short_names"]; + for (const auto &shortName : shortNames.GetArray()) { - const auto &shortCodes = unparsedEmoji["short_names"]; - for (const auto &_shortCode : shortCodes.GetArray()) - { - emojiData->shortCodes.emplace_back(_shortCode.GetString()); - } + emojiData->shortCodes.emplace_back(shortName.GetString()); } + } - rj::getSafe(unparsedEmoji, "non_qualified", - emojiData->nonQualifiedCode); - rj::getSafe(unparsedEmoji, "unified", emojiData->unifiedCode); + rj::getSafe(unparsedEmoji, "non_qualified", emojiData->nonQualifiedCode); + rj::getSafe(unparsedEmoji, "unified", emojiData->unifiedCode); + assert(!emojiData->unifiedCode.isEmpty()); - rj::getSafe(unparsedEmoji, "has_img_apple", capabilities.apple); - rj::getSafe(unparsedEmoji, "has_img_google", capabilities.google); - rj::getSafe(unparsedEmoji, "has_img_twitter", capabilities.twitter); - rj::getSafe(unparsedEmoji, "has_img_facebook", capabilities.facebook); + rj::getSafe(unparsedEmoji, "has_img_apple", capabilities.apple); + rj::getSafe(unparsedEmoji, "has_img_google", capabilities.google); + rj::getSafe(unparsedEmoji, "has_img_twitter", capabilities.twitter); + rj::getSafe(unparsedEmoji, "has_img_facebook", capabilities.facebook); - if (capabilities.apple) - { - emojiData->capabilities.insert("Apple"); - } - if (capabilities.google) - { - emojiData->capabilities.insert("Google"); - } - if (capabilities.twitter) - { - emojiData->capabilities.insert("Twitter"); - } - if (capabilities.facebook) - { - emojiData->capabilities.insert("Facebook"); - } + if (capabilities.apple) + { + emojiData->capabilities.insert("Apple"); + } + if (capabilities.google) + { + emojiData->capabilities.insert("Google"); + } + if (capabilities.twitter) + { + emojiData->capabilities.insert("Twitter"); + } + if (capabilities.facebook) + { + emojiData->capabilities.insert("Facebook"); + } - QStringList unicodeCharacters; - if (!emojiData->nonQualifiedCode.isEmpty()) - { - unicodeCharacters = - emojiData->nonQualifiedCode.toLower().split('-'); - } - else - { - unicodeCharacters = emojiData->unifiedCode.toLower().split('-'); - } - if (unicodeCharacters.length() < 1) + QStringList unicodeCharacters = emojiData->unifiedCode.toLower().split('-'); + + for (const QString &unicodeCharacter : unicodeCharacters) + { + bool ok{false}; + unicodeBytes.push_back(QString(unicodeCharacter).toUInt(&ok, 16)); + if (!ok) { + qCWarning(chatterinoEmoji) + << "Failed to parse emoji" << emojiData->shortCodes; return; } + } - int numUnicodeBytes = 0; + // We can safely do a narrowing static cast since unicodeBytes will never be a large number + emojiData->value = QString::fromUcs4(unicodeBytes.data(), + static_cast(unicodeBytes.size())); - for (const QString &unicodeCharacter : unicodeCharacters) + if (!emojiData->nonQualifiedCode.isEmpty()) + { + QStringList nonQualifiedCharacters = + emojiData->nonQualifiedCode.toLower().split('-'); + std::vector nonQualifiedBytes{}; + for (const QString &unicodeCharacter : nonQualifiedCharacters) { - unicodeBytes.at(numUnicodeBytes++) = - QString(unicodeCharacter).toUInt(nullptr, 16); + bool ok{false}; + nonQualifiedBytes.push_back( + QString(unicodeCharacter).toUInt(&ok, 16)); + if (!ok) + { + qCWarning(chatterinoEmoji) + << "Failed to parse emoji nonQualified" + << emojiData->shortCodes; + return; + } } - emojiData->value = - QString::fromUcs4(unicodeBytes.data(), numUnicodeBytes); + // We can safely do a narrowing static cast since unicodeBytes will never be a large number + emojiData->nonQualified = + QString::fromUcs4(nonQualifiedBytes.data(), + static_cast(nonQualifiedBytes.size())); } +} - // getToneNames takes a tones and returns their names in the same order - // The format of the tones is: "1F3FB-1F3FB" or "1F3FB" - // The output of the tone names is: "tone1-tone1" or "tone1" - QString getToneNames(const QString &tones) +// getToneNames takes a tones and returns their names in the same order +// The format of the tones is: "1F3FB-1F3FB" or "1F3FB" +// The output of the tone names is: "tone1-tone1" or "tone1" +QString getToneNames(const QString &tones) +{ + auto toneParts = tones.split('-'); + QStringList toneNameResults; + for (const auto &tonePart : toneParts) { - auto toneParts = tones.split('-'); - QStringList toneNameResults; - for (const auto &tonePart : toneParts) + auto toneNameIt = TONE_NAMES.find(tonePart); + if (toneNameIt == TONE_NAMES.end()) { - auto toneNameIt = toneNames.find(tonePart); - if (toneNameIt == toneNames.end()) - { - qDebug() << "Tone with key" << tonePart - << "does not exist in tone names map"; - continue; - } - - toneNameResults.append(toneNameIt->second); + qDebug() << "Tone with key" << tonePart + << "does not exist in tone names map"; + continue; } - assert(!toneNameResults.isEmpty()); - - return toneNameResults.join('-'); + toneNameResults.append(toneNameIt->second); } + assert(!toneNameResults.isEmpty()); + + return toneNameResults.join('-'); +} + } // namespace +namespace chatterino { + void Emojis::load() { + if (this->loaded_) + { + return; + } + this->loaded_ = true; + this->loadEmojis(); this->sortEmojis(); @@ -169,7 +196,7 @@ void Emojis::loadEmojis() this->emojiFirstByte_[emojiData->value.at(0)].append(emojiData); - this->emojis.insert(emojiData->unifiedCode, emojiData); + this->emojis.push_back(emojiData); if (unparsedEmoji.HasMember("skin_variations")) { @@ -191,8 +218,7 @@ void Emojis::loadEmojis() this->emojiFirstByte_[variationEmojiData->value.at(0)].append( variationEmojiData); - this->emojis.insert(variationEmojiData->unifiedCode, - variationEmojiData); + this->emojis.push_back(variationEmojiData); } } } @@ -216,13 +242,9 @@ void Emojis::sortEmojis() void Emojis::loadEmojiSet() { -#ifndef CHATTERINO_TEST getSettings()->emojiSet.connect([this](const auto &emojiSet) { -#else - const QString emojiSet = "twitter"; -#endif - this->emojis.each([=](const auto &name, - std::shared_ptr &emoji) { + for (const auto &emoji : this->emojis) + { QString emojiSetToUse = emojiSet; // clang-format off static std::map emojiSets = { @@ -247,7 +269,7 @@ void Emojis::loadEmojiSet() }; // clang-format on - if (emoji->capabilities.count(emojiSetToUse) == 0) + if (!emoji->capabilities.contains(emojiSetToUse)) { emojiSetToUse = "Twitter"; } @@ -264,17 +286,15 @@ void Emojis::loadEmojiSet() emoji->emote = std::make_shared(Emote{ EmoteName{emoji->value}, ImageSet{Image::fromUrl({url}, 0.35)}, Tooltip{":" + emoji->shortCodes[0] + ":
Emoji"}, Url{}}); - }); -#ifndef CHATTERINO_TEST + } }); -#endif } std::vector> Emojis::parse( - const QString &text) + const QString &text) const { auto result = std::vector>(); - int lastParsedEmojiEndIndex = 0; + QString::size_type lastParsedEmojiEndIndex = 0; for (auto i = 0; i < text.length(); ++i) { @@ -294,39 +314,47 @@ std::vector> Emojis::parse( const auto &possibleEmojis = it.value(); - int remainingCharacters = text.length() - i - 1; + auto remainingCharacters = text.length() - i - 1; std::shared_ptr matchedEmoji; - int matchedEmojiLength = 0; + QString::size_type matchedEmojiLength = 0; for (const std::shared_ptr &emoji : possibleEmojis) { - int emojiExtraCharacters = emoji->value.length() - 1; - if (emojiExtraCharacters > remainingCharacters) + auto emojiNonQualifiedExtraCharacters = + emoji->nonQualified.length() - 1; + auto emojiExtraCharacters = emoji->value.length() - 1; + if (remainingCharacters >= emojiExtraCharacters) { - // It cannot be this emoji, there's not enough space for it - continue; - } - - bool match = true; + // look in emoji->value + bool match = QStringView{emoji->value}.mid(1) == + QStringView{text}.mid(i + 1, emojiExtraCharacters); - for (int j = 1; j < emoji->value.length(); ++j) - { - if (text.at(i + j) != emoji->value.at(j)) + if (match) { - match = false; + matchedEmoji = emoji; + matchedEmojiLength = emoji->value.length(); break; } } - - if (match) + if (!emoji->nonQualified.isNull() && + remainingCharacters >= emojiNonQualifiedExtraCharacters) { - matchedEmoji = emoji; - matchedEmojiLength = emoji->value.length(); + // This checking here relies on the fact that the nonQualified string + // always starts with the same byte as value (the unified string) + bool match = QStringView{emoji->nonQualified}.mid(1) == + QStringView{text}.mid( + i + 1, emojiNonQualifiedExtraCharacters); + + if (match) + { + matchedEmoji = emoji; + matchedEmojiLength = emoji->nonQualified.length(); - break; + break; + } } } @@ -335,10 +363,10 @@ std::vector> Emojis::parse( continue; } - int currentParsedEmojiFirstIndex = i; - int currentParsedEmojiEndIndex = i + (matchedEmojiLength); + auto currentParsedEmojiFirstIndex = i; + auto currentParsedEmojiEndIndex = i + (matchedEmojiLength); - int charactersFromLastParsedEmoji = + auto charactersFromLastParsedEmoji = currentParsedEmojiFirstIndex - lastParsedEmojiEndIndex; if (charactersFromLastParsedEmoji > 0) @@ -365,7 +393,7 @@ std::vector> Emojis::parse( return result; } -QString Emojis::replaceShortCodes(const QString &text) +QString Emojis::replaceShortCodes(const QString &text) const { QString ret(text); auto it = this->findShortCodesRegex_.globalMatch(text); @@ -388,7 +416,7 @@ QString Emojis::replaceShortCodes(const QString &text) continue; } - auto emojiData = emojiIt.value(); + const auto &emojiData = emojiIt.value(); ret.replace(offset + match.capturedStart(), match.capturedLength(), emojiData->value); @@ -399,4 +427,14 @@ QString Emojis::replaceShortCodes(const QString &text) return ret; } +const std::vector &Emojis::getEmojis() const +{ + return this->emojis; +} + +const std::vector &Emojis::getShortCodes() const +{ + return this->shortCodes; +} + } // namespace chatterino diff --git a/src/providers/emoji/Emojis.hpp b/src/providers/emoji/Emojis.hpp index 2f1679b6e85..d6d783fc5a8 100644 --- a/src/providers/emoji/Emojis.hpp +++ b/src/providers/emoji/Emojis.hpp @@ -1,13 +1,11 @@ #pragma once -#include "util/ConcurrentMap.hpp" - #include #include #include #include -#include +#include #include #include @@ -21,6 +19,9 @@ struct EmojiData { // :male:) QString value; + // actual byte-representation of the non qualified emoji + QString nonQualified; + // i.e. 204e-50a2 QString unifiedCode; QString nonQualifiedCode; @@ -35,24 +36,41 @@ struct EmojiData { EmotePtr emote; }; -using EmojiMap = ConcurrentMap>; +using EmojiPtr = std::shared_ptr; + +class IEmojis +{ +public: + virtual ~IEmojis() = default; + + virtual std::vector> parse( + const QString &text) const = 0; + virtual const std::vector &getEmojis() const = 0; + virtual const std::vector &getShortCodes() const = 0; + virtual QString replaceShortCodes(const QString &text) const = 0; +}; -class Emojis +class Emojis : public IEmojis { public: void initialize(); void load(); - std::vector> parse(const QString &text); + std::vector> parse( + const QString &text) const override; - EmojiMap emojis; std::vector shortCodes; - QString replaceShortCodes(const QString &text); + QString replaceShortCodes(const QString &text) const override; + + const std::vector &getEmojis() const override; + const std::vector &getShortCodes() const override; private: void loadEmojis(); void sortEmojis(); void loadEmojiSet(); + std::vector emojis; + /// Emojis QRegularExpression findShortCodesRegex_{":([-+\\w]+):"}; @@ -62,6 +80,8 @@ class Emojis // Maps the first character of the emoji unicode string to a vector of // possible emojis QMap>> emojiFirstByte_; + + bool loaded_ = false; }; } // namespace chatterino diff --git a/src/providers/ffz/FfzBadges.cpp b/src/providers/ffz/FfzBadges.cpp index 7407c86038c..55748183542 100644 --- a/src/providers/ffz/FfzBadges.cpp +++ b/src/providers/ffz/FfzBadges.cpp @@ -1,9 +1,9 @@ #include "FfzBadges.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "messages/Emote.hpp" +#include "providers/ffz/FfzUtil.hpp" #include #include @@ -16,7 +16,7 @@ namespace chatterino { -void FfzBadges::initialize(Settings &settings, Paths &paths) +void FfzBadges::initialize(Settings &settings, const Paths &paths) { this->load(); } @@ -42,7 +42,7 @@ std::vector FfzBadges::getUserBadges(const UserId &id) return badges; } -boost::optional FfzBadges::getBadge(const int badgeID) +std::optional FfzBadges::getBadge(const int badgeID) { auto it = this->badges.find(badgeID); if (it != this->badges.end()) @@ -50,7 +50,7 @@ boost::optional FfzBadges::getBadge(const int badgeID) return it->second; } - return boost::none; + return std::nullopt; } void FfzBadges::load() @@ -58,7 +58,7 @@ void FfzBadges::load() static QUrl url("https://api.frankerfacez.com/v1/badges/ids"); NetworkRequest(url) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { std::unique_lock lock(this->mutex_); auto jsonRoot = result.parseJson(); @@ -67,14 +67,12 @@ void FfzBadges::load() auto jsonBadge = jsonBadge_.toObject(); auto jsonUrls = jsonBadge.value("urls").toObject(); - auto emote = Emote{ - EmoteName{}, - ImageSet{ - Url{QString("https:") + jsonUrls.value("1").toString()}, - Url{QString("https:") + jsonUrls.value("2").toString()}, - Url{QString("https:") + - jsonUrls.value("4").toString()}}, - Tooltip{jsonBadge.value("title").toString()}, Url{}}; + auto emote = + Emote{EmoteName{}, + ImageSet{parseFfzUrl(jsonUrls.value("1").toString()), + parseFfzUrl(jsonUrls.value("2").toString()), + parseFfzUrl(jsonUrls.value("4").toString())}, + Tooltip{jsonBadge.value("title").toString()}, Url{}}; Badge badge; @@ -104,8 +102,6 @@ void FfzBadges::load() } } } - - return Success; }) .execute(); } diff --git a/src/providers/ffz/FfzBadges.hpp b/src/providers/ffz/FfzBadges.hpp index c0cc80c681d..a2fde9c8a31 100644 --- a/src/providers/ffz/FfzBadges.hpp +++ b/src/providers/ffz/FfzBadges.hpp @@ -4,10 +4,10 @@ #include "common/Singleton.hpp" #include "util/QStringHash.hpp" -#include #include #include +#include #include #include #include @@ -21,7 +21,7 @@ using EmotePtr = std::shared_ptr; class FfzBadges : public Singleton { public: - virtual void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; FfzBadges() = default; struct Badge { @@ -32,7 +32,7 @@ class FfzBadges : public Singleton std::vector getUserBadges(const UserId &id); private: - boost::optional getBadge(int badgeID); + std::optional getBadge(int badgeID); void load(); diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 0cf533e5b6c..dfc047535ea 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -1,182 +1,173 @@ #include "providers/ffz/FfzEmotes.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/ffz/FfzUtil.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" -namespace chatterino { namespace { - const QString CHANNEL_HAS_NO_EMOTES( - "This channel has no FrankerFaceZ channel emotes."); +using namespace chatterino; - Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale) - { - auto emote = urls.value(emoteScale); - if (emote.isUndefined() || emote.isNull()) - { - return {""}; - } +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoFfzemotes; - assert(emote.isString()); +const QString CHANNEL_HAS_NO_EMOTES( + "This channel has no FrankerFaceZ channel emotes."); - return {"https:" + emote.toString()}; - } - void fillInEmoteData(const QJsonObject &urls, const EmoteName &name, - const QString &tooltip, Emote &emoteData) +Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale) +{ + auto emote = urls[emoteScale]; + if (emote.isUndefined() || emote.isNull()) { - auto url1x = getEmoteLink(urls, "1"); - auto url2x = getEmoteLink(urls, "2"); - auto url3x = getEmoteLink(urls, "4"); - - //, code, tooltip - emoteData.name = name; - emoteData.images = - ImageSet{Image::fromUrl(url1x, 1), - url2x.string.isEmpty() ? Image::getEmpty() - : Image::fromUrl(url2x, 0.5), - url3x.string.isEmpty() ? Image::getEmpty() - : Image::fromUrl(url3x, 0.25)}; - emoteData.tooltip = {tooltip}; + return {""}; } - EmotePtr cachedOrMake(Emote &&emote, const EmoteId &id) - { - static std::unordered_map> cache; - static std::mutex mutex; - return cachedOrMakeEmotePtr(std::move(emote), cache, mutex, id); - } - std::pair parseGlobalEmotes( - const QJsonObject &jsonRoot, const EmoteMap ¤tEmotes) - { - // Load default sets from the `default_sets` object - std::unordered_set defaultSets{}; - auto jsonDefaultSets = jsonRoot.value("default_sets").toArray(); - for (auto jsonDefaultSet : jsonDefaultSets) - { - defaultSets.insert(jsonDefaultSet.toInt()); - } + assert(emote.isString()); - auto jsonSets = jsonRoot.value("sets").toObject(); - auto emotes = EmoteMap(); + return parseFfzUrl(emote.toString()); +} - for (auto jsonSet : jsonSets) - { - auto jsonSetObject = jsonSet.toObject(); - const auto emoteSetID = jsonSetObject.value("id").toInt(); - if (defaultSets.find(emoteSetID) == defaultSets.end()) - { - qCDebug(chatterinoFfzemotes) - << "Skipping global emote set" << emoteSetID - << "as it's not part of the default sets"; - continue; - } +void fillInEmoteData(const QJsonObject &urls, const EmoteName &name, + const QString &tooltip, Emote &emoteData) +{ + auto url1x = getEmoteLink(urls, "1"); + auto url2x = getEmoteLink(urls, "2"); + auto url3x = getEmoteLink(urls, "4"); + + //, code, tooltip + emoteData.name = name; + emoteData.images = ImageSet{ + Image::fromUrl(url1x, 1), + url2x.string.isEmpty() ? Image::getEmpty() : Image::fromUrl(url2x, 0.5), + url3x.string.isEmpty() ? Image::getEmpty() + : Image::fromUrl(url3x, 0.25)}; + emoteData.tooltip = {tooltip}; +} - auto jsonEmotes = jsonSetObject.value("emoticons").toArray(); +EmotePtr cachedOrMake(Emote &&emote, const EmoteId &id) +{ + static std::unordered_map> cache; + static std::mutex mutex; - for (auto jsonEmoteValue : jsonEmotes) - { - auto jsonEmote = jsonEmoteValue.toObject(); - - auto name = EmoteName{jsonEmote.value("name").toString()}; - auto id = - EmoteId{QString::number(jsonEmote.value("id").toInt())}; - auto urls = jsonEmote.value("urls").toObject(); - - auto emote = Emote(); - fillInEmoteData(urls, name, - name.string + "
Global FFZ Emote", emote); - emote.homePage = - Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") - .arg(id.string) - .arg(name.string)}; - - emotes[name] = - cachedOrMakeEmotePtr(std::move(emote), currentEmotes); - } + return cachedOrMakeEmotePtr(std::move(emote), cache, mutex, id); +} + +void parseEmoteSetInto(const QJsonObject &emoteSet, const QString &kind, + EmoteMap &map) +{ + for (const auto emoteRef : emoteSet["emoticons"].toArray()) + { + const auto emoteJson = emoteRef.toObject(); + + // margins + auto id = EmoteId{QString::number(emoteJson["id"].toInt())}; + auto name = EmoteName{emoteJson["name"].toString()}; + auto author = + EmoteAuthor{emoteJson["owner"]["display_name"].toString()}; + auto urls = emoteJson["urls"].toObject(); + if (emoteJson["animated"].isObject()) + { + // prefer animated images if available + urls = emoteJson["animated"].toObject(); } - return {Success, std::move(emotes)}; + Emote emote; + fillInEmoteData(urls, name, + QString("%1
%2 FFZ Emote
By: %3") + .arg(name.string, kind, author.string), + emote); + emote.homePage = + Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") + .arg(id.string) + .arg(name.string)}; + + map[name] = cachedOrMake(std::move(emote), id); } +} - boost::optional parseAuthorityBadge(const QJsonObject &badgeUrls, - const QString tooltip) +EmoteMap parseGlobalEmotes(const QJsonObject &jsonRoot) +{ + // Load default sets from the `default_sets` object + std::unordered_set defaultSets{}; + auto jsonDefaultSets = jsonRoot["default_sets"].toArray(); + for (auto jsonDefaultSet : jsonDefaultSets) { - boost::optional authorityBadge; + defaultSets.insert(jsonDefaultSet.toInt()); + } + + auto emotes = EmoteMap(); - if (!badgeUrls.isEmpty()) + for (const auto emoteSetRef : jsonRoot["sets"].toObject()) + { + const auto emoteSet = emoteSetRef.toObject(); + auto emoteSetID = emoteSet["id"].toInt(); + if (!defaultSets.contains(emoteSetID)) { - auto authorityBadge1x = getEmoteLink(badgeUrls, "1"); - auto authorityBadge2x = getEmoteLink(badgeUrls, "2"); - auto authorityBadge3x = getEmoteLink(badgeUrls, "4"); - - auto authorityBadgeImageSet = ImageSet{ - Image::fromUrl(authorityBadge1x, 1), - authorityBadge2x.string.isEmpty() - ? Image::getEmpty() - : Image::fromUrl(authorityBadge2x, 0.5), - authorityBadge3x.string.isEmpty() - ? Image::getEmpty() - : Image::fromUrl(authorityBadge3x, 0.25), - }; - - authorityBadge = std::make_shared(Emote{ - {""}, - authorityBadgeImageSet, - Tooltip{tooltip}, - authorityBadge1x, - }); + qCDebug(LOG) << "Skipping global emote set" << emoteSetID + << "as it's not part of the default sets"; + continue; } - return authorityBadge; + + parseEmoteSetInto(emoteSet, "Global", emotes); } - EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot) + return emotes; +} + +std::optional parseAuthorityBadge(const QJsonObject &badgeUrls, + const QString &tooltip) +{ + std::optional authorityBadge; + + if (!badgeUrls.isEmpty()) { - auto jsonSets = jsonRoot.value("sets").toObject(); - auto emotes = EmoteMap(); + auto authorityBadge1x = getEmoteLink(badgeUrls, "1"); + auto authorityBadge2x = getEmoteLink(badgeUrls, "2"); + auto authorityBadge3x = getEmoteLink(badgeUrls, "4"); + + auto authorityBadgeImageSet = ImageSet{ + Image::fromUrl(authorityBadge1x, 1), + authorityBadge2x.string.isEmpty() + ? Image::getEmpty() + : Image::fromUrl(authorityBadge2x, 0.5), + authorityBadge3x.string.isEmpty() + ? Image::getEmpty() + : Image::fromUrl(authorityBadge3x, 0.25), + }; + + authorityBadge = std::make_shared(Emote{ + .name = {""}, + .images = authorityBadgeImageSet, + .tooltip = Tooltip{tooltip}, + .homePage = authorityBadge1x, + }); + } + return authorityBadge; +} - for (auto jsonSet : jsonSets) - { - auto jsonEmotes = jsonSet.toObject().value("emoticons").toArray(); +} // namespace - for (auto _jsonEmote : jsonEmotes) - { - auto jsonEmote = _jsonEmote.toObject(); - - // margins - auto id = - EmoteId{QString::number(jsonEmote.value("id").toInt())}; - auto name = EmoteName{jsonEmote.value("name").toString()}; - auto author = EmoteAuthor{jsonEmote.value("owner") - .toObject() - .value("display_name") - .toString()}; - auto urls = jsonEmote.value("urls").toObject(); - - Emote emote; - fillInEmoteData(urls, name, - QString("%1
Channel FFZ Emote
By: %2") - .arg(name.string) - .arg(author.string), - emote); - emote.homePage = - Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") - .arg(id.string) - .arg(name.string)}; - - emotes[name] = cachedOrMake(std::move(emote), id); - } - } +namespace chatterino { + +using namespace ffz::detail; + +EmoteMap ffz::detail::parseChannelEmotes(const QJsonObject &jsonRoot) +{ + auto emotes = EmoteMap(); - return emotes; + for (const auto emoteSetRef : jsonRoot["sets"].toObject()) + { + parseEmoteSetInto(emoteSetRef.toObject(), "Channel", emotes); } -} // namespace + + return emotes; +} FfzEmotes::FfzEmotes() : global_(std::make_shared()) @@ -188,20 +179,22 @@ std::shared_ptr FfzEmotes::emotes() const return this->global_.get(); } -boost::optional FfzEmotes::emote(const EmoteName &name) const +std::optional FfzEmotes::emote(const EmoteName &name) const { auto emotes = this->global_.get(); auto it = emotes->find(name); if (it != emotes->end()) + { return it->second; - return boost::none; + } + return std::nullopt; } void FfzEmotes::loadEmotes() { if (!Settings::instance().enableFFZGlobalEmotes) { - this->global_.set(EMPTY_EMOTE_MAP); + this->setEmotes(EMPTY_EMOTE_MAP); return; } @@ -210,42 +203,41 @@ void FfzEmotes::loadEmotes() NetworkRequest(url) .timeout(30000) - .onSuccess([this](auto result) -> Outcome { - auto emotes = this->emotes(); - auto pair = parseGlobalEmotes(result.parseJson(), *emotes); - if (pair.first) - this->global_.set( - std::make_shared(std::move(pair.second))); - return pair.first; + .onSuccess([this](auto result) { + auto parsedSet = parseGlobalEmotes(result.parseJson()); + this->setEmotes(std::make_shared(std::move(parsedSet))); }) .execute(); } +void FfzEmotes::setEmotes(std::shared_ptr emotes) +{ + this->global_.set(std::move(emotes)); +} + void FfzEmotes::loadChannel( - std::weak_ptr channel, const QString &channelId, + std::weak_ptr channel, const QString &channelID, std::function emoteCallback, - std::function)> modBadgeCallback, - std::function)> vipBadgeCallback, + std::function)> modBadgeCallback, + std::function)> vipBadgeCallback, bool manualRefresh) { - qCDebug(chatterinoFfzemotes) - << "[FFZEmotes] Reload FFZ Channel Emotes for channel" << channelId; + qCDebug(LOG) << "Reload FFZ Channel Emotes for channel" << channelID; - NetworkRequest("https://api.frankerfacez.com/v1/room/id/" + channelId) + NetworkRequest("https://api.frankerfacez.com/v1/room/id/" + channelID) .timeout(20000) .onSuccess([emoteCallback = std::move(emoteCallback), modBadgeCallback = std::move(modBadgeCallback), vipBadgeCallback = std::move(vipBadgeCallback), channel, - manualRefresh](auto result) -> Outcome { - auto json = result.parseJson(); + manualRefresh](const auto &result) { + const auto json = result.parseJson(); + auto emoteMap = parseChannelEmotes(json); auto modBadge = parseAuthorityBadge( - json.value("room").toObject().value("mod_urls").toObject(), - "Moderator"); + json["room"]["mod_urls"].toObject(), "Moderator"); auto vipBadge = parseAuthorityBadge( - json.value("room").toObject().value("vip_badge").toObject(), - "VIP"); + json["room"]["vip_badge"].toObject(), "VIP"); bool hasEmotes = !emoteMap.empty(); @@ -265,38 +257,33 @@ void FfzEmotes::loadChannel( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - - return Success; }) - .onError([channelId, channel, manualRefresh](NetworkResult result) { + .onError([channelID, channel, manualRefresh](const auto &result) { auto shared = channel.lock(); if (!shared) + { return; + } + if (result.status() == 404) { // User does not have any FFZ emotes if (manualRefresh) + { shared->addMessage( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); - } - else if (result.status() == NetworkResult::timedoutStatus) - { - // TODO: Auto retry in case of a timeout, with a delay - qCWarning(chatterinoFfzemotes) - << "Fetching FFZ emotes for channel" << channelId - << "failed due to timeout"; - shared->addMessage( - makeSystemMessage("Failed to fetch FrankerFaceZ channel " - "emotes. (timed out)")); + } } else { - qCWarning(chatterinoFfzemotes) - << "Error fetching FFZ emotes for channel" << channelId - << ", error" << result.status(); - shared->addMessage( - makeSystemMessage("Failed to fetch FrankerFaceZ channel " - "emotes. (unknown error)")); + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); + qCWarning(LOG) << "Error fetching FFZ emotes for channel" + << channelID << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch FrankerFaceZ channel " + "emotes. (Error: %1)") + .arg(errorString))); } }) .execute(); diff --git a/src/providers/ffz/FfzEmotes.hpp b/src/providers/ffz/FfzEmotes.hpp index be0726f045d..4b80789c6ff 100644 --- a/src/providers/ffz/FfzEmotes.hpp +++ b/src/providers/ffz/FfzEmotes.hpp @@ -3,9 +3,10 @@ #include "common/Aliases.hpp" #include "common/Atomic.hpp" -#include +#include #include +#include namespace chatterino { @@ -14,19 +15,26 @@ using EmotePtr = std::shared_ptr; class EmoteMap; class Channel; +namespace ffz::detail { + + EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot); + +} // namespace ffz::detail + class FfzEmotes final { public: FfzEmotes(); std::shared_ptr emotes() const; - boost::optional emote(const EmoteName &name) const; + std::optional emote(const EmoteName &name) const; void loadEmotes(); + void setEmotes(std::shared_ptr emotes); static void loadChannel( std::weak_ptr channel, const QString &channelId, std::function emoteCallback, - std::function)> modBadgeCallback, - std::function)> vipBadgeCallback, + std::function)> modBadgeCallback, + std::function)> vipBadgeCallback, bool manualRefresh); private: diff --git a/src/providers/ffz/FfzUtil.cpp b/src/providers/ffz/FfzUtil.cpp new file mode 100644 index 00000000000..762683a28d8 --- /dev/null +++ b/src/providers/ffz/FfzUtil.cpp @@ -0,0 +1,12 @@ +#include "providers/ffz/FfzUtil.hpp" + +namespace chatterino { + +Url parseFfzUrl(const QString &ffzUrl) +{ + QUrl asURL(ffzUrl); + asURL.setScheme("https"); + return {asURL.toString()}; +} + +} // namespace chatterino diff --git a/src/providers/ffz/FfzUtil.hpp b/src/providers/ffz/FfzUtil.hpp new file mode 100644 index 00000000000..1d4ef65c347 --- /dev/null +++ b/src/providers/ffz/FfzUtil.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "common/Aliases.hpp" + +#include +#include + +namespace chatterino { + +Url parseFfzUrl(const QString &ffzUrl); + +} // namespace chatterino diff --git a/src/providers/irc/AbstractIrcServer.cpp b/src/providers/irc/AbstractIrcServer.cpp index 77f0631ff7e..5880f2f6a6d 100644 --- a/src/providers/irc/AbstractIrcServer.cpp +++ b/src/providers/irc/AbstractIrcServer.cpp @@ -5,15 +5,12 @@ #include "messages/LimitedQueueSnapshot.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchChannel.hpp" #include namespace chatterino { -const int RECONNECT_BASE_INTERVAL = 2000; -// 60 falloff counter means it will try to reconnect at most every 60*2 seconds -const int MAX_FALLOFF_COUNTER = 60; - // Ratelimits for joinBucket_ const int JOIN_RATELIMIT_BUDGET = 18; const int JOIN_RATELIMIT_COOLDOWN = 12500; @@ -50,7 +47,7 @@ AbstractIrcServer::AbstractIrcServer() this->writeConnection_->connectionLost, [this](bool timeout) { qCDebug(chatterinoIrc) << "Write connection reconnect requested. Timeout:" << timeout; - this->writeConnection_->smartReconnect.invoke(); + this->writeConnection_->smartReconnect(); }); // Listen to read connection message signals @@ -86,8 +83,11 @@ AbstractIrcServer::AbstractIrcServer() this->addGlobalSystemMessage( "Server connection timed out, reconnecting"); } - this->readConnection_->smartReconnect.invoke(); + this->readConnection_->smartReconnect(); }); + this->connections_.managedConnect(this->readConnection_->heartbeat, [this] { + this->markChannelsConnected(); + }); } void AbstractIrcServer::initializeIrc() @@ -331,8 +331,6 @@ void AbstractIrcServer::onReadConnected(IrcConnection *connection) { chan->addMessage(connectedMsg); } - - chan->connected.invoke(); } this->falloffCounter_ = 1; @@ -360,9 +358,24 @@ void AbstractIrcServer::onDisconnected() } chan->addMessage(disconnectedMsg); + + if (auto *channel = dynamic_cast(chan.get())) + { + channel->markDisconnected(); + } } } +void AbstractIrcServer::markChannelsConnected() +{ + this->forEachChannel([](const ChannelPtr &chan) { + if (auto *channel = dynamic_cast(chan.get())) + { + channel->markConnected(); + } + }); +} + std::shared_ptr AbstractIrcServer::getCustomChannel( const QString &channelName) { @@ -378,7 +391,7 @@ QString AbstractIrcServer::cleanChannelName(const QString &dirtyChannelName) void AbstractIrcServer::addFakeMessage(const QString &data) { - auto fakeMessage = Communi::IrcMessage::fromData( + auto *fakeMessage = Communi::IrcMessage::fromData( data.toUtf8(), this->readConnection_.get()); if (fakeMessage->command() == "PRIVMSG") diff --git a/src/providers/irc/AbstractIrcServer.hpp b/src/providers/irc/AbstractIrcServer.hpp index fd6a4612ee6..cb8b17735ab 100644 --- a/src/providers/irc/AbstractIrcServer.hpp +++ b/src/providers/irc/AbstractIrcServer.hpp @@ -22,7 +22,11 @@ class AbstractIrcServer : public QObject public: enum ConnectionType { Read = 1, Write = 2, Both = 3 }; - virtual ~AbstractIrcServer() = default; + ~AbstractIrcServer() override = default; + AbstractIrcServer(const AbstractIrcServer &) = delete; + AbstractIrcServer(AbstractIrcServer &&) = delete; + AbstractIrcServer &operator=(const AbstractIrcServer &) = delete; + AbstractIrcServer &operator=(AbstractIrcServer &&) = delete; // initializeIrc must be called from the derived class // this allows us to initialize the abstract IRC server based on the derived class's parameters @@ -57,7 +61,11 @@ class AbstractIrcServer : public QObject // initializeConnectionSignals is called on a connection once in its lifetime. // it can be used to connect signals to your class virtual void initializeConnectionSignals(IrcConnection *connection, - ConnectionType type){}; + ConnectionType type) + { + (void)connection; + (void)type; + } // initializeConnection is called every time before we try to connect to the IRC server virtual void initializeConnection(IrcConnection *connection, @@ -73,6 +81,7 @@ class AbstractIrcServer : public QObject virtual void onReadConnected(IrcConnection *connection); virtual void onWriteConnected(IrcConnection *connection); virtual void onDisconnected(); + void markChannelsConnected(); virtual std::shared_ptr getCustomChannel( const QString &channelName); diff --git a/src/providers/irc/Irc2.cpp b/src/providers/irc/Irc2.cpp index fd1b66851c0..385c76baed1 100644 --- a/src/providers/irc/Irc2.cpp +++ b/src/providers/irc/Irc2.cpp @@ -1,5 +1,6 @@ #include "Irc2.hpp" +#include "Application.hpp" #include "common/Credentials.hpp" #include "common/SignalVectorModel.hpp" #include "providers/irc/IrcChannel2.hpp" @@ -18,9 +19,10 @@ namespace chatterino { namespace { + QString configPath() { - return combinePath(getPaths()->settingsDirectory, "irc.json"); + return combinePath(getIApp()->getPaths().settingsDirectory, "irc.json"); } class Model : public SignalVectorModel @@ -33,7 +35,7 @@ namespace { // turn a vector item into a model row IrcServerData getItemFromRow(std::vector &row, - const IrcServerData &original) + const IrcServerData &original) override { return IrcServerData{ row[0]->data(Qt::EditRole).toString(), // host @@ -50,7 +52,7 @@ namespace { // turns a row in the model into a vector item void getRowFromItem(const IrcServerData &item, - std::vector &row) + std::vector &row) override { setStringItem(row[0], item.host, false); setStringItem(row[1], QString::number(item.port)); @@ -60,6 +62,7 @@ namespace { setStringItem(row[5], item.real); } }; + } // namespace inline QString escape(QString str) @@ -88,7 +91,9 @@ void IrcServerData::setPassword(const QString &password) Irc::Irc() { - this->connections.itemInserted.connect([this](auto &&args) { + // We can safely ignore this signal connection since `connections` will always + // be destroyed before the Irc object + std::ignore = this->connections.itemInserted.connect([this](auto &&args) { // make sure only one id can only exist for one server assert(this->servers_.find(args.item.id) == this->servers_.end()); @@ -100,10 +105,16 @@ Irc::Irc() // set server of abandoned channels for (auto weak : ab->second) + { if (auto shared = weak.lock()) - if (auto ircChannel = + { + if (auto *ircChannel = dynamic_cast(shared.get())) + { ircChannel->setServer(server.get()); + } + } + } // add new server with abandoned channels this->servers_.emplace(args.item.id, std::move(server)); @@ -117,7 +128,9 @@ Irc::Irc() } }); - this->connections.itemRemoved.connect([this](auto &&args) { + // We can safely ignore this signal connection since `connections` will always + // be destroyed before the Irc object + std::ignore = this->connections.itemRemoved.connect([this](auto &&args) { // restore if (auto server = this->servers_.find(args.item.id); server != this->servers_.end()) @@ -126,10 +139,16 @@ Irc::Irc() // set server of abandoned servers to nullptr for (auto weak : abandoned) + { if (auto shared = weak.lock()) - if (auto ircChannel = + { + if (auto *ircChannel = dynamic_cast(shared.get())) + { ircChannel->setServer(nullptr); + } + } + } this->abandonedChannels_[args.item.id] = abandoned; this->servers_.erase(server); @@ -141,14 +160,16 @@ Irc::Irc() } }); - this->connections.delayedItemsChanged.connect([this] { + // We can safely ignore this signal connection since `connections` will always + // be destroyed before the Irc object + std::ignore = this->connections.delayedItemsChanged.connect([this] { this->save(); }); } QAbstractTableModel *Irc::newConnectionModel(QObject *parent) { - auto model = new Model(parent); + auto *model = new Model(parent); model->initialize(&this->connections); return model; } @@ -226,7 +247,9 @@ void Irc::save() void Irc::load() { if (this->loaded_) + { return; + } this->loaded_ = true; QString config = configPath(); diff --git a/src/providers/irc/IrcChannel2.cpp b/src/providers/irc/IrcChannel2.cpp index 164cc75f737..7eeea1c1af8 100644 --- a/src/providers/irc/IrcChannel2.cpp +++ b/src/providers/irc/IrcChannel2.cpp @@ -100,7 +100,9 @@ bool IrcChannel::canReconnect() const void IrcChannel::reconnect() { if (this->server()) + { this->server()->connect(); + } } } // namespace chatterino diff --git a/src/providers/irc/IrcChannel2.hpp b/src/providers/irc/IrcChannel2.hpp index 81b85ce18ca..3e26200dcb5 100644 --- a/src/providers/irc/IrcChannel2.hpp +++ b/src/providers/irc/IrcChannel2.hpp @@ -8,7 +8,7 @@ namespace chatterino { class Irc; class IrcServer; -class IrcChannel : public Channel, public ChannelChatters +class IrcChannel final : public Channel, public ChannelChatters { public: explicit IrcChannel(const QString &name, IrcServer *server); @@ -19,8 +19,8 @@ class IrcChannel : public Channel, public ChannelChatters IrcServer *server(); // Channel methods - virtual bool canReconnect() const override; - virtual void reconnect() override; + bool canReconnect() const override; + void reconnect() override; private: void setServer(IrcServer *server); diff --git a/src/providers/irc/IrcConnection2.cpp b/src/providers/irc/IrcConnection2.cpp index 7e4c4537916..9f97e6db641 100644 --- a/src/providers/irc/IrcConnection2.cpp +++ b/src/providers/irc/IrcConnection2.cpp @@ -16,7 +16,7 @@ IrcConnection::IrcConnection(QObject *parent) { // Log connection errors for ease-of-debugging QObject::connect(this, &Communi::IrcConnection::socketError, this, - [this](QAbstractSocket::SocketError error) { + [](QAbstractSocket::SocketError error) { qCDebug(chatterinoIrc) << "Connection error:" << error; }); @@ -38,18 +38,6 @@ IrcConnection::IrcConnection(QObject *parent) } }); - // Schedule a reconnect that won't violate RECONNECT_MIN_INTERVAL - this->smartReconnect.connect([this] { - if (this->reconnectTimer_.isActive()) - { - return; - } - - auto delay = this->reconnectBackoff_.next(); - qCDebug(chatterinoIrc) << "Reconnecting in" << delay.count() << "ms"; - this->reconnectTimer_.start(delay); - }); - this->reconnectTimer_.setSingleShot(true); QObject::connect(&this->reconnectTimer_, &QTimer::timeout, [this] { if (this->isConnected()) @@ -76,6 +64,7 @@ IrcConnection::IrcConnection(QObject *parent) // If we're still receiving messages, all is well this->recentlyReceivedMessage_ = false; this->waitingForPong_ = false; + this->heartbeat.invoke(); return; } @@ -123,6 +112,19 @@ IrcConnection::~IrcConnection() this->disconnect(); } +void IrcConnection::smartReconnect() +{ + if (this->reconnectTimer_.isActive()) + { + // Ignore this reconnect request, we already have a reconnect request queued up + return; + } + + auto delay = this->reconnectBackoff_.next(); + qCDebug(chatterinoIrc) << "Reconnecting in" << delay.count() << "ms"; + this->reconnectTimer_.start(delay); +} + void IrcConnection::open() { this->expectConnectionLoss_ = false; diff --git a/src/providers/irc/IrcConnection2.hpp b/src/providers/irc/IrcConnection2.hpp index 1a31ea5cdaa..150793ec8a7 100644 --- a/src/providers/irc/IrcConnection2.hpp +++ b/src/providers/irc/IrcConnection2.hpp @@ -19,8 +19,12 @@ class IrcConnection : public Communi::IrcConnection // receiver to trigger a reconnect, if desired pajlada::Signals::Signal connectionLost; + // Signal to indicate the connection is still healthy + pajlada::Signals::NoArgSignal heartbeat; + // Request a reconnect with a minimum interval between attempts. - pajlada::Signals::NoArgSignal smartReconnect; + // This won't violate RECONNECT_MIN_INTERVAL + void smartReconnect(); virtual void open(); virtual void close(); diff --git a/src/providers/irc/IrcMessageBuilder.cpp b/src/providers/irc/IrcMessageBuilder.cpp index 0474bdb76bf..3b7fb30da25 100644 --- a/src/providers/irc/IrcMessageBuilder.cpp +++ b/src/providers/irc/IrcMessageBuilder.cpp @@ -64,7 +64,11 @@ MessagePtr IrcMessageBuilder::build() // message this->addIrcMessageText(this->originalMessage_); - this->message().searchText = this->message().localizedName + " " + + QString stylizedUsername = + this->stylizeUsername(this->userName, this->message()); + + this->message().searchText = stylizedUsername + " " + + this->message().localizedName + " " + this->userName + ": " + this->originalMessage_; // highlights diff --git a/src/providers/irc/IrcServer.cpp b/src/providers/irc/IrcServer.cpp index b79f635bcbe..0ae9849f5cb 100644 --- a/src/providers/irc/IrcServer.cpp +++ b/src/providers/irc/IrcServer.cpp @@ -11,9 +11,9 @@ #include "providers/twitch/TwitchIrcServer.hpp" // NOTE: Included to access the mentions channel #include "singletons/Settings.hpp" #include "util/IrcHelpers.hpp" -#include "util/QObjectRef.hpp" #include +#include #include #include @@ -94,7 +94,8 @@ void IrcServer::initializeConnectionSignals(IrcConnection *connection, QObject::connect(connection, &Communi::IrcConnection::nickNameRequired, this, [](const QString &reserved, QString *result) { - *result = reserved + (std::rand() % 100); + *result = QString("%1%2").arg( + reserved, QString::number(std::rand() % 100)); }); QObject::connect(connection, &Communi::IrcConnection::noticeMessageReceived, @@ -150,7 +151,7 @@ void IrcServer::initializeConnection(IrcConnection *connection, [[fallthrough]]; case IrcAuthType::Pass: this->data_->getPassword( - this, [conn = new QObjectRef(connection) /* can't copy */, + this, [conn = new QPointer(connection) /* can't copy */, this](const QString &password) mutable { if (*conn) { @@ -260,7 +261,7 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) switch (message->type()) { case Communi::IrcMessage::Join: { - auto x = static_cast(message); + auto *x = static_cast(message); if (auto it = this->channels.find(x->channel()); it != this->channels.end()) @@ -273,9 +274,11 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) } else { - if (auto c = + if (auto *c = dynamic_cast(shared.get())) + { c->addJoinedUser(x->nick()); + } } } } @@ -283,7 +286,7 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) } case Communi::IrcMessage::Part: { - auto x = static_cast(message); + auto *x = static_cast(message); if (auto it = this->channels.find(x->channel()); it != this->channels.end()) @@ -296,9 +299,11 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) } else { - if (auto c = + if (auto *c = dynamic_cast(shared.get())) + { c->addPartedUser(x->nick()); + } } } } @@ -326,7 +331,9 @@ void IrcServer::readConnectionMessageReceived(Communi::IrcMessage *message) for (auto &&weak : this->channels) { if (auto shared = weak.lock()) + { shared->addMessage(msg); + } } }; } diff --git a/src/providers/liveupdates/BasicPubSubManager.hpp b/src/providers/liveupdates/BasicPubSubManager.hpp index d596866bc01..f82f703631d 100644 --- a/src/providers/liveupdates/BasicPubSubManager.hpp +++ b/src/providers/liveupdates/BasicPubSubManager.hpp @@ -87,8 +87,11 @@ class BasicPubSubManager this->websocketClient_.set_fail_handler([this](auto hdl) { this->onConnectionFail(hdl); }); - this->websocketClient_.set_user_agent("Chatterino/" CHATTERINO_VERSION - " (" CHATTERINO_GIT_HASH ")"); + this->websocketClient_.set_user_agent( + QStringLiteral("Chatterino/%1 (%2)") + .arg(Version::instance().version(), + Version::instance().commitHash()) + .toStdString()); } virtual ~BasicPubSubManager() = default; diff --git a/src/providers/recentmessages/Api.cpp b/src/providers/recentmessages/Api.cpp new file mode 100644 index 00000000000..88d456515c8 --- /dev/null +++ b/src/providers/recentmessages/Api.cpp @@ -0,0 +1,101 @@ +#include "providers/recentmessages/Api.hpp" + +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/QLogging.hpp" +#include "providers/recentmessages/Impl.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/PostToThread.hpp" + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoRecentMessages; + +} // namespace + +namespace chatterino::recentmessages { + +using namespace recentmessages::detail; + +void load( + const QString &channelName, std::weak_ptr channelPtr, + ResultCallback onLoaded, ErrorCallback onError, const int limit, + const std::optional> + after, + const std::optional> + before, + const bool jitter) +{ + qCDebug(LOG) << "Loading recent messages for" << channelName; + + const auto url = + constructRecentMessagesUrl(channelName, limit, after, before); + + const long delayMs = jitter ? std::rand() % 100 : 0; + QTimer::singleShot(delayMs, [=] { + NetworkRequest(url) + .onSuccess([channelPtr, onLoaded](const auto &result) { + auto shared = channelPtr.lock(); + if (!shared) + { + return; + } + + qCDebug(LOG) << "Successfully loaded recent messages for" + << shared->getName(); + + auto root = result.parseJson(); + auto parsedMessages = parseRecentMessages(root); + + // build the Communi messages into chatterino messages + auto builtMessages = + buildRecentMessages(parsedMessages, shared.get()); + + postToThread([shared = std::move(shared), + root = std::move(root), + messages = std::move(builtMessages), + onLoaded]() mutable { + // Notify user about a possible gap in logs if it returned some messages + // but isn't currently joined to a channel + const auto errorCode = root.value("error_code").toString(); + if (!errorCode.isEmpty()) + { + qCDebug(LOG) + << QString("Got error from API: error_code=%1, " + "channel=%2") + .arg(errorCode, shared->getName()); + if (errorCode == "channel_not_joined" && + !messages.empty()) + { + shared->addMessage(makeSystemMessage( + "Message history service recovering, there may " + "be gaps in the message history.")); + } + } + + onLoaded(messages); + }); + }) + .onError([channelPtr, onError](const NetworkResult &result) { + auto shared = channelPtr.lock(); + if (!shared) + { + return; + } + + qCDebug(LOG) << "Failed to load recent messages for" + << shared->getName(); + + shared->addMessage(makeSystemMessage( + QStringLiteral( + "Message history service unavailable (Error: %1)") + .arg(result.formatError()))); + + onError(); + }) + .execute(); + }); +} + +} // namespace chatterino::recentmessages diff --git a/src/providers/recentmessages/Api.hpp b/src/providers/recentmessages/Api.hpp new file mode 100644 index 00000000000..57193be1fc2 --- /dev/null +++ b/src/providers/recentmessages/Api.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace chatterino { + +class Channel; +using ChannelPtr = std::shared_ptr; + +struct Message; +using MessagePtr = std::shared_ptr; + +} // namespace chatterino + +namespace chatterino::recentmessages { + +using ResultCallback = std::function &)>; +using ErrorCallback = std::function; + +/** + * @brief Loads recent messages for a channel using the Recent Messages API + * + * @param channelName Name of Twitch channel + * @param channelPtr Weak pointer to Channel to use to build messages + * @param onLoaded Callback taking the built messages as a const std::vector & + * @param onError Callback called when the network request fails + * @param limit Maximum number of messages to query + * @param after Only return messages that were received after this timestamp; ignored if `std::nullopt` + * @param before Only return messages that were received before this timestamp; ignored if `std::nullopt` + * @param jitter Whether to delay the request by a small random duration + */ +void load( + const QString &channelName, std::weak_ptr channelPtr, + ResultCallback onLoaded, ErrorCallback onError, int limit, + std::optional> after, + std::optional> before, + bool jitter); + +} // namespace chatterino::recentmessages diff --git a/src/providers/recentmessages/Impl.cpp b/src/providers/recentmessages/Impl.cpp new file mode 100644 index 00000000000..2f784ef35f5 --- /dev/null +++ b/src/providers/recentmessages/Impl.cpp @@ -0,0 +1,129 @@ +#include "providers/recentmessages/Impl.hpp" + +#include "common/Env.hpp" +#include "common/QLogging.hpp" +#include "providers/twitch/IrcMessageHandler.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/FormatTime.hpp" + +#include +#include + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoRecentMessages; + +} // namespace + +namespace chatterino::recentmessages::detail { + +// Parse the IRC messages returned in JSON form into Communi messages +std::vector parseRecentMessages( + const QJsonObject &jsonRoot) +{ + const auto jsonMessages = jsonRoot.value("messages").toArray(); + std::vector messages; + + if (jsonMessages.empty()) + { + return messages; + } + + for (const auto &jsonMessage : jsonMessages) + { + auto content = jsonMessage.toString(); + + // For explanation of why this exists, see src/providers/twitch/TwitchChannel.hpp, + // where these constants are defined + content.replace(COMBINED_FIXER, ZERO_WIDTH_JOINER); + + auto *message = + Communi::IrcMessage::fromData(content.toUtf8(), nullptr); + + messages.emplace_back(message); + } + + return messages; +} + +// Build Communi messages retrieved from the recent messages API into +// proper chatterino messages. +std::vector buildRecentMessages( + std::vector &messages, Channel *channel) +{ + std::vector allBuiltMessages; + + for (auto *message : messages) + { + if (message->tags().contains("rm-received-ts")) + { + const auto msgDate = + QDateTime::fromMSecsSinceEpoch( + message->tags().value("rm-received-ts").toLongLong()) + .date(); + + // Check if we need to insert a message stating that a new day began + if (msgDate != channel->lastDate_) + { + channel->lastDate_ = msgDate; + auto msg = makeSystemMessage( + QLocale().toString(msgDate, QLocale::LongFormat), + QTime(0, 0)); + msg->flags.set(MessageFlag::RecentMessage); + allBuiltMessages.emplace_back(msg); + } + } + + auto builtMessages = IrcMessageHandler::parseMessageWithReply( + channel, message, allBuiltMessages); + + for (const auto &builtMessage : builtMessages) + { + builtMessage->flags.set(MessageFlag::RecentMessage); + allBuiltMessages.emplace_back(builtMessage); + } + + message->deleteLater(); + } + + return allBuiltMessages; +} + +// Returns the URL to be used for querying the Recent Messages API for the +// given channel. +QUrl constructRecentMessagesUrl( + const QString &name, const int limit, + const std::optional> + after, + const std::optional> + before) +{ + QUrl url(Env::get().recentMessagesApiUrl.arg(name)); + QUrlQuery urlQuery(url); + if (!urlQuery.hasQueryItem("limit")) + { + urlQuery.addQueryItem("limit", QString::number(limit)); + } + if (after.has_value()) + { + urlQuery.addQueryItem( + "after", QString::number( + std::chrono::duration_cast( + after->time_since_epoch()) + .count())); + } + if (before.has_value()) + { + urlQuery.addQueryItem( + "before", QString::number( + std::chrono::duration_cast( + before->time_since_epoch()) + .count())); + } + url.setQuery(urlQuery); + return url; +} + +} // namespace chatterino::recentmessages::detail diff --git a/src/providers/recentmessages/Impl.hpp b/src/providers/recentmessages/Impl.hpp new file mode 100644 index 00000000000..3c60b6c3d9d --- /dev/null +++ b/src/providers/recentmessages/Impl.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "common/Channel.hpp" +#include "messages/Message.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace chatterino::recentmessages::detail { + +// Parse the IRC messages returned in JSON form into Communi messages +std::vector parseRecentMessages( + const QJsonObject &jsonRoot); + +// Build Communi messages retrieved from the recent messages API into +// proper chatterino messages. +std::vector buildRecentMessages( + std::vector &messages, Channel *channel); + +// Returns the URL to be used for querying the Recent Messages API for the +// given channel. +QUrl constructRecentMessagesUrl( + const QString &name, int limit, + std::optional> after, + std::optional> before); + +} // namespace chatterino::recentmessages::detail diff --git a/src/providers/seventv/SeventvAPI.cpp b/src/providers/seventv/SeventvAPI.cpp new file mode 100644 index 00000000000..dd7a1375fd3 --- /dev/null +++ b/src/providers/seventv/SeventvAPI.cpp @@ -0,0 +1,82 @@ +#include "providers/seventv/SeventvAPI.hpp" + +#include "common/Literals.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" + +namespace { + +using namespace chatterino::literals; + +const QString API_URL_USER = u"https://7tv.io/v3/users/twitch/%1"_s; +const QString API_URL_EMOTE_SET = u"https://7tv.io/v3/emote-sets/%1"_s; +const QString API_URL_PRESENCES = u"https://7tv.io/v3/users/%1/presences"_s; + +} // namespace + +// NOLINTBEGIN(readability-convert-member-functions-to-static) +namespace chatterino { + +void SeventvAPI::getUserByTwitchID( + const QString &twitchID, SuccessCallback &&onSuccess, + ErrorCallback &&onError) +{ + NetworkRequest(API_URL_USER.arg(twitchID), NetworkRequestType::Get) + .timeout(20000) + .onSuccess( + [callback = std::move(onSuccess)](const NetworkResult &result) { + auto json = result.parseJson(); + callback(json); + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +void SeventvAPI::getEmoteSet(const QString &emoteSet, + SuccessCallback &&onSuccess, + ErrorCallback &&onError) +{ + NetworkRequest(API_URL_EMOTE_SET.arg(emoteSet), NetworkRequestType::Get) + .timeout(25000) + .onSuccess( + [callback = std::move(onSuccess)](const NetworkResult &result) { + auto json = result.parseJson(); + callback(json); + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +void SeventvAPI::updatePresence(const QString &twitchChannelID, + const QString &seventvUserID, + SuccessCallback<> &&onSuccess, + ErrorCallback &&onError) +{ + QJsonObject payload{ + {u"kind"_s, 1}, // UserPresenceKindChannel + {u"data"_s, + QJsonObject{ + {u"id"_s, twitchChannelID}, + {u"platform"_s, u"TWITCH"_s}, + }}, + }; + + NetworkRequest(API_URL_PRESENCES.arg(seventvUserID), + NetworkRequestType::Post) + .json(payload) + .timeout(10000) + .onSuccess([callback = std::move(onSuccess)](const auto &) { + callback(); + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +} // namespace chatterino +// NOLINTEND(readability-convert-member-functions-to-static) diff --git a/src/providers/seventv/SeventvAPI.hpp b/src/providers/seventv/SeventvAPI.hpp new file mode 100644 index 00000000000..c47f9dd33a2 --- /dev/null +++ b/src/providers/seventv/SeventvAPI.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include + +class QString; +class QJsonObject; + +namespace chatterino { + +class NetworkResult; + +class SeventvAPI : public Singleton +{ + using ErrorCallback = std::function; + template + using SuccessCallback = std::function; + +public: + SeventvAPI() = default; + ~SeventvAPI() override = default; + + SeventvAPI(const SeventvAPI &) = delete; + SeventvAPI(SeventvAPI &&) = delete; + SeventvAPI &operator=(const SeventvAPI &) = delete; + SeventvAPI &operator=(SeventvAPI &&) = delete; + + virtual void getUserByTwitchID( + const QString &twitchID, + SuccessCallback &&onSuccess, + ErrorCallback &&onError); + virtual void getEmoteSet(const QString &emoteSet, + SuccessCallback &&onSuccess, + ErrorCallback &&onError); + + virtual void updatePresence(const QString &twitchChannelID, + const QString &seventvUserID, + SuccessCallback<> &&onSuccess, + ErrorCallback &&onError); +}; + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.cpp b/src/providers/seventv/SeventvBadges.cpp index 9bdc51bf6f4..2f09bd087bd 100644 --- a/src/providers/seventv/SeventvBadges.cpp +++ b/src/providers/seventv/SeventvBadges.cpp @@ -1,77 +1,77 @@ #include "providers/seventv/SeventvBadges.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "messages/Emote.hpp" +#include "messages/Image.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include #include #include -#include - namespace chatterino { -void SeventvBadges::initialize(Settings & /*settings*/, Paths & /*paths*/) -{ - this->loadSeventvBadges(); -} - -boost::optional SeventvBadges::getBadge(const UserId &id) +std::optional SeventvBadges::getBadge(const UserId &id) const { std::shared_lock lock(this->mutex_); auto it = this->badgeMap_.find(id.string); if (it != this->badgeMap_.end()) { - return this->emotes_[it->second]; + return it->second; } - return boost::none; + return std::nullopt; } -void SeventvBadges::loadSeventvBadges() +void SeventvBadges::assignBadgeToUser(const QString &badgeID, + const UserId &userID) { - // Cosmetics will work differently in v3, until this is ready - // we'll use this endpoint. - static QUrl url("https://7tv.io/v2/cosmetics"); - - static QUrlQuery urlQuery; - // valid user_identifier values: "object_id", "twitch_id", "login" - urlQuery.addQueryItem("user_identifier", "twitch_id"); - - url.setQuery(urlQuery); - - NetworkRequest(url) - .onSuccess([this](const NetworkResult &result) -> Outcome { - auto root = result.parseJson(); - - std::shared_lock lock(this->mutex_); - - int index = 0; - for (const auto &jsonBadge : root.value("badges").toArray()) - { - auto badge = jsonBadge.toObject(); - auto urls = badge.value("urls").toArray(); - auto emote = - Emote{EmoteName{}, - ImageSet{Url{urls.at(0).toArray().at(1).toString()}, - Url{urls.at(1).toArray().at(1).toString()}, - Url{urls.at(2).toArray().at(1).toString()}}, - Tooltip{badge.value("tooltip").toString()}, Url{}}; - - this->emotes_.push_back( - std::make_shared(std::move(emote))); - - for (const auto &user : badge.value("users").toArray()) - { - this->badgeMap_[user.toString()] = index; - } - ++index; - } - - return Success; - }) - .execute(); + const std::unique_lock lock(this->mutex_); + + const auto badgeIt = this->knownBadges_.find(badgeID); + if (badgeIt != this->knownBadges_.end()) + { + this->badgeMap_[userID.string] = badgeIt->second; + } +} + +void SeventvBadges::clearBadgeFromUser(const QString &badgeID, + const UserId &userID) +{ + const std::unique_lock lock(this->mutex_); + + const auto it = this->badgeMap_.find(userID.string); + if (it != this->badgeMap_.end() && it->second->id.string == badgeID) + { + this->badgeMap_.erase(userID.string); + } +} + +void SeventvBadges::registerBadge(const QJsonObject &badgeJson) +{ + const auto badgeID = badgeJson["id"].toString(); + + const std::unique_lock lock(this->mutex_); + + if (this->knownBadges_.find(badgeID) != this->knownBadges_.end()) + { + return; + } + + auto emote = Emote{ + .name = EmoteName{}, + .images = SeventvEmotes::createImageSet(badgeJson), + .tooltip = Tooltip{badgeJson["tooltip"].toString()}, + .homePage = Url{}, + .id = EmoteId{badgeID}, + }; + + if (emote.images.getImage1()->isEmpty()) + { + return; // Bad images + } + + this->knownBadges_[badgeID] = + std::make_shared(std::move(emote)); } } // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.hpp b/src/providers/seventv/SeventvBadges.hpp index 182d37b1055..72552891612 100644 --- a/src/providers/seventv/SeventvBadges.hpp +++ b/src/providers/seventv/SeventvBadges.hpp @@ -4,9 +4,10 @@ #include "common/Singleton.hpp" #include "util/QStringHash.hpp" -#include +#include #include +#include #include #include @@ -18,18 +19,27 @@ using EmotePtr = std::shared_ptr; class SeventvBadges : public Singleton { public: - void initialize(Settings &settings, Paths &paths) override; + // Return the badge, if any, that is assigned to the user + std::optional getBadge(const UserId &id) const; - boost::optional getBadge(const UserId &id); + // Assign the given badge to the user + void assignBadgeToUser(const QString &badgeID, const UserId &userID); -private: - void loadSeventvBadges(); + // Remove the given badge from the user + void clearBadgeFromUser(const QString &badgeID, const UserId &userID); + + // Register a new known badge + // The json object will contain all information about the badge, like its ID & its images + void registerBadge(const QJsonObject &badgeJson); - // Mutex for both `badgeMap_` and `emotes_` - std::shared_mutex mutex_; +private: + // Mutex for both `badgeMap_` and `knownBadges_` + mutable std::shared_mutex mutex_; - std::unordered_map badgeMap_; - std::vector emotes_; + // user-id => badge + std::unordered_map badgeMap_; + // badge-id => badge + std::unordered_map knownBadges_; }; } // namespace chatterino diff --git a/src/providers/seventv/SeventvCosmetics.hpp b/src/providers/seventv/SeventvCosmetics.hpp new file mode 100644 index 00000000000..6302c51f45d --- /dev/null +++ b/src/providers/seventv/SeventvCosmetics.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace chatterino::seventv { + +enum class CosmeticKind { + Badge, + Paint, + EmoteSet, + + INVALID, +}; + +} // namespace chatterino::seventv + +template <> +constexpr magic_enum::customize::customize_t + magic_enum::customize::enum_name( + chatterino::seventv::CosmeticKind value) noexcept +{ + using chatterino::seventv::CosmeticKind; + switch (value) + { + case CosmeticKind::Badge: + return "BADGE"; + case CosmeticKind::Paint: + return "PAINT"; + case CosmeticKind::EmoteSet: + return "EMOTE_SET"; + + default: + return default_tag; + } +} diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp index 2f7883abc65..5d70d59a43a 100644 --- a/src/providers/seventv/SeventvEmotes.cpp +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -1,15 +1,18 @@ #include "providers/seventv/SeventvEmotes.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" +#include "Application.hpp" +#include "common/Literals.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/ImageSet.hpp" #include "messages/MessageBuilder.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" +#include "util/Helpers.hpp" #include #include @@ -36,15 +39,11 @@ using namespace seventv::eventapi; const QString CHANNEL_HAS_NO_EMOTES("This channel has no 7TV channel emotes."); const QString EMOTE_LINK_FORMAT("https://7tv.app/emotes/%1"); -const QString API_URL_USER("https://7tv.io/v3/users/twitch/%1"); -const QString API_URL_GLOBAL_EMOTE_SET("https://7tv.io/v3/emote-sets/global"); -const QString API_URL_EMOTE_SET("https://7tv.io/v3/emote-sets/%1"); - struct CreateEmoteResult { Emote emote; EmoteId id; EmoteName name; - bool hasImages; + bool hasImages{}; }; EmotePtr cachedOrMake(Emote &&emote, const EmoteId &id) @@ -77,71 +76,6 @@ bool isZeroWidthRecommended(const QJsonObject &emoteData) return flags.has(SeventvEmoteFlag::ZeroWidth); } -ImageSet makeImageSet(const QJsonObject &emoteData) -{ - auto host = emoteData["host"].toObject(); - // "//cdn.7tv[...]" - auto baseUrl = host["url"].toString(); - auto files = host["files"].toArray(); - - // TODO: emit four images - std::array sizes; - double baseWidth = 0.0; - int nextSize = 0; - - for (auto fileItem : files) - { - if (nextSize >= sizes.size()) - { - break; - } - - auto file = fileItem.toObject(); - if (file["format"].toString() != "WEBP") - { - continue; // We only use webp - } - - double width = file["width"].toDouble(); - double scale = 1.0; // in relation to first image - if (baseWidth > 0.0) - { - scale = baseWidth / width; - } - else - { - // => this is the first image - baseWidth = width; - } - - auto image = Image::fromUrl( - {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, - scale); - - sizes.at(nextSize) = image; - nextSize++; - } - - if (nextSize < sizes.size()) - { - // this should be really rare - // this means we didn't get all sizes of an emote - if (nextSize == 0) - { - qCDebug(chatterinoSeventv) - << "Got file list without any eligible files"; - // When this emote is typed, chatterino will crash. - return ImageSet{}; - } - for (; nextSize < sizes.size(); nextSize++) - { - sizes.at(nextSize) = Image::getEmpty(); - } - } - - return ImageSet{sizes[0], sizes[1], sizes[2]}; -} - Tooltip createTooltip(const QString &name, const QString &author, bool isGlobal) { return Tooltip{QString("%1
%2 7TV Emote
By: %3") @@ -172,12 +106,12 @@ CreateEmoteResult createEmote(const QJsonObject &activeEmote, ? createAliasedTooltip(emoteName.string, baseEmoteName.string, author.string, isGlobal) : createTooltip(emoteName.string, author.string, isGlobal); - auto imageSet = makeImageSet(emoteData); + auto imageSet = SeventvEmotes::createImageSet(emoteData); auto emote = Emote({emoteName, imageSet, tooltip, Url{EMOTE_LINK_FORMAT.arg(emoteId.string)}, zeroWidth, emoteId, - author, boost::make_optional(aliasedName, baseEmoteName)}); + author, makeConditionedOptional(aliasedName, baseEmoteName)}); return {emote, emoteId, emoteName, !emote.images.getImage1()->isEmpty()}; } @@ -194,7 +128,34 @@ bool checkEmoteVisibility(const QJsonObject &emoteData) return !flags.has(SeventvEmoteFlag::ContentTwitchDisallowed); } -EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal) +EmotePtr createUpdatedEmote(const EmotePtr &oldEmote, + const EmoteUpdateDispatch &dispatch) +{ + bool toNonAliased = oldEmote->baseName.has_value() && + dispatch.emoteName == oldEmote->baseName->string; + + auto baseName = oldEmote->baseName.value_or(oldEmote->name); + auto emote = std::make_shared(Emote( + {EmoteName{dispatch.emoteName}, oldEmote->images, + toNonAliased + ? createTooltip(dispatch.emoteName, oldEmote->author.string, false) + : createAliasedTooltip(dispatch.emoteName, baseName.string, + oldEmote->author.string, false), + oldEmote->homePage, oldEmote->zeroWidth, oldEmote->id, + oldEmote->author, makeConditionedOptional(!toNonAliased, baseName)})); + return emote; +} + +} // namespace + +namespace chatterino { + +using namespace seventv::eventapi; +using namespace seventv::detail; +using namespace literals; + +EmoteMap seventv::detail::parseEmotes(const QJsonArray &emoteSetEmotes, + bool isGlobal) { auto emotes = EmoteMap(); @@ -224,30 +185,6 @@ EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal) return emotes; } -EmotePtr createUpdatedEmote(const EmotePtr &oldEmote, - const EmoteUpdateDispatch &dispatch) -{ - bool toNonAliased = oldEmote->baseName.has_value() && - dispatch.emoteName == oldEmote->baseName->string; - - auto baseName = oldEmote->baseName.get_value_or(oldEmote->name); - auto emote = std::make_shared(Emote( - {EmoteName{dispatch.emoteName}, oldEmote->images, - toNonAliased - ? createTooltip(dispatch.emoteName, oldEmote->author.string, false) - : createAliasedTooltip(dispatch.emoteName, baseName.string, - oldEmote->author.string, false), - oldEmote->homePage, oldEmote->zeroWidth, oldEmote->id, - oldEmote->author, boost::make_optional(!toNonAliased, baseName)})); - return emote; -} - -} // namespace - -namespace chatterino { - -using namespace seventv::eventapi; - SeventvEmotes::SeventvEmotes() : global_(std::make_shared()) { @@ -258,15 +195,14 @@ std::shared_ptr SeventvEmotes::globalEmotes() const return this->global_.get(); } -boost::optional SeventvEmotes::globalEmote( - const EmoteName &name) const +std::optional SeventvEmotes::globalEmote(const EmoteName &name) const { auto emotes = this->global_.get(); auto it = emotes->find(name); if (it == emotes->end()) { - return boost::none; + return std::nullopt; } return it->second; } @@ -275,29 +211,32 @@ void SeventvEmotes::loadGlobalEmotes() { if (!Settings::instance().enableSevenTVGlobalEmotes) { - this->global_.set(EMPTY_EMOTE_MAP); + this->setGlobalEmotes(EMPTY_EMOTE_MAP); return; } qCDebug(chatterinoSeventv) << "Loading 7TV Global Emotes"; - NetworkRequest(API_URL_GLOBAL_EMOTE_SET, NetworkRequestType::Get) - .timeout(30000) - .onSuccess([this](const NetworkResult &result) -> Outcome { - QJsonArray parsedEmotes = result.parseJson()["emotes"].toArray(); + getIApp()->getSeventvAPI()->getEmoteSet( + u"global"_s, + [this](const auto &json) { + QJsonArray parsedEmotes = json["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, true); qCDebug(chatterinoSeventv) << "Loaded" << emoteMap.size() << "7TV Global Emotes"; - this->global_.set(std::make_shared(std::move(emoteMap))); - - return Success; - }) - .onError([](const NetworkResult &result) { + this->setGlobalEmotes( + std::make_shared(std::move(emoteMap))); + }, + [](const auto &result) { qCWarning(chatterinoSeventv) << "Couldn't load 7TV global emotes" << result.getData(); - }) - .execute(); + }); +} + +void SeventvEmotes::setGlobalEmotes(std::shared_ptr emotes) +{ + this->global_.set(std::move(emotes)); } void SeventvEmotes::loadChannelEmotes( @@ -307,13 +246,12 @@ void SeventvEmotes::loadChannelEmotes( qCDebug(chatterinoSeventv) << "Reloading 7TV Channel Emotes" << channelId << manualRefresh; - NetworkRequest(API_URL_USER.arg(channelId), NetworkRequestType::Get) - .timeout(20000) - .onSuccess([callback = std::move(callback), channel, channelId, - manualRefresh](const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); - auto emoteSet = json["emote_set"].toObject(); - auto parsedEmotes = emoteSet["emotes"].toArray(); + getIApp()->getSeventvAPI()->getUserByTwitchID( + channelId, + [callback = std::move(callback), channel, channelId, + manualRefresh](const auto &json) { + const auto emoteSet = json["emote_set"].toObject(); + const auto parsedEmotes = emoteSet["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, false); bool hasEmotes = !emoteMap.empty(); @@ -344,7 +282,7 @@ void SeventvEmotes::loadChannelEmotes( auto shared = channel.lock(); if (!shared) { - return Success; + return; } if (manualRefresh) @@ -360,49 +298,40 @@ void SeventvEmotes::loadChannelEmotes( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - return Success; - }) - .onError( - [channelId, channel, manualRefresh](const NetworkResult &result) { - auto shared = channel.lock(); - if (!shared) - { - return; - } - if (result.status() == 404) - { - qCWarning(chatterinoSeventv) - << "Error occurred fetching 7TV emotes: " - << result.parseJson(); - if (manualRefresh) - { - shared->addMessage( - makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); - } - } - else if (result.status() == NetworkResult::timedoutStatus) - { - // TODO: Auto retry in case of a timeout, with a delay - qCWarning(chatterinoSeventv) - << "Fetching 7TV emotes for channel" << channelId - << "failed due to timeout"; - shared->addMessage(makeSystemMessage( - "Failed to fetch 7TV channel emotes. (timed out)")); - } - else + }, + [channelId, channel, manualRefresh](const auto &result) { + auto shared = channel.lock(); + if (!shared) + { + return; + } + if (result.status() == 404) + { + qCWarning(chatterinoSeventv) + << "Error occurred fetching 7TV emotes: " + << result.parseJson(); + if (manualRefresh) { - qCWarning(chatterinoSeventv) - << "Error fetching 7TV emotes for channel" << channelId - << ", error" << result.status(); shared->addMessage( - makeSystemMessage("Failed to fetch 7TV channel " - "emotes. (unknown error)")); + makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } - }) - .execute(); + } + else + { + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); + qCWarning(chatterinoSeventv) + << "Error fetching 7TV emotes for channel" << channelId + << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch 7TV channel " + "emotes. (Error: %1)") + .arg(errorString))); + } + }); } -boost::optional SeventvEmotes::addEmote( +std::optional SeventvEmotes::addEmote( Atomic> &map, const EmoteAddDispatch &dispatch) { @@ -410,7 +339,7 @@ boost::optional SeventvEmotes::addEmote( auto emoteData = dispatch.emoteJson["data"].toObject(); if (emoteData.empty() || !checkEmoteVisibility(emoteData)) { - return boost::none; + return std::nullopt; } // This copies the map. @@ -421,7 +350,7 @@ boost::optional SeventvEmotes::addEmote( // Incoming emote didn't contain any images, abort qCDebug(chatterinoSeventv) << "Emote without images:" << dispatch.emoteJson; - return boost::none; + return std::nullopt; } auto emote = std::make_shared(std::move(result.emote)); updatedMap[result.name] = emote; @@ -430,7 +359,7 @@ boost::optional SeventvEmotes::addEmote( return emote; } -boost::optional SeventvEmotes::updateEmote( +std::optional SeventvEmotes::updateEmote( Atomic> &map, const EmoteUpdateDispatch &dispatch) { @@ -438,7 +367,7 @@ boost::optional SeventvEmotes::updateEmote( auto oldEmote = oldMap->findEmote(dispatch.emoteName, dispatch.emoteID); if (oldEmote == oldMap->end()) { - return boost::none; + return std::nullopt; } // This copies the map. @@ -452,7 +381,7 @@ boost::optional SeventvEmotes::updateEmote( return emote; } -boost::optional SeventvEmotes::removeEmote( +std::optional SeventvEmotes::removeEmote( Atomic> &map, const EmoteRemoveDispatch &dispatch) { @@ -463,7 +392,7 @@ boost::optional SeventvEmotes::removeEmote( { // We already copied the map at this point and are now discarding the copy. // This is fine, because this case should be really rare. - return boost::none; + return std::nullopt; } auto emote = it->second; updatedMap.erase(it); @@ -479,11 +408,9 @@ void SeventvEmotes::getEmoteSet( { qCDebug(chatterinoSeventv) << "Loading 7TV Emote Set" << emoteSetId; - NetworkRequest(API_URL_EMOTE_SET.arg(emoteSetId), NetworkRequestType::Get) - .timeout(20000) - .onSuccess([callback = std::move(successCallback), - emoteSetId](const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); + getIApp()->getSeventvAPI()->getEmoteSet( + emoteSetId, + [callback = std::move(successCallback), emoteSetId](const auto &json) { auto parsedEmotes = json["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, false); @@ -492,20 +419,74 @@ void SeventvEmotes::getEmoteSet( << "7TV Emotes from" << emoteSetId; callback(std::move(emoteMap), json["name"].toString()); - return Success; - }) - .onError([emoteSetId, callback = std::move(errorCallback)]( - const NetworkResult &result) { - if (result.status() == NetworkResult::timedoutStatus) - { - callback("timed out"); - } - else - { - callback(QString("status: %1").arg(result.status())); - } - }) - .execute(); + }, + [emoteSetId, callback = std::move(errorCallback)](const auto &result) { + callback(result.formatError()); + }); +} + +ImageSet SeventvEmotes::createImageSet(const QJsonObject &emoteData) +{ + auto host = emoteData["host"].toObject(); + // "//cdn.7tv[...]" + auto baseUrl = host["url"].toString(); + auto files = host["files"].toArray(); + + std::array sizes; + double baseWidth = 0.0; + size_t nextSize = 0; + + for (auto fileItem : files) + { + if (nextSize >= sizes.size()) + { + break; + } + + auto file = fileItem.toObject(); + if (file["format"].toString() != "WEBP") + { + continue; // We only use webp + } + + double width = file["width"].toDouble(); + double scale = 1.0; // in relation to first image + if (baseWidth > 0.0) + { + scale = baseWidth / width; + } + else + { + // => this is the first image + baseWidth = width; + } + + auto image = Image::fromUrl( + {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, + scale); + + sizes.at(nextSize) = image; + nextSize++; + } + + if (nextSize < sizes.size()) + { + // this should be really rare + // this means we didn't get all sizes of an emote + if (nextSize == 0) + { + qCDebug(chatterinoSeventv) + << "Got file list without any eligible files"; + // When this emote is typed, chatterino will crash. + return ImageSet{}; + } + for (; nextSize < sizes.size(); nextSize++) + { + sizes.at(nextSize) = Image::getEmpty(); + } + } + + return ImageSet{sizes[0], sizes[1], sizes[2]}; } } // namespace chatterino diff --git a/src/providers/seventv/SeventvEmotes.hpp b/src/providers/seventv/SeventvEmotes.hpp index f978337be43..7daf7acc096 100644 --- a/src/providers/seventv/SeventvEmotes.hpp +++ b/src/providers/seventv/SeventvEmotes.hpp @@ -1,16 +1,19 @@ #pragma once -#include "boost/optional.hpp" #include "common/Aliases.hpp" #include "common/Atomic.hpp" #include "common/FlagsEnum.hpp" +#include +#include + #include +#include namespace chatterino { +class ImageSet; class Channel; - namespace seventv::eventapi { struct EmoteAddDispatch; struct EmoteUpdateDispatch; @@ -61,6 +64,26 @@ struct Emote; using EmotePtr = std::shared_ptr; class EmoteMap; +enum class SeventvEmoteSetKind : uint8_t { + Global, + Personal, + Channel, +}; + +enum class SeventvEmoteSetFlag : uint32_t { + Immutable = (1 << 0), + Privileged = (1 << 1), + Personal = (1 << 2), + Commercial = (1 << 3), +}; +using SeventvEmoteSetFlags = FlagsEnum; + +namespace seventv::detail { + + EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal); + +} // namespace seventv::detail + class SeventvEmotes final { public: @@ -73,8 +96,9 @@ class SeventvEmotes final SeventvEmotes(); std::shared_ptr globalEmotes() const; - boost::optional globalEmote(const EmoteName &name) const; + std::optional globalEmote(const EmoteName &name) const; void loadGlobalEmotes(); + void setGlobalEmotes(std::shared_ptr emotes); static void loadChannelEmotes( const std::weak_ptr &channel, const QString &channelId, std::function callback, @@ -87,7 +111,7 @@ class SeventvEmotes final * * @return The added emote if an emote was added. */ - static boost::optional addEmote( + static std::optional addEmote( Atomic> &map, const seventv::eventapi::EmoteAddDispatch &dispatch); @@ -98,7 +122,7 @@ class SeventvEmotes final * * @return The updated emote if any emote was updated. */ - static boost::optional updateEmote( + static std::optional updateEmote( Atomic> &map, const seventv::eventapi::EmoteUpdateDispatch &dispatch); @@ -109,7 +133,7 @@ class SeventvEmotes final * * @return The removed emote if any emote was removed. */ - static boost::optional removeEmote( + static std::optional removeEmote( Atomic> &map, const seventv::eventapi::EmoteRemoveDispatch &dispatch); @@ -119,6 +143,13 @@ class SeventvEmotes final std::function successCallback, std::function errorCallback); + /** + * Creates an image set from a 7TV emote or badge. + * + * @param emoteData { host: { files: [], url } } + */ + static ImageSet createImageSet(const QJsonObject &emoteData); + private: Atomic> global_; }; diff --git a/src/providers/seventv/SeventvEventAPI.cpp b/src/providers/seventv/SeventvEventAPI.cpp index 5cec6ed30a3..2b8c0ec27d1 100644 --- a/src/providers/seventv/SeventvEventAPI.cpp +++ b/src/providers/seventv/SeventvEventAPI.cpp @@ -1,8 +1,11 @@ #include "providers/seventv/SeventvEventAPI.hpp" +#include "Application.hpp" #include "providers/seventv/eventapi/Client.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" #include "providers/seventv/eventapi/Message.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvCosmetics.hpp" #include @@ -10,6 +13,7 @@ namespace chatterino { +using namespace seventv; using namespace seventv::eventapi; SeventvEventAPI::SeventvEventAPI( @@ -35,6 +39,25 @@ void SeventvEventAPI::subscribeUser(const QString &userID, } } +void SeventvEventAPI::subscribeTwitchChannel(const QString &id) +{ + if (this->subscribedTwitchChannels_.insert(id).second) + { + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::CreateCosmetic, + }); + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::CreateEntitlement, + }); + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::DeleteEntitlement, + }); + } +} + void SeventvEventAPI::unsubscribeEmoteSet(const QString &id) { if (this->subscribedEmoteSets_.erase(id) > 0) @@ -53,6 +76,25 @@ void SeventvEventAPI::unsubscribeUser(const QString &id) } } +void SeventvEventAPI::unsubscribeTwitchChannel(const QString &id) +{ + if (this->subscribedTwitchChannels_.erase(id) > 0) + { + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::CreateCosmetic, + }); + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::CreateEntitlement, + }); + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::DeleteEntitlement, + }); + } +} + std::shared_ptr> SeventvEventAPI::createClient( liveupdates::WebsocketClient &client, websocketpp::connection_hdl hdl) { @@ -144,9 +186,49 @@ void SeventvEventAPI::handleDispatch(const Dispatch &dispatch) this->onUserUpdate(dispatch); } break; + case SubscriptionType::CreateCosmetic: { + const CosmeticCreateDispatch cosmetic(dispatch); + if (cosmetic.validate()) + { + this->onCosmeticCreate(cosmetic); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid cosmetic dispatch" << dispatch.body; + } + } + break; + case SubscriptionType::CreateEntitlement: { + const EntitlementCreateDeleteDispatch entitlement(dispatch); + if (entitlement.validate()) + { + this->onEntitlementCreate(entitlement); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid entitlement create dispatch" << dispatch.body; + } + } + break; + case SubscriptionType::DeleteEntitlement: { + const EntitlementCreateDeleteDispatch entitlement(dispatch); + if (entitlement.validate()) + { + this->onEntitlementDelete(entitlement); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid entitlement delete dispatch" << dispatch.body; + } + } + break; default: { qCDebug(chatterinoSeventvEventAPI) - << "Unknown subscription type:" << (int)dispatch.type + << "Unknown subscription type:" + << magic_enum::enum_name(dispatch.type).data() << "body:" << dispatch.body; } break; @@ -261,4 +343,53 @@ void SeventvEventAPI::onUserUpdate(const Dispatch &dispatch) } } +// NOLINTBEGIN(readability-convert-member-functions-to-static) + +void SeventvEventAPI::onCosmeticCreate(const CosmeticCreateDispatch &cosmetic) +{ + auto *badges = getIApp()->getSeventvBadges(); + switch (cosmetic.kind) + { + case CosmeticKind::Badge: { + badges->registerBadge(cosmetic.data); + } + break; + default: + break; + } +} + +void SeventvEventAPI::onEntitlementCreate( + const EntitlementCreateDeleteDispatch &entitlement) +{ + auto *badges = getIApp()->getSeventvBadges(); + switch (entitlement.kind) + { + case CosmeticKind::Badge: { + badges->assignBadgeToUser(entitlement.refID, + UserId{entitlement.userID}); + } + break; + default: + break; + } +} + +void SeventvEventAPI::onEntitlementDelete( + const EntitlementCreateDeleteDispatch &entitlement) +{ + auto *badges = getIApp()->getSeventvBadges(); + switch (entitlement.kind) + { + case CosmeticKind::Badge: { + badges->clearBadgeFromUser(entitlement.refID, + UserId{entitlement.userID}); + } + break; + default: + break; + } +} +// NOLINTEND(readability-convert-member-functions-to-static) + } // namespace chatterino diff --git a/src/providers/seventv/SeventvEventAPI.hpp b/src/providers/seventv/SeventvEventAPI.hpp index 5672e59b8d7..6a482731897 100644 --- a/src/providers/seventv/SeventvEventAPI.hpp +++ b/src/providers/seventv/SeventvEventAPI.hpp @@ -15,8 +15,12 @@ namespace seventv::eventapi { struct EmoteUpdateDispatch; struct EmoteRemoveDispatch; struct UserConnectionUpdateDispatch; + struct CosmeticCreateDispatch; + struct EntitlementCreateDeleteDispatch; } // namespace seventv::eventapi +class SeventvBadges; + class SeventvEventAPI : public BasicPubSubManager { @@ -44,11 +48,20 @@ class SeventvEventAPI * @param emoteSetID 7TV emote-set-id, may be empty. */ void subscribeUser(const QString &userID, const QString &emoteSetID); + /** + * Subscribes to cosmetics and entitlements in a Twitch channel + * if not already subscribed. + * + * @param id Twitch channel id + */ + void subscribeTwitchChannel(const QString &id); /** Unsubscribes from a user by its 7TV user id */ void unsubscribeUser(const QString &id); /** Unsubscribes from an emote-set by its id */ void unsubscribeEmoteSet(const QString &id); + /** Unsubscribes from cosmetics and entitlements in a Twitch channel */ + void unsubscribeTwitchChannel(const QString &id); protected: std::shared_ptr> @@ -64,11 +77,19 @@ class SeventvEventAPI void onEmoteSetUpdate(const seventv::eventapi::Dispatch &dispatch); void onUserUpdate(const seventv::eventapi::Dispatch &dispatch); + void onCosmeticCreate( + const seventv::eventapi::CosmeticCreateDispatch &cosmetic); + void onEntitlementCreate( + const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement); + void onEntitlementDelete( + const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement); /** emote-set ids */ std::unordered_set subscribedEmoteSets_; /** user ids */ std::unordered_set subscribedUsers_; + /** Twitch channel ids */ + std::unordered_set subscribedTwitchChannels_; std::chrono::milliseconds heartbeatInterval_; }; diff --git a/src/providers/seventv/eventapi/Dispatch.cpp b/src/providers/seventv/eventapi/Dispatch.cpp index bb4b4fa1da9..03fbdac970c 100644 --- a/src/providers/seventv/eventapi/Dispatch.cpp +++ b/src/providers/seventv/eventapi/Dispatch.cpp @@ -1,5 +1,7 @@ #include "providers/seventv/eventapi/Dispatch.hpp" +#include + #include namespace chatterino::seventv::eventapi { @@ -91,4 +93,45 @@ bool UserConnectionUpdateDispatch::validate() const !this->emoteSetID.isEmpty(); } +CosmeticCreateDispatch::CosmeticCreateDispatch(const Dispatch &dispatch) + : data(dispatch.body["object"]["data"].toObject()) + , kind(magic_enum::enum_cast( + dispatch.body["object"]["kind"].toString().toStdString()) + .value_or(CosmeticKind::INVALID)) +{ +} + +bool CosmeticCreateDispatch::validate() const +{ + return !this->data.empty() && this->kind != CosmeticKind::INVALID; +} + +EntitlementCreateDeleteDispatch::EntitlementCreateDeleteDispatch( + const Dispatch &dispatch) +{ + const auto obj = dispatch.body["object"].toObject(); + this->refID = obj["ref_id"].toString(); + this->kind = magic_enum::enum_cast( + obj["kind"].toString().toStdString()) + .value_or(CosmeticKind::INVALID); + + const auto userConnections = obj["user"]["connections"].toArray(); + for (const auto &connectionJson : userConnections) + { + const auto connection = connectionJson.toObject(); + if (connection["platform"].toString() == "TWITCH") + { + this->userID = connection["id"].toString(); + this->userName = connection["username"].toString(); + break; + } + } +} + +bool EntitlementCreateDeleteDispatch::validate() const +{ + return !this->userID.isEmpty() && !this->userName.isEmpty() && + !this->refID.isEmpty() && this->kind != CosmeticKind::INVALID; +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Dispatch.hpp b/src/providers/seventv/eventapi/Dispatch.hpp index 666f5c28ac6..04bad159bec 100644 --- a/src/providers/seventv/eventapi/Dispatch.hpp +++ b/src/providers/seventv/eventapi/Dispatch.hpp @@ -1,6 +1,7 @@ #pragma once #include "providers/seventv/eventapi/Subscription.hpp" +#include "providers/seventv/SeventvCosmetics.hpp" #include #include @@ -67,4 +68,26 @@ struct UserConnectionUpdateDispatch { bool validate() const; }; +struct CosmeticCreateDispatch { + QJsonObject data; + CosmeticKind kind; + + CosmeticCreateDispatch(const Dispatch &dispatch); + + bool validate() const; +}; + +struct EntitlementCreateDeleteDispatch { + /** id of the user */ + QString userID; + QString userName; + /** id of the entitlement */ + QString refID; + CosmeticKind kind; + + EntitlementCreateDeleteDispatch(const Dispatch &dispatch); + + bool validate() const; +}; + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Message.cpp b/src/providers/seventv/eventapi/Message.cpp index a216a72b44b..f3b59f7a9fb 100644 --- a/src/providers/seventv/eventapi/Message.cpp +++ b/src/providers/seventv/eventapi/Message.cpp @@ -8,4 +8,16 @@ Message::Message(QJsonObject _json) { } +std::optional parseBaseMessage(const QString &blob) +{ + QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8())); + + if (jsonDoc.isNull()) + { + return std::nullopt; + } + + return Message(jsonDoc.object()); +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Message.hpp b/src/providers/seventv/eventapi/Message.hpp index 1b857f9eaa7..5a3eebc9968 100644 --- a/src/providers/seventv/eventapi/Message.hpp +++ b/src/providers/seventv/eventapi/Message.hpp @@ -2,12 +2,13 @@ #include "providers/seventv/eventapi/Subscription.hpp" -#include -#include +#include #include #include #include +#include + namespace chatterino::seventv::eventapi { struct Message { @@ -18,25 +19,15 @@ struct Message { Message(QJsonObject _json); template - boost::optional toInner(); + std::optional toInner(); }; template -boost::optional Message::toInner() +std::optional Message::toInner() { return InnerClass{this->data}; } -static boost::optional parseBaseMessage(const QString &blob) -{ - QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8())); - - if (jsonDoc.isNull()) - { - return boost::none; - } - - return Message(jsonDoc.object()); -} +std::optional parseBaseMessage(const QString &blob); } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Subscription.cpp b/src/providers/seventv/eventapi/Subscription.cpp index 1de1f667e7f..91d330c5e61 100644 --- a/src/providers/seventv/eventapi/Subscription.cpp +++ b/src/providers/seventv/eventapi/Subscription.cpp @@ -102,4 +102,34 @@ QDebug &operator<<(QDebug &dbg, const ObjectIDCondition &condition) return dbg; } +ChannelCondition::ChannelCondition(QString twitchID) + : twitchID(std::move(twitchID)) +{ +} + +QJsonObject ChannelCondition::encode() const +{ + QJsonObject obj; + obj["ctx"] = "channel"; + obj["platform"] = "TWITCH"; + obj["id"] = this->twitchID; + return obj; +} + +QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition) +{ + dbg << "{ twitchID:" << condition.twitchID << '}'; + return dbg; +} + +bool ChannelCondition::operator==(const ChannelCondition &rhs) const +{ + return this->twitchID == rhs.twitchID; +} + +bool ChannelCondition::operator!=(const ChannelCondition &rhs) const +{ + return !(*this == rhs); +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Subscription.hpp b/src/providers/seventv/eventapi/Subscription.hpp index 53143fbd861..65cf0354443 100644 --- a/src/providers/seventv/eventapi/Subscription.hpp +++ b/src/providers/seventv/eventapi/Subscription.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -12,9 +12,22 @@ namespace chatterino::seventv::eventapi { // https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#subscription-types enum class SubscriptionType { + AnyEmoteSet, + CreateEmoteSet, UpdateEmoteSet, + UpdateUser, + AnyCosmetic, + CreateCosmetic, + UpdateCosmetic, + DeleteCosmetic, + + AnyEntitlement, + CreateEntitlement, + UpdateEntitlement, + DeleteEntitlement, + INVALID, }; @@ -46,7 +59,19 @@ struct ObjectIDCondition { bool operator!=(const ObjectIDCondition &rhs) const; }; -using Condition = std::variant; +struct ChannelCondition { + ChannelCondition(QString twitchID); + + QString twitchID; + + QJsonObject encode() const; + + friend QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition); + bool operator==(const ChannelCondition &rhs) const; + bool operator!=(const ChannelCondition &rhs) const; +}; + +using Condition = std::variant; struct Subscription { bool operator==(const Subscription &rhs) const; @@ -70,10 +95,30 @@ constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< using chatterino::seventv::eventapi::SubscriptionType; switch (value) { + case SubscriptionType::AnyEmoteSet: + return "emote_set.*"; + case SubscriptionType::CreateEmoteSet: + return "emote_set.create"; case SubscriptionType::UpdateEmoteSet: return "emote_set.update"; case SubscriptionType::UpdateUser: return "user.update"; + case SubscriptionType::AnyCosmetic: + return "cosmetic.*"; + case SubscriptionType::CreateCosmetic: + return "cosmetic.create"; + case SubscriptionType::UpdateCosmetic: + return "cosmetic.update"; + case SubscriptionType::DeleteCosmetic: + return "cosmetic.delete"; + case SubscriptionType::AnyEntitlement: + return "entitlement.*"; + case SubscriptionType::CreateEntitlement: + return "entitlement.create"; + case SubscriptionType::UpdateEntitlement: + return "entitlement.update"; + case SubscriptionType::DeleteEntitlement: + return "entitlement.delete"; default: return default_tag; @@ -91,6 +136,15 @@ struct hash { } }; +template <> +struct hash { + size_t operator()( + const chatterino::seventv::eventapi::ChannelCondition &c) const + { + return qHash(c.twitchID); + } +}; + template <> struct hash { size_t operator()( diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 1c04eac4e5c..ed9c8714cf6 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -1,8 +1,11 @@ -#include "IrcMessageHandler.hpp" +#include "providers/twitch/IrcMessageHandler.hpp" #include "Application.hpp" +#include "common/Common.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/ignores/IgnoreController.hpp" #include "messages/LimitedQueue.hpp" #include "messages/Link.hpp" #include "messages/Message.hpp" @@ -20,27 +23,34 @@ #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" +#include "util/ChannelHelpers.hpp" #include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" #include "util/StreamerMode.hpp" #include +#include +#include #include #include +using namespace chatterino::literals; + namespace { + using namespace chatterino; // Message types below are the ones that might contain special user's message on USERNOTICE -static const QSet specialMessageTypes{ - "sub", // - "subgift", // - "resub", // resub messages - "bitsbadgetier", // bits badge upgrade - "ritual", // new viewer ritual - "announcement", // new mod announcement thing +const QSet SPECIAL_MESSAGE_TYPES{ + "sub", // + "subgift", // + "resub", // resub messages + "bitsbadgetier", // bits badge upgrade + "ritual", // new viewer ritual + "announcement", // new mod announcement thing + "viewermilestone", // watch streak, but other categories possible in future }; MessagePtr generateBannedMessage(bool confirmedBan) @@ -118,39 +128,57 @@ void updateReplyParticipatedStatus(const QVariantMap &tags, bool isNew) { const auto ¤tLogin = - getApp()->accounts->twitch.getCurrent()->getUserName(); - if (thread->participated()) + getIApp()->getAccounts()->twitch.getCurrent()->getUserName(); + + if (thread->subscribed()) + { + builder.message().flags.set(MessageFlag::SubscribedThread); + return; + } + + if (thread->unsubscribed()) { - builder.message().flags.set(MessageFlag::ParticipatedThread); return; } - if (isNew) + if (getSettings()->autoSubToParticipatedThreads) { - if (const auto it = tags.find("reply-parent-user-login"); - it != tags.end()) + if (isNew) { - auto name = it.value().toString(); - if (name == currentLogin) + if (const auto it = tags.find("reply-parent-user-login"); + it != tags.end()) { - thread->markParticipated(); - builder.message().flags.set(MessageFlag::ParticipatedThread); - return; // already marked as participated + auto name = it.value().toString(); + if (name == currentLogin) + { + thread->markSubscribed(); + builder.message().flags.set(MessageFlag::SubscribedThread); + return; // already marked as participated + } } } + + if (senderLogin == currentLogin) + { + thread->markSubscribed(); + // don't set the highlight here + } } +} - if (senderLogin == currentLogin) +ChannelPtr channelOrEmptyByTarget(const QString &target, + TwitchIrcServer &server) +{ + QString channelName; + if (!trimChannelName(target, channelName)) { - thread->markParticipated(); - // don't set the highlight here + return Channel::getEmpty(); } -} -} // namespace -namespace chatterino { + return server.getChannelOrEmpty(channelName); +} -static float relativeSimilarity(const QString &str1, const QString &str2) +float relativeSimilarity(const QString &str1, const QString &str2) { // Longest Common Substring Problem std::vector> tree(str1.size(), @@ -184,118 +212,331 @@ static float relativeSimilarity(const QString &str1, const QString &str2) } // ensure that no div by 0 - return z == 0 ? 0.f - : float(z) / - std::max(1, std::max(str1.size(), str2.size())); -}; + if (z == 0) + { + return 0.F; + } -float IrcMessageHandler::similarity( - MessagePtr msg, const LimitedQueueSnapshot &messages) + auto div = std::max(1, std::max(str1.size(), str2.size())); + + return float(z) / float(div); +} + +QMap parseBadges(const QString &badgesString) { - float similarityPercent = 0.0f; - int checked = 0; - for (int i = 1; i <= messages.size(); ++i) + QMap badges; + + for (const auto &badgeData : badgesString.split(',')) { - if (checked >= getSettings()->hideSimilarMaxMessagesToCheck) - { - break; - } - const auto &prevMsg = messages[messages.size() - i]; - if (prevMsg->parseTime.secsTo(QTime::currentTime()) >= - getSettings()->hideSimilarMaxDelay) - { - break; - } - if (getSettings()->hideSimilarBySameUser && - msg->loginName != prevMsg->loginName) + auto parts = badgeData.split('/'); + if (parts.length() != 2) { continue; } - ++checked; - similarityPercent = std::max( - similarityPercent, - relativeSimilarity(msg->messageText, prevMsg->messageText)); + + badges.insert(parts[0], parts[1]); } - return similarityPercent; + + return badges; } -void IrcMessageHandler::setSimilarityFlags(MessagePtr msg, ChannelPtr chan) +void populateReply(TwitchChannel *channel, Communi::IrcMessage *message, + const std::vector &otherLoaded, + TwitchMessageBuilder &builder) { - if (getSettings()->similarityEnabled) + const auto &tags = message->tags(); + if (const auto it = tags.find("reply-thread-parent-msg-id"); + it != tags.end()) { - bool isMyself = msg->loginName == - getApp()->accounts->twitch.getCurrent()->getUserName(); - bool hideMyself = getSettings()->hideSimilarMyself; + const QString replyID = it.value().toString(); + auto threadIt = channel->threads().find(replyID); + std::shared_ptr rootThread; + if (threadIt != channel->threads().end()) + { + auto owned = threadIt->second.lock(); + if (owned) + { + // Thread already exists (has a reply) + updateReplyParticipatedStatus(tags, message->nick(), builder, + owned, false); + builder.setThread(owned); + rootThread = owned; + } + } - if (isMyself && !hideMyself) + if (!rootThread) { - return; + MessagePtr foundMessage; + + // Thread does not yet exist, find root reply and create thread. + // Linear search is justified by the infrequent use of replies + for (const auto &otherMsg : otherLoaded) + { + if (otherMsg->id == replyID) + { + // Found root reply message + foundMessage = otherMsg; + break; + } + } + + if (!foundMessage) + { + // We didn't find the reply root message in the otherLoaded messages + // which are typically the already-parsed recent messages from the + // Recent Messages API. We could have a really old message that + // still exists being replied to, so check for that here. + foundMessage = channel->findMessage(replyID); + } + + if (foundMessage) + { + std::shared_ptr newThread = + std::make_shared(foundMessage); + updateReplyParticipatedStatus(tags, message->nick(), builder, + newThread, true); + + builder.setThread(newThread); + rootThread = newThread; + // Store weak reference to thread in channel + channel->addReplyThread(newThread); + } } - if (IrcMessageHandler::similarity(msg, chan->getMessageSnapshot()) > - getSettings()->similarityPercentage) + if (const auto parentIt = tags.find("reply-parent-msg-id"); + parentIt != tags.end()) { - msg->flags.set(MessageFlag::Similar, true); - if (getSettings()->colorSimilarDisabled) + const QString parentID = parentIt.value().toString(); + if (replyID == parentID) + { + if (rootThread) + { + builder.setParent(rootThread->root()); + } + } + else { - msg->flags.set(MessageFlag::Disabled, true); + auto parentThreadIt = channel->threads().find(parentID); + if (parentThreadIt != channel->threads().end()) + { + auto thread = parentThreadIt->second.lock(); + if (thread) + { + builder.setParent(thread->root()); + } + } + else + { + auto parent = channel->findMessage(parentID); + if (parent) + { + builder.setParent(parent); + } + } } } } } -static QMap parseBadges(QString badgesString) +std::optional parseClearChatMessage( + Communi::IrcMessage *message) { - QMap badges; + // check parameter count + if (message->parameters().length() < 1) + { + return std::nullopt; + } - for (const auto &badgeData : badgesString.split(',')) + // check if the chat has been cleared by a moderator + if (message->parameters().length() == 1) { - auto parts = badgeData.split('/'); - if (parts.length() != 2) - { - continue; - } + return ClearChatMessage{ + .message = + makeSystemMessage("Chat has been cleared by a moderator.", + calculateMessageTime(message).time()), + .disableAllMessages = true, + }; + } - badges.insert(parts[0], parts[1]); + // get username, duration and message of the timed out user + QString username = message->parameter(1); + QString durationInSeconds; + QVariant v = message->tag("ban-duration"); + if (v.isValid()) + { + durationInSeconds = v.toString(); } - return badges; + auto timeoutMsg = + MessageBuilder(timeoutMessage, username, durationInSeconds, false, + calculateMessageTime(message).time()) + .release(); + + return ClearChatMessage{.message = timeoutMsg, .disableAllMessages = false}; } -IrcMessageHandler &IrcMessageHandler::instance() +/** + * Parse a single IRC NOTICE message into 0 or more Chatterino messages + **/ +std::vector parseNoticeMessage(Communi::IrcNoticeMessage *message) { - static IrcMessageHandler instance; - return instance; + assert(message != nullptr); + + if (message->content().startsWith("Login auth", Qt::CaseInsensitive)) + { + const auto linkColor = MessageColor(MessageColor::Link); + const auto accountsLink = Link(Link::OpenAccountsPage, QString()); + const auto curUser = getIApp()->getAccounts()->twitch.getCurrent(); + const auto expirationText = QString("Login expired for user \"%1\"!") + .arg(curUser->getUserName()); + const auto loginPromptText = QString("Try adding your account again."); + + MessageBuilder builder; + auto text = QString("%1 %2").arg(expirationText, loginPromptText); + builder.message().messageText = text; + builder.message().searchText = text; + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + + builder.emplace(); + builder.emplace(expirationText, MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(loginPromptText, MessageElementFlag::Text, + linkColor) + ->setLink(accountsLink); + + return {builder.release()}; + } + + if (message->content().startsWith("You are permanently banned ")) + { + return {generateBannedMessage(true)}; + } + + if (message->tags().value("msg-id") == "msg_timedout") + { + std::vector builtMessage; + + QString remainingTime = + formatTime(message->content().split(" ").value(5)); + QString formattedMessage = + QString("You are timed out for %1.") + .arg(remainingTime.isEmpty() ? "0s" : remainingTime); + + builtMessage.emplace_back(makeSystemMessage( + formattedMessage, calculateMessageTime(message).time())); + + return builtMessage; + } + + // default case + std::vector builtMessages; + + auto content = message->content(); + if (content.startsWith( + "Your settings prevent you from sending this whisper", + Qt::CaseInsensitive) && + getSettings()->helixTimegateWhisper.getValue() == + HelixTimegateOverride::Timegate) + { + content = content + + " Consider setting \"Helix timegate /w behaviour\" " + "to \"Always use Helix\" in your Chatterino settings."; + } + builtMessages.emplace_back( + makeSystemMessage(content, calculateMessageTime(message).time())); + + return builtMessages; } -std::vector IrcMessageHandler::parseMessage( - Channel *channel, Communi::IrcMessage *message) +/** + * Parse a single IRC USERNOTICE message into 0 or more Chatterino messages + **/ +std::vector parseUserNoticeMessage(Channel *channel, + Communi::IrcMessage *message) { + assert(channel != nullptr); + assert(message != nullptr); + std::vector builtMessages; - auto command = message->command(); + auto tags = message->tags(); + auto parameters = message->parameters(); - if (command == "PRIVMSG") + QString msgType = tags.value("msg-id").toString(); + QString content; + if (parameters.size() >= 2) { - return this->parsePrivMessage( - channel, static_cast(message)); + content = parameters[1]; } - else if (command == "USERNOTICE") + + if (isIgnoredMessage({ + .message = content, + .twitchUserID = tags.value("user-id").toString(), + .isMod = channel->isMod(), + .isBroadcaster = channel->isBroadcaster(), + })) { - return this->parseUserNoticeMessage(channel, message); + return {}; + } + + if (SPECIAL_MESSAGE_TYPES.contains(msgType)) + { + // Messages are not required, so they might be empty + if (!content.isEmpty()) + { + MessageParseArgs args; + args.trimSubscriberUsername = true; + + TwitchMessageBuilder builder(channel, message, args, content, + false); + builder->flags.set(MessageFlag::Subscription); + builder->flags.unset(MessageFlag::Highlighted); + builtMessages.emplace_back(builder.build()); + } } - else if (command == "NOTICE") + + auto it = tags.find("system-msg"); + + if (it != tags.end()) { - return this->parseNoticeMessage( - static_cast(message)); + // By default, we return value of system-msg tag + QString messageText = it.value().toString(); + + if (msgType == "bitsbadgetier") + { + messageText = + QString("%1 just earned a new %2 Bits badge!") + .arg(tags.value("display-name").toString(), + kFormatNumbers( + tags.value("msg-param-threshold").toInt())); + } + else if (msgType == "announcement") + { + messageText = "Announcement"; + } + + auto b = MessageBuilder(systemMessage, parseTagString(messageText), + calculateMessageTime(message).time()); + + b->flags.set(MessageFlag::Subscription); + auto newMessage = b.release(); + builtMessages.emplace_back(newMessage); } return builtMessages; } -std::vector IrcMessageHandler::parsePrivMessage( - Channel *channel, Communi::IrcPrivateMessage *message) +/** + * Parse a single IRC PRIVMSG into 0-1 Chatterino messages + */ +std::vector parsePrivMessage(Channel *channel, + Communi::IrcPrivateMessage *message) { + assert(channel != nullptr); + assert(message != nullptr); + std::vector builtMessages; MessageParseArgs args; TwitchMessageBuilder builder(channel, message, args, message->content(), @@ -305,39 +546,46 @@ std::vector IrcMessageHandler::parsePrivMessage( builtMessages.emplace_back(builder.build()); builder.triggerHighlights(); } + + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) + { + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) + { + builtMessages.emplace_back(std::move(ptr)); + } + } + return builtMessages; } -void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, - TwitchIrcServer &server) -{ - // This is for compatibility with older Chatterino versions. Twitch didn't use - // to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG - // instead. - // See https://github.com/Chatterino/chatterino2/issues/3384 and - // https://mm2pl.github.io/emoji_rfc.pdf for more details +} // namespace - this->addMessage( - message, message->target(), - message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, - false, message->isAction()); +namespace chatterino { + +using namespace literals; + +IrcMessageHandler &IrcMessageHandler::instance() +{ + static IrcMessageHandler instance; + return instance; } std::vector IrcMessageHandler::parseMessageWithReply( Channel *channel, Communi::IrcMessage *message, - const std::vector &otherLoaded) + std::vector &otherLoaded) { std::vector builtMessages; auto command = message->command(); - if (command == "PRIVMSG") + if (command == u"PRIVMSG"_s) { - auto privMsg = static_cast(message); - auto tc = dynamic_cast(channel); + auto *privMsg = dynamic_cast(message); + auto *tc = dynamic_cast(channel); if (!tc) { - return this->parsePrivMessage(channel, privMsg); + return parsePrivMessage(channel, privMsg); } QString content = privMsg->content(); @@ -347,215 +595,87 @@ std::vector IrcMessageHandler::parseMessageWithReply( privMsg->isAction()); builder.setMessageOffset(messageOffset); - this->populateReply(tc, message, otherLoaded, builder); + populateReply(tc, message, otherLoaded, builder); if (!builder.isIgnored()) { builtMessages.emplace_back(builder.build()); builder.triggerHighlights(); } + + return builtMessages; } - else if (command == "USERNOTICE") + + if (command == u"USERNOTICE"_s) { - return this->parseUserNoticeMessage(channel, message); + return parseUserNoticeMessage(channel, message); } - else if (command == "NOTICE") + + if (command == u"NOTICE"_s) { - return this->parseNoticeMessage( - static_cast(message)); + return parseNoticeMessage( + dynamic_cast(message)); } - return builtMessages; -} - -void IrcMessageHandler::populateReply( - TwitchChannel *channel, Communi::IrcMessage *message, - const std::vector &otherLoaded, TwitchMessageBuilder &builder) -{ - const auto &tags = message->tags(); - if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) + if (command == u"CLEARCHAT"_s) { - const QString replyID = it.value().toString(); - auto threadIt = channel->threads_.find(replyID); - if (threadIt != channel->threads_.end()) + auto cc = parseClearChatMessage(message); + if (!cc) { - auto owned = threadIt->second.lock(); - if (owned) - { - // Thread already exists (has a reply) - updateReplyParticipatedStatus(tags, message->nick(), builder, - owned, false); - builder.setThread(owned); - return; - } + return builtMessages; } - - MessagePtr foundMessage; - - // Thread does not yet exist, find root reply and create thread. - // Linear search is justified by the infrequent use of replies - for (auto &otherMsg : otherLoaded) + auto &clearChat = *cc; + if (clearChat.disableAllMessages) { - if (otherMsg->id == replyID) - { - // Found root reply message - foundMessage = otherMsg; - break; - } + builtMessages.emplace_back(std::move(clearChat.message)); } - - if (!foundMessage) + else { - // We didn't find the reply root message in the otherLoaded messages - // which are typically the already-parsed recent messages from the - // Recent Messages API. We could have a really old message that - // still exists being replied to, so check for that here. - foundMessage = channel->findMessage(replyID); + addOrReplaceChannelTimeout( + otherLoaded, std::move(clearChat.message), + calculateMessageTime(message).time(), + [&](auto idx, auto /*msg*/, auto &&replacement) { + replacement->flags.set(MessageFlag::RecentMessage); + otherLoaded[idx] = replacement; + }, + [&](auto &&msg) { + builtMessages.emplace_back(msg); + }, + false); } - if (foundMessage) - { - std::shared_ptr newThread = - std::make_shared(foundMessage); - updateReplyParticipatedStatus(tags, message->nick(), builder, - newThread, true); - - builder.setThread(newThread); - // Store weak reference to thread in channel - channel->addReplyThread(newThread); - } + return builtMessages; } + + return builtMessages; } -void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, - const QString &target, - const QString &content_, - TwitchIrcServer &server, bool isSub, - bool isAction) +void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, + TwitchIrcServer &server) { - QString channelName; - if (!trimChannelName(target, channelName)) - { - return; - } + // This is for compatibility with older Chatterino versions. Twitch didn't use + // to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG + // instead. + // See https://github.com/Chatterino/chatterino2/issues/3384 and + // https://mm2pl.github.io/emoji_rfc.pdf for more details - auto chan = server.getChannelOrEmpty(channelName); + this->addMessage( + message, channelOrEmptyByTarget(message->target(), server), + message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, + false, message->isAction()); + auto chan = channelOrEmptyByTarget(message->target(), server); if (chan->isEmpty()) { return; } - MessageParseArgs args; - if (isSub) + if (message->tags().contains(u"pinned-chat-paid-amount"_s)) { - args.isSubscriptionMessage = true; - args.trimSubscriberUsername = true; - } - - if (chan->isBroadcaster()) - { - args.isStaffOrBroadcaster = true; - } - - auto channel = dynamic_cast(chan.get()); - - const auto &tags = _message->tags(); - if (const auto it = tags.find("custom-reward-id"); it != tags.end()) - { - const auto rewardId = it.value().toString(); - if (!channel->isChannelPointRewardKnown(rewardId)) + auto ptr = TwitchMessageBuilder::buildHypeChatMessage(message); + if (ptr) { - // Need to wait for pubsub reward notification - auto clone = _message->clone(); - qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " - "callback since reward is not known:" - << rewardId; - channel->channelPointRewardAdded.connect( - [=, this, &server](ChannelPointReward reward) { - qCDebug(chatterinoTwitch) - << "TwitchChannel reward added callback:" << reward.id - << "-" << rewardId; - if (reward.id == rewardId) - { - this->addMessage(clone, target, content_, server, isSub, - isAction); - clone->deleteLater(); - return true; - } - return false; - }); - return; - } - args.channelPointRewardId = rewardId; - } - - QString content = content_; - int messageOffset = stripLeadingReplyMention(tags, content); - - TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction); - builder.setMessageOffset(messageOffset); - - if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) - { - const QString replyID = it.value().toString(); - auto threadIt = channel->threads_.find(replyID); - if (threadIt != channel->threads_.end() && !threadIt->second.expired()) - { - // Thread already exists (has a reply) - auto thread = threadIt->second.lock(); - updateReplyParticipatedStatus(tags, _message->nick(), builder, - thread, false); - builder.setThread(thread); - } - else - { - // Thread does not yet exist, find root reply and create thread. - auto root = channel->findMessage(replyID); - if (root) - { - // Found root reply message - auto newThread = std::make_shared(root); - updateReplyParticipatedStatus(tags, _message->nick(), builder, - newThread, true); - - builder.setThread(newThread); - // Store weak reference to thread in channel - channel->addReplyThread(newThread); - } - } - } - - if (isSub || !builder.isIgnored()) - { - if (isSub) - { - builder->flags.set(MessageFlag::Subscription); - builder->flags.unset(MessageFlag::Highlighted); - } - auto msg = builder.build(); - - IrcMessageHandler::setSimilarityFlags(msg, chan); - - if (!msg->flags.has(MessageFlag::Similar) || - (!getSettings()->hideSimilar && - getSettings()->shownSimilarTriggerHighlights)) - { - builder.triggerHighlights(); - } - - const auto highlighted = msg->flags.has(MessageFlag::Highlighted); - const auto showInMentions = msg->flags.has(MessageFlag::ShowInMentions); - - if (highlighted && showInMentions) - { - server.mentionsChannel->addMessage(msg); - } - - chan->addMessage(msg); - if (auto chatters = dynamic_cast(chan.get())) - { - chatters->addRecentChatter(msg->displayName); + chan->addMessage(ptr); } } } @@ -618,11 +738,12 @@ void IrcMessageHandler::handleRoomStateMessage(Communi::IrcMessage *message) void IrcMessageHandler::handleClearChatMessage(Communi::IrcMessage *message) { - // check parameter count - if (message->parameters().length() < 1) + auto cc = parseClearChatMessage(message); + if (!cc) { return; } + auto &clearChat = *cc; QString chanName; if (!trimChannelName(message->parameter(0), chanName)) @@ -636,42 +757,27 @@ void IrcMessageHandler::handleClearChatMessage(Communi::IrcMessage *message) if (chan->isEmpty()) { qCDebug(chatterinoTwitch) - << "[IrcMessageHandler:handleClearChatMessage] Twitch channel" + << "[IrcMessageHandler::handleClearChatMessage] Twitch channel" << chanName << "not found"; return; } - // check if the chat has been cleared by a moderator - if (message->parameters().length() == 1) + // chat has been cleared by a moderator + if (clearChat.disableAllMessages) { chan->disableAllMessages(); - chan->addMessage( - makeSystemMessage("Chat has been cleared by a moderator.", - calculateMessageTime(message).time())); + chan->addMessage(std::move(clearChat.message)); return; } - // get username, duration and message of the timed out user - QString username = message->parameter(1); - QString durationInSeconds; - QVariant v = message->tag("ban-duration"); - if (v.isValid()) - { - durationInSeconds = v.toString(); - } - - auto timeoutMsg = - MessageBuilder(timeoutMessage, username, durationInSeconds, false, - calculateMessageTime(message).time()) - .release(); - chan->addOrReplaceTimeout(timeoutMsg); + chan->addOrReplaceTimeout(std::move(clearChat.message)); // refresh all - getApp()->windows->repaintVisibleChatWidgets(chan.get()); + getIApp()->getWindows()->repaintVisibleChatWidgets(chan.get()); if (getSettings()->hideModerated) { - getApp()->windows->forceLayoutChannelViews(); + getIApp()->getWindows()->forceLayoutChannelViews(); } } @@ -707,7 +813,9 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) auto msg = chan->findMessage(targetID); if (msg == nullptr) + { return; + } msg->flags.set(MessageFlag::Disabled); if (!getSettings()->hideDeletionActions) @@ -720,7 +828,7 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); // set received emote-sets, used in TwitchAccount::loadUserstateEmotes bool emoteSetsChanged = currentUser->setUserstateEmoteSets( @@ -744,26 +852,26 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) } // Checking if currentUser is a VIP or staff member - QVariant _badges = message->tag("badges"); - if (_badges.isValid()) + QVariant badgesTag = message->tag("badges"); + if (badgesTag.isValid()) { - TwitchChannel *tc = dynamic_cast(c.get()); + auto *tc = dynamic_cast(c.get()); if (tc != nullptr) { - auto parsedBadges = parseBadges(_badges.toString()); + auto parsedBadges = parseBadges(badgesTag.toString()); tc->setVIP(parsedBadges.contains("vip")); tc->setStaff(parsedBadges.contains("staff")); } } // Checking if currentUser is a moderator - QVariant _mod = message->tag("mod"); - if (_mod.isValid()) + QVariant modTag = message->tag("mod"); + if (modTag.isValid()) { - TwitchChannel *tc = dynamic_cast(c.get()); + auto *tc = dynamic_cast(c.get()); if (tc != nullptr) { - tc->setMod(_mod == "1"); + tc->setMod(modTag == "1"); } } } @@ -772,7 +880,7 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) void IrcMessageHandler::handleGlobalUserStateMessage( Communi::IrcMessage *message) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); // set received emote-sets, this time used to initially load emotes // NOTE: this should always return true unless we reconnect @@ -786,17 +894,17 @@ void IrcMessageHandler::handleGlobalUserStateMessage( currentUser->loadEmotes(); } -void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) +void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) { MessageParseArgs args; args.isReceivedWhisper = true; - auto c = getApp()->twitch->whispersChannel.get(); + auto *c = getApp()->twitch->whispersChannel.get(); TwitchMessageBuilder builder( - c, message, args, - message->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), + c, ircMessage, args, + ircMessage->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), false); if (builder.isIgnored()) @@ -805,93 +913,31 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) } builder->flags.set(MessageFlag::Whisper); - MessagePtr _message = builder.build(); + MessagePtr message = builder.build(); builder.triggerHighlights(); - getApp()->twitch->lastUserThatWhisperedMe.set(builder.userName); - - if (_message->flags.has(MessageFlag::Highlighted)) - { - getApp()->twitch->mentionsChannel->addMessage(_message); - } - - c->addMessage(_message); - - auto overrideFlags = boost::optional(_message->flags); - overrideFlags->set(MessageFlag::DoNotTriggerNotification); - overrideFlags->set(MessageFlag::DoNotLog); - - if (getSettings()->inlineWhispers && - !(getSettings()->streamerModeSuppressInlineWhispers && - isInStreamerMode())) - { - getApp()->twitch->forEachChannel( - [&_message, overrideFlags](ChannelPtr channel) { - channel->addMessage(_message, overrideFlags); - }); - } -} - -std::vector IrcMessageHandler::parseUserNoticeMessage( - Channel *channel, Communi::IrcMessage *message) -{ - std::vector builtMessages; - - auto tags = message->tags(); - auto parameters = message->parameters(); - - QString msgType = tags.value("msg-id").toString(); - QString content; - if (parameters.size() >= 2) - { - content = parameters[1]; - } - - if (specialMessageTypes.contains(msgType)) - { - // Messages are not required, so they might be empty - if (!content.isEmpty()) - { - MessageParseArgs args; - args.trimSubscriberUsername = true; - - TwitchMessageBuilder builder(channel, message, args, content, - false); - builder->flags.set(MessageFlag::Subscription); - builder->flags.unset(MessageFlag::Highlighted); - builtMessages.emplace_back(builder.build()); - } - } - - auto it = tags.find("system-msg"); - - if (it != tags.end()) - { - // By default, we return value of system-msg tag - QString messageText = it.value().toString(); - - if (msgType == "bitsbadgetier") - { - messageText = - QString("%1 just earned a new %2 Bits badge!") - .arg(tags.value("display-name").toString(), - kFormatNumbers( - tags.value("msg-param-threshold").toInt())); - } - else if (msgType == "announcement") - { - messageText = "Announcement"; - } - - auto b = MessageBuilder(systemMessage, parseTagString(messageText), - calculateMessageTime(message).time()); + getApp()->twitch->lastUserThatWhisperedMe.set(builder.userName); - b->flags.set(MessageFlag::Subscription); - auto newMessage = b.release(); - builtMessages.emplace_back(newMessage); + if (message->flags.has(MessageFlag::ShowInMentions)) + { + getApp()->twitch->mentionsChannel->addMessage(message); } - return builtMessages; + c->addMessage(message); + + auto overrideFlags = std::optional(message->flags); + overrideFlags->set(MessageFlag::DoNotTriggerNotification); + overrideFlags->set(MessageFlag::DoNotLog); + + if (getSettings()->inlineWhispers && + !(getSettings()->streamerModeSuppressInlineWhispers && + isInStreamerMode())) + { + getApp()->twitch->forEachChannel( + [&message, overrideFlags](ChannelPtr channel) { + channel->addMessage(message, overrideFlags); + }); + } } void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, @@ -908,12 +954,23 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, content = parameters[1]; } - if (specialMessageTypes.contains(msgType)) + auto chn = server.getChannelOrEmpty(target); + if (isIgnoredMessage({ + .message = content, + .twitchUserID = tags.value("user-id").toString(), + .isMod = chn->isMod(), + .isBroadcaster = chn->isBroadcaster(), + })) + { + return; + } + + if (SPECIAL_MESSAGE_TYPES.contains(msgType)) { // Messages are not required, so they might be empty if (!content.isEmpty()) { - this->addMessage(message, target, content, server, true, false); + this->addMessage(message, chn, content, server, true, false); } } @@ -964,78 +1021,9 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, } } -std::vector IrcMessageHandler::parseNoticeMessage( - Communi::IrcNoticeMessage *message) -{ - if (message->content().startsWith("Login auth", Qt::CaseInsensitive)) - { - const auto linkColor = MessageColor(MessageColor::Link); - const auto accountsLink = Link(Link::OpenAccountsPage, QString()); - const auto curUser = getApp()->accounts->twitch.getCurrent(); - const auto expirationText = QString("Login expired for user \"%1\"!") - .arg(curUser->getUserName()); - const auto loginPromptText = QString("Try adding your account again."); - - MessageBuilder builder; - auto text = QString("%1 %2").arg(expirationText, loginPromptText); - builder.message().messageText = text; - builder.message().searchText = text; - builder.message().flags.set(MessageFlag::System); - builder.message().flags.set(MessageFlag::DoNotTriggerNotification); - - builder.emplace(); - builder.emplace(expirationText, MessageElementFlag::Text, - MessageColor::System); - builder - .emplace(loginPromptText, MessageElementFlag::Text, - linkColor) - ->setLink(accountsLink); - - return {builder.release()}; - } - else if (message->content().startsWith("You are permanently banned ")) - { - return {generateBannedMessage(true)}; - } - else if (message->tags().value("msg-id") == "msg_timedout") - { - std::vector builtMessage; - - QString remainingTime = - formatTime(message->content().split(" ").value(5)); - QString formattedMessage = - QString("You are timed out for %1.") - .arg(remainingTime.isEmpty() ? "0s" : remainingTime); - - builtMessage.emplace_back(makeSystemMessage( - formattedMessage, calculateMessageTime(message).time())); - - return builtMessage; - } - - // default case - std::vector builtMessages; - - auto content = message->content(); - if (content.startsWith( - "Your settings prevent you from sending this whisper", - Qt::CaseInsensitive) && - getSettings()->helixTimegateWhisper.getValue() == - HelixTimegateOverride::Timegate) - { - content = content + - " Consider setting \"Helix timegate /w behaviour\" " - "to \"Always use Helix\" in your Chatterino settings."; - } - builtMessages.emplace_back( - makeSystemMessage(content, calculateMessageTime(message).time())); - - return builtMessages; -} - void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) { - auto builtMessages = this->parseNoticeMessage(message); + auto builtMessages = parseNoticeMessage(message); for (const auto &msg : builtMessages) { @@ -1114,7 +1102,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) QStringList msgParts = noticeText.split(':'); MessageBuilder builder; - auto tc = dynamic_cast(channel.get()); + auto *tc = dynamic_cast(channel.get()); assert(tc != nullptr && "IrcMessageHandler::handleNoticeMessage. Twitch specific " "functionality called in non twitch channel"); @@ -1145,9 +1133,13 @@ void IrcMessageHandler::handleJoinMessage(Communi::IrcMessage *message) return; } - if (message->nick() != - getApp()->accounts->twitch.getCurrent()->getUserName() && - getSettings()->showJoins.getValue()) + if (message->nick() == + getIApp()->getAccounts()->twitch.getCurrent()->getUserName()) + { + twitchChannel->addMessage(makeSystemMessage("joined channel")); + twitchChannel->joined.invoke(); + } + else if (getSettings()->showJoins.getValue()) { twitchChannel->addJoinedUser(message->nick()); } @@ -1165,7 +1157,7 @@ void IrcMessageHandler::handlePartMessage(Communi::IrcMessage *message) } const auto selfAccountName = - getApp()->accounts->twitch.getCurrent()->getUserName(); + getIApp()->getAccounts()->twitch.getCurrent()->getUserName(); if (message->nick() != selfAccountName && getSettings()->showParts.getValue()) { @@ -1177,4 +1169,214 @@ void IrcMessageHandler::handlePartMessage(Communi::IrcMessage *message) channel->addMessage(generateBannedMessage(false)); } } + +float IrcMessageHandler::similarity( + const MessagePtr &msg, const LimitedQueueSnapshot &messages) +{ + float similarityPercent = 0.0F; + int checked = 0; + + for (int i = 1; i <= messages.size(); ++i) + { + if (checked >= getSettings()->hideSimilarMaxMessagesToCheck) + { + break; + } + const auto &prevMsg = messages[messages.size() - i]; + if (prevMsg->parseTime.secsTo(QTime::currentTime()) >= + getSettings()->hideSimilarMaxDelay) + { + break; + } + if (getSettings()->hideSimilarBySameUser && + msg->loginName != prevMsg->loginName) + { + continue; + } + ++checked; + similarityPercent = std::max( + similarityPercent, + relativeSimilarity(msg->messageText, prevMsg->messageText)); + } + + return similarityPercent; +} + +void IrcMessageHandler::setSimilarityFlags(const MessagePtr &message, + const ChannelPtr &channel) +{ + if (getSettings()->similarityEnabled) + { + bool isMyself = + message->loginName == + getIApp()->getAccounts()->twitch.getCurrent()->getUserName(); + bool hideMyself = getSettings()->hideSimilarMyself; + + if (isMyself && !hideMyself) + { + return; + } + + if (IrcMessageHandler::similarity(message, + channel->getMessageSnapshot()) > + getSettings()->similarityPercentage) + { + message->flags.set(MessageFlag::Similar, true); + if (getSettings()->colorSimilarDisabled) + { + message->flags.set(MessageFlag::Disabled, true); + } + } + } +} + +void IrcMessageHandler::addMessage(Communi::IrcMessage *message, + const ChannelPtr &chan, + const QString &originalContent, + TwitchIrcServer &server, bool isSub, + bool isAction) +{ + if (chan->isEmpty()) + { + return; + } + + MessageParseArgs args; + if (isSub) + { + args.isSubscriptionMessage = true; + args.trimSubscriberUsername = true; + } + + if (chan->isBroadcaster()) + { + args.isStaffOrBroadcaster = true; + } + + auto *channel = dynamic_cast(chan.get()); + + const auto &tags = message->tags(); + if (const auto it = tags.find("custom-reward-id"); it != tags.end()) + { + const auto rewardId = it.value().toString(); + if (!rewardId.isEmpty() && + !channel->isChannelPointRewardKnown(rewardId)) + { + // Need to wait for pubsub reward notification + qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " + "callback since reward is not known:" + << rewardId; + channel->addQueuedRedemption(rewardId, originalContent, message); + return; + } + args.channelPointRewardId = rewardId; + } + + QString content = originalContent; + int messageOffset = stripLeadingReplyMention(tags, content); + + TwitchMessageBuilder builder(channel, message, args, content, isAction); + builder.setMessageOffset(messageOffset); + + if (const auto it = tags.find("reply-thread-parent-msg-id"); + it != tags.end()) + { + const QString replyID = it.value().toString(); + auto threadIt = channel->threads().find(replyID); + std::shared_ptr rootThread; + if (threadIt != channel->threads().end() && !threadIt->second.expired()) + { + // Thread already exists (has a reply) + auto thread = threadIt->second.lock(); + updateReplyParticipatedStatus(tags, message->nick(), builder, + thread, false); + builder.setThread(thread); + rootThread = thread; + } + else + { + // Thread does not yet exist, find root reply and create thread. + auto root = channel->findMessage(replyID); + if (root) + { + // Found root reply message + auto newThread = std::make_shared(root); + updateReplyParticipatedStatus(tags, message->nick(), builder, + newThread, true); + + builder.setThread(newThread); + rootThread = newThread; + // Store weak reference to thread in channel + channel->addReplyThread(newThread); + } + } + + if (const auto parentIt = tags.find("reply-parent-msg-id"); + parentIt != tags.end()) + { + const QString parentID = parentIt.value().toString(); + if (replyID == parentID) + { + if (rootThread) + { + builder.setParent(rootThread->root()); + } + } + else + { + auto parentThreadIt = channel->threads().find(parentID); + if (parentThreadIt != channel->threads().end()) + { + auto thread = parentThreadIt->second.lock(); + if (thread) + { + builder.setParent(thread->root()); + } + } + else + { + auto parent = channel->findMessage(parentID); + if (parent) + { + builder.setParent(parent); + } + } + } + } + } + + if (isSub || !builder.isIgnored()) + { + if (isSub) + { + builder->flags.set(MessageFlag::Subscription); + builder->flags.unset(MessageFlag::Highlighted); + } + auto msg = builder.build(); + + IrcMessageHandler::setSimilarityFlags(msg, chan); + + if (!msg->flags.has(MessageFlag::Similar) || + (!getSettings()->hideSimilar && + getSettings()->shownSimilarTriggerHighlights)) + { + builder.triggerHighlights(); + } + + const auto highlighted = msg->flags.has(MessageFlag::Highlighted); + const auto showInMentions = msg->flags.has(MessageFlag::ShowInMentions); + + if (highlighted && showInMentions) + { + server.mentionsChannel->addMessage(msg); + } + + chan->addMessage(msg); + if (auto *chatters = dynamic_cast(chan.get())) + { + chatters->addRecentChatter(msg->displayName); + } + } +} + } // namespace chatterino diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index 114831009b9..26c21f6da64 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -4,6 +4,7 @@ #include +#include #include namespace chatterino { @@ -16,6 +17,11 @@ using MessagePtr = std::shared_ptr; class TwitchChannel; class TwitchMessageBuilder; +struct ClearChatMessage { + MessagePtr message; + bool disableAllMessages; +}; + class IrcMessageHandler { IrcMessageHandler() = default; @@ -23,17 +29,14 @@ class IrcMessageHandler public: static IrcMessageHandler &instance(); - // parseMessage parses a single IRC message into 0+ Chatterino messages - std::vector parseMessage(Channel *channel, - Communi::IrcMessage *message); - - std::vector parseMessageWithReply( + /** + * Parse an IRC message into 0 or more Chatterino messages + * Takes previously loaded messages into consideration to add reply contexts + **/ + static std::vector parseMessageWithReply( Channel *channel, Communi::IrcMessage *message, - const std::vector &otherLoaded); + std::vector &otherLoaded); - // parsePrivMessage arses a single IRC PRIVMSG into 0-1 Chatterino messages - std::vector parsePrivMessage( - Channel *channel, Communi::IrcPrivateMessage *message); void handlePrivMessage(Communi::IrcPrivateMessage *message, TwitchIrcServer &server); @@ -42,38 +45,25 @@ class IrcMessageHandler void handleClearMessageMessage(Communi::IrcMessage *message); void handleUserStateMessage(Communi::IrcMessage *message); void handleGlobalUserStateMessage(Communi::IrcMessage *message); - void handleWhisperMessage(Communi::IrcMessage *message); + void handleWhisperMessage(Communi::IrcMessage *ircMessage); - // parseUserNoticeMessage parses a single IRC USERNOTICE message into 0+ - // Chatterino messages - std::vector parseUserNoticeMessage( - Channel *channel, Communi::IrcMessage *message); void handleUserNoticeMessage(Communi::IrcMessage *message, TwitchIrcServer &server); - void handleModeMessage(Communi::IrcMessage *message); - - // parseNoticeMessage parses a single IRC NOTICE message into 0+ chatterino - // messages - std::vector parseNoticeMessage( - Communi::IrcNoticeMessage *message); void handleNoticeMessage(Communi::IrcNoticeMessage *message); void handleJoinMessage(Communi::IrcMessage *message); void handlePartMessage(Communi::IrcMessage *message); - static float similarity(MessagePtr msg, - const LimitedQueueSnapshot &messages); - static void setSimilarityFlags(MessagePtr message, ChannelPtr channel); + void addMessage(Communi::IrcMessage *message, const ChannelPtr &chan, + const QString &originalContent, TwitchIrcServer &server, + bool isSub, bool isAction); private: - void addMessage(Communi::IrcMessage *message, const QString &target, - const QString &content, TwitchIrcServer &server, - bool isResub, bool isAction); - - void populateReply(TwitchChannel *channel, Communi::IrcMessage *message, - const std::vector &otherLoaded, - TwitchMessageBuilder &builder); + static float similarity(const MessagePtr &msg, + const LimitedQueueSnapshot &messages); + static void setSimilarityFlags(const MessagePtr &message, + const ChannelPtr &channel); }; } // namespace chatterino diff --git a/src/providers/twitch/PubSubActions.hpp b/src/providers/twitch/PubSubActions.hpp index d698b9aefbe..89abdb24464 100644 --- a/src/providers/twitch/PubSubActions.hpp +++ b/src/providers/twitch/PubSubActions.hpp @@ -33,6 +33,7 @@ inline QDebug operator<<(QDebug dbg, const ActionUser &user) } struct PubSubAction { + PubSubAction() = default; PubSubAction(const QJsonObject &data, const QString &_roomID); ActionUser source; diff --git a/src/providers/twitch/PubSubClient.cpp b/src/providers/twitch/PubSubClient.cpp index c35d9a41839..80b6d66ed35 100644 --- a/src/providers/twitch/PubSubClient.cpp +++ b/src/providers/twitch/PubSubClient.cpp @@ -22,6 +22,8 @@ PubSubClient::PubSubClient(WebsocketClient &websocketClient, const PubSubClientOptions &clientOptions) : websocketClient_(websocketClient) , handle_(handle) + , heartbeatTimer_(std::make_shared( + this->websocketClient_.get_io_service())) , clientOptions_(clientOptions) { } @@ -40,32 +42,41 @@ void PubSubClient::stop() assert(this->started_); this->started_ = false; + this->heartbeatTimer_->cancel(); } void PubSubClient::close(const std::string &reason, websocketpp::close::status::value code) { - WebsocketErrorCode ec; - - auto conn = this->websocketClient_.get_con_from_hdl(this->handle_, ec); - if (ec) - { - qCDebug(chatterinoPubSub) - << "Error getting con:" << ec.message().c_str(); - return; - } - - conn->close(code, reason, ec); - if (ec) - { - qCDebug(chatterinoPubSub) << "Error closing:" << ec.message().c_str(); - return; - } + boost::asio::post( + this->websocketClient_.get_io_service().get_executor(), + [this, reason, code] { + // We need to post this request to the io service executor + // to ensure the weak pointer used in get_con_from_hdl is used in a safe way + WebsocketErrorCode ec; + + auto conn = + this->websocketClient_.get_con_from_hdl(this->handle_, ec); + if (ec) + { + qCDebug(chatterinoPubSub) + << "Error getting con:" << ec.message().c_str(); + return; + } + + conn->close(code, reason, ec); + if (ec) + { + qCDebug(chatterinoPubSub) + << "Error closing:" << ec.message().c_str(); + return; + } + }); } -bool PubSubClient::listen(PubSubListenMessage msg) +bool PubSubClient::listen(const PubSubListenMessage &msg) { - int numRequestedListens = msg.topics.size(); + auto numRequestedListens = msg.topics.size(); if (this->numListens_ + numRequestedListens > PubSubClient::MAX_LISTENS) { @@ -73,11 +84,19 @@ bool PubSubClient::listen(PubSubListenMessage msg) return false; } this->numListens_ += numRequestedListens; - DebugCount::increase("PubSub topic pending listens", numRequestedListens); + DebugCount::increase("PubSub topic pending listens", + static_cast(numRequestedListens)); for (const auto &topic : msg.topics) { - this->listeners_.emplace_back(Listener{topic, false, false, false}); + this->listeners_.emplace_back(Listener{ + TopicData{ + topic, + false, + false, + }, + false, + }); } qCDebug(chatterinoPubSub) @@ -116,7 +135,7 @@ PubSubClient::UnlistenPrefixResponse PubSubClient::unlistenPrefix( this->numListens_ -= numRequestedUnlistens; DebugCount::increase("PubSub topic pending unlistens", - numRequestedUnlistens); + static_cast(numRequestedUnlistens)); PubSubUnlistenMessage message(topics); @@ -179,8 +198,9 @@ void PubSubClient::ping() auto self = this->shared_from_this(); - runAfter(this->websocketClient_.get_io_service(), - this->clientOptions_.pingInterval_, [self](auto timer) { + runAfter(this->heartbeatTimer_, this->clientOptions_.pingInterval_, + [self](auto timer) { + (void)timer; if (!self->started_) { return; diff --git a/src/providers/twitch/PubSubClient.hpp b/src/providers/twitch/PubSubClient.hpp index ee0e40c1a15..01d212de506 100644 --- a/src/providers/twitch/PubSubClient.hpp +++ b/src/providers/twitch/PubSubClient.hpp @@ -45,7 +45,7 @@ class PubSubClient : public std::enable_shared_from_this websocketpp::close::status::value code = websocketpp::close::status::normal); - bool listen(PubSubListenMessage msg); + bool listen(const PubSubListenMessage &msg); UnlistenPrefixResponse unlistenPrefix(const QString &prefix); void handleListenResponse(const PubSubMessage &message); @@ -70,6 +70,7 @@ class PubSubClient : public std::enable_shared_from_this std::atomic awaitingPong_{false}; std::atomic started_{false}; + std::shared_ptr heartbeatTimer_; const PubSubClientOptions &clientOptions_; }; diff --git a/src/providers/twitch/PubSubManager.cpp b/src/providers/twitch/PubSubManager.cpp index 644e494cbfe..7f75fd8c26c 100644 --- a/src/providers/twitch/PubSubManager.cpp +++ b/src/providers/twitch/PubSubManager.cpp @@ -7,20 +7,24 @@ #include "providers/twitch/PubSubHelpers.hpp" #include "providers/twitch/PubSubMessages.hpp" #include "providers/twitch/TwitchAccount.hpp" +#include "pubsubmessages/LowTrustUsers.hpp" #include "util/DebugCount.hpp" #include "util/Helpers.hpp" #include "util/RapidjsonHelpers.hpp" +#include "util/StreamerMode.hpp" #include #include #include +#include #include #include using websocketpp::lib::bind; using websocketpp::lib::placeholders::_1; using websocketpp::lib::placeholders::_2; +using namespace std::chrono_literals; namespace chatterino { @@ -34,7 +38,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) const auto &roomID) { ClearChatAction action(data, roomID); - this->signals_.moderation.chatCleared.invoke(action); + this->moderation.chatCleared.invoke(action); }; this->moderationActionHandlers["slowoff"] = [this](const auto &data, @@ -44,7 +48,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::Slow; action.state = ModeChangedAction::State::Off; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["slow"] = [this](const auto &data, @@ -67,7 +71,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.duration = args.at(0).toString().toUInt(&ok, 10); - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["r9kbetaoff"] = [this](const auto &data, @@ -77,7 +81,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::R9K; action.state = ModeChangedAction::State::Off; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["r9kbeta"] = [this](const auto &data, @@ -87,7 +91,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::R9K; action.state = ModeChangedAction::State::On; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["subscribersoff"] = @@ -97,7 +101,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::SubscribersOnly; action.state = ModeChangedAction::State::Off; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["subscribers"] = [this](const auto &data, @@ -107,7 +111,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::SubscribersOnly; action.state = ModeChangedAction::State::On; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["emoteonlyoff"] = @@ -117,7 +121,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::EmoteOnly; action.state = ModeChangedAction::State::Off; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["emoteonly"] = [this](const auto &data, @@ -127,7 +131,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.mode = ModeChangedAction::Mode::EmoteOnly; action.state = ModeChangedAction::State::On; - this->signals_.moderation.modeChanged.invoke(action); + this->moderation.modeChanged.invoke(action); }; this->moderationActionHandlers["unmod"] = [this](const auto &data, @@ -147,7 +151,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.modded = false; - this->signals_.moderation.moderationStateChanged.invoke(action); + this->moderation.moderationStateChanged.invoke(action); }; this->moderationActionHandlers["mod"] = [this](const auto &data, @@ -165,7 +169,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.target.id = data.value("target_user_id").toString(); action.target.login = data.value("target_user_login").toString(); - this->signals_.moderation.moderationStateChanged.invoke(action); + this->moderation.moderationStateChanged.invoke(action); }; this->moderationActionHandlers["timeout"] = [this](const auto &data, @@ -189,7 +193,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.duration = args[1].toString().toUInt(&ok, 10); action.reason = args[2].toString(); // May be omitted - this->signals_.moderation.userBanned.invoke(action); + this->moderation.userBanned.invoke(action); }; this->moderationActionHandlers["delete"] = [this](const auto &data, @@ -209,11 +213,10 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) } action.target.login = args[0].toString(); - bool ok; action.messageText = args[1].toString(); action.messageId = args[2].toString(); - this->signals_.moderation.messageDeleted.invoke(action); + this->moderation.messageDeleted.invoke(action); }; this->moderationActionHandlers["ban"] = [this](const auto &data, @@ -235,7 +238,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.target.login = args[0].toString(); action.reason = args[1].toString(); // May be omitted - this->signals_.moderation.userBanned.invoke(action); + this->moderation.userBanned.invoke(action); }; this->moderationActionHandlers["unban"] = [this](const auto &data, @@ -258,7 +261,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.target.login = args[0].toString(); - this->signals_.moderation.userUnbanned.invoke(action); + this->moderation.userUnbanned.invoke(action); }; this->moderationActionHandlers["untimeout"] = [this](const auto &data, @@ -281,7 +284,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.target.login = args[0].toString(); - this->signals_.moderation.userUnbanned.invoke(action); + this->moderation.userUnbanned.invoke(action); }; /* @@ -314,7 +317,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = args[1].toString(); // May be omitted action.reason = args[2].toString(); // May be omitted - this->signals_.moderation.autoModMessageBlocked.invoke(action); + this->moderation.autoModMessageBlocked.invoke(action); }; */ @@ -322,21 +325,21 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) [this](const auto &data, const auto &roomID) { AutomodInfoAction action(data, roomID); action.type = AutomodInfoAction::OnHold; - this->signals_.moderation.automodInfoMessage.invoke(action); + this->moderation.automodInfoMessage.invoke(action); }; this->moderationActionHandlers["automod_message_denied"] = [this](const auto &data, const auto &roomID) { AutomodInfoAction action(data, roomID); action.type = AutomodInfoAction::Denied; - this->signals_.moderation.automodInfoMessage.invoke(action); + this->moderation.automodInfoMessage.invoke(action); }; this->moderationActionHandlers["automod_message_approved"] = [this](const auto &data, const auto &roomID) { AutomodInfoAction action(data, roomID); action.type = AutomodInfoAction::Approved; - this->signals_.moderation.automodInfoMessage.invoke(action); + this->moderation.automodInfoMessage.invoke(action); }; this->channelTermsActionHandlers["add_permitted_term"] = @@ -350,7 +353,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = data.value("text").toString(); action.source.login = data.value("requester_login").toString(); - this->signals_.moderation.automodUserMessage.invoke(action); + this->moderation.automodUserMessage.invoke(action); }; this->channelTermsActionHandlers["add_blocked_term"] = @@ -364,7 +367,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = data.value("text").toString(); action.source.login = data.value("requester_login").toString(); - this->signals_.moderation.automodUserMessage.invoke(action); + this->moderation.automodUserMessage.invoke(action); }; this->moderationActionHandlers["delete_permitted_term"] = @@ -384,7 +387,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = args[0].toString(); - this->signals_.moderation.automodUserMessage.invoke(action); + this->moderation.automodUserMessage.invoke(action); }; this->channelTermsActionHandlers["delete_permitted_term"] = @@ -398,7 +401,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = data.value("text").toString(); action.source.login = data.value("requester_login").toString(); - this->signals_.moderation.automodUserMessage.invoke(action); + this->moderation.automodUserMessage.invoke(action); }; this->moderationActionHandlers["delete_blocked_term"] = @@ -419,7 +422,7 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = args[0].toString(); - this->signals_.moderation.automodUserMessage.invoke(action); + this->moderation.automodUserMessage.invoke(action); }; this->channelTermsActionHandlers["delete_blocked_term"] = [this](const auto &data, const auto &roomID) { @@ -433,21 +436,9 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) action.message = data.value("text").toString(); action.source.login = data.value("requester_login").toString(); - this->signals_.moderation.automodUserMessage.invoke(action); + this->moderation.automodUserMessage.invoke(action); }; - // We don't get this one anymore or anything similiar - // We need some new topic so we can listen - // - //this->moderationActionHandlers["modified_automod_properties"] = - // [this](const auto &data, const auto &roomID) { - // // The automod settings got modified - // AutomodUserAction action(data, roomID); - // getCreatedByUser(data, action.source); - // action.type = AutomodUserAction::Properties; - // this->signals_.moderation.automodUserMessage.invoke(action); - // }; - this->moderationActionHandlers["denied_automod_message"] = [](const auto &data, const auto &roomID) { // This message got denied by a moderator @@ -481,16 +472,15 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) bind(&PubSub::onConnectionFail, this, ::_1)); } -void PubSub::setAccount(std::shared_ptr account) +PubSub::~PubSub() { - this->token_ = account->getOAuthToken(); - this->userID_ = account->getUserId(); + this->stop(); } -void PubSub::setAccountData(QString token, QString userID) +void PubSub::setAccount(std::shared_ptr account) { - this->token_ = token; - this->userID_ = userID; + this->token_ = account->getOAuthToken(); + this->userID_ = account->getUserId(); } void PubSub::addClient() @@ -524,83 +514,41 @@ void PubSub::start() { this->work = std::make_shared( this->websocketClient.get_io_service()); - this->mainThread.reset( - new std::thread(std::bind(&PubSub::runThread, this))); + this->thread.reset(new std::thread(std::bind(&PubSub::runThread, this))); } void PubSub::stop() { this->stopping_ = true; - for (const auto &client : this->clients) + for (const auto &[hdl, client] : this->clients) { - client.second->close("Shutting down"); - } - - this->work.reset(); + (void)hdl; - if (this->mainThread->joinable()) - { - this->mainThread->join(); + client->close("Shutting down"); } - assert(this->clients.empty()); -} + this->work.reset(); -void PubSub::unlistenAllModerationActions() -{ - for (const auto &p : this->clients) + if (this->thread->joinable()) { - const auto &client = p.second; - if (const auto &[topics, nonce] = - client->unlistenPrefix("chat_moderator_actions."); - !topics.empty()) + // NOTE: We spawn a new thread to join the websocket thread. + // There is a case where a new client was initiated but not added to the clients list. + // We just don't join the thread & let the operating system nuke the thread if joining fails + // within 1s. + // We could fix the underlying bug, but this is easier & we realistically won't use this exact code + // for super much longer. + auto joiner = std::async(std::launch::async, &std::thread::join, + this->thread.get()); + if (joiner.wait_for(1s) == std::future_status::timeout) { - this->registerNonce(nonce, { - client, - "UNLISTEN", - topics, - topics.size(), - }); + qCWarning(chatterinoPubSub) + << "Thread didn't join within 1 second, rip it out"; + this->websocketClient.stop(); } } -} -void PubSub::unlistenAutomod() -{ - for (const auto &p : this->clients) - { - const auto &client = p.second; - if (const auto &[topics, nonce] = - client->unlistenPrefix("automod-queue."); - !topics.empty()) - { - this->registerNonce(nonce, { - client, - "UNLISTEN", - topics, - topics.size(), - }); - } - } -} - -void PubSub::unlistenWhispers() -{ - for (const auto &p : this->clients) - { - const auto &client = p.second; - if (const auto &[topics, nonce] = client->unlistenPrefix("whispers."); - !topics.empty()) - { - this->registerNonce(nonce, { - client, - "UNLISTEN", - topics, - topics.size(), - }); - } - } + assert(this->clients.empty()); } bool PubSub::listenToWhispers() @@ -622,6 +570,11 @@ bool PubSub::listenToWhispers() return true; } +void PubSub::unlistenWhispers() +{ + this->unlistenPrefix("whispers."); +} + void PubSub::listenToChannelModerationActions(const QString &channelID) { if (this->userID_.isEmpty()) @@ -646,6 +599,11 @@ void PubSub::listenToChannelModerationActions(const QString &channelID) this->listenToTopic(topic); } +void PubSub::unlistenChannelModerationActions() +{ + this->unlistenPrefix("chat_moderator_actions."); +} + void PubSub::listenToAutomod(const QString &channelID) { if (this->userID_.isEmpty()) @@ -670,6 +628,40 @@ void PubSub::listenToAutomod(const QString &channelID) this->listenToTopic(topic); } +void PubSub::unlistenAutomod() +{ + this->unlistenPrefix("automod-queue."); +} + +void PubSub::listenToLowTrustUsers(const QString &channelID) +{ + if (this->userID_.isEmpty()) + { + qCDebug(chatterinoPubSub) + << "Unable to listen to low trust users topic, no user logged in"; + return; + } + + static const QString topicFormat("low-trust-users.%1.%2"); + assert(!channelID.isEmpty()); + + auto topic = topicFormat.arg(this->userID_, channelID); + + if (this->isListeningToTopic(topic)) + { + return; + } + + qCDebug(chatterinoPubSub) << "Listen to topic" << topic; + + this->listenToTopic(topic); +} + +void PubSub::unlistenLowTrustUsers() +{ + this->unlistenPrefix("low-trust-users."); +} + void PubSub::listenToChannelPointRewards(const QString &channelID) { static const QString topicFormat("community-points-channel-v1.%1"); @@ -686,6 +678,30 @@ void PubSub::listenToChannelPointRewards(const QString &channelID) this->listenToTopic(topic); } +void PubSub::unlistenChannelPointRewards() +{ + this->unlistenPrefix("community-points-channel-v1."); +} + +void PubSub::unlistenPrefix(const QString &prefix) +{ + for (const auto &p : this->clients) + { + const auto &client = p.second; + if (const auto &[topics, nonce] = client->unlistenPrefix(prefix); + !topics.empty()) + { + NonceInfo nonceInfo{ + client, + "UNLISTEN", + topics, + topics.size(), + }; + this->registerNonce(nonce, nonceInfo); + } + } +} + void PubSub::listen(PubSubListenMessage msg) { if (this->tryListen(msg)) @@ -726,14 +742,14 @@ void PubSub::registerNonce(QString nonce, NonceInfo info) this->nonces_[nonce] = std::move(info); } -boost::optional PubSub::findNonceInfo(QString nonce) +std::optional PubSub::findNonceInfo(QString nonce) { // TODO: This should also DELETE the nonceinfo from the map auto it = this->nonces_.find(nonce); if (it == this->nonces_.end()) { - return boost::none; + return std::nullopt; } return it->second; @@ -1039,11 +1055,11 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message) switch (whisperMessage.type) { case PubSubWhisperMessage::Type::WhisperReceived: { - this->signals_.whisper.received.invoke(whisperMessage); + this->whisper.received.invoke(whisperMessage); } break; case PubSubWhisperMessage::Type::WhisperSent: { - this->signals_.whisper.sent.invoke(whisperMessage); + this->whisper.sent.invoke(whisperMessage); } break; case PubSubWhisperMessage::Type::Thread: { @@ -1138,7 +1154,7 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message) case PubSubCommunityPointsChannelV1Message::Type::RewardRedeemed: { auto redemption = innerMessage.data.value("redemption").toObject(); - this->signals_.pointReward.redeemed.invoke(redemption); + this->pointReward.redeemed.invoke(redemption); } break; @@ -1166,8 +1182,38 @@ void PubSub::handleMessageResponse(const PubSubMessageMessage &message) // Channel ID where the moderator actions are coming from auto channelID = topicParts[2]; - this->signals_.moderation.autoModMessageCaught.invoke(innerMessage, - channelID); + this->moderation.autoModMessageCaught.invoke(innerMessage, channelID); + } + else if (topic.startsWith("low-trust-users.")) + { + auto oInnerMessage = message.toInner(); + if (!oInnerMessage) + { + return; + } + + auto innerMessage = *oInnerMessage; + + switch (innerMessage.type) + { + case PubSubLowTrustUsersMessage::Type::UserMessage: { + this->moderation.suspiciousMessageReceived.invoke(innerMessage); + } + break; + + case PubSubLowTrustUsersMessage::Type::TreatmentUpdate: { + this->moderation.suspiciousTreatmentUpdated.invoke( + innerMessage); + } + break; + + case PubSubLowTrustUsersMessage::Type::INVALID: { + qCWarning(chatterinoPubSub) + << "Invalid low trust users event type:" + << innerMessage.typeString; + } + break; + } } else { diff --git a/src/providers/twitch/PubSubManager.hpp b/src/providers/twitch/PubSubManager.hpp index ea8138d2d9c..6ddd98369aa 100644 --- a/src/providers/twitch/PubSubManager.hpp +++ b/src/providers/twitch/PubSubManager.hpp @@ -5,7 +5,6 @@ #include "util/ExponentialBackoff.hpp" #include "util/QStringHash.hpp" -#include #include #include #include @@ -15,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -34,12 +34,20 @@ struct PubSubAutoModQueueMessage; struct AutomodAction; struct AutomodUserAction; struct AutomodInfoAction; +struct PubSubLowTrustUsersMessage; struct PubSubWhisperMessage; struct PubSubListenMessage; struct PubSubMessage; struct PubSubMessageMessage; +/** + * This handles the Twitch PubSub connection + * + * Known issues: + * - Upon closing a channel, we don't unsubscribe to its pubsub connections + * - Stop is never called, meaning we never do a clean shutdown + */ class PubSub { using WebsocketMessagePtr = @@ -59,84 +67,116 @@ class PubSub }; WebsocketClient websocketClient; - std::unique_ptr mainThread; + std::unique_ptr thread; // Account credentials - // Set from setAccount or setAccountData + // Set from setAccount QString token_; QString userID_; public: - // The max amount of connections we may open - static constexpr int maxConnections = 10; - PubSub(const QString &host, std::chrono::seconds pingInterval = std::chrono::seconds(15)); + ~PubSub(); + + PubSub(const PubSub &) = delete; + PubSub(PubSub &&) = delete; + PubSub &operator=(const PubSub &) = delete; + PubSub &operator=(PubSub &&) = delete; void setAccount(std::shared_ptr account); - void setAccountData(QString token, QString userID); + void start(); + void stop(); - ~PubSub() = delete; + struct { + Signal chatCleared; + Signal messageDeleted; + Signal modeChanged; + Signal moderationStateChanged; - enum class State { - Connected, - Disconnected, - }; + Signal userBanned; + Signal userUnbanned; - void start(); - void stop(); + Signal suspiciousMessageReceived; + Signal suspiciousTreatmentUpdated; + + // Message caught by automod + // channelID + pajlada::Signals::Signal + autoModMessageCaught; + + // Message blocked by moderator + Signal autoModMessageBlocked; - bool isConnected() const - { - return this->state == State::Connected; - } + Signal automodUserMessage; + Signal automodInfoMessage; + } moderation; struct { - struct { - Signal chatCleared; - Signal messageDeleted; - Signal modeChanged; - Signal moderationStateChanged; - - Signal userBanned; - Signal userUnbanned; - - // Message caught by automod - // channelID - pajlada::Signals::Signal - autoModMessageCaught; - - // Message blocked by moderator - Signal autoModMessageBlocked; - - Signal automodUserMessage; - Signal automodInfoMessage; - } moderation; - - struct { - // Parsing should be done in PubSubManager as well, - // but for now we just send the raw data - Signal received; - Signal sent; - } whisper; - - struct { - Signal redeemed; - } pointReward; - } signals_; - - void unlistenAllModerationActions(); - void unlistenAutomod(); - void unlistenWhispers(); + // Parsing should be done in PubSubManager as well, + // but for now we just send the raw data + Signal received; + Signal sent; + } whisper; + struct { + Signal redeemed; + } pointReward; + + /** + * Listen to incoming whispers for the currently logged in user. + * This topic is relevant for everyone. + * + * PubSub topic: whispers.{currentUserID} + */ bool listenToWhispers(); + void unlistenWhispers(); + + /** + * Listen to moderation actions in the given channel. + * This topic is relevant for everyone. + * For moderators, this topic includes blocked/permitted terms updates, + * roomstate changes, general mod/vip updates, all bans/timeouts/deletions. + * For normal users, this topic includes moderation actions that are targetted at the local user: + * automod catching a user's sent message, a moderator approving or denying their caught messages, + * the user gaining/losing mod/vip, the user receiving a ban/timeout/deletion. + * + * PubSub topic: chat_moderator_actions.{currentUserID}.{channelID} + */ void listenToChannelModerationActions(const QString &channelID); + void unlistenChannelModerationActions(); + + /** + * Listen to Automod events in the given channel. + * This topic is only relevant for moderators. + * This will send events about incoming messages that + * are caught by Automod. + * + * PubSub topic: automod-queue.{currentUserID}.{channelID} + */ void listenToAutomod(const QString &channelID); + void unlistenAutomod(); + /** + * Listen to Low Trust events in the given channel. + * This topic is only relevant for moderators. + * This will fire events about suspicious treatment updates + * and messages sent by restricted/monitored users. + * + * PubSub topic: low-trust-users.{currentUserID}.{channelID} + */ + void listenToLowTrustUsers(const QString &channelID); + void unlistenLowTrustUsers(); + + /** + * Listen to incoming channel point redemptions in the given channel. + * This topic is relevant for everyone. + * + * PubSub topic: community-points-channel-v1.{channelID} + */ void listenToChannelPointRewards(const QString &channelID); - - std::vector requests; + void unlistenChannelPointRewards(); struct { std::atomic connectionsClosed{0}; @@ -149,20 +189,26 @@ class PubSub std::atomic unlistenResponses{0}; } diag; +private: + /** + * Unlistens to all topics matching the prefix in all clients + */ + void unlistenPrefix(const QString &prefix); + void listenToTopic(const QString &topic); -private: void listen(PubSubListenMessage msg); bool tryListen(PubSubListenMessage msg); bool isListeningToTopic(const QString &topic); void addClient(); + + std::vector requests; + std::atomic addingClient{false}; ExponentialBackoff<5> connectBackoff{std::chrono::milliseconds(1000)}; - State state = State::Connected; - std::map, std::owner_less> clients; @@ -190,7 +236,7 @@ class PubSub void registerNonce(QString nonce, NonceInfo nonceInfo); // Find client associated with a nonce - boost::optional findNonceInfo(QString nonce); + std::optional findNonceInfo(QString nonce); std::unordered_map nonces_; diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 789ed8339f0..8b531156fca 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -3,18 +3,19 @@ #include "Application.hpp" #include "common/Channel.hpp" #include "common/Env.hpp" -#include "common/NetworkRequest.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/irc/IrcMessageBuilder.hpp" #include "providers/IvrApi.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchCommon.hpp" -#include "providers/twitch/TwitchUser.hpp" #include "singletons/Emotes.hpp" +#include "util/CancellationToken.hpp" #include "util/Helpers.hpp" #include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" @@ -100,77 +101,79 @@ bool TwitchAccount::isAnon() const void TwitchAccount::loadBlocks() { + assertInGuiThread(); + + auto token = CancellationToken(false); + this->blockToken_ = token; + this->ignores_.clear(); + this->ignoresUserIds_.clear(); + getHelix()->loadBlocks( getIApp()->getAccounts()->twitch.getCurrent()->userId_, - [this](std::vector blocks) { - auto ignores = this->ignores_.access(); - auto userIds = this->ignoresUserIds_.access(); - ignores->clear(); - userIds->clear(); + [this](const std::vector &blocks) { + assertInGuiThread(); for (const HelixBlock &block : blocks) { TwitchUser blockedUser; blockedUser.fromHelixBlock(block); - ignores->insert(blockedUser); - userIds->insert(blockedUser.id); + this->ignores_.insert(blockedUser); + this->ignoresUserIds_.insert(blockedUser.id); } }, - [] { - qCWarning(chatterinoTwitch) << "Fetching blocks failed!"; - }); + [](auto error) { + qCWarning(chatterinoTwitch).noquote() + << "Fetching blocks failed:" << error; + }, + std::move(token)); } -void TwitchAccount::blockUser(QString userId, std::function onSuccess, +void TwitchAccount::blockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure) { getHelix()->blockUser( - userId, - [this, userId, onSuccess] { + userId, caller, + [this, userId, onSuccess = std::move(onSuccess)] { + assertInGuiThread(); + TwitchUser blockedUser; blockedUser.id = userId; - { - auto ignores = this->ignores_.access(); - auto userIds = this->ignoresUserIds_.access(); - - ignores->insert(blockedUser); - userIds->insert(blockedUser.id); - } + this->ignores_.insert(blockedUser); + this->ignoresUserIds_.insert(blockedUser.id); onSuccess(); }, std::move(onFailure)); } -void TwitchAccount::unblockUser(QString userId, std::function onSuccess, +void TwitchAccount::unblockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure) { getHelix()->unblockUser( - userId, - [this, userId, onSuccess] { + userId, caller, + [this, userId, onSuccess = std::move(onSuccess)] { + assertInGuiThread(); + TwitchUser ignoredUser; ignoredUser.id = userId; - { - auto ignores = this->ignores_.access(); - auto userIds = this->ignoresUserIds_.access(); - - ignores->erase(ignoredUser); - userIds->erase(ignoredUser.id); - } + this->ignores_.erase(ignoredUser); + this->ignoresUserIds_.erase(ignoredUser.id); onSuccess(); }, std::move(onFailure)); } -SharedAccessGuard> TwitchAccount::accessBlocks() - const +const std::unordered_set &TwitchAccount::blocks() const { - return this->ignores_.accessConst(); + assertInGuiThread(); + return this->ignores_; } -SharedAccessGuard> TwitchAccount::accessBlockedUserIds() - const +const std::unordered_set &TwitchAccount::blockedUserIds() const { - return this->ignoresUserIds_.accessConst(); + assertInGuiThread(); + return this->ignoresUserIds_; } void TwitchAccount::loadEmotes(std::weak_ptr weakChannel) @@ -260,11 +263,32 @@ void TwitchAccount::loadUserstateEmotes(std::weak_ptr weakChannel) [this, weakChannel](QJsonArray emoteSetArray) { auto emoteData = this->emotes_.access(); auto localEmoteData = this->localEmotes_.access(); - for (auto emoteSet_ : emoteSetArray) + + std::unordered_set subscriberChannelIDs; + std::vector ivrEmoteSets; + ivrEmoteSets.reserve(emoteSetArray.size()); + + for (auto emoteSet : emoteSetArray) { - auto emoteSet = std::make_shared(); + IvrEmoteSet ivrEmoteSet(emoteSet.toObject()); + if (!ivrEmoteSet.tier.isNull()) + { + subscriberChannelIDs.insert(ivrEmoteSet.channelId); + } + ivrEmoteSets.emplace_back(ivrEmoteSet); + } + + for (const auto &emoteSet : emoteData->emoteSets) + { + if (emoteSet->subscriber) + { + subscriberChannelIDs.insert(emoteSet->channelID); + } + } - IvrEmoteSet ivrEmoteSet(emoteSet_.toObject()); + for (const auto &ivrEmoteSet : ivrEmoteSets) + { + auto emoteSet = std::make_shared(); QString setKey = ivrEmoteSet.setId; emoteSet->key = setKey; @@ -281,8 +305,15 @@ void TwitchAccount::loadUserstateEmotes(std::weak_ptr weakChannel) continue; } + emoteSet->channelID = ivrEmoteSet.channelId; emoteSet->channelName = ivrEmoteSet.login; emoteSet->text = ivrEmoteSet.displayName; + emoteSet->subscriber = !ivrEmoteSet.tier.isNull(); + + // NOTE: If a user does not have a subscriber emote set, but a follower emote set, this logic will be wrong + // However, that's not a realistic problem. + bool haveSubscriberSetForChannel = + subscriberChannelIDs.contains(ivrEmoteSet.channelId); for (const auto &emoteObj : ivrEmoteSet.emotes) { @@ -294,11 +325,15 @@ void TwitchAccount::loadUserstateEmotes(std::weak_ptr weakChannel) emoteSet->emotes.push_back(TwitchEmote{id, code}); - auto emote = - getApp()->emotes->twitch.getOrCreateEmote(id, code); + auto emote = getIApp() + ->getEmotes() + ->getTwitchEmotes() + ->getOrCreateEmote(id, code); // Follower emotes can be only used in their origin channel - if (ivrEmote.emoteType == "FOLLOWER") + // unless the user is subscribed, then they can be used anywhere. + if (ivrEmote.emoteType == "FOLLOWER" && + !haveSubscriberSetForChannel) { emoteSet->local = true; @@ -442,4 +477,43 @@ void TwitchAccount::autoModDeny(const QString msgID, ChannelPtr channel) }); } +const QString &TwitchAccount::getSeventvUserID() const +{ + return this->seventvUserID_; +} + +void TwitchAccount::loadSeventvUserID() +{ + if (this->isAnon()) + { + return; + } + if (!this->seventvUserID_.isEmpty()) + { + return; + } + + auto *seventv = getIApp()->getSeventvAPI(); + if (!seventv) + { + qCWarning(chatterinoSeventv) + << "Not loading 7TV User ID because the 7TV API is not initialized"; + return; + } + + seventv->getUserByTwitchID( + this->getUserId(), + [this](const auto &json) { + const auto id = json["user"]["id"].toString(); + if (!id.isEmpty()) + { + this->seventvUserID_ = id; + } + }, + [](const auto &result) { + qCDebug(chatterinoSeventv) + << "Failed to load 7TV user-id:" << result.formatError(); + }); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index bfb8b98ca66..69032b80c9c 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -5,20 +5,23 @@ #include "common/UniqueAccess.hpp" #include "controllers/accounts/Account.hpp" #include "messages/Emote.hpp" +#include "providers/twitch/TwitchUser.hpp" +#include "util/CancellationToken.hpp" #include "util/QStringHash.hpp" #include #include +#include #include #include #include #include -#include +#include +#include namespace chatterino { -struct TwitchUser; class Channel; using ChannelPtr = std::shared_ptr; @@ -33,7 +36,9 @@ class TwitchAccount : public Account struct EmoteSet { QString key; QString channelName; + QString channelID; QString text; + bool subscriber{false}; bool local{false}; std::vector emotes; }; @@ -49,13 +54,19 @@ class TwitchAccount : public Account TwitchAccount(const QString &username, const QString &oauthToken_, const QString &oauthClient_, const QString &_userID); - virtual QString toString() const override; + QString toString() const override; const QString &getUserName() const; const QString &getOAuthToken() const; const QString &getOAuthClient() const; const QString &getUserId() const; + /** + * The Seventv user-id of the current user. + * Empty if there's no associated Seventv user with this twitch user. + */ + const QString &getSeventvUserID() const; + QColor color(); void setColor(QColor color); @@ -70,13 +81,15 @@ class TwitchAccount : public Account bool isAnon() const; void loadBlocks(); - void blockUser(QString userId, std::function onSuccess, + void blockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure); - void unblockUser(QString userId, std::function onSuccess, + void unblockUser(const QString &userId, const QObject *caller, + std::function onSuccess, std::function onFailure); - SharedAccessGuard> accessBlockedUserIds() const; - SharedAccessGuard> accessBlocks() const; + [[nodiscard]] const std::unordered_set &blocks() const; + [[nodiscard]] const std::unordered_set &blockedUserIds() const; void loadEmotes(std::weak_ptr weakChannel = {}); // loadUserstateEmotes loads emote sets that are part of the USERSTATE emote-sets key @@ -93,6 +106,8 @@ class TwitchAccount : public Account void autoModAllow(const QString msgID, ChannelPtr channel); void autoModDeny(const QString msgID, ChannelPtr channel); + void loadSeventvUserID(); + private: QString oauthClient_; QString oauthToken_; @@ -101,14 +116,17 @@ class TwitchAccount : public Account const bool isAnon_; Atomic color_; - mutable std::mutex ignoresMutex_; QStringList userstateEmoteSets_; - UniqueAccess> ignores_; - UniqueAccess> ignoresUserIds_; + + ScopedCancellationToken blockToken_; + std::unordered_set ignores_; + std::unordered_set ignoresUserIds_; // std::map emotes; UniqueAccess emotes_; UniqueAccess> localEmotes_; + + QString seventvUserID_; }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 6b02d3bb245..7d1d302468a 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -17,9 +17,12 @@ TwitchAccountManager::TwitchAccountManager() this->currentUserChanged.connect([this] { auto currentUser = this->getCurrent(); currentUser->loadBlocks(); + currentUser->loadSeventvUserID(); }); - this->accounts.itemRemoved.connect([this](const auto &acc) { + // We can safely ignore this signal connection since accounts will always be removed + // before TwitchAccountManager + std::ignore = this->accounts.itemRemoved.connect([this](const auto &acc) { this->removeUser(acc.item.get()); }); } diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 2eb0b1910a9..6eb5c6cd9f1 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -1,18 +1,19 @@ -#include "TwitchBadges.hpp" +#include "providers/twitch/TwitchBadges.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "providers/twitch/api/Helix.hpp" #include "util/DisplayBadge.hpp" #include +#include #include #include #include -#include +#include #include #include #include @@ -28,64 +29,107 @@ void TwitchBadges::loadTwitchBadges() { assert(this->loaded_ == false); - QUrl url("https://badges.twitch.tv/v1/badges/global/display"); + if (!getHelix()) + { + // This is intended for tests and benchmarks. + return; + } - QUrlQuery urlQuery; - urlQuery.addQueryItem("language", "en"); - url.setQuery(urlQuery); + getHelix()->getGlobalBadges( + [this](auto globalBadges) { + auto badgeSets = this->badgeSets_.access(); - NetworkRequest(url) - .onSuccess([this](auto result) -> Outcome { + for (const auto &badgeSet : globalBadges.badgeSets) { - auto root = result.parseJson(); - auto badgeSets = this->badgeSets_.access(); - - auto jsonSets = root.value("badge_sets").toObject(); - for (auto sIt = jsonSets.begin(); sIt != jsonSets.end(); ++sIt) + const auto &setID = badgeSet.setID; + for (const auto &version : badgeSet.versions) { - auto key = sIt.key(); - auto versions = - sIt.value().toObject().value("versions").toObject(); - - for (auto vIt = versions.begin(); vIt != versions.end(); - ++vIt) - { - auto versionObj = vIt.value().toObject(); - - auto emote = Emote{ - {""}, + const auto &emote = Emote{ + .name = EmoteName{}, + .images = ImageSet{ - Image::fromUrl({versionObj.value("image_url_1x") - .toString()}, - 1), - Image::fromUrl({versionObj.value("image_url_2x") - .toString()}, - .5), - Image::fromUrl({versionObj.value("image_url_4x") - .toString()}, - .25), + Image::fromUrl(version.imageURL1x, 1), + Image::fromUrl(version.imageURL2x, .5), + Image::fromUrl(version.imageURL4x, .25), }, - Tooltip{versionObj.value("title").toString()}, - Url{versionObj.value("click_url").toString()}}; - // "title" - // "clickAction" - - (*badgeSets)[key][vIt.key()] = - std::make_shared(emote); - } + .tooltip = Tooltip{version.title}, + .homePage = version.clickURL, + }; + (*badgeSets)[setID][version.id] = + std::make_shared(emote); } } + this->loaded(); - return Success; - }) - .onError([this](auto res) { - qCDebug(chatterinoTwitch) - << "Error loading Twitch Badges:" << res.status(); - // Despite erroring out, we still want to reach the same point - // Loaded should still be set to true to not build up an endless queue, and the quuee should still be flushed. + }, + [this](auto error, auto message) { + QString errorMessage("Failed to load global badges - "); + + switch (error) + { + case HelixGetGlobalBadgesError::Forwarded: { + errorMessage += message; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixGetGlobalBadgesError::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + qCWarning(chatterinoTwitch) << errorMessage; + QFile file(":/twitch-badges.json"); + if (!file.open(QFile::ReadOnly)) + { + // Despite erroring out, we still want to reach the same point + // Loaded should still be set to true to not build up an endless queue, and the quuee should still be flushed. + qCWarning(chatterinoTwitch) + << "Error loading Twitch Badges from the local backup file"; + this->loaded(); + return; + } + auto bytes = file.readAll(); + auto doc = QJsonDocument::fromJson(bytes); + + this->parseTwitchBadges(doc.object()); + this->loaded(); - }) - .execute(); + }); +} + +void TwitchBadges::parseTwitchBadges(QJsonObject root) +{ + auto badgeSets = this->badgeSets_.access(); + + auto jsonSets = root.value("badge_sets").toObject(); + for (auto sIt = jsonSets.begin(); sIt != jsonSets.end(); ++sIt) + { + auto key = sIt.key(); + auto versions = sIt.value().toObject().value("versions").toObject(); + + for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt) + { + auto versionObj = vIt.value().toObject(); + + auto emote = Emote{ + .name = {""}, + .images = + ImageSet{ + Image::fromUrl( + {versionObj.value("image_url_1x").toString()}, 1), + Image::fromUrl( + {versionObj.value("image_url_2x").toString()}, .5), + Image::fromUrl( + {versionObj.value("image_url_4x").toString()}, .25), + }, + .tooltip = Tooltip{versionObj.value("title").toString()}, + .homePage = Url{versionObj.value("click_url").toString()}, + }; + + (*badgeSets)[key][vIt.key()] = std::make_shared(emote); + } + } } void TwitchBadges::loaded() @@ -110,8 +154,8 @@ void TwitchBadges::loaded() } } -boost::optional TwitchBadges::badge(const QString &set, - const QString &version) const +std::optional TwitchBadges::badge(const QString &set, + const QString &version) const { auto badgeSets = this->badgeSets_.access(); auto it = badgeSets->find(set); @@ -123,21 +167,22 @@ boost::optional TwitchBadges::badge(const QString &set, return it2->second; } } - return boost::none; + return std::nullopt; } -boost::optional TwitchBadges::badge(const QString &set) const +std::optional TwitchBadges::badge(const QString &set) const { auto badgeSets = this->badgeSets_.access(); auto it = badgeSets->find(set); if (it != badgeSets->end()) { - if (it->second.size() > 0) + const auto &badges = it->second; + if (!badges.empty()) { - return it->second.begin()->second; + return badges.begin()->second; } } - return boost::none; + return std::nullopt; } void TwitchBadges::getBadgeIcon(const QString &name, BadgeIconCallback callback) @@ -149,7 +194,7 @@ void TwitchBadges::getBadgeIcon(const QString &name, BadgeIconCallback callback) { // Badges have not been loaded yet, store callback in a queue std::unique_lock queueLock(this->queueMutex_); - this->callbackQueue_.push({name, std::move(callback)}); + this->callbackQueue_.emplace(name, std::move(callback)); return; } } @@ -199,7 +244,7 @@ void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, NetworkRequest(image->url().string) .concurrent() .cache() - .onSuccess([this, name, callback](auto result) -> Outcome { + .onSuccess([this, name, callback](auto result) { auto data = result.getData(); // const cast since we are only reading from it @@ -209,18 +254,18 @@ void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, if (!reader.canRead() || reader.size().isEmpty()) { - return Failure; + return; } QImage image = reader.read(); if (image.isNull()) { - return Failure; + return; } if (reader.imageCount() <= 0) { - return Failure; + return; } auto icon = std::make_shared(QPixmap::fromImage(image)); @@ -231,22 +276,8 @@ void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, } callback(name, icon); - - return Success; }) .execute(); } -TwitchBadges *TwitchBadges::instance_; - -TwitchBadges *TwitchBadges::instance() -{ - if (TwitchBadges::instance_ == nullptr) - { - TwitchBadges::instance_ = new TwitchBadges(); - } - - return TwitchBadges::instance_; -} - } // namespace chatterino diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index 5d371d5a966..cb56207a9ce 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -3,13 +3,14 @@ #include "common/UniqueAccess.hpp" #include "util/QStringHash.hpp" -#include #include #include +#include #include #include #include +#include #include #include #include @@ -31,13 +32,13 @@ class TwitchBadges using BadgeIconCallback = std::function; public: - static TwitchBadges *instance(); + TwitchBadges(); // Get badge from name and version - boost::optional badge(const QString &set, - const QString &version) const; + std::optional badge(const QString &set, + const QString &version) const; // Get first matching badge with name, regardless of version - boost::optional badge(const QString &set) const; + std::optional badge(const QString &set) const; void getBadgeIcon(const QString &name, BadgeIconCallback callback); void getBadgeIcon(const DisplayBadge &badge, BadgeIconCallback callback); @@ -45,10 +46,8 @@ class TwitchBadges BadgeIconCallback callback); private: - static TwitchBadges *instance_; - - TwitchBadges(); void loadTwitchBadges(); + void parseTwitchBadges(QJsonObject root); void loaded(); void loadEmoteImage(const QString &name, ImagePtr image, BadgeIconCallback &&callback); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 89972c8d023..a8eace253ed 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -3,11 +3,12 @@ #include "Application.hpp" #include "common/Common.hpp" #include "common/Env.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/twitch/LiveController.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/Link.hpp" @@ -17,8 +18,10 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/bttv/BttvLiveUpdates.hpp" #include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" -#include "providers/RecentMessagesApi.hpp" +#include "providers/ffz/FfzEmotes.hpp" +#include "providers/recentmessages/Api.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvEmotes.hpp" #include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/api/Helix.hpp" @@ -33,12 +36,14 @@ #include "singletons/Settings.hpp" #include "singletons/Toasts.hpp" #include "singletons/WindowManager.hpp" +#include "util/Helpers.hpp" #include "util/PostToThread.hpp" #include "util/QStringHash.hpp" #include "widgets/Window.hpp" #include #include +#include #include #include #include @@ -52,7 +57,6 @@ namespace { #else const QString MAGIC_MESSAGE_SUFFIX = QString::fromUtf8(u8" \U000E0000"); #endif - constexpr int TITLE_REFRESH_PERIOD = 10000; constexpr int CLIP_CREATION_COOLDOWN = 5000; const QString CLIPS_LINK("https://clips.twitch.tv/%1"); const QString CLIPS_FAILURE_CLIPS_DISABLED_TEXT( @@ -72,7 +76,7 @@ namespace { TwitchChannel::TwitchChannel(const QString &name) : Channel(name, Channel::Type::Twitch) , ChannelChatters(*static_cast(this)) - , nameOptions{name, name} + , nameOptions{name, name, name} , subscriptionUrl_("https://www.twitch.tv/subs/" + name) , channelUrl_("https://twitch.tv/" + name) , popoutPlayerUrl_("https://player.twitch.tv/?parent=twitch.tv&channel=" + @@ -80,59 +84,41 @@ TwitchChannel::TwitchChannel(const QString &name) , bttvEmotes_(std::make_shared()) , ffzEmotes_(std::make_shared()) , seventvEmotes_(std::make_shared()) - , mod_(false) { qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened"; + if (!getApp()) + { + // This is intended for tests and benchmarks. + // Irc, Pubsub, live-updates, and live-notifications aren't mocked there. + return; + } + this->bSignals_.emplace_back( - getApp()->accounts->twitch.currentUserChanged.connect([this] { + getIApp()->getAccounts()->twitch.currentUserChanged.connect([this] { this->setMod(false); this->refreshPubSub(); })); this->refreshPubSub(); - this->userStateChanged.connect([this] { - this->refreshPubSub(); - }); - - // room id loaded -> refresh live status - this->roomIdChanged.connect([this]() { + // We can safely ignore this signal connection since it's a private signal, meaning + // it will only ever be invoked by TwitchChannel itself + std::ignore = this->userStateChanged.connect([this] { this->refreshPubSub(); - this->refreshTitle(); - this->refreshLiveStatus(); - this->refreshBadges(); - this->refreshCheerEmotes(); - this->refreshFFZChannelEmotes(false); - this->refreshBTTVChannelEmotes(false); - this->refreshSevenTVChannelEmotes(false); - this->joinBttvChannel(); - }); - - this->connected.connect([this]() { - if (this->roomId().isEmpty()) - { - // If we get a reconnected event when the room id is not set, we - // just connected for the first time. After receiving the first - // message from a channel, setRoomId is called and further - // invocations of this event will load recent messages. - return; - } - - this->loadRecentMessagesReconnect(); }); - this->messageRemovedFromStart.connect([this](MessagePtr &msg) { - if (msg->replyThread) + // We can safely ignore this signal connection this has no external dependencies - once the signal + // is destroyed, it will no longer be able to fire + std::ignore = this->joined.connect([this]() { + if (this->disconnected_) { - if (msg->replyThread->liveCount(msg) == 0) - { - this->threads_.erase(msg->replyThread->rootId()); - } + this->loadRecentMessagesReconnect(); + this->lastConnectedAt_ = std::chrono::system_clock::now(); + this->disconnected_ = false; } }); // timers - QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [this] { this->refreshChatters(); }); @@ -151,6 +137,85 @@ TwitchChannel::TwitchChannel(const QString &name) }); this->threadClearTimer_.start(5 * 60 * 1000); + auto onLiveStatusChanged = [this](auto isLive) { + if (isLive) + { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Online"; + if (getIApp()->getNotifications()->isChannelNotified( + this->getName(), Platform::Twitch)) + { + if (Toasts::isEnabled()) + { + getIApp()->getToasts()->sendChannelNotification( + this->getName(), this->accessStreamStatus()->title, + Platform::Twitch); + } + if (getSettings()->notificationPlaySound) + { + getIApp()->getNotifications()->playSound(); + } + if (getSettings()->notificationFlashTaskbar) + { + getIApp()->getWindows()->sendAlert(); + } + } + // Channel live message + MessageBuilder builder; + TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // Message in /live channel + MessageBuilder builder2; + TwitchMessageBuilder::liveMessage(this->getDisplayName(), + &builder2); + getApp()->twitch->liveChannel->addMessage(builder2.release()); + + // Notify on all channels with a ping sound + if (getSettings()->notificationOnAnyChannel && + !(isInStreamerMode() && + getSettings()->streamerModeSuppressLiveNotifications)) + { + getIApp()->getNotifications()->playSound(); + } + } + else + { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Offline"; + // Channel offline message + MessageBuilder builder; + TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // "delete" old 'CHANNEL is live' message + LimitedQueueSnapshot snapshot = + getApp()->twitch->liveChannel->getMessageSnapshot(); + int snapshotLength = snapshot.size(); + + // MSVC hates this code if the parens are not there + int end = (std::max)(0, snapshotLength - 200); + auto liveMessageSearchText = + QString("%1 is live!").arg(this->getDisplayName()); + + for (int i = snapshotLength - 1; i >= end; --i) + { + const auto &s = snapshot[i]; + + if (s->messageText == liveMessageSearchText) + { + s->flags.set(MessageFlag::Disabled); + break; + } + } + } + }; + + this->signalHolder_.managedConnect(this->liveStatusChanged, + onLiveStatusChanged); + // debugging #if 0 for (int i = 0; i < 1000; i++) { @@ -161,6 +226,13 @@ TwitchChannel::TwitchChannel(const QString &name) TwitchChannel::~TwitchChannel() { + if (!getApp()) + { + // This is for tests and benchmarks, where live-updates aren't mocked + // see comment in constructor. + return; + } + getApp()->twitch->dropSeventvChannel(this->seventvUserID_, this->seventvEmoteSetID_); @@ -168,11 +240,16 @@ TwitchChannel::~TwitchChannel() { getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId()); } + + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->unsubscribeTwitchChannel( + this->roomId()); + } } void TwitchChannel::initialize() { - this->fetchDisplayName(); this->refreshChatters(); this->refreshBadges(); } @@ -219,8 +296,9 @@ void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh) weakOf(this), this->roomId(), this->getLocalizedName(), [this, weak = weakOf(this)](auto &&emoteMap) { if (auto shared = weak.lock()) - this->bttvEmotes_.set( - std::make_shared(std::move(emoteMap))); + { + this->setBttvEmotes(std::make_shared(emoteMap)); + } }, manualRefresh); } @@ -237,19 +315,22 @@ void TwitchChannel::refreshFFZChannelEmotes(bool manualRefresh) weakOf(this), this->roomId(), [this, weak = weakOf(this)](auto &&emoteMap) { if (auto shared = weak.lock()) - this->ffzEmotes_.set( - std::make_shared(std::move(emoteMap))); + { + this->setFfzEmotes(std::make_shared(emoteMap)); + } }, [this, weak = weakOf(this)](auto &&modBadge) { if (auto shared = weak.lock()) { - this->ffzCustomModBadge_.set(std::move(modBadge)); + this->ffzCustomModBadge_.set( + std::forward(modBadge)); } }, [this, weak = weakOf(this)](auto &&vipBadge) { if (auto shared = weak.lock()) { - this->ffzCustomVipBadge_.set(std::move(vipBadge)); + this->ffzCustomVipBadge_.set( + std::forward(vipBadge)); } }, manualRefresh); @@ -269,8 +350,8 @@ void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh) auto channelInfo) { if (auto shared = weak.lock()) { - this->seventvEmotes_.set(std::make_shared( - std::forward(emoteMap))); + this->setSeventvEmotes( + std::make_shared(emoteMap)); this->updateSeventvData(channelInfo.userID, channelInfo.emoteSetID); this->seventvUserTwitchConnectionIndex_ = @@ -280,6 +361,32 @@ void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh) manualRefresh); } +void TwitchChannel::setBttvEmotes(std::shared_ptr &&map) +{ + this->bttvEmotes_.set(std::move(map)); +} + +void TwitchChannel::setFfzEmotes(std::shared_ptr &&map) +{ + this->ffzEmotes_.set(std::move(map)); +} + +void TwitchChannel::setSeventvEmotes(std::shared_ptr &&map) +{ + this->seventvEmotes_.set(std::move(map)); +} + +void TwitchChannel::addQueuedRedemption(const QString &rewardId, + const QString &originalContent, + Communi::IrcMessage *message) +{ + this->waitingRedemptions_.push_back({ + rewardId, + originalContent, + {message->clone(), {}}, + }); +} + void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) { assertInGuiThread(); @@ -300,25 +407,26 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) } if (result) { + const auto &channelName = this->getName(); qCDebug(chatterinoTwitch) - << "[TwitchChannel" << this->getName() + << "[TwitchChannel" << channelName << "] Channel point reward added:" << reward.id << "," << reward.title << "," << reward.isUserInputRequired; - // TODO: There's an underlying bug here. This bug should be fixed. - // This only attempts to prevent a crash when invoking the signal. - try - { - this->channelPointRewardAdded.invoke(reward); - } - catch (const std::bad_function_call &) - { - qCWarning(chatterinoTwitch).nospace() - << "[TwitchChannel " << this->getName() - << "] Caught std::bad_function_call when adding channel point " - "reward ChannelPointReward{ id: " - << reward.id << ", title: " << reward.title << " }."; - } + auto *server = getApp()->twitch; + auto it = std::remove_if( + this->waitingRedemptions_.begin(), this->waitingRedemptions_.end(), + [&](const QueuedRedemption &msg) { + if (reward.id == msg.rewardID) + { + IrcMessageHandler::instance().addMessage( + msg.message.get(), shared_from_this(), + msg.originalContent, *server, false, false); + return true; + } + return false; + }); + this->waitingRedemptions_.erase(it, this->waitingRedemptions_.end()); } } @@ -329,22 +437,109 @@ bool TwitchChannel::isChannelPointRewardKnown(const QString &rewardId) return it != pointRewards->end(); } -boost::optional TwitchChannel::channelPointReward( +std::optional TwitchChannel::channelPointReward( const QString &rewardId) const { auto rewards = this->channelPointRewards_.accessConst(); auto it = rewards->find(rewardId); if (it == rewards->end()) - return boost::none; + { + return std::nullopt; + } return it->second; } +void TwitchChannel::updateStreamStatus( + const std::optional &helixStream) +{ + if (helixStream) + { + auto stream = *helixStream; + { + auto status = this->streamStatus_.access(); + status->viewerCount = stream.viewerCount; + status->gameId = stream.gameId; + status->game = stream.gameName; + status->title = stream.title; + QDateTime since = + QDateTime::fromString(stream.startedAt, Qt::ISODate); + auto diff = since.secsTo(QDateTime::currentDateTime()); + status->uptime = QString::number(diff / 3600) + "h " + + QString::number(diff % 3600 / 60) + "m"; + + status->rerun = false; + status->streamType = stream.type; + } + if (this->setLive(true)) + { + this->liveStatusChanged.invoke(true); + } + this->streamStatusChanged.invoke(); + } + else + { + if (this->setLive(false)) + { + this->liveStatusChanged.invoke(false); + this->streamStatusChanged.invoke(); + } + } +} + +void TwitchChannel::updateStreamTitle(const QString &title) +{ + { + auto status = this->streamStatus_.access(); + if (status->title == title) + { + // Title has not changed + return; + } + status->title = title; + } + this->streamStatusChanged.invoke(); +} + +void TwitchChannel::updateDisplayName(const QString &displayName) +{ + if (displayName == this->nameOptions.actualDisplayName) + { + // Display name has not changed + return; + } + + // Display name has changed + + this->nameOptions.actualDisplayName = displayName; + + if (QString::compare(displayName, this->getName(), Qt::CaseInsensitive) == + 0) + { + // Display name is only a case variation of the login name + this->setDisplayName(displayName); + + this->setLocalizedName(displayName); + } + else + { + // Display name contains Chinese, Japanese, or Korean characters + this->setDisplayName(this->getName()); + + this->setLocalizedName( + QString("%1(%2)").arg(this->getName()).arg(displayName)); + } + + this->addRecentChatter(this->getDisplayName()); + + this->displayNameChanged.invoke(); +} + void TwitchChannel::showLoginMessage() { const auto linkColor = MessageColor(MessageColor::Link); const auto accountsLink = Link(Link::OpenAccountsPage, QString()); - const auto currentUser = getApp()->accounts->twitch.getCurrent(); + const auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); const auto expirationText = QStringLiteral("You need to log in to send messages. You can link your " "Twitch account"); @@ -365,10 +560,25 @@ void TwitchChannel::showLoginMessage() this->addMessage(builder.release()); } +void TwitchChannel::roomIdChanged() +{ + this->refreshPubSub(); + this->refreshBadges(); + this->refreshCheerEmotes(); + this->refreshFFZChannelEmotes(false); + this->refreshBTTVChannelEmotes(false); + this->refreshSevenTVChannelEmotes(false); + this->joinBttvChannel(); + this->listenSevenTVCosmetics(); + getIApp()->getTwitchLiveController()->add( + std::dynamic_pointer_cast(shared_from_this())); +} + QString TwitchChannel::prepareMessage(const QString &message) const { - auto app = getApp(); - QString parsedMessage = app->emotes->emojis.replaceShortCodes(message); + auto *app = getApp(); + QString parsedMessage = + app->getEmotes()->getEmojis()->replaceShortCodes(message); parsedMessage = parsedMessage.simplified(); @@ -413,8 +623,8 @@ QString TwitchChannel::prepareMessage(const QString &message) const void TwitchChannel::sendMessage(const QString &message) { - auto app = getApp(); - if (!app->accounts->twitch.isLoggedIn()) + auto *app = getApp(); + if (!app->getAccounts()->twitch.isLoggedIn()) { if (!message.isEmpty()) { @@ -436,6 +646,7 @@ void TwitchChannel::sendMessage(const QString &message) bool messageSent = false; this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent); + this->updateSevenTVActivity(); if (messageSent) { @@ -446,8 +657,8 @@ void TwitchChannel::sendMessage(const QString &message) void TwitchChannel::sendReply(const QString &message, const QString &replyId) { - auto app = getApp(); - if (!app->accounts->twitch.isLoggedIn()) + auto *app = getApp(); + if (!app->getAccounts()->twitch.isLoggedIn()) { if (!message.isEmpty()) { @@ -525,9 +736,10 @@ void TwitchChannel::setStaff(bool value) bool TwitchChannel::isBroadcaster() const { - auto app = getApp(); + auto *app = getIApp(); - return this->getName() == app->accounts->twitch.getCurrent()->getUserName(); + return this->getName() == + app->getAccounts()->twitch.getCurrent()->getUserName(); } bool TwitchChannel::hasHighRateLimit() const @@ -555,27 +767,33 @@ void TwitchChannel::setRoomId(const QString &id) if (*this->roomID_.accessConst() != id) { *this->roomID_.access() = id; - this->roomIdChanged.invoke(); - this->loadRecentMessages(); + // This is intended for tests and benchmarks. See comment in constructor. + if (getApp()) + { + this->roomIdChanged(); + this->loadRecentMessages(); + } + this->disconnected_ = false; + this->lastConnectedAt_ = std::chrono::system_clock::now(); } } SharedAccessGuard TwitchChannel::accessRoomModes() const { - return this->roomModes_.accessConst(); + return this->roomModes.accessConst(); } -void TwitchChannel::setRoomModes(const RoomModes &_roomModes) +void TwitchChannel::setRoomModes(const RoomModes &newRoomModes) { - this->roomModes_ = _roomModes; + this->roomModes = newRoomModes; this->roomModesChanged.invoke(); } bool TwitchChannel::isLive() const { - return this->streamStatus_.access()->live; + return this->streamStatus_.accessConst()->live; } SharedAccessGuard @@ -584,35 +802,38 @@ SharedAccessGuard return this->streamStatus_.accessConst(); } -boost::optional TwitchChannel::bttvEmote(const EmoteName &name) const +std::optional TwitchChannel::bttvEmote(const EmoteName &name) const { auto emotes = this->bttvEmotes_.get(); auto it = emotes->find(name); if (it == emotes->end()) - return boost::none; + { + return std::nullopt; + } return it->second; } -boost::optional TwitchChannel::ffzEmote(const EmoteName &name) const +std::optional TwitchChannel::ffzEmote(const EmoteName &name) const { auto emotes = this->ffzEmotes_.get(); auto it = emotes->find(name); if (it == emotes->end()) - return boost::none; + { + return std::nullopt; + } return it->second; } -boost::optional TwitchChannel::seventvEmote( - const EmoteName &name) const +std::optional TwitchChannel::seventvEmote(const EmoteName &name) const { auto emotes = this->seventvEmotes_.get(); auto it = emotes->find(name); if (it == emotes->end()) { - return boost::none; + return std::nullopt; } return it->second; } @@ -645,7 +866,8 @@ void TwitchChannel::joinBttvChannel() const { if (getApp()->twitch->bttvLiveUpdates) { - const auto currentAccount = getApp()->accounts->twitch.getCurrent(); + const auto currentAccount = + getIApp()->getAccounts()->twitch.getCurrent(); QString userName; if (currentAccount && !currentAccount->isAnon()) { @@ -698,7 +920,7 @@ void TwitchChannel::removeBttvEmote( } this->addOrReplaceLiveUpdatesAddRemove(false, "BTTV", QString() /*actor*/, - removed.get()->name.string); + (*removed)->name.string); } void TwitchChannel::addSeventvEmote( @@ -737,7 +959,7 @@ void TwitchChannel::removeSeventvEmote( } this->addOrReplaceLiveUpdatesAddRemove(false, "7TV", dispatch.actorName, - removed.get()->name.string); + (*removed)->name.string); } void TwitchChannel::updateSeventvUser( @@ -788,13 +1010,13 @@ void TwitchChannel::updateSeventvData(const QString &newUserID, return; } - boost::optional oldUserID = boost::make_optional( + const auto oldUserID = makeConditionedOptional( !this->seventvUserID_.isEmpty() && this->seventvUserID_ != newUserID, this->seventvUserID_); - boost::optional oldEmoteSetID = - boost::make_optional(!this->seventvEmoteSetID_.isEmpty() && - this->seventvEmoteSetID_ != newEmoteSetID, - this->seventvEmoteSetID_); + const auto oldEmoteSetID = + makeConditionedOptional(!this->seventvEmoteSetID_.isEmpty() && + this->seventvEmoteSetID_ != newEmoteSetID, + this->seventvEmoteSetID_); this->seventvUserID_ = newUserID; this->seventvEmoteSetID_ = newEmoteSetID; @@ -807,8 +1029,8 @@ void TwitchChannel::updateSeventvData(const QString &newUserID, if (oldUserID || oldEmoteSetID) { getApp()->twitch->dropSeventvChannel( - oldUserID.get_value_or(QString()), - oldEmoteSetID.get_value_or(QString())); + oldUserID.value_or(QString()), + oldEmoteSetID.value_or(QString())); } } }); @@ -889,6 +1111,17 @@ bool TwitchChannel::tryReplaceLastLiveUpdateAddOrRemove( return true; } +void TwitchChannel::messageRemovedFromStart(const MessagePtr &msg) +{ + if (msg->replyThread) + { + if (msg->replyThread->liveCount(msg) == 0) + { + this->threads_.erase(msg->replyThread->rootId()); + } + } +} + const QString &TwitchChannel::subscriptionUrl() { return this->subscriptionUrl_; @@ -904,191 +1137,40 @@ const QString &TwitchChannel::popoutPlayerUrl() return this->popoutPlayerUrl_; } -int TwitchChannel::chatterCount() +int TwitchChannel::chatterCount() const { return this->chatterCount_; } -void TwitchChannel::setLive(bool newLiveStatus) +bool TwitchChannel::setLive(bool newLiveStatus) { - bool gotNewLiveStatus = false; + auto guard = this->streamStatus_.access(); + if (guard->live == newLiveStatus) { - auto guard = this->streamStatus_.access(); - if (guard->live != newLiveStatus) - { - gotNewLiveStatus = true; - if (newLiveStatus) - { - if (getApp()->notifications->isChannelNotified( - this->getName(), Platform::Twitch)) - { - if (Toasts::isEnabled()) - { - getApp()->toasts->sendChannelNotification( - this->getName(), guard->title, Platform::Twitch); - } - if (getSettings()->notificationPlaySound) - { - getApp()->notifications->playSound(); - } - if (getSettings()->notificationFlashTaskbar) - { - getApp()->windows->sendAlert(); - } - } - // Channel live message - MessageBuilder builder; - TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release()); - - // Message in /live channel - MessageBuilder builder2; - TwitchMessageBuilder::liveMessage(this->getDisplayName(), - &builder2); - getApp()->twitch->liveChannel->addMessage(builder2.release()); - - // Notify on all channels with a ping sound - if (getSettings()->notificationOnAnyChannel && - !(isInStreamerMode() && - getSettings()->streamerModeSuppressLiveNotifications)) - { - getApp()->notifications->playSound(); - } - } - else - { - // Channel offline message - MessageBuilder builder; - TwitchMessageBuilder::offlineSystemMessage( - this->getDisplayName(), &builder); - this->addMessage(builder.release()); - - // "delete" old 'CHANNEL is live' message - LimitedQueueSnapshot snapshot = - getApp()->twitch->liveChannel->getMessageSnapshot(); - int snapshotLength = snapshot.size(); - - // MSVC hates this code if the parens are not there - int end = (std::max)(0, snapshotLength - 200); - auto liveMessageSearchText = - QString("%1 is live!").arg(this->getDisplayName()); - - for (int i = snapshotLength - 1; i >= end; --i) - { - auto &s = snapshot[i]; - - if (s->messageText == liveMessageSearchText) - { - s->flags.set(MessageFlag::Disabled); - break; - } - } - } - guard->live = newLiveStatus; - } - } - - if (gotNewLiveStatus) - { - this->liveStatusChanged.invoke(); - } -} - -void TwitchChannel::refreshTitle() -{ - // timer has never started, proceed and start it - if (!this->titleRefreshedTimer_.isValid()) - { - this->titleRefreshedTimer_.start(); - } - else if (this->roomId().isEmpty() || - this->titleRefreshedTimer_.elapsed() < TITLE_REFRESH_PERIOD) - { - return; + return false; } - this->titleRefreshedTimer_.restart(); - - getHelix()->getChannel( - this->roomId(), - [this, weak = weakOf(this)](HelixChannel channel) { - ChannelPtr shared = weak.lock(); - - if (!shared) - { - return; - } + guard->live = newLiveStatus; - { - auto status = this->streamStatus_.access(); - status->title = channel.title; - } - - this->liveStatusChanged.invoke(); - }, - [] { - // failure - }); + return true; } -void TwitchChannel::refreshLiveStatus() +void TwitchChannel::markConnected() { - auto roomID = this->roomId(); - - if (roomID.isEmpty()) + if (this->lastConnectedAt_.has_value() && !this->disconnected_) { - qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName() - << "] Refreshing live status (Missing ID)"; - this->setLive(false); - return; + this->lastConnectedAt_ = std::chrono::system_clock::now(); } - - getHelix()->getStreamById( - roomID, - [this, weak = weakOf(this)](bool live, const auto &stream) { - ChannelPtr shared = weak.lock(); - if (!shared) - { - return; - } - - this->parseLiveStatus(live, stream); - }, - [] { - // failure - }, - [] { - // finally - }); } -void TwitchChannel::parseLiveStatus(bool live, const HelixStream &stream) +void TwitchChannel::markDisconnected() { - if (!live) + if (this->roomId().isEmpty()) { - this->setLive(false); + // we were never joined in the first place return; } - { - auto status = this->streamStatus_.access(); - status->viewerCount = stream.viewerCount; - status->gameId = stream.gameId; - status->game = stream.gameName; - status->title = stream.title; - QDateTime since = QDateTime::fromString(stream.startedAt, Qt::ISODate); - auto diff = since.secsTo(QDateTime::currentDateTime()); - status->uptime = QString::number(diff / 3600) + "h " + - QString::number(diff % 3600 / 60) + "m"; - - status->rerun = false; - status->streamType = stream.type; - } - - this->setLive(true); - - // Signal all listeners that the stream status has been updated - this->liveStatusChanged.invoke(); + this->disconnected_ = true; } void TwitchChannel::loadRecentMessages() @@ -1104,31 +1186,58 @@ void TwitchChannel::loadRecentMessages() } auto weak = weakOf(this); - RecentMessagesApi::loadRecentMessages( + recentmessages::load( this->getName(), weak, [weak](const auto &messages) { auto shared = weak.lock(); if (!shared) + { return; + } - auto tc = dynamic_cast(shared.get()); + auto *tc = dynamic_cast(shared.get()); if (!tc) + { return; + } tc->addMessagesAtStart(messages); tc->loadingRecentMessages_.clear(); + + std::vector msgs; + for (const auto &msg : messages) + { + const auto highlighted = + msg->flags.has(MessageFlag::Highlighted); + const auto showInMentions = + msg->flags.has(MessageFlag::ShowInMentions); + if (highlighted && showInMentions) + { + msgs.push_back(msg); + } + + tc->addRecentChatter(msg->displayName); + } + + getApp()->twitch->mentionsChannel->fillInMissingMessages(msgs); }, [weak]() { auto shared = weak.lock(); if (!shared) + { return; + } - auto tc = dynamic_cast(shared.get()); + auto *tc = dynamic_cast(shared.get()); if (!tc) + { return; + } tc->loadingRecentMessages_.clear(); - }); + }, + getSettings()->twitchMessageHistoryLimit.getValue(), std::nullopt, + std::nullopt, false); } void TwitchChannel::loadRecentMessagesReconnect() @@ -1143,17 +1252,36 @@ void TwitchChannel::loadRecentMessagesReconnect() return; // already loading } + const auto now = std::chrono::system_clock::now(); + int limit = getSettings()->twitchMessageHistoryLimit.getValue(); + if (this->lastConnectedAt_.has_value()) + { + // calculate how many messages could have occured + // while we were not connected to the channel + // assuming a maximum of 10 messages per second + const auto secondsSinceDisconnect = + std::chrono::duration_cast( + now - this->lastConnectedAt_.value()) + .count(); + limit = + std::min(static_cast(secondsSinceDisconnect + 1) * 10, limit); + } + auto weak = weakOf(this); - RecentMessagesApi::loadRecentMessages( + recentmessages::load( this->getName(), weak, [weak](const auto &messages) { auto shared = weak.lock(); if (!shared) + { return; + } - auto tc = dynamic_cast(shared.get()); + auto *tc = dynamic_cast(shared.get()); if (!tc) + { return; + } tc->fillInMissingMessages(messages); tc->loadingRecentMessages_.clear(); @@ -1161,14 +1289,19 @@ void TwitchChannel::loadRecentMessagesReconnect() [weak]() { auto shared = weak.lock(); if (!shared) + { return; + } - auto tc = dynamic_cast(shared.get()); + auto *tc = dynamic_cast(shared.get()); if (!tc) + { return; + } tc->loadingRecentMessages_.clear(); - }); + }, + limit, this->lastConnectedAt_, now, true); } void TwitchChannel::refreshPubSub() @@ -1179,13 +1312,17 @@ void TwitchChannel::refreshPubSub() return; } - auto currentAccount = getApp()->accounts->twitch.getCurrent(); + auto currentAccount = getIApp()->getAccounts()->twitch.getCurrent(); - getApp()->twitch->pubsub->setAccount(currentAccount); + getIApp()->getTwitchPubSub()->setAccount(currentAccount); - getApp()->twitch->pubsub->listenToChannelModerationActions(roomId); - getApp()->twitch->pubsub->listenToAutomod(roomId); - getApp()->twitch->pubsub->listenToChannelPointRewards(roomId); + getIApp()->getTwitchPubSub()->listenToChannelModerationActions(roomId); + if (this->hasModRights()) + { + getIApp()->getTwitchPubSub()->listenToAutomod(roomId); + getIApp()->getTwitchPubSub()->listenToLowTrustUsers(roomId); + } + getIApp()->getTwitchPubSub()->listenToChannelPointRewards(roomId); } void TwitchChannel::refreshChatters() @@ -1210,7 +1347,8 @@ void TwitchChannel::refreshChatters() // Get chatter list via helix api getHelix()->getChatters( - this->roomId(), getApp()->accounts->twitch.getCurrent()->getUserId(), + this->roomId(), + getIApp()->getAccounts()->twitch.getCurrent()->getUserId(), MAX_CHATTERS_TO_FETCH, [this, weak = weakOf(this)](auto result) { if (auto shared = weak.lock()) @@ -1220,34 +1358,10 @@ void TwitchChannel::refreshChatters() } }, // Refresh chatters should only be used when failing silently is an option - [](auto error, auto message) {}); -} - -void TwitchChannel::fetchDisplayName() -{ - getHelix()->getUserByName( - this->getName(), - [weak = weakOf(this)](const auto &user) { - auto shared = weak.lock(); - if (!shared) - return; - auto channel = static_cast(shared.get()); - if (QString::compare(user.displayName, channel->getName(), - Qt::CaseInsensitive) == 0) - { - channel->setDisplayName(user.displayName); - channel->setLocalizedName(user.displayName); - } - else - { - channel->setLocalizedName(QString("%1(%2)") - .arg(channel->getName()) - .arg(user.displayName)); - } - channel->addRecentChatter(channel->getDisplayName()); - channel->displayNameChanged.invoke(); - }, - [] {}); + [](auto error, auto message) { + (void)error; + (void)message; + }); } void TwitchChannel::addReplyThread(const std::shared_ptr &thread) @@ -1255,12 +1369,28 @@ void TwitchChannel::addReplyThread(const std::shared_ptr &thread) this->threads_[thread->rootId()] = thread; } -const std::unordered_map> - &TwitchChannel::threads() const +const std::unordered_map> & + TwitchChannel::threads() const { return this->threads_; } +std::shared_ptr TwitchChannel::getOrCreateThread( + const MessagePtr &message) +{ + assert(message != nullptr); + + auto threadIt = this->threads_.find(message->id); + if (threadIt != this->threads_.end() && !threadIt->second.expired()) + { + return threadIt->second.lock(); + } + + auto thread = std::make_shared(message); + this->addReplyThread(thread); + return thread; +} + void TwitchChannel::cleanUpReplyThreads() { for (auto it = this->threads_.begin(), last = this->threads_.end(); @@ -1285,50 +1415,72 @@ void TwitchChannel::cleanUpReplyThreads() void TwitchChannel::refreshBadges() { - auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" + - this->roomId() + "/display?language=en"}; - NetworkRequest(url.string) + if (this->roomId().isEmpty()) + { + return; + } - .onSuccess([this, - weak = weakOf(this)](auto result) -> Outcome { + getHelix()->getChannelBadges( + this->roomId(), + // successCallback + [this, weak = weakOf(this)](auto channelBadges) { auto shared = weak.lock(); if (!shared) - return Failure; + { + // The channel has been closed inbetween us making the request and the request finishing + return; + } auto badgeSets = this->badgeSets_.access(); - auto jsonRoot = result.parseJson(); + for (const auto &badgeSet : channelBadges.badgeSets) + { + const auto &setID = badgeSet.setID; + for (const auto &version : badgeSet.versions) + { + auto emote = Emote{ + .name = EmoteName{}, + .images = + ImageSet{ + Image::fromUrl(version.imageURL1x, 1), + Image::fromUrl(version.imageURL2x, .5), + Image::fromUrl(version.imageURL4x, .25), + }, + .tooltip = Tooltip{version.title}, + .homePage = version.clickURL, + }; + (*badgeSets)[setID][version.id] = + std::make_shared(emote); + } + } + }, + // failureCallback + [this, weak = weakOf(this)](auto error, auto message) { + auto shared = weak.lock(); + if (!shared) + { + // The channel has been closed inbetween us making the request and the request finishing + return; + } - auto _ = jsonRoot["badge_sets"].toObject(); - for (auto jsonBadgeSet = _.begin(); jsonBadgeSet != _.end(); - jsonBadgeSet++) + QString errorMessage("Failed to load channel badges - "); + + switch (error) { - auto &versions = (*badgeSets)[jsonBadgeSet.key()]; + case HelixGetChannelBadgesError::Forwarded: { + errorMessage += message; + } + break; - auto _set = jsonBadgeSet->toObject()["versions"].toObject(); - for (auto jsonVersion_ = _set.begin(); - jsonVersion_ != _set.end(); jsonVersion_++) - { - auto jsonVersion = jsonVersion_->toObject(); - auto emote = std::make_shared(Emote{ - EmoteName{}, - ImageSet{ - Image::fromUrl( - {jsonVersion["image_url_1x"].toString()}, 1), - Image::fromUrl( - {jsonVersion["image_url_2x"].toString()}, .5), - Image::fromUrl( - {jsonVersion["image_url_4x"].toString()}, .25)}, - Tooltip{jsonVersion["description"].toString()}, - Url{jsonVersion["clickURL"].toString()}}); - - versions.emplace(jsonVersion_.key(), emote); - }; + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixGetChannelBadgesError::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; } - return Success; - }) - .execute(); + this->addMessage(makeSystemMessage(errorMessage)); + }); } void TwitchChannel::refreshCheerEmotes() @@ -1336,11 +1488,11 @@ void TwitchChannel::refreshCheerEmotes() getHelix()->getCheermotes( this->roomId(), [this, weak = weakOf(this)]( - const std::vector &cheermoteSets) -> Outcome { + const std::vector &cheermoteSets) { auto shared = weak.lock(); if (!shared) { - return Failure; + return; } std::vector emoteSets; @@ -1367,22 +1519,28 @@ void TwitchChannel::refreshCheerEmotes() // Combine the prefix (e.g. BibleThump) with the tier (1, 100 etc.) auto emoteTooltip = set.prefix + tier.id + "
Twitch Cheer Emote"; - cheerEmote.animatedEmote = std::make_shared( - Emote{EmoteName{"cheer emote"}, - ImageSet{ - tier.darkAnimated.imageURL1x, - tier.darkAnimated.imageURL2x, - tier.darkAnimated.imageURL4x, - }, - Tooltip{emoteTooltip}, Url{}}); - cheerEmote.staticEmote = std::make_shared( - Emote{EmoteName{"cheer emote"}, - ImageSet{ - tier.darkStatic.imageURL1x, - tier.darkStatic.imageURL2x, - tier.darkStatic.imageURL4x, - }, - Tooltip{emoteTooltip}, Url{}}); + cheerEmote.animatedEmote = std::make_shared(Emote{ + .name = EmoteName{"cheer emote"}, + .images = + ImageSet{ + tier.darkAnimated.imageURL1x, + tier.darkAnimated.imageURL2x, + tier.darkAnimated.imageURL4x, + }, + .tooltip = Tooltip{emoteTooltip}, + .homePage = Url{}, + }); + cheerEmote.staticEmote = std::make_shared(Emote{ + .name = EmoteName{"cheer emote"}, + .images = + ImageSet{ + tier.darkStatic.imageURL1x, + tier.darkStatic.imageURL2x, + tier.darkStatic.imageURL4x, + }, + .tooltip = Tooltip{emoteTooltip}, + .homePage = Url{}, + }); cheerEmoteSet.cheerEmotes.emplace_back( std::move(cheerEmote)); @@ -1399,12 +1557,9 @@ void TwitchChannel::refreshCheerEmotes() } *this->cheerEmoteSets_.access() = std::move(emoteSets); - - return Success; }, [] { // Failure - return Failure; }); } @@ -1521,8 +1676,8 @@ void TwitchChannel::createClip() }); } -boost::optional TwitchChannel::twitchBadge( - const QString &set, const QString &version) const +std::optional TwitchChannel::twitchBadge(const QString &set, + const QString &version) const { auto badgeSets = this->badgeSets_.access(); auto it = badgeSets->find(set); @@ -1534,20 +1689,20 @@ boost::optional TwitchChannel::twitchBadge( return it2->second; } } - return boost::none; + return std::nullopt; } -boost::optional TwitchChannel::ffzCustomModBadge() const +std::optional TwitchChannel::ffzCustomModBadge() const { return this->ffzCustomModBadge_.get(); } -boost::optional TwitchChannel::ffzCustomVipBadge() const +std::optional TwitchChannel::ffzCustomVipBadge() const { return this->ffzCustomVipBadge_.get(); } -boost::optional TwitchChannel::cheerEmote(const QString &string) +std::optional TwitchChannel::cheerEmote(const QString &string) { auto sets = this->cheerEmoteSets_.access(); for (const auto &set : *sets) @@ -1573,7 +1728,62 @@ boost::optional TwitchChannel::cheerEmote(const QString &string) } } } - return boost::none; + return std::nullopt; +} + +void TwitchChannel::updateSevenTVActivity() +{ + static const QString seventvActivityUrl = + QStringLiteral("https://7tv.io/v3/users/%1/presences"); + + const auto currentSeventvUserID = + getIApp()->getAccounts()->twitch.getCurrent()->getSeventvUserID(); + if (currentSeventvUserID.isEmpty()) + { + return; + } + + if (!getSettings()->enableSevenTVEventAPI || + !getSettings()->sendSevenTVActivity) + { + return; + } + + if (this->nextSeventvActivity_.isValid() && + QDateTime::currentDateTimeUtc() < this->nextSeventvActivity_) + { + return; + } + // Make sure to not send activity again before receiving the response + this->nextSeventvActivity_ = this->nextSeventvActivity_.addSecs(300); + + qCDebug(chatterinoSeventv) << "Sending activity in" << this->getName(); + + getIApp()->getSeventvAPI()->updatePresence( + this->roomId(), currentSeventvUserID, + [chan = weakOf(this)]() { + const auto self = + std::dynamic_pointer_cast(chan.lock()); + if (!self) + { + return; + } + self->nextSeventvActivity_ = + QDateTime::currentDateTimeUtc().addSecs(60); + }, + [](const auto &result) { + qCDebug(chatterinoSeventv) + << "Failed to update 7TV activity:" << result.formatError(); + }); +} + +void TwitchChannel::listenSevenTVCosmetics() const +{ + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->subscribeTwitchChannel( + this->roomId()); + } } } // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 746b54ed625..a331812af78 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -4,13 +4,14 @@ #include "common/Atomic.hpp" #include "common/Channel.hpp" #include "common/ChannelChatters.hpp" -#include "common/Outcome.hpp" +#include "common/Common.hpp" #include "common/UniqueAccess.hpp" #include "providers/twitch/TwitchEmotes.hpp" #include "util/QStringHash.hpp" -#include +#include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include +#include #include namespace chatterino { @@ -67,7 +69,9 @@ struct HelixStream; class TwitchIrcServer; -class TwitchChannel : public Channel, public ChannelChatters +const int MAX_QUEUED_REDEMPTIONS = 16; + +class TwitchChannel final : public Channel, public ChannelChatters { public: struct StreamStatus { @@ -106,44 +110,62 @@ class TwitchChannel : public Channel, public ChannelChatters explicit TwitchChannel(const QString &channelName); ~TwitchChannel() override; + TwitchChannel(const TwitchChannel &) = delete; + TwitchChannel(TwitchChannel &&) = delete; + TwitchChannel &operator=(const TwitchChannel &) = delete; + TwitchChannel &operator=(TwitchChannel &&) = delete; + void initialize(); // Channel methods - virtual bool isEmpty() const override; - virtual bool canSendMessage() const override; - virtual void sendMessage(const QString &message) override; - virtual void sendReply(const QString &message, const QString &replyId); - virtual bool isMod() const override; + bool isEmpty() const override; + bool canSendMessage() const override; + void sendMessage(const QString &message) override; + void sendReply(const QString &message, const QString &replyId); + bool isMod() const override; bool isVip() const; bool isStaff() const; - virtual bool isBroadcaster() const override; - virtual bool hasHighRateLimit() const override; - virtual bool canReconnect() const override; - virtual void reconnect() override; - void refreshTitle(); + bool isBroadcaster() const override; + bool hasHighRateLimit() const override; + bool canReconnect() const override; + void reconnect() override; void createClip(); // Data const QString &subscriptionUrl(); const QString &channelUrl(); const QString &popoutPlayerUrl(); - int chatterCount(); - virtual bool isLive() const override; + int chatterCount() const; + bool isLive() const override; QString roomId() const; SharedAccessGuard accessRoomModes() const; SharedAccessGuard accessStreamStatus() const; + /** + * Records that the channel is no longer joined. + */ + void markDisconnected(); + + /** + * Records that the channel's read connection is healthy. + */ + void markConnected(); + // Emotes - boost::optional bttvEmote(const EmoteName &name) const; - boost::optional ffzEmote(const EmoteName &name) const; - boost::optional seventvEmote(const EmoteName &name) const; + std::optional bttvEmote(const EmoteName &name) const; + std::optional ffzEmote(const EmoteName &name) const; + std::optional seventvEmote(const EmoteName &name) const; std::shared_ptr bttvEmotes() const; std::shared_ptr ffzEmotes() const; std::shared_ptr seventvEmotes() const; - virtual void refreshBTTVChannelEmotes(bool manualRefresh); - virtual void refreshFFZChannelEmotes(bool manualRefresh); - virtual void refreshSevenTVChannelEmotes(bool manualRefresh); + void refreshBTTVChannelEmotes(bool manualRefresh); + void refreshFFZChannelEmotes(bool manualRefresh); + void refreshSevenTVChannelEmotes(bool manualRefresh); + + void setBttvEmotes(std::shared_ptr &&map); + void setFfzEmotes(std::shared_ptr &&map); + void setSeventvEmotes(std::shared_ptr &&map); const QString &seventvUserID() const; const QString &seventvEmoteSetID() const; @@ -172,13 +194,13 @@ class TwitchChannel : public Channel, public ChannelChatters const QString &newEmoteSetID); // Badges - boost::optional ffzCustomModBadge() const; - boost::optional ffzCustomVipBadge() const; - boost::optional twitchBadge(const QString &set, - const QString &version) const; + std::optional ffzCustomModBadge() const; + std::optional ffzCustomVipBadge() const; + std::optional twitchBadge(const QString &set, + const QString &version) const; // Cheers - boost::optional cheerEmote(const QString &string); + std::optional cheerEmote(const QString &string); // Replies /** @@ -191,52 +213,124 @@ class TwitchChannel : public Channel, public ChannelChatters const std::unordered_map> &threads() const; - // Signals - pajlada::Signals::NoArgSignal roomIdChanged; + /** + * Get the thread for the given message + * If no thread can be found for the message, create one + */ + std::shared_ptr getOrCreateThread(const MessagePtr &message); + + /** + * This signal fires when the local user has joined the channel + **/ + pajlada::Signals::NoArgSignal joined; + + // Only TwitchChannel may invoke this signal pajlada::Signals::NoArgSignal userStateChanged; - pajlada::Signals::NoArgSignal liveStatusChanged; + + /** + * This signals fires whenever the live status is changed + * + * Streams are counted as offline by default, so if a stream does not go online + * this signal will never fire + **/ + pajlada::Signals::Signal liveStatusChanged; + + /** + * This signal fires whenever the stream status is changed + * + * This includes when the stream goes from offline to online, + * or the viewer count changes, or the title has been updated + **/ + pajlada::Signals::NoArgSignal streamStatusChanged; + pajlada::Signals::NoArgSignal roomModesChanged; // Channel point rewards - pajlada::Signals::SelfDisconnectingSignal - channelPointRewardAdded; + void addQueuedRedemption(const QString &rewardId, + const QString &originalContent, + Communi::IrcMessage *message); + /** + * A rich & hydrated redemption from PubSub has arrived, add it to the channel. + * This will look at queued up partial messages, and if one is found it will add the queued up partial messages fully hydrated. + **/ void addChannelPointReward(const ChannelPointReward &reward); bool isChannelPointRewardKnown(const QString &rewardId); - boost::optional channelPointReward( + std::optional channelPointReward( const QString &rewardId) const; + // Live status + void updateStreamStatus(const std::optional &helixStream); + void updateStreamTitle(const QString &title); + + void updateDisplayName(const QString &displayName); + private: struct NameOptions { + // displayName is the non-CJK-display name for this user + // This will always be the same as their `name_`, but potentially with different casing QString displayName; + + // localizedName is their display name that *may* contain CJK characters + // If the display name does not contain any CJK characters, this will be + // the same as `displayName` QString localizedName; + + // actualDisplayName is the raw display name string received from Twitch + QString actualDisplayName; } nameOptions; -private: - // Methods - void refreshLiveStatus(); - void parseLiveStatus(bool live, const HelixStream &stream); + struct QueuedRedemption { + QString rewardID; + QString originalContent; + QObjectPtr message; + }; + void refreshPubSub(); void refreshChatters(); void refreshBadges(); void refreshCheerEmotes(); void loadRecentMessages(); void loadRecentMessagesReconnect(); - void fetchDisplayName(); void cleanUpReplyThreads(); void showLoginMessage(); + + /// roomIdChanged is called whenever this channel's ID has been changed + /// This should only happen once per channel, whenever the ID goes from unset to set + void roomIdChanged(); + /** Joins (subscribes to) a Twitch channel for updates on BTTV. */ void joinBttvChannel() const; + /** + * Indicates an activity to 7TV in this channel for this user. + * This is done at most once every 60s. + */ + void updateSevenTVActivity(); + void listenSevenTVCosmetics() const; - void setLive(bool newLiveStatus); + /** + * @brief Sets the live status of this Twitch channel + * + * Returns true if the live status changed with this call + **/ + bool setLive(bool newLiveStatus); void setMod(bool value); void setVIP(bool value); void setStaff(bool value); void setRoomId(const QString &id); - void setRoomModes(const RoomModes &roomModes_); + void setRoomModes(const RoomModes &newRoomModes); void setDisplayName(const QString &name); void setLocalizedName(const QString &name); + /** + * Returns the display name of the user + * + * If the display name contained chinese, japenese, or korean characters, the user's login name is returned instead + **/ const QString &getDisplayName() const override; + + /** + * Returns the localized name of the user + **/ const QString &getLocalizedName() const override; QString prepareMessage(const QString &message) const; @@ -280,18 +374,23 @@ class TwitchChannel : public Channel, public ChannelChatters const QString subscriptionUrl_; const QString channelUrl_; const QString popoutPlayerUrl_; - int chatterCount_; + int chatterCount_{}; UniqueAccess streamStatus_; - UniqueAccess roomModes_; + UniqueAccess roomModes; + bool disconnected_{}; + std::optional> + lastConnectedAt_{}; std::atomic_flag loadingRecentMessages_ = ATOMIC_FLAG_INIT; std::unordered_map> threads_; protected: + void messageRemovedFromStart(const MessagePtr &msg) override; + Atomic> bttvEmotes_; Atomic> ffzEmotes_; Atomic> seventvEmotes_; - Atomic> ffzCustomModBadge_; - Atomic> ffzCustomVipBadge_; + Atomic> ffzCustomModBadge_; + Atomic> ffzCustomVipBadge_; private: // Badges @@ -299,6 +398,8 @@ class TwitchChannel : public Channel, public ChannelChatters badgeSets_; // "subscribers": { "0": ... "3": ... "6": ... UniqueAccess> cheerEmoteSets_; UniqueAccess> channelPointRewards_; + boost::circular_buffer_space_optimized + waitingRedemptions_{MAX_QUEUED_REDEMPTIONS}; bool mod_ = false; bool vip_ = false; @@ -328,7 +429,13 @@ class TwitchChannel : public Channel, public ChannelChatters * The index of the twitch connection in * 7TV's user representation. */ - size_t seventvUserTwitchConnectionIndex_; + size_t seventvUserTwitchConnectionIndex_{}; + + /** + * The next moment in time to signal activity in this channel to 7TV. + * Or: Up until this moment we don't need to send activity. + */ + QDateTime nextSeventvActivity_; /** The platform of the last live emote update ("7TV", "BTTV", "FFZ"). */ QString lastLiveUpdateEmotePlatform_; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index c608f9e0b9b..acad48f8664 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -1,18 +1,21 @@ -#include "TwitchIrcServer.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "Application.hpp" +#include "common/Channel.hpp" #include "common/Env.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/bttv/BttvEmotes.hpp" #include "providers/bttv/BttvLiveUpdates.hpp" +#include "providers/ffz/FfzEmotes.hpp" #include "providers/seventv/eventapi/Subscription.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/IrcMessageHandler.hpp" -#include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" @@ -24,11 +27,8 @@ #include -// using namespace Communi; using namespace std::chrono_literals; -#define TWITCH_PUBSUB_URL "wss://pubsub-edge.twitch.tv" - namespace { const QString BTTV_LIVE_UPDATES_URL = "wss://sockets.betterttv.net/ws"; @@ -42,12 +42,11 @@ TwitchIrcServer::TwitchIrcServer() : whispersChannel(new Channel("/whispers", Channel::Type::TwitchWhispers)) , mentionsChannel(new Channel("/mentions", Channel::Type::TwitchMentions)) , liveChannel(new Channel("/live", Channel::Type::TwitchLive)) + , automodChannel(new Channel("/automod", Channel::Type::TwitchAutomod)) , watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching) { this->initializeIrc(); - this->pubsub = new PubSub(TWITCH_PUBSUB_URL); - if (getSettings()->enableBTTVLiveUpdates && getSettings()->enableBTTVChannelEmotes) { @@ -68,31 +67,24 @@ TwitchIrcServer::TwitchIrcServer() // false); } -void TwitchIrcServer::initialize(Settings &settings, Paths &paths) +void TwitchIrcServer::initialize(Settings &settings, const Paths &paths) { - getApp()->accounts->twitch.currentUserChanged.connect([this]() { + getIApp()->getAccounts()->twitch.currentUserChanged.connect([this]() { postToThread([this] { this->connect(); - this->pubsub->setAccount(getApp()->accounts->twitch.getCurrent()); }); }); this->reloadBTTVGlobalEmotes(); this->reloadFFZGlobalEmotes(); this->reloadSevenTVGlobalEmotes(); - - /* Refresh all twitch channel's live status in bulk every 30 seconds after starting chatterino */ - QObject::connect(&this->bulkLiveStatusTimer_, &QTimer::timeout, [this] { - this->bulkRefreshLiveStatus(); - }); - this->bulkLiveStatusTimer_.start(30 * 1000); } void TwitchIrcServer::initializeConnection(IrcConnection *connection, ConnectionType type) { std::shared_ptr account = - getApp()->accounts->twitch.getCurrent(); + getIApp()->getAccounts()->twitch.getCurrent(); qCDebug(chatterinoTwitch) << "logging in as" << account->getUserName(); @@ -139,21 +131,24 @@ void TwitchIrcServer::initializeConnection(IrcConnection *connection, std::shared_ptr TwitchIrcServer::createChannel( const QString &channelName) { - auto channel = - std::shared_ptr(new TwitchChannel(channelName)); + auto channel = std::make_shared(channelName); channel->initialize(); - channel->sendMessageSignal.connect( + // We can safely ignore these signal connections since the TwitchIrcServer is only + // ever destroyed when the full Application state is about to be destroyed, at which point + // no Channel's should live + // NOTE: CHANNEL_LIFETIME + std::ignore = channel->sendMessageSignal.connect( [this, channel = channel.get()](auto &chan, auto &msg, bool &sent) { this->onMessageSendRequested(channel, msg, sent); }); - channel->sendReplySignal.connect( + std::ignore = channel->sendReplySignal.connect( [this, channel = channel.get()](auto &chan, auto &msg, auto &replyId, bool &sent) { this->onReplySendRequested(channel, msg, replyId, sent); }); - return std::shared_ptr(channel); + return channel; } void TwitchIrcServer::privateMessageReceived( @@ -221,6 +216,7 @@ void TwitchIrcServer::readConnectionMessageReceived( { this->addGlobalSystemMessage( "Twitch Servers requested us to reconnect, reconnecting"); + this->markChannelsConnected(); this->connect(); } else if (command == "GLOBALUSERSTATE") @@ -274,24 +270,107 @@ std::shared_ptr TwitchIrcServer::getCustomChannel( return this->liveChannel; } - if (channelName == "$$$") + if (channelName == "/automod") { - static auto channel = - std::make_shared("$$$", chatterino::Channel::Type::Misc); - static auto getTimer = [&] { + return this->automodChannel; + } + + static auto getTimer = [](ChannelPtr channel, int msBetweenMessages, + bool addInitialMessages) { + if (addInitialMessages) + { for (auto i = 0; i < 1000; i++) { channel->addMessage(makeSystemMessage(QString::number(i + 1))); } + } + + auto *timer = new QTimer; + QObject::connect(timer, &QTimer::timeout, [channel] { + channel->addMessage( + makeSystemMessage(QTime::currentTime().toString())); + }); + timer->start(msBetweenMessages); + return timer; + }; + + if (channelName == "$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 500, true); + + return channel; + } + if (channelName == "$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 500, false); + + return channel; + } + if (channelName == "$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 250, true); + + return channel; + } + if (channelName == "$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 250, false); + + return channel; + } + if (channelName == "$$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 100, true); + + return channel; + } + if (channelName == "$$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 100, false); + + return channel; + } + if (channelName == "$$$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 50, true); + + return channel; + } + if (channelName == "$$$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 50, false); + + return channel; + } + if (channelName == "$$$$$$$") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 25, true); - auto timer = new QTimer; - QObject::connect(timer, &QTimer::timeout, [] { - channel->addMessage( - makeSystemMessage(QTime::currentTime().toString())); - }); - timer->start(500); - return timer; - }(); + return channel; + } + if (channelName == "$$$$$$$:e") + { + static auto channel = std::make_shared( + channelName, chatterino::Channel::Type::Misc); + getTimer(channel, 25, false); return channel; } @@ -307,6 +386,7 @@ void TwitchIrcServer::forEachChannelAndSpecialChannels( func(this->whispersChannel); func(this->mentionsChannel); func(this->liveChannel); + func(this->automodChannel); } std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( @@ -318,14 +398,18 @@ std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( { auto channel = weakChannel.lock(); if (!channel) + { continue; + } auto twitchChannel = std::dynamic_pointer_cast(channel); if (!twitchChannel) + { continue; + } if (twitchChannel->roomId() == channelId && - twitchChannel->getName().splitRef(":").size() < 3) + twitchChannel->getName().count(':') < 2) { return twitchChannel; } @@ -334,65 +418,16 @@ std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( return Channel::getEmpty(); } -void TwitchIrcServer::bulkRefreshLiveStatus() -{ - auto twitchChans = std::make_shared>(); - - this->forEachChannel([twitchChans](ChannelPtr chan) { - auto tc = dynamic_cast(chan.get()); - if (tc && !tc->roomId().isEmpty()) - { - twitchChans->insert(tc->roomId(), tc); - } - }); - - // iterate over batches of channel IDs - for (const auto &batch : splitListIntoBatches(twitchChans->keys())) - { - getHelix()->fetchStreams( - batch, {}, - [twitchChans](std::vector streams) { - for (const auto &stream : streams) - { - // remaining channels will be used later to set their stream status as offline - // so we use take(id) to remove it - auto tc = twitchChans->take(stream.userId); - if (tc == nullptr) - { - continue; - } - - tc->parseLiveStatus(true, stream); - } - }, - []() { - // failure - }, - [batch, twitchChans] { - // All the channels that were not present in fetchStreams response should be assumed to be offline - // It is necessary to update their stream status in case they've gone live -> offline - // Otherwise some of them will be marked as live forever - for (const auto &chID : batch) - { - auto tc = twitchChans->value(chID); - // early out in case channel does not exist anymore - if (tc == nullptr) - { - continue; - } - - tc->parseLiveStatus(false, {}); - } - }); - } -} - QString TwitchIrcServer::cleanChannelName(const QString &dirtyChannelName) { if (dirtyChannelName.startsWith('#')) + { return dirtyChannelName.mid(1).toLower(); + } else + { return dirtyChannelName.toLower(); + } } bool TwitchIrcServer::hasSeparateWriteConnection() const @@ -485,22 +520,14 @@ void TwitchIrcServer::onReplySendRequested(TwitchChannel *channel, sent = true; } -const BttvEmotes &TwitchIrcServer::getBttvEmotes() const -{ - return this->bttv; -} -const FfzEmotes &TwitchIrcServer::getFfzEmotes() const -{ - return this->ffz; -} -const SeventvEmotes &TwitchIrcServer::getSeventvEmotes() const +const IndirectChannel &TwitchIrcServer::getWatchingChannel() const { - return this->seventv_; + return this->watchingChannel; } void TwitchIrcServer::reloadBTTVGlobalEmotes() { - this->bttv.loadEmotes(); + getIApp()->getBttvEmotes()->loadEmotes(); } void TwitchIrcServer::reloadAllBTTVChannelEmotes() @@ -515,7 +542,7 @@ void TwitchIrcServer::reloadAllBTTVChannelEmotes() void TwitchIrcServer::reloadFFZGlobalEmotes() { - this->ffz.loadEmotes(); + getIApp()->getFfzEmotes()->loadEmotes(); } void TwitchIrcServer::reloadAllFFZChannelEmotes() @@ -530,7 +557,7 @@ void TwitchIrcServer::reloadAllFFZChannelEmotes() void TwitchIrcServer::reloadSevenTVGlobalEmotes() { - this->seventv_.loadGlobalEmotes(); + getIApp()->getSeventvEmotes()->loadGlobalEmotes(); } void TwitchIrcServer::reloadAllSevenTVChannelEmotes() diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 9a9a2280076..6fe36c0a2fd 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -3,10 +3,7 @@ #include "common/Atomic.hpp" #include "common/Channel.hpp" #include "common/Singleton.hpp" -#include "providers/bttv/BttvEmotes.hpp" -#include "providers/ffz/FfzEmotes.hpp" #include "providers/irc/AbstractIrcServer.hpp" -#include "providers/seventv/SeventvEmotes.hpp" #include @@ -18,25 +15,37 @@ namespace chatterino { class Settings; class Paths; -class PubSub; class TwitchChannel; class BttvLiveUpdates; class SeventvEventAPI; +class BttvEmotes; +class FfzEmotes; +class SeventvEmotes; -class TwitchIrcServer final : public AbstractIrcServer, public Singleton +class ITwitchIrcServer +{ +public: + virtual ~ITwitchIrcServer() = default; + + virtual const IndirectChannel &getWatchingChannel() const = 0; + + // Update this interface with TwitchIrcServer methods as needed +}; + +class TwitchIrcServer final : public AbstractIrcServer, + public Singleton, + public ITwitchIrcServer { public: TwitchIrcServer(); - virtual ~TwitchIrcServer() override = default; + ~TwitchIrcServer() override = default; - virtual void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; void forEachChannelAndSpecialChannels(std::function func); std::shared_ptr getChannelOrEmptyByID(const QString &channelID); - void bulkRefreshLiveStatus(); - void reloadBTTVGlobalEmotes(); void reloadAllBTTVChannelEmotes(); void reloadFFZGlobalEmotes(); @@ -64,34 +73,28 @@ class TwitchIrcServer final : public AbstractIrcServer, public Singleton const ChannelPtr whispersChannel; const ChannelPtr mentionsChannel; const ChannelPtr liveChannel; + const ChannelPtr automodChannel; IndirectChannel watchingChannel; - PubSub *pubsub; std::unique_ptr bttvLiveUpdates; std::unique_ptr seventvEventAPI; - const BttvEmotes &getBttvEmotes() const; - const FfzEmotes &getFfzEmotes() const; - const SeventvEmotes &getSeventvEmotes() const; + const IndirectChannel &getWatchingChannel() const override; protected: - virtual void initializeConnection(IrcConnection *connection, - ConnectionType type) override; - virtual std::shared_ptr createChannel( - const QString &channelName) override; - - virtual void privateMessageReceived( - Communi::IrcPrivateMessage *message) override; - virtual void readConnectionMessageReceived( - Communi::IrcMessage *message) override; - virtual void writeConnectionMessageReceived( - Communi::IrcMessage *message) override; - - virtual std::shared_ptr getCustomChannel( + void initializeConnection(IrcConnection *connection, + ConnectionType type) override; + std::shared_ptr createChannel(const QString &channelName) override; + + void privateMessageReceived(Communi::IrcPrivateMessage *message) override; + void readConnectionMessageReceived(Communi::IrcMessage *message) override; + void writeConnectionMessageReceived(Communi::IrcMessage *message) override; + + std::shared_ptr getCustomChannel( const QString &channelname) override; - virtual QString cleanChannelName(const QString &dirtyChannelName) override; - virtual bool hasSeparateWriteConnection() const override; + QString cleanChannelName(const QString &dirtyChannelName) override; + bool hasSeparateWriteConnection() const override; private: void onMessageSendRequested(TwitchChannel *channel, const QString &message, @@ -107,11 +110,6 @@ class TwitchIrcServer final : public AbstractIrcServer, public Singleton std::chrono::steady_clock::time_point lastErrorTimeSpeed_; std::chrono::steady_clock::time_point lastErrorTimeAmount_; - BttvEmotes bttv; - FfzEmotes ffz; - SeventvEmotes seventv_; - QTimer bulkLiveStatusTimer_; - pajlada::Signals::SignalHolder signalHolder_; }; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 7987f34bd1b..4d99241a8ce 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1,8 +1,11 @@ #include "providers/twitch/TwitchMessageBuilder.hpp" #include "Application.hpp" +#include "common/LinkParser.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/userdata/UserDataController.hpp" @@ -10,10 +13,13 @@ #include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageThread.hpp" +#include "providers/bttv/BttvEmotes.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/colors/ColorProvider.hpp" #include "providers/ffz/FfzBadges.hpp" +#include "providers/ffz/FfzEmotes.hpp" #include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/PubSubActions.hpp" @@ -27,18 +33,26 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" +#include "util/QStringHash.hpp" #include "util/Qt.hpp" #include "widgets/Window.hpp" #include #include #include -#include + +#include +#include + +using namespace chatterino::literals; namespace { +using namespace std::chrono_literals; + const QString regexHelpString("(\\w+)[.,!?;:]*?$"); // matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username" @@ -52,6 +66,19 @@ const QSet zeroWidthEmotes{ "ReinDeer", "CandyCane", "cvMask", "cvHazmat", }; +struct HypeChatPaidLevel { + std::chrono::seconds duration; + uint8_t numeric; +}; + +const std::unordered_map HYPE_CHAT_PAID_LEVEL{ + {u"ONE"_s, {30s, 1}}, {u"TWO"_s, {2min + 30s, 2}}, + {u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}}, + {u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}}, + {u"SEVEN"_s, {2h, 7}}, {u"EIGHT"_s, {3h, 8}}, + {u"NINE"_s, {4h, 9}}, {u"TEN"_s, {5h, 10}}, +}; + } // namespace namespace chatterino { @@ -129,6 +156,241 @@ namespace { } } + std::optional getTwitchBadge(const Badge &badge, + const TwitchChannel *twitchChannel) + { + if (auto channelBadge = + twitchChannel->twitchBadge(badge.key_, badge.value_)) + { + return channelBadge; + } + + if (auto globalBadge = + getIApp()->getTwitchBadges()->badge(badge.key_, badge.value_)) + { + return globalBadge; + } + + return std::nullopt; + } + + void appendBadges(MessageBuilder *builder, const std::vector &badges, + const std::unordered_map &badgeInfos, + const TwitchChannel *twitchChannel) + { + if (twitchChannel == nullptr) + { + return; + } + + for (const auto &badge : badges) + { + auto badgeEmote = getTwitchBadge(badge, twitchChannel); + if (!badgeEmote) + { + continue; + } + auto tooltip = (*badgeEmote)->tooltip.string; + + if (badge.key_ == "bits") + { + const auto &cheerAmount = badge.value_; + tooltip = QString("Twitch cheer %0").arg(cheerAmount); + } + else if (badge.key_ == "moderator" && + getSettings()->useCustomFfzModeratorBadges) + { + if (auto customModBadge = twitchChannel->ffzCustomModBadge()) + { + builder + ->emplace( + *customModBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customModBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.key_ == "vip" && + getSettings()->useCustomFfzVipBadges) + { + if (auto customVipBadge = twitchChannel->ffzCustomVipBadge()) + { + builder + ->emplace( + *customVipBadge, + MessageElementFlag::BadgeChannelAuthority) + ->setTooltip((*customVipBadge)->tooltip.string); + // early out, since we have to add a custom badge element here + continue; + } + } + else if (badge.flag_ == MessageElementFlag::BadgeSubscription) + { + auto badgeInfoIt = badgeInfos.find(badge.key_); + if (badgeInfoIt != badgeInfos.end()) + { + // badge.value_ is 4 chars long if user is subbed on higher tier + // (tier + amount of months with leading zero if less than 100) + // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub + const auto &subTier = + badge.value_.length() > 3 ? badge.value_.at(0) : '1'; + const auto &subMonths = badgeInfoIt->second; + tooltip += QString(" (%1%2 months)") + .arg(subTier != '1' + ? QString("Tier %1, ").arg(subTier) + : "") + .arg(subMonths); + } + } + else if (badge.flag_ == MessageElementFlag::BadgePredictions) + { + auto badgeInfoIt = badgeInfos.find(badge.key_); + if (badgeInfoIt != badgeInfos.end()) + { + auto infoValue = badgeInfoIt->second; + auto predictionText = + infoValue + .replace(R"(\s)", " ") // standard IRC escapes + .replace(R"(\:)", ";") + .replace(R"(\\)", R"(\)") + .replace("⸝", ","); // twitch's comma escape + // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma + + tooltip = QString("Predicted %1").arg(predictionText); + } + } + + builder->emplace(*badgeEmote, badge.flag_) + ->setTooltip(tooltip); + } + + builder->message().badges = badges; + builder->message().badgeInfos = badgeInfos; + } + + /** + * Computes (only) the replacement of @a match in @a source. + * The parts before and after the match in @a source are ignored. + * + * Occurrences of \b{\\1}, \b{\\2}, ..., in @a replacement are replaced + * with the string captured by the corresponding capturing group. + * This function should only be used if the regex contains capturing groups. + * + * Since Qt doesn't provide a way of replacing a single match with some replacement + * while supporting both capturing groups and lookahead/-behind in the regex, + * this is included here. It's essentially the implementation of + * QString::replace(const QRegularExpression &, const QString &). + * @see https://github.com/qt/qtbase/blob/97bb0ecfe628b5bb78e798563212adf02129c6f6/src/corelib/text/qstring.cpp#L4594-L4703 + */ + QString makeRegexReplacement(QStringView source, + const QRegularExpression ®ex, + const QRegularExpressionMatch &match, + const QString &replacement) + { + using SizeType = QString::size_type; + struct QStringCapture { + SizeType pos; + SizeType len; + int captureNumber; + }; + + qsizetype numCaptures = regex.captureCount(); + + // 1. build the backreferences list, holding where the backreferences + // are in the replacement string + QVarLengthArray backReferences; + + SizeType replacementLength = replacement.size(); + for (SizeType i = 0; i < replacementLength - 1; i++) + { + if (replacement[i] != u'\\') + { + continue; + } + + int no = replacement[i + 1].digitValue(); + if (no <= 0 || no > numCaptures) + { + continue; + } + + QStringCapture backReference{.pos = i, .len = 2}; + + if (i < replacementLength - 2) + { + int secondDigit = replacement[i + 2].digitValue(); + if (secondDigit != -1 && + ((no * 10) + secondDigit) <= numCaptures) + { + no = (no * 10) + secondDigit; + ++backReference.len; + } + } + + backReference.captureNumber = no; + backReferences.append(backReference); + } + + // 2. iterate on the matches. + // For every match, copy the replacement string in chunks + // with the proper replacements for the backreferences + + // length of the new string, with all the replacements + SizeType newLength = 0; + QVarLengthArray chunks; + QStringView replacementView{replacement}; + + // Initially: empty, as we only care about the replacement + SizeType len = 0; + SizeType lastEnd = 0; + for (const QStringCapture &backReference : + std::as_const(backReferences)) + { + // part of "replacement" before the backreference + len = backReference.pos - lastEnd; + if (len > 0) + { + chunks << replacementView.mid(lastEnd, len); + newLength += len; + } + + // backreference itself + len = match.capturedLength(backReference.captureNumber); + if (len > 0) + { + chunks << source.mid( + match.capturedStart(backReference.captureNumber), len); + newLength += len; + } + + lastEnd = backReference.pos + backReference.len; + } + + // add the last part of the replacement string + len = replacementView.size() - lastEnd; + if (len > 0) + { + chunks << replacementView.mid(lastEnd, len); + newLength += len; + } + + // 3. assemble the chunks together + QString dst; + dst.reserve(newLength); + for (const QStringView &chunk : std::as_const(chunks)) + { +#if QT_VERSION < QT_VERSION_CHECK(5, 15, 2) + static_assert(sizeof(QChar) == sizeof(decltype(*chunk.utf16()))); + dst.append(reinterpret_cast(chunk.utf16()), + chunk.length()); +#else + dst += chunk; +#endif + } + return dst; + } + } // namespace TwitchMessageBuilder::TwitchMessageBuilder( @@ -157,6 +419,17 @@ bool TwitchMessageBuilder::isIgnored() const }); } +bool TwitchMessageBuilder::isIgnoredReply() const +{ + return isIgnoredMessage({ + /*.message = */ this->originalMessage_, + /*.twitchUserID = */ + this->tags.value("reply-parent-user-id").toString(), + /*.isMod = */ this->channel->isMod(), + /*.isBroadcaster = */ this->channel->isBroadcaster(), + }); +} + void TwitchMessageBuilder::triggerHighlights() { if (this->historicalMessage_) @@ -170,6 +443,9 @@ void TwitchMessageBuilder::triggerHighlights() MessagePtr TwitchMessageBuilder::build() { + assert(this->ircMessage != nullptr); + assert(this->channel != nullptr); + // PARSE this->userId_ = this->ircMessage->tag("user-id").toString(); @@ -193,8 +469,8 @@ MessagePtr TwitchMessageBuilder::build() this->args.channelPointRewardId); if (reward) { - this->appendChannelPointRewardMessage( - reward.get(), this, this->channel->isMod(), + TwitchMessageBuilder::appendChannelPointRewardMessage( + *reward, this, this->channel->isMod(), this->channel->isBroadcaster()); } } @@ -265,7 +541,9 @@ MessagePtr TwitchMessageBuilder::build() this->tags, this->originalMessage_, this->messageOffset_); // This runs through all ignored phrases and runs its replacements on this->originalMessage_ - this->runIgnoreReplaces(twitchEmotes); + TwitchMessageBuilder::processIgnorePhrases( + *getSettings()->ignoredMessages.readOnly(), this->originalMessage_, + twitchEmotes); std::sort(twitchEmotes.begin(), twitchEmotes.end(), [](const auto &a, const auto &b) { @@ -282,8 +560,12 @@ MessagePtr TwitchMessageBuilder::build() this->addWords(splits, twitchEmotes); + QString stylizedUsername = + this->stylizeUsername(this->userName, this->message()); + this->message().messageText = this->originalMessage_; - this->message().searchText = this->message().localizedName + " " + + this->message().searchText = stylizedUsername + " " + + this->message().localizedName + " " + this->userName + ": " + this->originalMessage_; // highlights @@ -394,7 +676,8 @@ void TwitchMessageBuilder::addWords( // 1. Add text before the emote QString preText = word.left(currentTwitchEmote.start - cursor); - for (auto &variant : getApp()->emotes->emojis.parse(preText)) + for (auto &variant : + getIApp()->getEmotes()->getEmojis()->parse(preText)) { boost::apply_visitor( [&](auto &&arg) { @@ -414,7 +697,7 @@ void TwitchMessageBuilder::addWords( } // split words - for (auto &variant : getApp()->emotes->emojis.parse(word)) + for (auto &variant : getIApp()->getEmotes()->getEmojis()->parse(word)) { boost::apply_visitor( [&](auto &&arg) { @@ -454,12 +737,12 @@ void TwitchMessageBuilder::addTextOrEmoji(const QString &string_) } // Actually just text - auto linkString = this->matchLink(string); + LinkParser parsed(string); auto textColor = this->textColor_; - if (!linkString.isEmpty()) + if (parsed.result()) { - this->addLink(string, linkString); + this->addLink(*parsed.result()); return; } @@ -581,15 +864,24 @@ void TwitchMessageBuilder::parseThread() { // set references this->message().replyThread = this->thread_; + this->message().replyParent = this->parent_; this->thread_->addToThread(this->weakOf()); // enable reply flag this->message().flags.set(MessageFlag::ReplyMessage); - const auto &threadRoot = this->thread_->root(); + MessagePtr threadRoot; + if (!this->parent_) + { + threadRoot = this->thread_->root(); + } + else + { + threadRoot = this->parent_; + } QString usernameText = SharedMessageBuilder::stylizeUsername( - threadRoot->loginName, *threadRoot.get()); + threadRoot->loginName, *threadRoot); this->emplace(); @@ -622,19 +914,27 @@ void TwitchMessageBuilder::parseThread() if (replyDisplayName != this->tags.end() && replyBody != this->tags.end()) { - auto name = replyDisplayName->toString(); - auto body = parseTagString(replyBody->toString()); + QString body; this->emplace(); - this->emplace( "Replying to", MessageElementFlag::RepliedMessage, MessageColor::System, FontStyle::ChatMediumSmall); - this->emplace( - "@" + name + ":", MessageElementFlag::RepliedMessage, - this->textColor_, FontStyle::ChatMediumSmall) - ->setLink({Link::UserInfo, name}); + if (this->isIgnoredReply()) + { + body = QString("[Blocked user]"); + } + else + { + auto name = replyDisplayName->toString(); + body = parseTagString(replyBody->toString()); + + this->emplace( + "@" + name + ":", MessageElementFlag::RepliedMessage, + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, name}); + } this->emplace( body, @@ -701,7 +1001,7 @@ void TwitchMessageBuilder::parseUsername() } // Update current user color if this is our message - auto currentUser = getApp()->accounts->twitch.getCurrent(); + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); if (this->ircMessage->nick() == currentUser->getUserName()) { currentUser->setColor(this->usernameColor_); @@ -710,7 +1010,7 @@ void TwitchMessageBuilder::parseUsername() void TwitchMessageBuilder::appendUsername() { - auto app = getApp(); + auto *app = getIApp(); QString username = this->userName; this->message().loginName = username; @@ -755,7 +1055,7 @@ void TwitchMessageBuilder::appendUsername() FontStyle::ChatMediumBold) ->setLink({Link::UserWhisper, this->message().displayName}); - auto currentUser = app->accounts->twitch.getCurrent(); + auto currentUser = app->getAccounts()->twitch.getCurrent(); // Separator this->emplace("->", MessageElementFlag::Username, @@ -784,31 +1084,28 @@ void TwitchMessageBuilder::appendUsername() } } -void TwitchMessageBuilder::runIgnoreReplaces( +void TwitchMessageBuilder::processIgnorePhrases( + const std::vector &phrases, QString &originalMessage, std::vector &twitchEmotes) { - auto phrases = getCSettings().ignoredMessages.readOnly(); - auto removeEmotesInRange = [](int pos, int len, - auto &twitchEmotes) mutable { + using SizeType = QString::size_type; + + auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) { + // all emotes outside the range come before `it` + // all emotes in the range start at `it` auto it = std::partition( twitchEmotes.begin(), twitchEmotes.end(), [pos, len](const auto &item) { + // returns true for emotes outside the range return !((item.start >= pos) && item.start < (pos + len)); }); - for (auto copy = it; copy != twitchEmotes.end(); ++copy) - { - if ((*copy).ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "remem nullptr" << (*copy).name.string; - } - } - std::vector v(it, twitchEmotes.end()); + std::vector emotesInRange(it, + twitchEmotes.end()); twitchEmotes.erase(it, twitchEmotes.end()); - return v; + return emotesInRange; }; - auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) mutable { + auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) { for (auto &item : twitchEmotes) { auto &index = item.start; @@ -821,15 +1118,19 @@ void TwitchMessageBuilder::runIgnoreReplaces( }; auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, - const QStringRef &midrepl, - int startIndex) mutable { + const auto &midrepl, + SizeType startIndex) { if (!phrase.containsEmote()) { return; } - QVector words = midrepl.split(' '); - int pos = 0; +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + auto words = midrepl.tokenize(u' '); +#else + auto words = midrepl.split(' '); +#endif + SizeType pos = 0; for (const auto &word : words) { for (const auto &emote : phrase.getEmotes()) @@ -842,8 +1143,9 @@ void TwitchMessageBuilder::runIgnoreReplaces( << "emote null" << emote.first.string; } twitchEmotes.push_back(TwitchEmoteOccurrence{ - startIndex + pos, - startIndex + pos + emote.first.string.length(), + static_cast(startIndex + pos), + static_cast(startIndex + pos + + emote.first.string.length()), emote.second, emote.first, }); @@ -853,7 +1155,64 @@ void TwitchMessageBuilder::runIgnoreReplaces( } }; - for (const auto &phrase : *phrases) + auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from, + SizeType length, const QString &replacement) { + auto removedEmotes = removeEmotesInRange(from, length); + originalMessage.replace(from, length, replacement); + auto wordStart = from; + while (wordStart > 0) + { + if (originalMessage[wordStart - 1] == ' ') + { + break; + } + --wordStart; + } + auto wordEnd = from + replacement.length(); + while (wordEnd < originalMessage.length()) + { + if (originalMessage[wordEnd] == ' ') + { + break; + } + ++wordEnd; + } + + shiftIndicesAfter(static_cast(from + length), + static_cast(replacement.length() - length)); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + auto midExtendedRef = + QStringView{originalMessage}.mid(wordStart, wordEnd - wordStart); +#else + auto midExtendedRef = + originalMessage.midRef(wordStart, wordEnd - wordStart); +#endif + + for (auto &emote : removedEmotes) + { + if (emote.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "Invalid emote occurrence" << emote.name.string; + continue; + } + QRegularExpression emoteregex( + "\\b" + emote.name.string + "\\b", + QRegularExpression::UseUnicodePropertiesOption); + auto match = emoteregex.match(midExtendedRef); + if (match.hasMatch()) + { + emote.start = static_cast(from + match.capturedStart()); + emote.end = static_cast(from + match.capturedEnd()); + twitchEmotes.push_back(std::move(emote)); + } + } + + addReplEmotes(phrase, midExtendedRef, wordStart); + }; + + for (const auto &phrase : phrases) { if (phrase.isBlock()) { @@ -871,148 +1230,56 @@ void TwitchMessageBuilder::runIgnoreReplaces( { continue; } + QRegularExpressionMatch match; - int from = 0; - while ((from = this->originalMessage_.indexOf(regex, from, - &match)) != -1) + size_t iterations = 0; + SizeType from = 0; + while ((from = originalMessage.indexOf(regex, from, &match)) != -1) { - int len = match.capturedLength(); - auto vret = removeEmotesInRange(from, len, twitchEmotes); - auto mid = this->originalMessage_.mid(from, len); - mid.replace(regex, phrase.getReplace()); - - int midsize = mid.size(); - this->originalMessage_.replace(from, len, mid); - int pos1 = from; - while (pos1 > 0) - { - if (this->originalMessage_[pos1 - 1] == ' ') - { - break; - } - --pos1; - } - int pos2 = from + midsize; - while (pos2 < this->originalMessage_.length()) + auto replacement = phrase.getReplace(); + if (regex.captureCount() > 0) { - if (this->originalMessage_[pos2] == ' ') - { - break; - } - ++pos2; + replacement = makeRegexReplacement(originalMessage, regex, + match, replacement); } - shiftIndicesAfter(from + len, midsize - len); - - auto midExtendedRef = - this->originalMessage_.midRef(pos1, pos2 - pos1); - - for (auto &tup : vret) + replaceMessageAt(phrase, from, match.capturedLength(), + replacement); + from += phrase.getReplace().length(); + iterations++; + if (iterations >= 128) { - if (tup.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "v nullptr" << tup.name.string; - continue; - } - QRegularExpression emoteregex( - "\\b" + tup.name.string + "\\b", - QRegularExpression::UseUnicodePropertiesOption); - auto _match = emoteregex.match(midExtendedRef); - if (_match.hasMatch()) - { - int last = _match.lastCapturedIndex(); - for (int i = 0; i <= last; ++i) - { - tup.start = from + _match.capturedStart(); - twitchEmotes.push_back(std::move(tup)); - } - } + originalMessage = + u"Too many replacements - check your ignores!"_s; + return; } - - addReplEmotes(phrase, midExtendedRef, pos1); - - from += midsize; } - } - else - { - int from = 0; - while ((from = this->originalMessage_.indexOf( - pattern, from, phrase.caseSensitivity())) != -1) - { - int len = pattern.size(); - auto vret = removeEmotesInRange(from, len, twitchEmotes); - auto replace = phrase.getReplace(); - int replacesize = replace.size(); - this->originalMessage_.replace(from, len, replace); - - int pos1 = from; - while (pos1 > 0) - { - if (this->originalMessage_[pos1 - 1] == ' ') - { - break; - } - --pos1; - } - int pos2 = from + replacesize; - while (pos2 < this->originalMessage_.length()) - { - if (this->originalMessage_[pos2] == ' ') - { - break; - } - ++pos2; - } - - shiftIndicesAfter(from + len, replacesize - len); - - auto midExtendedRef = - this->originalMessage_.midRef(pos1, pos2 - pos1); - - for (auto &tup : vret) - { - if (tup.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "v nullptr" << tup.name.string; - continue; - } - QRegularExpression emoteregex( - "\\b" + tup.name.string + "\\b", - QRegularExpression::UseUnicodePropertiesOption); - auto match = emoteregex.match(midExtendedRef); - if (match.hasMatch()) - { - int last = match.lastCapturedIndex(); - for (int i = 0; i <= last; ++i) - { - tup.start = from + match.capturedStart(); - twitchEmotes.push_back(std::move(tup)); - } - } - } - - addReplEmotes(phrase, midExtendedRef, pos1); + continue; + } - from += replacesize; - } + SizeType from = 0; + while ((from = originalMessage.indexOf(pattern, from, + phrase.caseSensitivity())) != -1) + { + replaceMessageAt(phrase, from, pattern.length(), + phrase.getReplace()); + from += phrase.getReplace().length(); } } } Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) { - auto *app = getApp(); + auto *app = getIApp(); - const auto &globalBttvEmotes = app->twitch->getBttvEmotes(); - const auto &globalFfzEmotes = app->twitch->getFfzEmotes(); - const auto &globalSeventvEmotes = app->twitch->getSeventvEmotes(); + const auto *globalBttvEmotes = app->getBttvEmotes(); + const auto *globalFfzEmotes = app->getFfzEmotes(); + const auto *globalSeventvEmotes = app->getSeventvEmotes(); auto flags = MessageElementFlags(); - auto emote = boost::optional{}; + auto emote = std::optional{}; + bool zeroWidth = false; // Emote order: // - FrankerFaceZ Channel @@ -1034,58 +1301,62 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) (emote = this->twitchChannel->seventvEmote(name))) { flags = MessageElementFlag::SevenTVEmote; - if (emote.value()->zeroWidth) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = emote.value()->zeroWidth; } - else if ((emote = globalFfzEmotes.emote(name))) + else if ((emote = globalFfzEmotes->emote(name))) { flags = MessageElementFlag::FfzEmote; } - else if ((emote = globalBttvEmotes.emote(name))) + else if ((emote = globalBttvEmotes->emote(name))) { flags = MessageElementFlag::BttvEmote; - - if (zeroWidthEmotes.contains(name.string)) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = zeroWidthEmotes.contains(name.string); } - else if ((emote = globalSeventvEmotes.globalEmote(name))) + else if ((emote = globalSeventvEmotes->globalEmote(name))) { flags = MessageElementFlag::SevenTVEmote; - if (emote.value()->zeroWidth) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = emote.value()->zeroWidth; } if (emote) { - this->emplace(emote.get(), flags, this->textColor_); - return Success; - } + if (zeroWidth && getSettings()->enableZeroWidthEmotes && + !this->isEmpty()) + { + // Attempt to merge current zero-width emote into any previous emotes + auto *asEmote = dynamic_cast(&this->back()); + if (asEmote) + { + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = this->releaseBack(); + + std::vector layers = { + {baseEmote, baseEmoteElement->getFlags()}, {*emote, flags}}; + this->emplace( + std::move(layers), baseEmoteElement->getFlags() | flags, + this->textColor_); + return Success; + } - return Failure; -} + auto *asLayered = + dynamic_cast(&this->back()); + if (asLayered) + { + asLayered->addEmoteLayer({*emote, flags}); + asLayered->addFlags(flags); + return Success; + } -boost::optional TwitchMessageBuilder::getTwitchBadge( - const Badge &badge) -{ - if (auto channelBadge = - this->twitchChannel->twitchBadge(badge.key_, badge.value_)) - { - return channelBadge; - } + // No emote to merge with, just show as regular emote + } - if (auto globalBadge = - TwitchBadges::instance()->badge(badge.key_, badge.value_)) - { - return globalBadge; + this->emplace(*emote, flags, this->textColor_); + return Success; } - return boost::none; + return Failure; } std::unordered_map TwitchMessageBuilder::parseBadgeInfoTag( @@ -1095,7 +1366,9 @@ std::unordered_map TwitchMessageBuilder::parseBadgeInfoTag( auto infoIt = tags.constFind("badge-info"); if (infoIt == tags.end()) + { return infoMap; + } auto info = infoIt.value().toString().split(',', Qt::SkipEmptyParts); @@ -1146,93 +1419,14 @@ void TwitchMessageBuilder::appendTwitchBadges() } auto badgeInfos = TwitchMessageBuilder::parseBadgeInfoTag(this->tags); - auto badges = this->parseBadgeTag(this->tags); - - for (const auto &badge : badges) - { - auto badgeEmote = this->getTwitchBadge(badge); - if (!badgeEmote) - { - continue; - } - auto tooltip = (*badgeEmote)->tooltip.string; - - if (badge.key_ == "bits") - { - const auto &cheerAmount = badge.value_; - tooltip = QString("Twitch cheer %0").arg(cheerAmount); - } - else if (badge.key_ == "moderator" && - getSettings()->useCustomFfzModeratorBadges) - { - if (auto customModBadge = this->twitchChannel->ffzCustomModBadge()) - { - this->emplace( - customModBadge.get(), - MessageElementFlag::BadgeChannelAuthority) - ->setTooltip((*customModBadge)->tooltip.string); - // early out, since we have to add a custom badge element here - continue; - } - } - else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges) - { - if (auto customVipBadge = this->twitchChannel->ffzCustomVipBadge()) - { - this->emplace( - customVipBadge.get(), - MessageElementFlag::BadgeChannelAuthority) - ->setTooltip((*customVipBadge)->tooltip.string); - // early out, since we have to add a custom badge element here - continue; - } - } - else if (badge.flag_ == MessageElementFlag::BadgeSubscription) - { - auto badgeInfoIt = badgeInfos.find(badge.key_); - if (badgeInfoIt != badgeInfos.end()) - { - // badge.value_ is 4 chars long if user is subbed on higher tier - // (tier + amount of months with leading zero if less than 100) - // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub - const auto &subTier = - badge.value_.length() > 3 ? badge.value_.at(0) : '1'; - const auto &subMonths = badgeInfoIt->second; - tooltip += - QString(" (%1%2 months)") - .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier) - : "") - .arg(subMonths); - } - } - else if (badge.flag_ == MessageElementFlag::BadgePredictions) - { - auto badgeInfoIt = badgeInfos.find(badge.key_); - if (badgeInfoIt != badgeInfos.end()) - { - auto predictionText = - badgeInfoIt->second - .replace(R"(\s)", " ") // standard IRC escapes - .replace(R"(\:)", ";") - .replace(R"(\\)", R"(\)") - .replace("⸝", ","); // twitch's comma escape - // Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma - - tooltip = QString("Predicted %1").arg(predictionText); - } - } - - this->emplace(badgeEmote.get(), badge.flag_) - ->setTooltip(tooltip); - } - - this->message().badges = badges; - this->message().badgeInfos = badgeInfos; + auto badges = TwitchMessageBuilder::parseBadgeTag(this->tags); + appendBadges(this, badges, badgeInfos, this->twitchChannel); } void TwitchMessageBuilder::appendChatterinoBadges() { - if (auto badge = getApp()->chatterinoBadges->getBadge({this->userId_})) + if (auto badge = + getIApp()->getChatterinoBadges()->getBadge({this->userId_})) { this->emplace(*badge, MessageElementFlag::BadgeChatterino); @@ -1242,7 +1436,7 @@ void TwitchMessageBuilder::appendChatterinoBadges() void TwitchMessageBuilder::appendFfzBadges() { for (const auto &badge : - getApp()->ffzBadges->getUserBadges({this->userId_})) + getIApp()->getFfzBadges()->getUserBadges({this->userId_})) { this->emplace( badge.emote, MessageElementFlag::BadgeFfz, badge.color); @@ -1251,7 +1445,7 @@ void TwitchMessageBuilder::appendFfzBadges() void TwitchMessageBuilder::appendSeventvBadges() { - if (auto badge = getApp()->seventvBadges->getBadge({this->userId_})) + if (auto badge = getIApp()->getSeventvBadges()->getBadge({this->userId_})) { this->emplace(*badge, MessageElementFlag::BadgeSevenTV); } @@ -1410,6 +1604,7 @@ void TwitchMessageBuilder::appendChannelPointRewardMessage( textList.append({redeemed, reward.title, QString::number(reward.cost)}); builder->message().messageText = textList.join(" "); builder->message().searchText = textList.join(" "); + builder->message().loginName = reward.user.login; } void TwitchMessageBuilder::liveMessage(const QString &channelName, @@ -1591,7 +1786,7 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, builder->emplace(prefix, MessageElementFlag::Text, MessageColor::System); bool isFirst = true; - auto tc = dynamic_cast(channel); + auto *tc = dynamic_cast(channel); for (const QString &username : users) { if (!isFirst) @@ -1682,11 +1877,431 @@ void TwitchMessageBuilder::listOfUsersSystemMessage( builder->message().searchText = text; } +MessagePtr TwitchMessageBuilder::buildHypeChatMessage( + Communi::IrcPrivateMessage *message) +{ + auto levelID = message->tag(u"pinned-chat-paid-level"_s).toString(); + auto currency = message->tag(u"pinned-chat-paid-currency"_s).toString(); + bool okAmount = false; + auto amount = message->tag(u"pinned-chat-paid-amount"_s).toInt(&okAmount); + bool okExponent = false; + auto exponent = + message->tag(u"pinned-chat-paid-exponent"_s).toInt(&okExponent); + if (!okAmount || !okExponent || currency.isEmpty()) + { + return {}; + } + // additionally, there's `pinned-chat-paid-is-system-message` which isn't used by Chatterino. + + QString subtitle; + auto levelIt = HYPE_CHAT_PAID_LEVEL.find(levelID); + if (levelIt != HYPE_CHAT_PAID_LEVEL.end()) + { + const auto &level = levelIt->second; + subtitle = u"Level %1 Hype Chat (%2) "_s.arg(level.numeric) + .arg(formatTime(level.duration)); + } + else + { + subtitle = u"Hype Chat "_s; + } + + // actualAmount = amount * 10^(-exponent) + double actualAmount = std::pow(10.0, double(-exponent)) * double(amount); + subtitle += QLocale::system().toCurrencyString(actualAmount, currency); + + MessageBuilder builder(systemMessage, parseTagString(subtitle), + calculateMessageTime(message).time()); + builder->flags.set(MessageFlag::ElevatedMessage); + return builder.release(); +} + +EmotePtr makeAutoModBadge() +{ + return std::make_shared(Emote{ + EmoteName{}, + ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, + Tooltip{"AutoMod"}, + Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); +} + +MessagePtr TwitchMessageBuilder::makeAutomodInfoMessage( + const AutomodInfoAction &action) +{ + auto builder = MessageBuilder(); + QString text("AutoMod: "); + + builder.emplace(); + builder.message().flags.set(MessageFlag::PubSub); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + builder.emplace( + "AutoMod:", MessageElementFlag::NonBoldUsername, + MessageColor(QColor("blue"))); + switch (action.type) + { + case AutomodInfoAction::OnHold: { + QString info("Hey! Your message is being checked " + "by mods and has not been sent."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + case AutomodInfoAction::Denied: { + QString info("Mods have removed your message."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + case AutomodInfoAction::Approved: { + QString info("Mods have accepted your message."); + text += info; + builder.emplace(info, MessageElementFlag::Text, + MessageColor::Text); + } + break; + } + + builder.message().flags.set(MessageFlag::AutoMod); + builder.message().messageText = text; + builder.message().searchText = text; + + auto message = builder.release(); + + return message; +} + +std::pair TwitchMessageBuilder::makeAutomodMessage( + const AutomodAction &action, const QString &channelName) +{ + MessageBuilder builder, builder2; + + // + // Builder for AutoMod message with explanation + builder.message().loginName = "automod"; + builder.message().channelName = channelName; + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::Timeout); + builder.message().flags.set(MessageFlag::AutoMod); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + // AutoMod "username" + builder.emplace("AutoMod:", MessageElementFlag::BoldUsername, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + builder.emplace( + "AutoMod:", MessageElementFlag::NonBoldUsername, + MessageColor(QColor("blue"))); + // AutoMod header message + builder.emplace( + ("Held a message for reason: " + action.reason + + ". Allow will post it in chat. "), + MessageElementFlag::Text, MessageColor::Text); + // Allow link button + builder + .emplace("Allow", MessageElementFlag::Text, + MessageColor(QColor("green")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModAllow, action.msgID}); + // Deny link button + builder + .emplace(" Deny", MessageElementFlag::Text, + MessageColor(QColor("red")), + FontStyle::ChatMediumBold) + ->setLink({Link::AutoModDeny, action.msgID}); + // ID of message caught by AutoMod + // builder.emplace(action.msgID, MessageElementFlag::Text, + // MessageColor::Text); + auto text1 = + QString("AutoMod: Held a message for reason: %1. Allow will post " + "it in chat. Allow Deny") + .arg(action.reason); + builder.message().messageText = text1; + builder.message().searchText = text1; + + auto message1 = builder.release(); + + // + // Builder for offender's message + builder2.message().channelName = channelName; + builder2 + .emplace("#" + channelName, + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, channelName}); + builder2.emplace(); + builder2.emplace(); + builder2.message().loginName = action.target.login; + builder2.message().flags.set(MessageFlag::PubSub); + builder2.message().flags.set(MessageFlag::Timeout); + builder2.message().flags.set(MessageFlag::AutoMod); + builder2.message().flags.set(MessageFlag::AutoModOffendingMessage); + + // sender username + builder2 + .emplace( + action.target.displayName + ":", MessageElementFlag::BoldUsername, + MessageColor(action.target.color), FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.target.login}); + builder2 + .emplace(action.target.displayName + ":", + MessageElementFlag::NonBoldUsername, + MessageColor(action.target.color)) + ->setLink({Link::UserInfo, action.target.login}); + // sender's message caught by AutoMod + builder2.emplace(action.message, MessageElementFlag::Text, + MessageColor::Text); + auto text2 = + QString("%1: %2").arg(action.target.displayName, action.message); + builder2.message().messageText = text2; + builder2.message().searchText = text2; + + auto message2 = builder2.release(); + + // Normally highlights would be checked & triggered during the builder parse steps + // and when the message is added to the channel + // We do this a bit weird since the message comes in from PubSub and not the normal message route + auto [highlighted, highlightResult] = getIApp()->getHighlights()->check( + {}, {}, action.target.login, action.message, message2->flags); + if (highlighted) + { + SharedMessageBuilder::triggerHighlights( + channelName, highlightResult.playSound, + highlightResult.customSoundUrl, highlightResult.alert); + } + + return std::make_pair(message1, message2); +} + +MessagePtr TwitchMessageBuilder::makeLowTrustUpdateMessage( + const PubSubLowTrustUsersMessage &action) +{ + /** + * Known issues: + * - Non-Twitch badges are not shown + * - Non-Twitch emotes are not shown + */ + + MessageBuilder builder; + builder.emplace(); + builder.message().flags.set(MessageFlag::System); + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::DoNotTriggerNotification); + + builder + .emplace(action.updatedByUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.updatedByUserLogin}); + + assert(action.treatment != PubSubLowTrustUsersMessage::Treatment::INVALID); + switch (action.treatment) + { + case PubSubLowTrustUsersMessage::Treatment::NoTreatment: { + builder.emplace("removed", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("from the suspicious user list.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + case PubSubLowTrustUsersMessage::Treatment::ActiveMonitoring: { + builder.emplace("added", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("as a monitored suspicious chatter.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + case PubSubLowTrustUsersMessage::Treatment::Restricted: { + builder.emplace("added", MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(action.suspiciousUserDisplayName, + MessageElementFlag::Username, + MessageColor::System, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder.emplace("as a restricted suspicious chatter.", + MessageElementFlag::Text, + MessageColor::System); + } + break; + + default: + qCDebug(chatterinoTwitch) << "Unexpected suspicious treatment: " + << action.treatmentString; + break; + } + + return builder.release(); +} + +std::pair TwitchMessageBuilder::makeLowTrustUserMessage( + const PubSubLowTrustUsersMessage &action, const QString &channelName, + const TwitchChannel *twitchChannel) +{ + MessageBuilder builder, builder2; + + // Builder for low trust user message with explanation + builder.message().channelName = channelName; + builder.message().flags.set(MessageFlag::PubSub); + builder.message().flags.set(MessageFlag::LowTrustUsers); + + // AutoMod shield badge + builder.emplace(makeAutoModBadge(), + MessageElementFlag::BadgeChannelAuthority); + + // Suspicious user header message + QString prefix = "Suspicious User:"; + builder.emplace(prefix, MessageElementFlag::Text, + MessageColor(QColor("blue")), + FontStyle::ChatMediumBold); + + QString headerMessage; + if (action.treatment == PubSubLowTrustUsersMessage::Treatment::Restricted) + { + headerMessage = "Restricted"; + builder2.message().flags.set(MessageFlag::RestrictedMessage); + } + else + { + headerMessage = "Monitored"; + builder2.message().flags.set(MessageFlag::MonitoredMessage); + } + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::ManuallyAdded)) + { + headerMessage += " by " + action.updatedByUserLogin; + } + + headerMessage += " at " + action.updatedAt; + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::DetectedBanEvader)) + { + QString evader; + if (action.evasionEvaluation == + PubSubLowTrustUsersMessage::EvasionEvaluation::LikelyEvader) + { + evader = "likely"; + } + else + { + evader = "possible"; + } + + headerMessage += ". Detected as " + evader + " ban evader"; + } + + if (action.restrictionTypes.has( + PubSubLowTrustUsersMessage::RestrictionType::BannedInSharedChannel)) + { + headerMessage += ". Banned in " + + QString::number(action.sharedBanChannelIDs.size()) + + " shared channels"; + } + + builder.emplace(headerMessage, MessageElementFlag::Text, + MessageColor::Text); + builder.message().messageText = prefix + " " + headerMessage; + builder.message().searchText = prefix + " " + headerMessage; + + auto message1 = builder.release(); + + // + // Builder for offender's message + builder2.message().channelName = channelName; + builder2 + .emplace("#" + channelName, + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, channelName}); + builder2.emplace(); + builder2.emplace(); + builder2.message().loginName = action.suspiciousUserLogin; + builder2.message().flags.set(MessageFlag::PubSub); + builder2.message().flags.set(MessageFlag::LowTrustUsers); + + // sender badges + appendBadges(&builder2, action.senderBadges, {}, twitchChannel); + + // sender username + builder2 + .emplace(action.suspiciousUserDisplayName + ":", + MessageElementFlag::BoldUsername, + MessageColor(action.suspiciousUserColor), + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + builder2 + .emplace(action.suspiciousUserDisplayName + ":", + MessageElementFlag::NonBoldUsername, + MessageColor(action.suspiciousUserColor)) + ->setLink({Link::UserInfo, action.suspiciousUserLogin}); + + // sender's message caught by AutoMod + for (const auto &fragment : action.fragments) + { + if (fragment.emoteID.isEmpty()) + { + builder2.emplace( + fragment.text, MessageElementFlag::Text, MessageColor::Text); + } + else + { + const auto emotePtr = + getIApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{fragment.emoteID}, EmoteName{fragment.text}); + builder2.emplace( + emotePtr, MessageElementFlag::TwitchEmote, MessageColor::Text); + } + } + + auto text = + QString("%1: %2").arg(action.suspiciousUserDisplayName, action.text); + builder2.message().messageText = text; + builder2.message().searchText = text; + + auto message2 = builder2.release(); + + return std::make_pair(message1, message2); +} + void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); } +void TwitchMessageBuilder::setParent(MessagePtr parent) +{ + this->parent_ = std::move(parent); +} + void TwitchMessageBuilder::setMessageOffset(int offset) { this->messageOffset_ = offset; diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index c866d967596..dd38fc79078 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -3,12 +3,13 @@ #include "common/Aliases.hpp" #include "common/Outcome.hpp" #include "messages/SharedMessageBuilder.hpp" +#include "pubsubmessages/LowTrustUsers.hpp" -#include #include #include #include +#include #include namespace chatterino { @@ -19,6 +20,7 @@ using EmotePtr = std::shared_ptr; class Channel; class TwitchChannel; class MessageThread; +class IgnorePhrase; struct HelixVip; using HelixModerator = HelixVip; struct ChannelPointReward; @@ -53,10 +55,12 @@ class TwitchMessageBuilder : public SharedMessageBuilder TwitchChannel *twitchChannel; [[nodiscard]] bool isIgnored() const override; + bool isIgnoredReply() const; void triggerHighlights() override; MessagePtr build() override; void setThread(std::shared_ptr thread); + void setParent(MessagePtr parent); void setMessageOffset(int offset); static void appendChannelPointRewardMessage( @@ -85,6 +89,18 @@ class TwitchMessageBuilder : public SharedMessageBuilder QString prefix, const std::vector &users, Channel *channel, MessageBuilder *builder); + static MessagePtr buildHypeChatMessage(Communi::IrcPrivateMessage *message); + + static std::pair makeAutomodMessage( + const AutomodAction &action, const QString &channelName); + static MessagePtr makeAutomodInfoMessage(const AutomodInfoAction &action); + + static std::pair makeLowTrustUserMessage( + const PubSubLowTrustUsersMessage &action, const QString &channelName, + const TwitchChannel *twitchChannel); + static MessagePtr makeLowTrustUpdateMessage( + const PubSubLowTrustUsersMessage &action); + // Shares some common logic from SharedMessageBuilder::parseBadgeTag static std::unordered_map parseBadgeInfoTag( const QVariantMap &tags); @@ -93,6 +109,10 @@ class TwitchMessageBuilder : public SharedMessageBuilder const QVariantMap &tags, const QString &originalMessage, int messageOffset); + static void processIgnorePhrases( + const std::vector &phrases, QString &originalMessage, + std::vector &twitchEmotes); + private: void parseUsernameColor() override; void parseUsername() override; @@ -103,9 +123,6 @@ class TwitchMessageBuilder : public SharedMessageBuilder void parseThread(); void appendUsername(); - void runIgnoreReplaces(std::vector &twitchEmotes); - - boost::optional getTwitchBadge(const Badge &badge); Outcome tryAppendEmote(const EmoteName &name) override; void addWords(const QStringList &words, @@ -124,10 +141,11 @@ class TwitchMessageBuilder : public SharedMessageBuilder QString roomID_; bool hasBits_ = false; QString bits; - int bitsLeft; + int bitsLeft{}; bool bitsStacked = false; bool historicalMessage_ = false; std::shared_ptr thread_; + MessagePtr parent_; /** * Starting offset to be used on index-based operations on `originalMessage_`. diff --git a/src/providers/twitch/TwitchUser.hpp b/src/providers/twitch/TwitchUser.hpp index 7593abd3c8e..d615c95b011 100644 --- a/src/providers/twitch/TwitchUser.hpp +++ b/src/providers/twitch/TwitchUser.hpp @@ -1,5 +1,6 @@ #pragma once +#include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" #include @@ -31,6 +32,16 @@ struct TwitchUser { { return this->id < rhs.id; } + + bool operator==(const TwitchUser &rhs) const + { + return this->id == rhs.id; + } + + bool operator!=(const TwitchUser &rhs) const + { + return !(*this == rhs); + } }; } // namespace chatterino @@ -75,3 +86,11 @@ struct Deserialize { }; } // namespace pajlada + +template <> +struct std::hash { + inline size_t operator()(const chatterino::TwitchUser &user) const noexcept + { + return std::hash{}(user.id); + } +}; diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 5df03372e09..1c5a0ee3f10 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1,25 +1,28 @@ #include "providers/twitch/api/Helix.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/Literals.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" +#include "util/CancellationToken.hpp" -#include +#include #include namespace { using namespace chatterino; -static constexpr auto NUM_MODERATORS_TO_FETCH_PER_REQUEST = 100; +constexpr auto NUM_MODERATORS_TO_FETCH_PER_REQUEST = 100; -static constexpr auto NUM_CHATTERS_TO_FETCH = 1000; +constexpr auto NUM_CHATTERS_TO_FETCH = 1000; } // namespace namespace chatterino { +using namespace literals; + static IHelix *instance = nullptr; HelixChatters::HelixChatters(const QJsonObject &jsonObject) @@ -52,15 +55,15 @@ void Helix::fetchUsers(QStringList userIds, QStringList userLogins, } // TODO: set on success and on error - this->makeRequest("users", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("users", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector users; @@ -71,8 +74,6 @@ void Helix::fetchUsers(QStringList userIds, QStringList userLogins, } successCallback(users); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -122,52 +123,42 @@ void Helix::getUserById(QString userId, failureCallback); } -void Helix::fetchUsersFollows( - QString fromId, QString toId, - ResultCallback successCallback, - HelixFailureCallback failureCallback) +void Helix::getChannelFollowers( + QString broadcasterID, + ResultCallback successCallback, + std::function failureCallback) { - assert(!fromId.isEmpty() || !toId.isEmpty()); + assert(!broadcasterID.isEmpty()); QUrlQuery urlQuery; - - if (!fromId.isEmpty()) - { - urlQuery.addQueryItem("from_id", fromId); - } - - if (!toId.isEmpty()) - { - urlQuery.addQueryItem("to_id", toId); - } + urlQuery.addQueryItem("broadcaster_id", broadcasterID); // TODO: set on success and on error - this->makeRequest("users/follows", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("channels/followers", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); if (root.empty()) { - failureCallback(); - return Failure; + failureCallback("Bad JSON response"); + return; } - successCallback(HelixUsersFollowsResponse(root)); - return Success; + successCallback(HelixGetChannelFollowersResponse(root)); }) - .onError([failureCallback](auto /*result*/) { - // TODO: make better xd - failureCallback(); + .onError([failureCallback](auto result) { + auto root = result.parseJson(); + if (root.empty()) + { + failureCallback("Unknown error"); + return; + } + + // Forward "message" from Twitch + HelixError error(root); + failureCallback(error.message); }) .execute(); } -void Helix::getUserFollowers( - QString userId, ResultCallback successCallback, - HelixFailureCallback failureCallback) -{ - this->fetchUsersFollows("", std::move(userId), std::move(successCallback), - std::move(failureCallback)); -} - void Helix::fetchStreams( QStringList userIds, QStringList userLogins, ResultCallback> successCallback, @@ -186,15 +177,15 @@ void Helix::fetchStreams( } // TODO: set on success and on error - this->makeRequest("streams", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("streams", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector streams; @@ -205,8 +196,6 @@ void Helix::fetchStreams( } successCallback(streams); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -279,15 +268,15 @@ void Helix::fetchGames(QStringList gameIds, QStringList gameNames, } // TODO: set on success and on error - this->makeRequest("games", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("games", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector games; @@ -298,8 +287,6 @@ void Helix::fetchGames(QStringList gameIds, QStringList gameNames, } successCallback(games); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -315,15 +302,15 @@ void Helix::searchGames(QString gameName, QUrlQuery urlQuery; urlQuery.addQueryItem("query", gameName); - this->makeRequest("search/categories", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("search/categories", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector games; @@ -334,8 +321,6 @@ void Helix::searchGames(QString gameName, } successCallback(games); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -372,26 +357,24 @@ void Helix::createClip(QString channelId, QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", channelId); - this->makeRequest("clips", urlQuery) - .type(NetworkRequestType::Post) + this->makePost("clips", urlQuery) .header("Content-Type", "application/json") - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(HelixClipError::Unknown); - return Failure; + return; } HelixClip clip(data.toArray()[0].toObject()); successCallback(clip); - return Success; }) .onError([failureCallback](auto result) { - switch (result.status()) + switch (result.status().value_or(0)) { case 503: { // Channel has disabled clip-creation, or channel has made cliops only creatable by followers and the user is not a follower (or subscriber) @@ -407,7 +390,7 @@ void Helix::createClip(QString channelId, default: { qCDebug(chatterinoTwitch) - << "Failed to create a clip: " << result.status() + << "Failed to create a clip: " << result.formatError() << result.getData(); failureCallback(HelixClipError::Unknown); } @@ -418,6 +401,44 @@ void Helix::createClip(QString channelId, .execute(); } +void Helix::fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + for (const auto &userID : userIDs) + { + urlQuery.addQueryItem("broadcaster_id", userID); + } + + this->makeGet("channels", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(); + return; + } + + std::vector channels; + + for (const auto &unparsedChannel : data.toArray()) + { + channels.emplace_back(unparsedChannel.toObject()); + } + + successCallback(channels); + }) + .onError([failureCallback](auto /*result*/) { + failureCallback(); + }) + .execute(); +} + void Helix::getChannel(QString broadcasterId, ResultCallback successCallback, HelixFailureCallback failureCallback) @@ -425,21 +446,20 @@ void Helix::getChannel(QString broadcasterId, QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("channels", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("channels", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } HelixChannel channel(data.toArray()[0].toObject()); successCallback(channel); - return Success; }) .onError([failureCallback](auto /*result*/) { failureCallback(); @@ -460,27 +480,24 @@ void Helix::createStreamMarker( } payload.insert("user_id", QJsonValue(broadcasterId)); - this->makeRequest("streams/markers", QUrlQuery()) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePost("streams/markers", QUrlQuery()) + .json(payload) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(HelixStreamMarkerError::Unknown); - return Failure; + return; } HelixStreamMarker streamMarker(data.toArray()[0].toObject()); successCallback(streamMarker); - return Success; }) .onError([failureCallback](NetworkResult result) { - switch (result.status()) + switch (result.status().value_or(0)) { case 403: { // User isn't a Channel Editor, so he can't create markers @@ -498,7 +515,7 @@ void Helix::createStreamMarker( default: { qCDebug(chatterinoTwitch) << "Failed to create a stream marker: " - << result.status() << result.getData(); + << result.formatError() << result.getData(); failureCallback(HelixStreamMarkerError::Unknown); } break; @@ -508,54 +525,66 @@ void Helix::createStreamMarker( }; void Helix::loadBlocks(QString userId, - ResultCallback> successCallback, - HelixFailureCallback failureCallback) + ResultCallback> pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) { - QUrlQuery urlQuery; - urlQuery.addQueryItem("broadcaster_id", userId); - urlQuery.addQueryItem("first", "100"); + constexpr const size_t blockLimit = 1000; - this->makeRequest("users/blocks", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { - auto root = result.parseJson(); - auto data = root.value("data"); + // TODO(Qt 5.13): use initializer list + QUrlQuery query; + query.addQueryItem(u"broadcaster_id"_s, userId); + query.addQueryItem(u"first"_s, u"100"_s); - if (!data.isArray()) + size_t receivedItems = 0; + this->paginate( + u"users/blocks"_s, query, + [pageCallback, receivedItems](const QJsonObject &json) mutable { + const auto data = json["data"_L1].toArray(); + + if (data.isEmpty()) { - failureCallback(); - return Failure; + return false; } std::vector ignores; + ignores.reserve(data.count()); - for (const auto &jsonStream : data.toArray()) + for (const auto &ignore : data) { - ignores.emplace_back(jsonStream.toObject()); + ignores.emplace_back(ignore.toObject()); } - successCallback(ignores); + pageCallback(ignores); - return Success; - }) - .onError([failureCallback](auto /*result*/) { - // TODO: make better xd - failureCallback(); - }) - .execute(); + receivedItems += data.count(); + + if (receivedItems >= blockLimit) + { + qCInfo(chatterinoTwitch) << "Reached the limit of" << blockLimit + << "Twitch blocks fetched"; + return false; + } + + return true; + }, + [failureCallback](const NetworkResult &result) { + failureCallback(result.formatError()); + }, + std::move(token)); } -void Helix::blockUser(QString targetUserId, +void Helix::blockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) { QUrlQuery urlQuery; urlQuery.addQueryItem("target_user_id", targetUserId); - this->makeRequest("users/blocks", urlQuery) - .type(NetworkRequestType::Put) - .onSuccess([successCallback](auto /*result*/) -> Outcome { + this->makePut("users/blocks", urlQuery) + .caller(caller) + .onSuccess([successCallback](auto /*result*/) { successCallback(); - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -564,18 +593,17 @@ void Helix::blockUser(QString targetUserId, .execute(); } -void Helix::unblockUser(QString targetUserId, +void Helix::unblockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) { QUrlQuery urlQuery; urlQuery.addQueryItem("target_user_id", targetUserId); - this->makeRequest("users/blocks", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback](auto /*result*/) -> Outcome { + this->makeDelete("users/blocks", urlQuery) + .caller(caller) + .onSuccess([successCallback](auto /*result*/) { successCallback(); - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -590,7 +618,6 @@ void Helix::updateChannel(QString broadcasterId, QString gameId, HelixFailureCallback failureCallback) { QUrlQuery urlQuery; - auto data = QJsonDocument(); auto obj = QJsonObject(); if (!gameId.isEmpty()) { @@ -611,15 +638,11 @@ void Helix::updateChannel(QString broadcasterId, QString gameId, return; } - data.setObject(obj); urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("channels", urlQuery) - .type(NetworkRequestType::Patch) - .header("Content-Type", "application/json") - .payload(data.toJson()) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePatch("channels", urlQuery) + .json(obj) + .onSuccess([successCallback, failureCallback](auto result) { successCallback(result); - return Success; }) .onError([failureCallback](NetworkResult result) { failureCallback(); @@ -638,16 +661,13 @@ void Helix::manageAutoModMessages( payload.insert("msg_id", msgID); payload.insert("action", action); - this->makeRequest("moderation/automod/message", QUrlQuery()) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePost("moderation/automod/message", QUrlQuery()) + .json(payload) + .onSuccess([successCallback, failureCallback](auto result) { successCallback(); - return Success; }) .onError([failureCallback, msgID, action](NetworkResult result) { - switch (result.status()) + switch (result.status().value_or(0)) { case 400: { // Message was already processed @@ -679,7 +699,7 @@ void Helix::manageAutoModMessages( default: { qCDebug(chatterinoTwitch) << "Failed to manage automod message: " << action - << msgID << result.status() << result.getData(); + << msgID << result.formatError() << result.getData(); failureCallback(HelixAutoModMessageError::Unknown); } break; @@ -697,15 +717,15 @@ void Helix::getCheermotes( urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("bits/cheermotes", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeGet("bits/cheermotes", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector cheermoteSets; @@ -716,12 +736,11 @@ void Helix::getCheermotes( } successCallback(cheermoteSets); - return Success; }) .onError([broadcasterId, failureCallback](NetworkResult result) { qCDebug(chatterinoTwitch) << "Failed to get cheermotes(broadcaster_id=" << broadcasterId - << "): " << result.status() << result.getData(); + << "): " << result.formatError() << result.getData(); failureCallback(); }) .execute(); @@ -735,22 +754,20 @@ void Helix::getEmoteSetData(QString emoteSetId, urlQuery.addQueryItem("emote_set_id", emoteSetId); - this->makeRequest("chat/emotes/set", urlQuery) - .onSuccess([successCallback, failureCallback, - emoteSetId](auto result) -> Outcome { + this->makeGet("chat/emotes/set", urlQuery) + .onSuccess([successCallback, failureCallback, emoteSetId](auto result) { QJsonObject root = result.parseJson(); auto data = root.value("data"); if (!data.isArray() || data.toArray().isEmpty()) { failureCallback(); - return Failure; + return; } HelixEmoteSetData emoteSetData(data.toArray()[0].toObject()); successCallback(emoteSetData); - return Success; }) .onError([failureCallback](NetworkResult result) { // TODO: make better xd @@ -767,16 +784,15 @@ void Helix::getChannelEmotes( QUrlQuery urlQuery; urlQuery.addQueryItem("broadcaster_id", broadcasterId); - this->makeRequest("chat/emotes", urlQuery) - .onSuccess([successCallback, - failureCallback](NetworkResult result) -> Outcome { + this->makeGet("chat/emotes", urlQuery) + .onSuccess([successCallback, failureCallback](NetworkResult result) { QJsonObject root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector channelEmotes; @@ -787,7 +803,6 @@ void Helix::getChannelEmotes( } successCallback(channelEmotes); - return Success; }) .onError([failureCallback](auto result) { // TODO: make better xd @@ -807,27 +822,31 @@ void Helix::updateUserChatColor( payload.insert("user_id", QJsonValue(userID)); payload.insert("color", QJsonValue(color)); - this->makeRequest("chat/color", QUrlQuery()) - .type(NetworkRequestType::Put) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePut("chat/color", QUrlQuery()) + .json(payload) + .onSuccess([successCallback, failureCallback](auto result) { auto obj = result.parseJson(); if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for updating chat color was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.startsWith("invalid color", @@ -860,7 +879,7 @@ void Helix::updateUserChatColor( default: { qCDebug(chatterinoTwitch) << "Unhandled error changing user color:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -887,24 +906,29 @@ void Helix::deleteChatMessages( urlQuery.addQueryItem("message_id", messageID); } - this->makeRequest("moderation/chat", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeDelete("moderation/chat", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for deleting chat messages was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 404: { // A 404 on this endpoint means message id is invalid or unable to be deleted. @@ -946,7 +970,7 @@ void Helix::deleteChatMessages( default: { qCDebug(chatterinoTwitch) << "Unhandled error deleting chat messages:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -966,24 +990,29 @@ void Helix::addChannelModerator( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("moderation/moderators", urlQuery) - .type(NetworkRequestType::Post) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePost("moderation/moderators", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for adding a moderator was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 401: { if (message.startsWith("Missing scope", @@ -1035,7 +1064,7 @@ void Helix::addChannelModerator( default: { qCDebug(chatterinoTwitch) << "Unhandled error adding channel moderator:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1055,24 +1084,29 @@ void Helix::removeChannelModerator( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("moderation/moderators", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeDelete("moderation/moderators", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for unmodding user was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.compare("user is not a mod", @@ -1114,8 +1148,8 @@ void Helix::removeChannelModerator( default: { qCDebug(chatterinoTwitch) - << "Unhandled error unmodding user:" << result.status() - << result.getData() << obj; + << "Unhandled error unmodding user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1142,26 +1176,30 @@ void Helix::sendChatAnnouncement( std::string{magic_enum::enum_name(color)}; body.insert("color", QString::fromStdString(colorStr).toLower()); - this->makeRequest("chat/announcements", urlQuery) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(body).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePost("chat/announcements", urlQuery) + .json(body) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for sending an announcement was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { // These errors are generally well formatted, so we just forward them. @@ -1194,7 +1232,7 @@ void Helix::sendChatAnnouncement( default: { qCDebug(chatterinoTwitch) << "Unhandled error sending an announcement:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1214,24 +1252,29 @@ void Helix::addChannelVIP( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("channels/vips", urlQuery) - .type(NetworkRequestType::Post) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePost("channels/vips", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for adding channel VIP was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: case 409: @@ -1273,7 +1316,7 @@ void Helix::addChannelVIP( default: { qCDebug(chatterinoTwitch) << "Unhandled error adding channel VIP:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1293,24 +1336,29 @@ void Helix::removeChannelVIP( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("channels/vips", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeDelete("channels/vips", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for removing channel VIP was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: case 409: @@ -1351,7 +1399,7 @@ void Helix::removeChannelVIP( default: { qCDebug(chatterinoTwitch) << "Unhandled error removing channel VIP:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1383,24 +1431,29 @@ void Helix::unbanUser( urlQuery.addQueryItem("moderator_id", moderatorID); urlQuery.addQueryItem("user_id", userID); - this->makeRequest("moderation/bans", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeDelete("moderation/bans", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for unbanning user was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.startsWith("The user in the user_id query " @@ -1456,8 +1509,8 @@ void Helix::unbanUser( default: { qCDebug(chatterinoTwitch) - << "Unhandled error unbanning user:" << result.status() - << result.getData() << obj; + << "Unhandled error unbanning user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1489,18 +1542,21 @@ void Helix::startRaid( urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); - this->makeRequest("raids", urlQuery) - .type(NetworkRequestType::Post) - .onSuccess( - [successCallback, failureCallback](auto /*result*/) -> Outcome { - successCallback(); - return Success; - }) - .onError([failureCallback](auto result) { + this->makePost("raids", urlQuery) + .onSuccess([successCallback, failureCallback](auto /*result*/) { + successCallback(); + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.compare("The IDs in from_broadcaster_id and " @@ -1551,7 +1607,7 @@ void Helix::startRaid( default: { qCDebug(chatterinoTwitch) << "Unhandled error while starting a raid:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1570,24 +1626,29 @@ void Helix::cancelRaid( urlQuery.addQueryItem("broadcaster_id", broadcasterID); - this->makeRequest("raids", urlQuery) - .type(NetworkRequestType::Delete) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makeDelete("raids", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for canceling the raid was" - << result.status() << "but we only expected it to be 204"; + << result.formatError() + << "but we only expected it to be 204"; } successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 401: { if (message.startsWith("Missing scope", @@ -1624,7 +1685,7 @@ void Helix::cancelRaid( default: { qCDebug(chatterinoTwitch) << "Unhandled error while canceling the raid:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1646,7 +1707,7 @@ void Helix::updateEmoteMode( void Helix::updateFollowerMode( QString broadcasterID, QString moderatorID, - boost::optional followerModeDuration, + std::optional followerModeDuration, ResultCallback successCallback, FailureCallback failureCallback) { @@ -1663,7 +1724,7 @@ void Helix::updateFollowerMode( void Helix::updateNonModeratorChatDelay( QString broadcasterID, QString moderatorID, - boost::optional nonModeratorChatDelayDuration, + std::optional nonModeratorChatDelayDuration, ResultCallback successCallback, FailureCallback failureCallback) { @@ -1682,7 +1743,7 @@ void Helix::updateNonModeratorChatDelay( void Helix::updateSlowMode( QString broadcasterID, QString moderatorID, - boost::optional slowModeWaitTime, + std::optional slowModeWaitTime, ResultCallback successCallback, FailureCallback failureCallback) { @@ -1731,27 +1792,30 @@ void Helix::updateChatSettings( urlQuery.addQueryItem("broadcaster_id", broadcasterID); urlQuery.addQueryItem("moderator_id", moderatorID); - this->makeRequest("chat/settings", urlQuery) - .type(NetworkRequestType::Patch) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback](auto result) -> Outcome { + this->makePatch("chat/settings", urlQuery) + .json(payload) + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for updating chat settings was" - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixChatSettings( response.value("data").toArray().first().toObject())); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.contains("must be in the range")) @@ -1798,7 +1862,7 @@ void Helix::updateChatSettings( default: { qCDebug(chatterinoTwitch) << "Unhandled error updating chat settings:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -1857,24 +1921,29 @@ void Helix::fetchChatters( urlQuery.addQueryItem("after", after); } - this->makeRequest("chat/chatters", urlQuery) - .onSuccess([successCallback](auto result) -> Outcome { + this->makeGet("chat/chatters", urlQuery) + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for getting chatters was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixChatters(response)); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { failureCallback(Error::Forwarded, message); @@ -1905,7 +1974,7 @@ void Helix::fetchChatters( default: { qCDebug(chatterinoTwitch) - << "Unhandled error data:" << result.status() + << "Unhandled error data:" << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } @@ -1966,24 +2035,29 @@ void Helix::fetchModerators( urlQuery.addQueryItem("after", after); } - this->makeRequest("moderation/moderators", urlQuery) - .onSuccess([successCallback](auto result) -> Outcome { + this->makeGet("moderation/moderators", urlQuery) + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for getting moderators was " - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); successCallback(HelixModerators(response)); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { failureCallback(Error::Forwarded, message); @@ -2014,7 +2088,7 @@ void Helix::fetchModerators( default: { qCDebug(chatterinoTwitch) - << "Unhandled error data:" << result.status() + << "Unhandled error data:" << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } @@ -2027,7 +2101,7 @@ void Helix::fetchModerators( // Ban/timeout a user // https://dev.twitch.tv/docs/api/reference#ban-user void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, - boost::optional duration, QString reason, + std::optional duration, QString reason, ResultCallback<> successCallback, FailureCallback failureCallback) { @@ -2051,26 +2125,29 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, payload["data"] = data; } - this->makeRequest("moderation/bans", urlQuery) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback](auto result) -> Outcome { + this->makePost("moderation/bans", urlQuery) + .json(payload) + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) << "Success result for banning a user was" - << result.status() << "but we expected it to be 200"; + << result.formatError() << "but we expected it to be 200"; } // we don't care about the response successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.startsWith("The user specified in the user_id " @@ -2124,8 +2201,8 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, default: { qCDebug(chatterinoTwitch) - << "Unhandled error banning user:" << result.status() - << result.getData() << obj; + << "Unhandled error banning user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2150,26 +2227,29 @@ void Helix::sendWhisper( QJsonObject payload; payload["message"] = message; - this->makeRequest("whispers", urlQuery) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback](auto result) -> Outcome { + this->makePost("whispers", urlQuery) + .json(payload) + .onSuccess([successCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) << "Success result for sending a whisper was" - << result.status() << "but we expected it to be 204"; + << result.formatError() << "but we expected it to be 204"; } // we don't care about the response successCallback(); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.startsWith("A user cannot whisper themself", @@ -2230,8 +2310,8 @@ void Helix::sendWhisper( default: { qCDebug(chatterinoTwitch) - << "Unhandled error banning user:" << result.status() - << result.getData() << obj; + << "Unhandled error banning user:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2295,15 +2375,14 @@ void Helix::getChannelVIPs( // as the mod list can go over 100 (I assume, I see no limit) urlQuery.addQueryItem("first", "100"); - this->makeRequest("channels/vips", urlQuery) - .type(NetworkRequestType::Get) + this->makeGet("channels/vips", urlQuery) .header("Content-Type", "application/json") - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) - << "Success result for getting VIPs was" << result.status() - << "but we expected it to be 200"; + << "Success result for getting VIPs was" + << result.formatError() << "but we expected it to be 200"; } auto response = result.parseJson(); @@ -2315,13 +2394,18 @@ void Helix::getChannelVIPs( } successCallback(channelVips); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { failureCallback(Error::Forwarded, message); @@ -2361,8 +2445,8 @@ void Helix::getChannelVIPs( default: { qCDebug(chatterinoTwitch) - << "Unhandled error listing VIPs:" << result.status() - << result.getData() << obj; + << "Unhandled error listing VIPs:" + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2383,28 +2467,31 @@ void Helix::startCommercial( payload.insert("broadcaster_id", QJsonValue(broadcasterID)); payload.insert("length", QJsonValue(length)); - this->makeRequest("channels/commercial", QUrlQuery()) - .type(NetworkRequestType::Post) - .header("Content-Type", "application/json") - .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + this->makePost("channels/commercial", QUrlQuery()) + .json(payload) + .onSuccess([successCallback, failureCallback](auto result) { auto obj = result.parseJson(); if (obj.isEmpty()) { failureCallback( Error::Unknown, "Twitch didn't send any information about this error."); - return Failure; + return; } successCallback(HelixStartCommercialResponse(obj)); - return Success; }) - .onError([failureCallback](auto result) { + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + auto obj = result.parseJson(); auto message = obj.value("message").toString(); - switch (result.status()) + switch (*result.status()) { case 400: { if (message.startsWith("Missing scope", @@ -2459,7 +2546,7 @@ void Helix::startCommercial( default: { qCDebug(chatterinoTwitch) << "Unhandled error starting commercial:" - << result.status() << result.getData() << obj; + << result.formatError() << result.getData() << obj; failureCallback(Error::Unknown, message); } break; @@ -2468,7 +2555,293 @@ void Helix::startCommercial( .execute(); } -NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) +// Twitch global badges +// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges +void Helix::getGlobalBadges( + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetGlobalBadgesError; + + this->makeGet("chat/badges/global", QUrlQuery()) + .onSuccess([successCallback](auto result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting global badges was " + << result.formatError() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixGlobalBadges(response)); + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (*result.status()) + { + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix global badges, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + +// Badges for the `broadcasterID` channel +// https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges +void Helix::getChannelBadges( + QString broadcasterID, ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetChannelBadgesError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + + this->makeGet("chat/badges", urlQuery) + .onSuccess([successCallback](auto result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting badges was " + << result.formatError() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixChannelBadges(response)); + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (*result.status()) + { + case 400: + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix channel badges, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + +// https://dev.twitch.tv/docs/api/reference/#update-shield-mode-status +void Helix::updateShieldMode( + QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixUpdateShieldModeError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + payload["is_active"] = isActive; + + this->makePut("moderation/shield_mode", urlQuery) + .json(payload) + .onSuccess([successCallback](auto result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for updating shield mode was " + << result.formatError() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + successCallback( + HelixShieldModeStatus(response["data"][0].toObject())); + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (*result.status()) + { + case 400: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + break; + } + + failureCallback(Error::Forwarded, message); + } + break; + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + case 403: { + if (message.startsWith( + "Requester does not have permissions", + Qt::CaseInsensitive)) + { + failureCallback(Error::MissingPermission, message); + break; + } + } + + default: { + qCWarning(chatterinoTwitch) + << "Helix shield mode, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + +// https://dev.twitch.tv/docs/api/reference/#send-a-shoutout +void Helix::sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixSendShoutoutError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); + urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + this->makePost("chat/shoutouts", urlQuery) + .header("Content-Type", "application/json") + .onSuccess([successCallback](NetworkResult result) { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending shoutout was " + << result.formatError() << "but we expected it to be 204"; + } + + successCallback(); + }) + .onError([failureCallback](const NetworkResult &result) -> void { + if (!result.status()) + { + failureCallback(Error::Unknown, result.formatError()); + return; + } + + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (*result.status()) + { + case 400: { + if (message.startsWith("The broadcaster may not give " + "themselves a Shoutout.", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserIsBroadcaster, message); + } + else if (message.startsWith( + "The broadcaster is not streaming live or " + "does not have one or more viewers.", + Qt::CaseInsensitive)) + { + failureCallback(Error::BroadcasterNotLive, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + case 500: { + if (message.isEmpty()) + { + failureCallback(Error::Unknown, + "Twitch internal server error"); + } + else + { + failureCallback(Error::Unknown, message); + } + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix send shoutout, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + } + }) + .execute(); +} + +NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery, + NetworkRequestType type) { assert(!url.startsWith("/")); @@ -2476,14 +2849,14 @@ NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) { qCDebug(chatterinoTwitch) << "Helix::makeRequest called without a client ID set BabyRage"; - // return boost::none; + // return std::nullopt; } if (this->oauthToken.isEmpty()) { qCDebug(chatterinoTwitch) << "Helix::makeRequest called without an oauth token set BabyRage"; - // return boost::none; + // return std::nullopt; } const QString baseUrl("https://api.twitch.tv/helix/"); @@ -2492,13 +2865,89 @@ NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) fullUrl.setQuery(urlQuery); - return NetworkRequest(fullUrl) + return NetworkRequest(fullUrl, type) .timeout(5 * 1000) .header("Accept", "application/json") .header("Client-ID", this->clientId) .header("Authorization", "Bearer " + this->oauthToken); } +NetworkRequest Helix::makeGet(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Get); +} + +NetworkRequest Helix::makeDelete(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Delete); +} + +NetworkRequest Helix::makePost(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Post); +} + +NetworkRequest Helix::makePut(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Put); +} + +NetworkRequest Helix::makePatch(const QString &url, const QUrlQuery &urlQuery) +{ + return this->makeRequest(url, urlQuery, NetworkRequestType::Patch); +} + +void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, + std::function onPage, + std::function onError, + CancellationToken &&cancellationToken) +{ + auto onSuccess = + std::make_shared>(nullptr); + // This is the actual callback passed to NetworkRequest. + // It wraps the shared-ptr. + auto onSuccessCb = [onSuccess](const auto &res) { + return (*onSuccess)(res); + }; + + *onSuccess = [this, onPage = std::move(onPage), onError, onSuccessCb, + url{url}, baseQuery{baseQuery}, + cancellationToken = + std::move(cancellationToken)](const NetworkResult &res) { + if (cancellationToken.isCancelled()) + { + return; + } + + const auto json = res.parseJson(); + if (!onPage(json)) + { + // The consumer doesn't want any more pages + return; + } + + auto cursor = json["pagination"_L1]["cursor"_L1].toString(); + if (cursor.isEmpty()) + { + return; + } + + auto query = baseQuery; + query.removeAllQueryItems(u"after"_s); + query.addQueryItem(u"after"_s, cursor); + + this->makeGet(url, query) + .onSuccess(onSuccessCb) + .onError(onError) + .execute(); + }; + + this->makeGet(url, baseQuery) + .onSuccess(std::move(onSuccessCb)) + .onError(std::move(onError)) + .execute(); +} + void Helix::update(QString clientId, QString oauthToken) { this->clientId = std::move(clientId); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 42242fc8e06..25668099497 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -1,11 +1,12 @@ #pragma once #include "common/Aliases.hpp" -#include "common/NetworkRequest.hpp" +#include "common/network/NetworkRequest.hpp" #include "providers/twitch/TwitchEmotes.hpp" +#include "util/Helpers.hpp" #include "util/QStringHash.hpp" -#include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include +#include #include #include @@ -23,6 +25,8 @@ using HelixFailureCallback = std::function; template using ResultCallback = std::function; +class CancellationToken; + struct HelixUser { QString id; QString login; @@ -42,44 +46,12 @@ struct HelixUser { } }; -struct HelixUsersFollowsRecord { - QString fromId; - QString fromName; - QString toId; - QString toName; - QString followedAt; // date time object - - HelixUsersFollowsRecord() - : fromId("") - , fromName("") - , toId("") - , toName("") - , followedAt("") - { - } - - explicit HelixUsersFollowsRecord(QJsonObject jsonObject) - : fromId(jsonObject.value("from_id").toString()) - , fromName(jsonObject.value("from_name").toString()) - , toId(jsonObject.value("to_id").toString()) - , toName(jsonObject.value("to_name").toString()) - , followedAt(jsonObject.value("followed_at").toString()) - { - } -}; - -struct HelixUsersFollowsResponse { +struct HelixGetChannelFollowersResponse { int total; - std::vector data; - explicit HelixUsersFollowsResponse(QJsonObject jsonObject) + + explicit HelixGetChannelFollowersResponse(const QJsonObject &jsonObject) : total(jsonObject.value("total").toInt()) { - const auto &jsonData = jsonObject.value("data").toArray(); - std::transform(jsonData.begin(), jsonData.end(), - std::back_inserter(this->data), - [](const QJsonValue &record) { - return HelixUsersFollowsRecord(record.toObject()); - }); } }; @@ -306,24 +278,23 @@ struct HelixChannelEmote { struct HelixChatSettings { const QString broadcasterId; const bool emoteMode; - // boost::none if disabled - const boost::optional followerModeDuration; // time in minutes - const boost::optional - nonModeratorChatDelayDuration; // time in seconds - const boost::optional slowModeWaitTime; // time in seconds + // std::nullopt if disabled + const std::optional followerModeDuration; // time in minutes + const std::optional nonModeratorChatDelayDuration; // time in seconds + const std::optional slowModeWaitTime; // time in seconds const bool subscriberMode; const bool uniqueChatMode; explicit HelixChatSettings(QJsonObject jsonObject) : broadcasterId(jsonObject.value("broadcaster_id").toString()) , emoteMode(jsonObject.value("emote_mode").toBool()) - , followerModeDuration(boost::make_optional( + , followerModeDuration(makeConditionedOptional( jsonObject.value("follower_mode").toBool(), jsonObject.value("follower_mode_duration").toInt())) - , nonModeratorChatDelayDuration(boost::make_optional( + , nonModeratorChatDelayDuration(makeConditionedOptional( jsonObject.value("non_moderator_chat_delay").toBool(), jsonObject.value("non_moderator_chat_delay_duration").toInt())) - , slowModeWaitTime(boost::make_optional( + , slowModeWaitTime(makeConditionedOptional( jsonObject.value("slow_mode").toBool(), jsonObject.value("slow_mode_wait_time").toInt())) , subscriberMode(jsonObject.value("subscriber_mode").toBool()) @@ -352,7 +323,7 @@ struct HelixVip { struct HelixChatters { std::unordered_set chatters; - int total; + int total{}; QString cursor; HelixChatters() = default; @@ -384,6 +355,55 @@ struct HelixModerators { } }; +struct HelixBadgeVersion { + QString id; + Url imageURL1x; + Url imageURL2x; + Url imageURL4x; + QString title; + Url clickURL; + + explicit HelixBadgeVersion(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , imageURL1x(Url{jsonObject.value("image_url_1x").toString()}) + , imageURL2x(Url{jsonObject.value("image_url_2x").toString()}) + , imageURL4x(Url{jsonObject.value("image_url_4x").toString()}) + , title(jsonObject.value("title").toString()) + , clickURL(Url{jsonObject.value("click_url").toString()}) + { + } +}; + +struct HelixBadgeSet { + QString setID; + std::vector versions; + + explicit HelixBadgeSet(const QJsonObject &json) + : setID(json.value("set_id").toString()) + { + const auto jsonVersions = json.value("versions").toArray(); + for (const auto &version : jsonVersions) + { + versions.emplace_back(version.toObject()); + } + } +}; + +struct HelixGlobalBadges { + std::vector badgeSets; + + explicit HelixGlobalBadges(const QJsonObject &jsonObject) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &set : data) + { + this->badgeSets.emplace_back(set.toObject()); + } + } +}; + +using HelixChannelBadges = HelixGlobalBadges; + enum class HelixAnnouncementColor { Blue, Green, @@ -587,6 +607,18 @@ enum class HelixListVIPsError { // /vips Forwarded, }; // /vips +enum class HelixSendShoutoutError { + Unknown, + // 400 + UserIsBroadcaster, + BroadcasterNotLive, + // 401 + UserNotAuthorized, + UserMissingScope, + + Ratelimited, +}; + struct HelixStartCommercialResponse { // Length of the triggered commercial int length; @@ -604,6 +636,39 @@ struct HelixStartCommercialResponse { } }; +struct HelixShieldModeStatus { + /// A Boolean value that determines whether Shield Mode is active. Is `true` if Shield Mode is active; otherwise, `false`. + bool isActive; + /// An ID that identifies the moderator that last activated Shield Mode. + QString moderatorID; + /// The moderator's login name. + QString moderatorLogin; + /// The moderator's display name. + QString moderatorName; + /// The UTC timestamp of when Shield Mode was last activated. + QDateTime lastActivatedAt; + + explicit HelixShieldModeStatus(const QJsonObject &json) + : isActive(json["is_active"].toBool()) + , moderatorID(json["moderator_id"].toString()) + , moderatorLogin(json["moderator_login"].toString()) + , moderatorName(json["moderator_name"].toString()) + , lastActivatedAt(QDateTime::fromString( + json["last_activated_at"].toString(), Qt::ISODate)) + { + this->lastActivatedAt.setTimeSpec(Qt::UTC); + } +}; + +enum class HelixUpdateShieldModeError { + Unknown, + UserMissingScope, + MissingPermission, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixStartCommercialError { Unknown, TokenMustMatchBroadcaster, @@ -616,6 +681,31 @@ enum class HelixStartCommercialError { Forwarded, }; +enum class HelixGetGlobalBadgesError { + Unknown, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + +struct HelixError { + /// Text version of the HTTP error that happened (e.g. Bad Request) + QString error; + /// Number version of the HTTP error that happened (e.g. 400) + int status; + /// The error message string + QString message; + + explicit HelixError(const QJsonObject &json) + : error(json["error"].toString()) + , status(json["status"].toInt()) + , message(json["message"].toString()) + { + } +}; + +using HelixGetChannelBadgesError = HelixGetGlobalBadgesError; + class IHelix { public: @@ -634,16 +724,11 @@ class IHelix ResultCallback successCallback, HelixFailureCallback failureCallback) = 0; - // https://dev.twitch.tv/docs/api/reference#get-users-follows - virtual void fetchUsersFollows( - QString fromId, QString toId, - ResultCallback successCallback, - HelixFailureCallback failureCallback) = 0; - - virtual void getUserFollowers( - QString userId, - ResultCallback successCallback, - HelixFailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference/#get-channel-followers + virtual void getChannelFollowers( + QString broadcasterID, + ResultCallback successCallback, + std::function failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#get-streams virtual void fetchStreams( @@ -684,6 +769,12 @@ class IHelix std::function failureCallback, std::function finallyCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-channel-information + virtual void fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-channel-information virtual void getChannel(QString broadcasterId, ResultCallback successCallback, @@ -697,16 +788,17 @@ class IHelix // https://dev.twitch.tv/docs/api/reference#get-user-block-list virtual void loadBlocks( - QString userId, ResultCallback> successCallback, - HelixFailureCallback failureCallback) = 0; + QString userId, ResultCallback> pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) = 0; // https://dev.twitch.tv/docs/api/reference#block-user - virtual void blockUser(QString targetUserId, + virtual void blockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#unblock-user - virtual void unblockUser(QString targetUserId, + virtual void unblockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) = 0; @@ -816,7 +908,7 @@ class IHelix // https://dev.twitch.tv/docs/api/reference#update-chat-settings virtual void updateFollowerMode( QString broadcasterID, QString moderatorID, - boost::optional followerModeDuration, + std::optional followerModeDuration, ResultCallback successCallback, FailureCallback failureCallback) = 0; @@ -825,7 +917,7 @@ class IHelix // https://dev.twitch.tv/docs/api/reference#update-chat-settings virtual void updateNonModeratorChatDelay( QString broadcasterID, QString moderatorID, - boost::optional nonModeratorChatDelayDuration, + std::optional nonModeratorChatDelayDuration, ResultCallback successCallback, FailureCallback failureCallback) = 0; @@ -834,7 +926,7 @@ class IHelix // https://dev.twitch.tv/docs/api/reference#update-chat-settings virtual void updateSlowMode( QString broadcasterID, QString moderatorID, - boost::optional slowModeWaitTime, + std::optional slowModeWaitTime, ResultCallback successCallback, FailureCallback failureCallback) = 0; @@ -859,7 +951,7 @@ class IHelix // https://dev.twitch.tv/docs/api/reference#ban-user virtual void banUser( QString broadcasterID, QString moderatorID, QString userID, - boost::optional duration, QString reason, + std::optional duration, QString reason, ResultCallback<> successCallback, FailureCallback failureCallback) = 0; @@ -899,6 +991,34 @@ class IHelix FailureCallback failureCallback) = 0; + // Get global Twitch badges + // https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + virtual void getGlobalBadges( + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Get badges for the `broadcasterID` channel + // https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + virtual void getChannelBadges( + QString broadcasterID, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // https://dev.twitch.tv/docs/api/reference/#update-shield-mode-status + virtual void updateShieldMode( + QString broadcasterID, QString moderatorID, bool isActive, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + virtual void sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -923,16 +1043,11 @@ class Helix final : public IHelix void getUserById(QString userId, ResultCallback successCallback, HelixFailureCallback failureCallback) final; - // https://dev.twitch.tv/docs/api/reference#get-users-follows - void fetchUsersFollows( - QString fromId, QString toId, - ResultCallback successCallback, - HelixFailureCallback failureCallback) final; - - void getUserFollowers( - QString userId, - ResultCallback successCallback, - HelixFailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference/#get-channel-followers + void getChannelFollowers( + QString broadcasterID, + ResultCallback successCallback, + std::function failureCallback) final; // https://dev.twitch.tv/docs/api/reference#get-streams void fetchStreams(QStringList userIds, QStringList userLogins, @@ -969,6 +1084,12 @@ class Helix final : public IHelix std::function failureCallback, std::function finallyCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-channel-information + void fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-channel-information void getChannel(QString broadcasterId, ResultCallback successCallback, @@ -982,15 +1103,17 @@ class Helix final : public IHelix // https://dev.twitch.tv/docs/api/reference#get-user-block-list void loadBlocks(QString userId, - ResultCallback> successCallback, - HelixFailureCallback failureCallback) final; + ResultCallback> pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) final; // https://dev.twitch.tv/docs/api/reference#block-user - void blockUser(QString targetUserId, std::function successCallback, + void blockUser(QString targetUserId, const QObject *caller, + std::function successCallback, HelixFailureCallback failureCallback) final; // https://dev.twitch.tv/docs/api/reference#unblock-user - void unblockUser(QString targetUserId, + void unblockUser(QString targetUserId, const QObject *caller, std::function successCallback, HelixFailureCallback failureCallback) final; @@ -1101,7 +1224,7 @@ class Helix final : public IHelix // https://dev.twitch.tv/docs/api/reference#update-chat-settings void updateFollowerMode( QString broadcasterID, QString moderatorID, - boost::optional followerModeDuration, + std::optional followerModeDuration, ResultCallback successCallback, FailureCallback failureCallback) final; @@ -1110,7 +1233,7 @@ class Helix final : public IHelix // https://dev.twitch.tv/docs/api/reference#update-chat-settings void updateNonModeratorChatDelay( QString broadcasterID, QString moderatorID, - boost::optional nonModeratorChatDelayDuration, + std::optional nonModeratorChatDelayDuration, ResultCallback successCallback, FailureCallback failureCallback) final; @@ -1118,7 +1241,7 @@ class Helix final : public IHelix // Updates the slow mode using // https://dev.twitch.tv/docs/api/reference#update-chat-settings void updateSlowMode(QString broadcasterID, QString moderatorID, - boost::optional slowModeWaitTime, + std::optional slowModeWaitTime, ResultCallback successCallback, FailureCallback failureCallback) final; @@ -1143,7 +1266,7 @@ class Helix final : public IHelix // https://dev.twitch.tv/docs/api/reference#ban-user void banUser( QString broadcasterID, QString moderatorID, QString userID, - boost::optional duration, QString reason, + std::optional duration, QString reason, ResultCallback<> successCallback, FailureCallback failureCallback) final; @@ -1184,6 +1307,32 @@ class Helix final : public IHelix FailureCallback failureCallback) final; + // Get global Twitch badges + // https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + void getGlobalBadges(ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Get badges for the `broadcasterID` channel + // https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + void getChannelBadges(QString broadcasterID, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // https://dev.twitch.tv/docs/api/reference/#update-shield-mode-status + void updateShieldMode(QString broadcasterID, QString moderatorID, + bool isActive, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + void sendShoutout( + QString fromBroadcasterID, QString toBroadcasterID, QString moderatorID, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); @@ -1227,7 +1376,20 @@ class Helix final : public IHelix FailureCallback failureCallback); private: - NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); + NetworkRequest makeRequest(const QString &url, const QUrlQuery &urlQuery, + NetworkRequestType type); + NetworkRequest makeGet(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makeDelete(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makePost(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makePut(const QString &url, const QUrlQuery &urlQuery); + NetworkRequest makePatch(const QString &url, const QUrlQuery &urlQuery); + + /// Paginate the `url` endpoint and use `baseQuery` as the starting point for pagination. + /// @param onPage returns true while a new page is expected. Once false is returned, pagination will stop. + void paginate(const QString &url, const QUrlQuery &baseQuery, + std::function onPage, + std::function onError, + CancellationToken &&token); QString clientId; QString oauthToken; diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index b889761840c..23509c94b8d 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -12,7 +12,7 @@ If you're adding support for a new endpoint, these are the things you should kno 1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible. 1. Override the virtual function in the `Helix` class. -1. Mock the function in the `MockHelix` class in the `tests/src/HighlightController.cpp` file. +1. Mock the function in the `mock::Helix` class in the `mocks/include/mocks/Helix.hpp` file. 1. (Optional) Make a new error enum for the failure callback. For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`. @@ -43,7 +43,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-streams Used in: -- `TwitchChannel` to get live status, game, title, and viewer count of a channel +- `LiveController` to get live status, game, title, and viewer count of a channel - `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for ### Create Clip @@ -61,7 +61,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-channel-information Used in: -- `TwitchChannel` to refresh stream title +- `LiveController` to refresh stream title & display name ### Update Channel @@ -136,6 +136,22 @@ Used in: - `providers/twitch/TwitchChannel.cpp` to resolve a chats available cheer emotes. This helps us parse incoming messages like `pajaCheer1000` +### Get Global Badges + +URL: https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + +Used in: + +- `providers/twitch/TwitchBadges.cpp` to load global badges + +### Get Channel Badges + +URL: https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + +Used in: + +- `providers/twitch/TwitchChannel.cpp` to load channel badges + ### Get Emote Sets URL: https://dev.twitch.tv/docs/api/reference#get-emote-sets @@ -148,13 +164,70 @@ URL: https://dev.twitch.tv/docs/api/reference#get-channel-emotes Not used anywhere at the moment. -## TMI +### Get Chatters + +URL: https://dev.twitch.tv/docs/api/reference/#get-chatters -The TMI api is undocumented. +Used for the chatter list for moderators/broadcasters. -### Get Chatters +### Send Shoutout + +URL: https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + +Used in: + +- `controllers/commands/CommandController.cpp` to send Twitch native shoutout using "/shoutout " + +## PubSub + +### Whispers + +We listen to the `whispers.` PubSub topic to receive information about incoming whispers to the user + +No EventSub alternative available. + +### Chat Moderator Actions + +We listen to the `chat_moderator_actions..` PubSub topic to receive information about incoming moderator events in a channel. + +We listen to this topic in every channel the user is a moderator. + +No complete EventSub alternative available yet. Some functionality can be pieced together but it would not be zero cost, causing the `max_total_cost` of 10 to cause issues. + +- For showing bans & timeouts: `channel.ban`, but does not work with moderator token??? +- For showing unbans & untimeouts: `channel.unban`, but does not work with moderator token??? +- Clear/delete message: not in eventsub, and IRC doesn't tell us which mod performed the action +- Roomstate (slow(off), followers(off), r9k(off), emoteonly(off), subscribers(off)) => not in eventsub, and IRC doesn't tell us which mod performed the action +- VIP added => not in eventsub, but not critical +- VIP removed => not in eventsub, but not critical +- Moderator added => channel.moderator.add eventsub, but doesn't work with moderator token +- Moderator removed => channel.moderator.remove eventsub, but doesn't work with moderator token +- Raid started => channel.raid eventsub, but cost=1 for moderator token +- Unraid => not in eventsub +- Add permitted term => not in eventsub +- Delete permitted term => not in eventsub +- Add blocked term => not in eventsub +- Delete blocked term => not in eventsub +- Modified automod properties => not in eventsub +- Approve unban request => cannot read moderator message in eventsub +- Deny unban request => not in eventsub + +### AutoMod Queue + +We listen to the `automod-queue..` PubSub topic to receive information about incoming automod events in a channel. + +We listen to this topic in every channel the user is a moderator. + +No EventSub alternative available yet. + +### Channel Point Rewards + +We listen to the `community-points-channel-v1.` PubSub topic to receive information about incoming channel points redemptions in a channel. + +The EventSub alternative requires broadcaster auth, which is not a feasible alternative. + +### Low Trust Users -**Undocumented** +We want to listen to the `low-trust-users` PubSub topic to receive information about messages from users who are marked as low-trust. -- We use this in `widgets/splits/Split.cpp showViewerList` -- We use this in `providers/twitch/TwitchChannel.cpp refreshChatters` +There is no EventSub alternative available yet. diff --git a/src/providers/twitch/pubsubmessages/AutoMod.hpp b/src/providers/twitch/pubsubmessages/AutoMod.hpp index d0e52082585..9f40d39da67 100644 --- a/src/providers/twitch/pubsubmessages/AutoMod.hpp +++ b/src/providers/twitch/pubsubmessages/AutoMod.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -31,7 +31,8 @@ struct PubSubAutoModQueueMessage { QString senderUserDisplayName; QColor senderUserChatColor; - PubSubAutoModQueueMessage(const QJsonObject &root); + PubSubAutoModQueueMessage() = default; + explicit PubSubAutoModQueueMessage(const QJsonObject &root); }; } // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Base.cpp b/src/providers/twitch/pubsubmessages/Base.cpp index fd921e7656e..7bc4a2f5f79 100644 --- a/src/providers/twitch/pubsubmessages/Base.cpp +++ b/src/providers/twitch/pubsubmessages/Base.cpp @@ -16,4 +16,16 @@ PubSubMessage::PubSubMessage(QJsonObject _object) } } +std::optional parsePubSubBaseMessage(const QString &blob) +{ + QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8())); + + if (jsonDoc.isNull()) + { + return std::nullopt; + } + + return PubSubMessage(jsonDoc.object()); +} + } // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/Base.hpp b/src/providers/twitch/pubsubmessages/Base.hpp index c6d817718f4..a1da168ce34 100644 --- a/src/providers/twitch/pubsubmessages/Base.hpp +++ b/src/providers/twitch/pubsubmessages/Base.hpp @@ -1,11 +1,12 @@ #pragma once -#include -#include +#include #include #include #include +#include + namespace chatterino { struct PubSubMessage { @@ -27,16 +28,16 @@ struct PubSubMessage { PubSubMessage(QJsonObject _object); template - boost::optional toInner(); + std::optional toInner(); }; template -boost::optional PubSubMessage::toInner() +std::optional PubSubMessage::toInner() { auto dataValue = this->object.value("data"); if (!dataValue.isObject()) { - return boost::none; + return std::nullopt; } auto data = dataValue.toObject(); @@ -44,18 +45,7 @@ boost::optional PubSubMessage::toInner() return InnerClass{this->nonce, data}; } -static boost::optional parsePubSubBaseMessage( - const QString &blob) -{ - QJsonDocument jsonDoc(QJsonDocument::fromJson(blob.toUtf8())); - - if (jsonDoc.isNull()) - { - return boost::none; - } - - return PubSubMessage(jsonDoc.object()); -} +std::optional parsePubSubBaseMessage(const QString &blob); } // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/ChannelPoints.hpp b/src/providers/twitch/pubsubmessages/ChannelPoints.hpp index c5a3ffe88d5..be8d1bd68af 100644 --- a/src/providers/twitch/pubsubmessages/ChannelPoints.hpp +++ b/src/providers/twitch/pubsubmessages/ChannelPoints.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include diff --git a/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp b/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp index 5f29673e3d3..e04019cb7cf 100644 --- a/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp +++ b/src/providers/twitch/pubsubmessages/ChatModeratorAction.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include diff --git a/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp b/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp new file mode 100644 index 00000000000..2a7fd6f50fc --- /dev/null +++ b/src/providers/twitch/pubsubmessages/LowTrustUsers.cpp @@ -0,0 +1,105 @@ +#include "providers/twitch/pubsubmessages/LowTrustUsers.hpp" + +#include +#include + +namespace chatterino { + +PubSubLowTrustUsersMessage::PubSubLowTrustUsersMessage(const QJsonObject &root) + : typeString(root.value("type").toString()) +{ + if (const auto oType = + magic_enum::enum_cast(this->typeString.toStdString()); + oType.has_value()) + { + this->type = oType.value(); + } + + auto data = root.value("data").toObject(); + + if (this->type == Type::UserMessage) + { + this->msgID = data.value("message_id").toString(); + this->sentAt = data.value("sent_at").toString(); + const auto content = data.value("message_content").toObject(); + this->text = content.value("text").toString(); + for (const auto &part : content.value("fragments").toArray()) + { + this->fragments.emplace_back(part.toObject()); + } + + // the rest of the data is within a nested object + data = data.value("low_trust_user").toObject(); + + const auto sender = data.value("sender").toObject(); + this->suspiciousUserID = sender.value("user_id").toString(); + this->suspiciousUserLogin = sender.value("login").toString(); + this->suspiciousUserDisplayName = + sender.value("display_name").toString(); + this->suspiciousUserColor = + QColor(sender.value("chat_color").toString()); + + for (const auto &badge : sender.value("badges").toArray()) + { + const auto badgeObj = badge.toObject(); + const auto badgeID = badgeObj.value("id").toString(); + const auto badgeVersion = badgeObj.value("version").toString(); + this->senderBadges.emplace_back(Badge{badgeID, badgeVersion}); + } + + const auto sharedValue = data.value("shared_ban_channel_ids"); + if (!sharedValue.isNull()) + { + for (const auto &id : sharedValue.toArray()) + { + this->sharedBanChannelIDs.emplace_back(id.toString()); + } + } + } + else + { + this->suspiciousUserID = data.value("target_user_id").toString(); + this->suspiciousUserLogin = data.value("target_user").toString(); + this->suspiciousUserDisplayName = this->suspiciousUserLogin; + } + + this->channelID = data.value("channel_id").toString(); + this->updatedAtString = data.value("updated_at").toString(); + this->updatedAt = QDateTime::fromString(this->updatedAtString, Qt::ISODate) + .toLocalTime() + .toString("MMM d yyyy, h:mm ap"); + + const auto updatedBy = data.value("updated_by").toObject(); + this->updatedByUserID = updatedBy.value("id").toString(); + this->updatedByUserLogin = updatedBy.value("login").toString(); + this->updatedByUserDisplayName = updatedBy.value("display_name").toString(); + + this->treatmentString = data.value("treatment").toString(); + if (const auto oTreatment = magic_enum::enum_cast( + this->treatmentString.toStdString()); + oTreatment.has_value()) + { + this->treatment = oTreatment.value(); + } + + this->evasionEvaluationString = + data.value("ban_evasion_evaluation").toString(); + if (const auto oEvaluation = magic_enum::enum_cast( + this->evasionEvaluationString.toStdString()); + oEvaluation.has_value()) + { + this->evasionEvaluation = oEvaluation.value(); + } + + for (const auto &rType : data.value("types").toArray()) + { + if (const auto oRestriction = magic_enum::enum_cast( + rType.toString().toStdString()); + oRestriction.has_value()) + { + this->restrictionTypes.set(oRestriction.value()); + } + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp b/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp new file mode 100644 index 00000000000..e266628136b --- /dev/null +++ b/src/providers/twitch/pubsubmessages/LowTrustUsers.hpp @@ -0,0 +1,266 @@ +#pragma once + +#include "providers/twitch/TwitchBadge.hpp" + +#include +#include +#include +#include +#include + +namespace chatterino { + +struct PubSubLowTrustUsersMessage { + struct Fragment { + QString text; + QString emoteID; + + explicit Fragment(const QJsonObject &obj) + : text(obj.value("text").toString()) + , emoteID(obj.value("emoticon") + .toObject() + .value("emoticonID") + .toString()) + { + } + }; + + /** + * The type of low trust message update + */ + enum class Type { + /** + * An incoming message from someone marked as low trust + */ + UserMessage, + + /** + * An incoming update about a user's low trust status + */ + TreatmentUpdate, + + INVALID, + }; + + /** + * The treatment set for the suspicious user + */ + enum class Treatment { + NoTreatment, + ActiveMonitoring, + Restricted, + + INVALID, + }; + + /** + * A ban evasion likelihood value (if any) that has been applied to the user + * automatically by Twitch + */ + enum class EvasionEvaluation { + UnknownEvader, + UnlikelyEvader, + LikelyEvader, + PossibleEvader, + + INVALID, + }; + + /** + * Restriction type (if any) that apply to the suspicious user + */ + enum class RestrictionType : uint8_t { + UnknownType = 1 << 0, + ManuallyAdded = 1 << 1, + DetectedBanEvader = 1 << 2, + BannedInSharedChannel = 1 << 3, + + INVALID = 1 << 4, + }; + + Type type = Type::INVALID; + + Treatment treatment = Treatment::INVALID; + + EvasionEvaluation evasionEvaluation = EvasionEvaluation::INVALID; + + FlagsEnum restrictionTypes; + + QString channelID; + + QString suspiciousUserID; + QString suspiciousUserLogin; + QString suspiciousUserDisplayName; + + QString updatedByUserID; + QString updatedByUserLogin; + QString updatedByUserDisplayName; + + /** + * Formatted timestamp of when the treatment was last updated for the suspicious user + */ + QString updatedAt; + + /** + * Plain text of the message sent. + * Only used for the UserMessage type. + */ + QString text; + + /** + * Pre-parsed components of the message. + * Only used for the UserMessage type. + */ + std::vector fragments; + + /** + * ID of the message. + * Only used for the UserMessage type. + */ + QString msgID; + + /** + * RFC3339 timestamp of when the message was sent. + * Only used for the UserMessage type. + */ + QString sentAt; + + /** + * Color of the user who sent the message. + * Only used for the UserMessage type. + */ + QColor suspiciousUserColor; + + /** + * A list of channel IDs where the suspicious user is also banned. + * Only used for the UserMessage type. + */ + std::vector sharedBanChannelIDs; + + /** + * A list of badges of the user who sent the message. + * Only used for the UserMessage type. + */ + std::vector senderBadges; + + /** + * Stores the string value of `type` + * Useful in case type shows up as invalid after being parsed + */ + QString typeString; + + /** + * Stores the string value of `treatment` + * Useful in case treatment shows up as invalid after being parsed + */ + QString treatmentString; + + /** + * Stores the string value of `ban_evasion_evaluation` + * Useful in case evasionEvaluation shows up as invalid after being parsed + */ + QString evasionEvaluationString; + + /** + * Stores the string value of `updated_at` + * Useful in case formattedUpdatedAt doesn't parse correctly + */ + QString updatedAtString; + + PubSubLowTrustUsersMessage() = default; + explicit PubSubLowTrustUsersMessage(const QJsonObject &root); +}; + +} // namespace chatterino + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::Type>( + chatterino::PubSubLowTrustUsersMessage::Type value) noexcept +{ + switch (value) + { + case chatterino::PubSubLowTrustUsersMessage::Type::UserMessage: + return "low_trust_user_new_message"; + + case chatterino::PubSubLowTrustUsersMessage::Type::TreatmentUpdate: + return "low_trust_user_treatment_update"; + + default: + return default_tag; + } +} + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::Treatment>( + chatterino::PubSubLowTrustUsersMessage::Treatment value) noexcept +{ + using Treatment = chatterino::PubSubLowTrustUsersMessage::Treatment; + switch (value) + { + case Treatment::NoTreatment: + return "NO_TREATMENT"; + + case Treatment::ActiveMonitoring: + return "ACTIVE_MONITORING"; + + case Treatment::Restricted: + return "RESTRICTED"; + + default: + return default_tag; + } +} + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::EvasionEvaluation>( + chatterino::PubSubLowTrustUsersMessage::EvasionEvaluation value) noexcept +{ + using EvasionEvaluation = + chatterino::PubSubLowTrustUsersMessage::EvasionEvaluation; + switch (value) + { + case EvasionEvaluation::UnknownEvader: + return "UNKNOWN_EVADER"; + + case EvasionEvaluation::UnlikelyEvader: + return "UNLIKELY_EVADER"; + + case EvasionEvaluation::LikelyEvader: + return "LIKELY_EVADER"; + + case EvasionEvaluation::PossibleEvader: + return "POSSIBLE_EVADER"; + + default: + return default_tag; + } +} + +template <> +constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< + chatterino::PubSubLowTrustUsersMessage::RestrictionType>( + chatterino::PubSubLowTrustUsersMessage::RestrictionType value) noexcept +{ + using RestrictionType = + chatterino::PubSubLowTrustUsersMessage::RestrictionType; + switch (value) + { + case RestrictionType::UnknownType: + return "UNKNOWN_TYPE"; + + case RestrictionType::ManuallyAdded: + return "MANUALLY_ADDED"; + + case RestrictionType::DetectedBanEvader: + return "DETECTED_BAN_EVADER"; + + case RestrictionType::BannedInSharedChannel: + return "BANNED_IN_SHARED_CHANNEL"; + + default: + return default_tag; + } +} diff --git a/src/providers/twitch/pubsubmessages/Message.hpp b/src/providers/twitch/pubsubmessages/Message.hpp index 2f061d0b9c8..e854929f936 100644 --- a/src/providers/twitch/pubsubmessages/Message.hpp +++ b/src/providers/twitch/pubsubmessages/Message.hpp @@ -2,11 +2,12 @@ #include "common/QLogging.hpp" -#include #include #include #include +#include + namespace chatterino { struct PubSubMessageMessage { @@ -42,15 +43,15 @@ struct PubSubMessageMessage { } template - boost::optional toInner() const; + std::optional toInner() const; }; template -boost::optional PubSubMessageMessage::toInner() const +std::optional PubSubMessageMessage::toInner() const { if (this->messageObject.empty()) { - return boost::none; + return std::nullopt; } return InnerClass{this->messageObject}; diff --git a/src/providers/twitch/pubsubmessages/Whisper.hpp b/src/providers/twitch/pubsubmessages/Whisper.hpp index 96179f07f76..979cb6a1ebf 100644 --- a/src/providers/twitch/pubsubmessages/Whisper.hpp +++ b/src/providers/twitch/pubsubmessages/Whisper.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -28,7 +28,8 @@ struct PubSubWhisperMessage { QString fromUserDisplayName; QColor fromUserColor; - PubSubWhisperMessage(const QJsonObject &root); + PubSubWhisperMessage() = default; + explicit PubSubWhisperMessage(const QJsonObject &root); }; } // namespace chatterino diff --git a/src/singletons/CrashHandler.cpp b/src/singletons/CrashHandler.cpp new file mode 100644 index 00000000000..84f19793c57 --- /dev/null +++ b/src/singletons/CrashHandler.cpp @@ -0,0 +1,234 @@ +#include "singletons/CrashHandler.hpp" + +#include "common/Args.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "singletons/Paths.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef CHATTERINO_WITH_CRASHPAD +# include + +# include +# include +#endif + +namespace { + +using namespace chatterino; +using namespace literals; + +/// The name of the crashpad handler executable. +/// This varies across platforms +#if defined(Q_OS_UNIX) +const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad-handler"); +#elif defined(Q_OS_WINDOWS) +const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad-handler.exe"); +#else +# error Unsupported platform +#endif + +/// Converts a QString into the platform string representation. +#if defined(Q_OS_UNIX) +std::string nativeString(const QString &s) +{ + return s.toStdString(); +} +#elif defined(Q_OS_WINDOWS) +std::wstring nativeString(const QString &s) +{ + return s.toStdWString(); +} +#else +# error Unsupported platform +#endif + +const QString RECOVERY_FILE = u"chatterino-recovery.json"_s; + +/// The recovery options are saved outside the settings +/// to be able to read them without loading the settings. +/// +/// The flags are saved in the `RECOVERY_FILE` as JSON. +std::optional readRecoverySettings(const Paths &paths) +{ + QFile file(QDir(paths.crashdumpDirectory).filePath(RECOVERY_FILE)); + if (!file.open(QFile::ReadOnly)) + { + return std::nullopt; + } + + QJsonParseError error{}; + auto doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError) + { + qCWarning(chatterinoCrashhandler) + << "Failed to parse recovery settings" << error.errorString(); + return std::nullopt; + } + + const auto obj = doc.object(); + auto shouldRecover = obj["shouldRecover"_L1]; + if (!shouldRecover.isBool()) + { + return std::nullopt; + } + + return shouldRecover.toBool(); +} + +bool canRestart(const Paths &paths, const Args &args) +{ +#ifdef NDEBUG + if (args.isFramelessEmbed || args.shouldRunBrowserExtensionHost) + { + return false; + } + + auto settings = readRecoverySettings(paths); + if (!settings) + { + return false; // default, no settings found + } + return *settings; +#else + (void)paths; + return false; +#endif +} + +/// This encodes the arguments into a single string. +/// +/// The command line arguments are joined by '+'. A plus is escaped by an +/// additional plus ('++' -> '+'). +/// +/// The decoding happens in crash-handler/src/CommandLine.cpp +std::string encodeArguments(const Args &appArgs) +{ + std::string args; + for (auto arg : appArgs.currentArguments()) + { + if (!args.empty()) + { + args.push_back('+'); + } + args += arg.replace(u'+', u"++"_s).toStdString(); + } + return args; +} + +} // namespace + +namespace chatterino { + +using namespace std::string_literals; + +CrashHandler::CrashHandler(const Paths &paths_) + : paths(paths_) +{ +} + +void CrashHandler::initialize(Settings & /*settings*/, const Paths &paths_) +{ + auto optSettings = readRecoverySettings(paths); + if (optSettings) + { + this->shouldRecover_ = *optSettings; + } + else + { + // By default, we don't restart after a crash. + this->saveShouldRecover(false); + } +} + +void CrashHandler::saveShouldRecover(bool value) +{ + this->shouldRecover_ = value; + + QFile file(QDir(this->paths.crashdumpDirectory).filePath(RECOVERY_FILE)); + if (!file.open(QFile::WriteOnly | QFile::Truncate)) + { + qCWarning(chatterinoCrashhandler) + << "Failed to open" << file.fileName(); + return; + } + file.write(QJsonDocument(QJsonObject{ + {"shouldRecover"_L1, value}, + }) + .toJson(QJsonDocument::Compact)); +} + +#ifdef CHATTERINO_WITH_CRASHPAD +std::unique_ptr installCrashHandler( + const Args &args, const Paths &paths) +{ + // Currently, the following directory layout is assumed: + // [applicationDirPath] + // ├─chatterino(.exe) + // ╰─[crashpad] + // ╰─crashpad-handler(.exe) + // TODO: The location of the binary might vary across platforms + auto crashpadBinDir = QDir(QApplication::applicationDirPath()); + + if (!crashpadBinDir.cd("crashpad")) + { + qCDebug(chatterinoCrashhandler) << "Cannot find crashpad directory"; + return nullptr; + } + if (!crashpadBinDir.exists(CRASHPAD_EXECUTABLE_NAME)) + { + qCDebug(chatterinoCrashhandler) + << "Cannot find crashpad handler executable"; + return nullptr; + } + + auto handlerPath = base::FilePath(nativeString( + crashpadBinDir.absoluteFilePath(CRASHPAD_EXECUTABLE_NAME))); + + // Argument passed in --database + // > Crash reports are written to this database, and if uploads are enabled, + // uploaded from this database to a crash report collection server. + auto databaseDir = base::FilePath(nativeString(paths.crashdumpDirectory)); + + auto client = std::make_unique(); + + std::map annotations{ + { + "canRestart"s, + canRestart(paths, args) ? "true"s : "false"s, + }, + { + "exePath"s, + QApplication::applicationFilePath().toStdString(), + }, + { + "startedAt"s, + QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toStdString(), + }, + { + "exeArguments"s, + encodeArguments(args), + }, + }; + + // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md + // for documentation on available options. + if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, annotations, + {}, true, false)) + { + qCDebug(chatterinoCrashhandler) << "Failed to start crashpad handler"; + return nullptr; + } + + qCDebug(chatterinoCrashhandler) << "Started crashpad handler"; + return client; +} +#endif + +} // namespace chatterino diff --git a/src/singletons/CrashHandler.hpp b/src/singletons/CrashHandler.hpp new file mode 100644 index 00000000000..9fbdf358c53 --- /dev/null +++ b/src/singletons/CrashHandler.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include + +#ifdef CHATTERINO_WITH_CRASHPAD +# include + +# include +#endif + +namespace chatterino { + +class Args; +class Paths; + +class CrashHandler : public Singleton +{ + const Paths &paths; + +public: + explicit CrashHandler(const Paths &paths_); + + bool shouldRecover() const + { + return this->shouldRecover_; + } + + /// Sets and saves whether Chatterino should restart on a crash + void saveShouldRecover(bool value); + + void initialize(Settings &settings, const Paths &paths) override; + +private: + bool shouldRecover_ = false; +}; + +#ifdef CHATTERINO_WITH_CRASHPAD +std::unique_ptr installCrashHandler( + const Args &args, const Paths &paths); +#endif + +} // namespace chatterino diff --git a/src/singletons/Emotes.cpp b/src/singletons/Emotes.cpp index 5b30faad3fc..5051dab69e2 100644 --- a/src/singletons/Emotes.cpp +++ b/src/singletons/Emotes.cpp @@ -6,7 +6,7 @@ Emotes::Emotes() { } -void Emotes::initialize(Settings &settings, Paths &paths) +void Emotes::initialize(Settings &settings, const Paths &paths) { this->emojis.load(); diff --git a/src/singletons/Emotes.hpp b/src/singletons/Emotes.hpp index 1a65a17d0c7..3ffeb6d7a57 100644 --- a/src/singletons/Emotes.hpp +++ b/src/singletons/Emotes.hpp @@ -16,6 +16,8 @@ class IEmotes virtual ~IEmotes() = default; virtual ITwitchEmotes *getTwitchEmotes() = 0; + virtual IEmojis *getEmojis() = 0; + virtual GIFTimer &getGIFTimer() = 0; }; class Emotes final : public IEmotes, public Singleton @@ -23,7 +25,7 @@ class Emotes final : public IEmotes, public Singleton public: Emotes(); - virtual void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; bool isIgnoredEmote(const QString &emote); @@ -32,6 +34,16 @@ class Emotes final : public IEmotes, public Singleton return &this->twitch; } + IEmojis *getEmojis() final + { + return &this->emojis; + } + + GIFTimer &getGIFTimer() final + { + return this->gifTimer; + } + TwitchEmotes twitch; Emojis emojis; diff --git a/src/singletons/Fonts.cpp b/src/singletons/Fonts.cpp index 08c00ca390f..b9f54451bba 100644 --- a/src/singletons/Fonts.cpp +++ b/src/singletons/Fonts.cpp @@ -25,10 +25,45 @@ namespace chatterino { namespace { int getBoldness() { -#ifdef CHATTERINO - return getSettings()->boldScale.getValue(); +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + // From qfont.cpp + // https://github.com/qt/qtbase/blob/589c6d066f84833a7c3dda1638037f4b2e91b7aa/src/gui/text/qfont.cpp#L143-L169 + static constexpr std::array, 9> legacyToOpenTypeMap{{ + {0, QFont::Thin}, + {12, QFont::ExtraLight}, + {25, QFont::Light}, + {50, QFont::Normal}, + {57, QFont::Medium}, + {63, QFont::DemiBold}, + {75, QFont::Bold}, + {81, QFont::ExtraBold}, + {87, QFont::Black}, + }}; + + const int target = getSettings()->boldScale.getValue(); + + int result = QFont::Medium; + int closestDist = INT_MAX; + + // Go through and find the closest mapped value + for (const auto [weightOld, weightNew] : legacyToOpenTypeMap) + { + const int dist = qAbs(weightOld - target); + if (dist < closestDist) + { + result = weightNew; + closestDist = dist; + } + else + { + // Break early since following values will be further away + break; + } + } + + return result; #else - return QFont::Bold; + return getSettings()->boldScale.getValue(); #endif } } // namespace @@ -44,7 +79,7 @@ Fonts::Fonts() this->fontsByType_.resize(size_t(FontStyle::EndType)); } -void Fonts::initialize(Settings &, Paths &) +void Fonts::initialize(Settings &, const Paths &) { this->chatFontFamily.connect( [this]() { @@ -76,7 +111,7 @@ void Fonts::initialize(Settings &, Paths &) assertInGuiThread(); // REMOVED - getApp()->windows->incGeneration(); + getIApp()->getWindows()->incGeneration(); for (auto &map : this->fontsByType_) { diff --git a/src/singletons/Fonts.hpp b/src/singletons/Fonts.hpp index a346350000d..1f0704d9970 100644 --- a/src/singletons/Fonts.hpp +++ b/src/singletons/Fonts.hpp @@ -3,7 +3,6 @@ #include "common/ChatterinoSetting.hpp" #include "common/Singleton.hpp" -#include #include #include #include @@ -44,7 +43,7 @@ class Fonts final : public Singleton public: Fonts(); - virtual void initialize(Settings &settings, Paths &paths) override; + void initialize(Settings &settings, const Paths &paths) override; // font data gets set in createFontData(...) diff --git a/src/util/NuulsUploader.cpp b/src/singletons/ImageUploader.cpp similarity index 56% rename from src/util/NuulsUploader.cpp rename to src/singletons/ImageUploader.cpp index 79f7b3794e7..d528c9929bc 100644 --- a/src/util/NuulsUploader.cpp +++ b/src/singletons/ImageUploader.cpp @@ -1,9 +1,11 @@ -#include "NuulsUploader.hpp" +#include "singletons/ImageUploader.hpp" +#include "Application.hpp" #include "common/Env.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" @@ -16,6 +18,7 @@ #include #include #include +#include #include #define UPLOAD_DELAY 2000 @@ -23,7 +26,7 @@ namespace { -boost::optional convertToPng(QImage image) +std::optional convertToPng(const QImage &image) { QByteArray imageData; QBuffer buf(&imageData); @@ -31,27 +34,24 @@ boost::optional convertToPng(QImage image) bool success = image.save(&buf, "png"); if (success) { - return boost::optional(imageData); - } - else - { - return boost::optional(boost::none); + return imageData; } + + return std::nullopt; } + } // namespace namespace chatterino { -// These variables are only used from the main thread. -static auto uploadMutex = QMutex(); -static std::queue uploadQueue; // logging information on successful uploads to a json file -void logToFile(const QString originalFilePath, QString imageLink, - QString deletionLink, ChannelPtr channel) +void ImageUploader::logToFile(const QString &originalFilePath, + const QString &imageLink, + const QString &deletionLink, ChannelPtr channel) { const QString logFileName = combinePath((getSettings()->logPath.getValue().isEmpty() - ? getPaths()->messageLogDirectory + ? getIApp()->getPaths().messageLogDirectory : getSettings()->logPath), "ImageUploader.json"); @@ -120,12 +120,14 @@ QString getLinkFromResponse(NetworkResult response, QString pattern) return pattern; } -void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, - ResizingTextEdit &textEdit) +void ImageUploader::save() +{ +} + +void ImageUploader::sendImageUploadRequest(RawImageData imageData, + ChannelPtr channel, + QPointer textEdit) { - const static char *const boundary = "thisistheboudaryasd"; - const static QString contentType = - QString("multipart/form-data; boundary=%1").arg(boundary); QUrl url(getSettings()->imageUploaderUrl.getValue().isEmpty() ? getSettings()->imageUploaderUrl.getDefaultValue() : getSettings()->imageUploaderUrl); @@ -148,100 +150,108 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, QString("form-data; name=\"%1\"; filename=\"control_v.%2\"") .arg(formField) .arg(imageData.format)); - payload->setBoundary(boundary); payload->append(part); NetworkRequest(url, NetworkRequestType::Post) - .header("Content-Type", contentType) .headerList(extraHeaders) .multiPart(payload) - .onSuccess([&textEdit, channel, - originalFilePath](NetworkResult result) -> Outcome { - QString link = getSettings()->imageUploaderLink.getValue().isEmpty() - ? result.getData() - : getLinkFromResponse( - result, getSettings()->imageUploaderLink); - QString deletionLink = - getSettings()->imageUploaderDeletionLink.getValue().isEmpty() - ? "" - : getLinkFromResponse( - result, getSettings()->imageUploaderDeletionLink); - qCDebug(chatterinoNuulsuploader) << link << deletionLink; - textEdit.insertPlainText(link + " "); - if (uploadQueue.empty()) - { - channel->addMessage(makeSystemMessage( - QString("Your image has been uploaded to %1 %2.") - .arg(link) - .arg(deletionLink.isEmpty() - ? "" - : QString("(Deletion link: %1 )") - .arg(deletionLink)))); - uploadMutex.unlock(); - } - else - { - channel->addMessage(makeSystemMessage( - QString("Your image has been uploaded to %1 %2. %3 left. " - "Please wait until all of them are uploaded. " - "About %4 seconds left.") - .arg(link) - .arg(deletionLink.isEmpty() - ? "" - : QString("(Deletion link: %1 )") - .arg(deletionLink)) - .arg(uploadQueue.size()) - .arg(uploadQueue.size() * (UPLOAD_DELAY / 1000 + 1)))); - // 2 seconds for the timer that's there not to spam the remote server - // and 1 second of actual uploading. + .onSuccess( + [textEdit, channel, originalFilePath, this](NetworkResult result) { + this->handleSuccessfulUpload(result, originalFilePath, channel, + textEdit); + }) + .onError([channel, this](NetworkResult result) -> bool { + this->handleFailedUpload(result, channel); + return true; + }) + .execute(); +} - QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit]() { - uploadImageToNuuls(uploadQueue.front(), channel, textEdit); - uploadQueue.pop(); - }); - } +void ImageUploader::handleFailedUpload(const NetworkResult &result, + ChannelPtr channel) +{ + auto errorMessage = + QString("An error happened while uploading your image: %1") + .arg(result.formatError()); - logToFile(originalFilePath, link, deletionLink, channel); + // Try to read more information from the result body + auto obj = result.parseJson(); + if (!obj.isEmpty()) + { + auto apiCode = obj.value("code"); + if (!apiCode.isUndefined()) + { + auto codeString = apiCode.toVariant().toString(); + codeString.truncate(20); + errorMessage += QString(" - code: %1").arg(codeString); + } - return Success; - }) - .onError([channel](NetworkResult result) -> bool { - auto errorMessage = - QString("An error happened while uploading your image: %1") - .arg(result.status()); + auto apiError = obj.value("error").toString(); + if (!apiError.isEmpty()) + { + apiError.truncate(300); + errorMessage += QString(" - error: %1").arg(apiError.trimmed()); + } + } - // Try to read more information from the result body - auto obj = result.parseJson(); - if (!obj.isEmpty()) - { - auto apiCode = obj.value("code"); - if (!apiCode.isUndefined()) - { - auto codeString = apiCode.toVariant().toString(); - codeString.truncate(20); - errorMessage += QString(" - code: %1").arg(codeString); - } + channel->addMessage(makeSystemMessage(errorMessage)); + this->uploadMutex_.unlock(); +} - auto apiError = obj.value("error").toString(); - if (!apiError.isEmpty()) - { - apiError.truncate(300); - errorMessage += - QString(" - error: %1").arg(apiError.trimmed()); - } - } +void ImageUploader::handleSuccessfulUpload(const NetworkResult &result, + QString originalFilePath, + ChannelPtr channel, + QPointer textEdit) +{ + if (textEdit == nullptr) + { + // Split was destroyed abort further uploads - channel->addMessage(makeSystemMessage(errorMessage)); - uploadMutex.unlock(); - return true; - }) - .execute(); + while (!this->uploadQueue_.empty()) + { + this->uploadQueue_.pop(); + } + this->uploadMutex_.unlock(); + return; + } + QString link = + getSettings()->imageUploaderLink.getValue().isEmpty() + ? result.getData() + : getLinkFromResponse(result, getSettings()->imageUploaderLink); + QString deletionLink = + getSettings()->imageUploaderDeletionLink.getValue().isEmpty() + ? "" + : getLinkFromResponse(result, + getSettings()->imageUploaderDeletionLink); + qCDebug(chatterinoImageuploader) << link << deletionLink; + textEdit->insertPlainText(link + " "); + + // 2 seconds for the timer that's there not to spam the remote server + // and 1 second of actual uploading. + auto timeToUpload = this->uploadQueue_.size() * (UPLOAD_DELAY / 1000 + 1); + MessageBuilder builder(imageUploaderResultMessage, link, deletionLink, + this->uploadQueue_.size(), timeToUpload); + channel->addMessage(builder.release()); + if (this->uploadQueue_.empty()) + { + this->uploadMutex_.unlock(); + } + else + { + QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit, this]() { + this->sendImageUploadRequest(this->uploadQueue_.front(), channel, + textEdit); + this->uploadQueue_.pop(); + }); + } + + this->logToFile(originalFilePath, link, deletionLink, channel); } -void upload(const QMimeData *source, ChannelPtr channel, - ResizingTextEdit &outputTextEdit) +void ImageUploader::upload(const QMimeData *source, ChannelPtr channel, + QPointer outputTextEdit) { - if (!uploadMutex.tryLock()) + if (!this->uploadMutex_.tryLock()) { channel->addMessage(makeSystemMessage( QString("Please wait until the upload finishes."))); @@ -267,15 +277,15 @@ void upload(const QMimeData *source, ChannelPtr channel, { channel->addMessage( makeSystemMessage(QString("Couldn't load image :("))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } - boost::optional imageData = convertToPng(img); + auto imageData = convertToPng(img); if (imageData) { - RawImageData data = {imageData.get(), "png", localPath}; - uploadQueue.push(data); + RawImageData data = {*imageData, "png", localPath}; + this->uploadQueue_.push(data); } else { @@ -283,7 +293,7 @@ void upload(const QMimeData *source, ChannelPtr channel, QString("Cannot upload file: %1. Couldn't convert " "image to png.") .arg(localPath))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } } @@ -297,11 +307,11 @@ void upload(const QMimeData *source, ChannelPtr channel, { channel->addMessage( makeSystemMessage(QString("Failed to open file. :("))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } RawImageData data = {file.readAll(), "gif", localPath}; - uploadQueue.push(data); + this->uploadQueue_.push(data); file.close(); // file.readAll() => might be a bit big but it /should/ work } @@ -310,47 +320,48 @@ void upload(const QMimeData *source, ChannelPtr channel, channel->addMessage(makeSystemMessage( QString("Cannot upload file: %1. Not an image.") .arg(localPath))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } } - if (!uploadQueue.empty()) + if (!this->uploadQueue_.empty()) { - uploadImageToNuuls(uploadQueue.front(), channel, outputTextEdit); - uploadQueue.pop(); + this->sendImageUploadRequest(this->uploadQueue_.front(), channel, + outputTextEdit); + this->uploadQueue_.pop(); } } else if (source->hasFormat("image/png")) { // the path to file is not present every time, thus the filePath is empty - uploadImageToNuuls({source->data("image/png"), "png", ""}, channel, - outputTextEdit); + this->sendImageUploadRequest({source->data("image/png"), "png", ""}, + channel, outputTextEdit); } else if (source->hasFormat("image/jpeg")) { - uploadImageToNuuls({source->data("image/jpeg"), "jpeg", ""}, channel, - outputTextEdit); + this->sendImageUploadRequest({source->data("image/jpeg"), "jpeg", ""}, + channel, outputTextEdit); } else if (source->hasFormat("image/gif")) { - uploadImageToNuuls({source->data("image/gif"), "gif", ""}, channel, - outputTextEdit); + this->sendImageUploadRequest({source->data("image/gif"), "gif", ""}, + channel, outputTextEdit); } else { // not PNG, try loading it into QImage and save it to a PNG. - QImage image = qvariant_cast(source->imageData()); - boost::optional imageData = convertToPng(image); + auto image = qvariant_cast(source->imageData()); + auto imageData = convertToPng(image); if (imageData) { - uploadImageToNuuls({imageData.get(), "png", ""}, channel, - outputTextEdit); + sendImageUploadRequest({*imageData, "png", ""}, channel, + outputTextEdit); } else { channel->addMessage(makeSystemMessage( QString("Cannot upload file, failed to convert to png."))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); } } } diff --git a/src/singletons/ImageUploader.hpp b/src/singletons/ImageUploader.hpp new file mode 100644 index 00000000000..260180583d7 --- /dev/null +++ b/src/singletons/ImageUploader.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include +#include +#include + +#include +#include + +namespace chatterino { + +class ResizingTextEdit; +class Channel; +class NetworkResult; +using ChannelPtr = std::shared_ptr; + +struct RawImageData { + QByteArray data; + QString format; + QString filePath; +}; + +class ImageUploader final : public Singleton +{ +public: + void save() override; + void upload(const QMimeData *source, ChannelPtr channel, + QPointer outputTextEdit); + +private: + void sendImageUploadRequest(RawImageData imageData, ChannelPtr channel, + QPointer textEdit); + + // This is called from the onSuccess handler of the NetworkRequest in sendImageUploadRequest + void handleSuccessfulUpload(const NetworkResult &result, + QString originalFilePath, ChannelPtr channel, + QPointer textEdit); + void handleFailedUpload(const NetworkResult &result, ChannelPtr channel); + + void logToFile(const QString &originalFilePath, const QString &imageLink, + const QString &deletionLink, ChannelPtr channel); + + // These variables are only used from the main thread. + QMutex uploadMutex_; + std::queue uploadQueue_; +}; +} // namespace chatterino diff --git a/src/singletons/Logging.cpp b/src/singletons/Logging.cpp index cdf85b5ae9e..8ea65289c35 100644 --- a/src/singletons/Logging.cpp +++ b/src/singletons/Logging.cpp @@ -12,18 +12,23 @@ namespace chatterino { -void Logging::initialize(Settings &settings, Paths & /*paths*/) +Logging::Logging(Settings &settings) { - settings.loggedChannels.delayedItemsChanged.connect([this, &settings]() { - this->threadGuard.guard(); + // We can safely ignore this signal connection since settings are only-ever destroyed + // on application exit + // NOTE: SETTINGS_LIFETIME + std::ignore = settings.loggedChannels.delayedItemsChanged.connect( + [this, &settings]() { + this->threadGuard.guard(); - this->onlyLogListedChannels.clear(); + this->onlyLogListedChannels.clear(); - for (const auto &loggedChannel : *settings.loggedChannels.readOnly()) - { - this->onlyLogListedChannels.insert(loggedChannel.channelName()); - } - }); + for (const auto &loggedChannel : + *settings.loggedChannels.readOnly()) + { + this->onlyLogListedChannels.insert(loggedChannel.channelName()); + } + }); } void Logging::addMessage(const QString &channelName, MessagePtr message, @@ -47,7 +52,7 @@ void Logging::addMessage(const QString &channelName, MessagePtr message, auto platIt = this->loggingChannels_.find(platformName); if (platIt == this->loggingChannels_.end()) { - auto channel = new LoggingChannel(channelName, platformName); + auto *channel = new LoggingChannel(channelName, platformName); channel->addMessage(message); auto map = std::map>(); this->loggingChannels_[platformName] = std::move(map); @@ -58,7 +63,7 @@ void Logging::addMessage(const QString &channelName, MessagePtr message, auto chanIt = platIt->second.find(channelName); if (chanIt == platIt->second.end()) { - auto channel = new LoggingChannel(channelName, platformName); + auto *channel = new LoggingChannel(channelName, platformName); channel->addMessage(message); platIt->second.emplace(channelName, std::move(channel)); } diff --git a/src/singletons/Logging.hpp b/src/singletons/Logging.hpp index 19fd2bacd72..edd1ac07fc9 100644 --- a/src/singletons/Logging.hpp +++ b/src/singletons/Logging.hpp @@ -1,6 +1,5 @@ #pragma once -#include "common/Singleton.hpp" #include "util/QStringHash.hpp" #include "util/ThreadGuard.hpp" @@ -12,19 +11,15 @@ namespace chatterino { -class Paths; +class Settings; struct Message; using MessagePtr = std::shared_ptr; class LoggingChannel; -class Logging : public Singleton +class Logging { - Paths *pathManager = nullptr; - public: - Logging() = default; - - virtual void initialize(Settings &settings, Paths &paths) override; + Logging(Settings &settings); void addMessage(const QString &channelName, MessagePtr message, const QString &platformName); diff --git a/src/singletons/NativeMessaging.cpp b/src/singletons/NativeMessaging.cpp index 620e5ee3755..6150cd8faeb 100644 --- a/src/singletons/NativeMessaging.cpp +++ b/src/singletons/NativeMessaging.cpp @@ -1,90 +1,87 @@ #include "singletons/NativeMessaging.hpp" #include "Application.hpp" +#include "common/Literals.hpp" +#include "common/Modes.hpp" #include "common/QLogging.hpp" +#include "debug/AssertInGuiThread.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Paths.hpp" +#include "util/IpcQueue.hpp" #include "util/PostToThread.hpp" -#include -#include #include #include #include #include #include #include - -namespace ipc = boost::interprocess; +#include #ifdef Q_OS_WIN -// clang-format off -# include -# include -// clang-format on -# include "singletons/WindowManager.hpp" # include "widgets/AttachedWindow.hpp" #endif -#include +namespace { + +using namespace chatterino::literals; + +const QString EXTENSION_ID = u"glknmaideaikkmemifbfkhnomoknepka"_s; +constexpr const size_t MESSAGE_SIZE = 1024; -#define EXTENSION_ID "glknmaideaikkmemifbfkhnomoknepka" -#define MESSAGE_SIZE 1024 +} // namespace namespace chatterino { -void registerNmManifest(Paths &paths, const QString &manifestFilename, +using namespace literals; + +void registerNmManifest(const Paths &paths, const QString &manifestFilename, const QString ®istryKeyName, const QJsonDocument &document); -void registerNmHost(Paths &paths) +void registerNmHost(const Paths &paths) { - if (paths.isPortable()) + if (Modes::instance().isPortable) + { return; + } - auto getBaseDocument = [&] { - QJsonObject obj; - obj.insert("name", "com.chatterino.chatterino"); - obj.insert("description", "Browser interaction with chatterino."); - obj.insert("path", QCoreApplication::applicationFilePath()); - obj.insert("type", "stdio"); - - return obj; + auto getBaseDocument = [] { + return QJsonObject{ + {u"name"_s, "com.chatterino.chatterino"_L1}, + {u"description"_s, "Browser interaction with chatterino."_L1}, + {u"path"_s, QCoreApplication::applicationFilePath()}, + {u"type"_s, "stdio"_L1}, + }; }; // chrome { - QJsonDocument document; - auto obj = getBaseDocument(); - QJsonArray allowed_origins_arr = {"chrome-extension://" EXTENSION_ID - "/"}; - obj.insert("allowed_origins", allowed_origins_arr); - document.setObject(obj); + QJsonArray allowedOriginsArr = { + u"chrome-extension://%1/"_s.arg(EXTENSION_ID)}; + obj.insert("allowed_origins", allowedOriginsArr); registerNmManifest(paths, "/native-messaging-manifest-chrome.json", "HKCU\\Software\\Google\\Chrome\\NativeMessagingHost" "s\\com.chatterino.chatterino", - document); + QJsonDocument(obj)); } // firefox { - QJsonDocument document; - auto obj = getBaseDocument(); - QJsonArray allowed_extensions = {"chatterino_native@chatterino.com"}; - obj.insert("allowed_extensions", allowed_extensions); - document.setObject(obj); + QJsonArray allowedExtensions = {"chatterino_native@chatterino.com"}; + obj.insert("allowed_extensions", allowedExtensions); registerNmManifest(paths, "/native-messaging-manifest-firefox.json", "HKCU\\Software\\Mozilla\\NativeMessagingHosts\\com." "chatterino.chatterino", - document); + QJsonDocument(obj)); } } -void registerNmManifest(Paths &paths, const QString &manifestFilename, +void registerNmManifest(const Paths &paths, const QString &manifestFilename, const QString ®istryKeyName, const QJsonDocument &document) { @@ -103,7 +100,7 @@ void registerNmManifest(Paths &paths, const QString &manifestFilename, #endif } -std::string &getNmQueueName(Paths &paths) +std::string &getNmQueueName(const Paths &paths) { static std::string name = "chatterino_gui" + paths.applicationFilePathHash.toStdString(); @@ -112,187 +109,213 @@ std::string &getNmQueueName(Paths &paths) // CLIENT -void NativeMessagingClient::sendMessage(const QByteArray &array) -{ - try - { - ipc::message_queue messageQueue(ipc::open_only, "chatterino_gui"); +namespace nm::client { - messageQueue.try_send(array.data(), size_t(array.size()), 1); - // messageQueue.timed_send(array.data(), size_t(array.size()), 1, - // boost::posix_time::second_clock::local_time() + - // boost::posix_time::seconds(10)); - } - catch (ipc::interprocess_exception &ex) + void sendMessage(const QByteArray &array) { - qCDebug(chatterinoNativeMessage) << "send to gui process:" << ex.what(); + ipc::sendMessage("chatterino_gui", array); } -} -void NativeMessagingClient::writeToCout(const QByteArray &array) -{ - auto *data = array.data(); - auto size = uint32_t(array.size()); + void writeToCout(const QByteArray &array) + { + const auto *data = array.data(); + auto size = uint32_t(array.size()); + + // We're writing the raw bytes to cout. + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + std::cout.write(reinterpret_cast(&size), 4); + std::cout.write(data, size); + std::cout.flush(); + } - std::cout.write(reinterpret_cast(&size), 4); - std::cout.write(data, size); - std::cout.flush(); -} +} // namespace nm::client // SERVER +NativeMessagingServer::NativeMessagingServer() + : thread(*this) +{ +} void NativeMessagingServer::start() { this->thread.start(); } -void NativeMessagingServer::ReceiverThread::run() +NativeMessagingServer::ReceiverThread::ReceiverThread( + NativeMessagingServer &parent) + : parent_(parent) { - try - { - ipc::message_queue::remove("chatterino_gui"); - ipc::message_queue messageQueue(ipc::open_or_create, "chatterino_gui", - 100, MESSAGE_SIZE); - - while (true) - { - try - { - auto buf = std::make_unique(MESSAGE_SIZE); - auto retSize = ipc::message_queue::size_type(); - auto priority = static_cast(0); +} - messageQueue.receive(buf.get(), MESSAGE_SIZE, retSize, - priority); +void NativeMessagingServer::ReceiverThread::run() +{ + auto [messageQueue, error] = + ipc::IpcQueue::tryReplaceOrCreate("chatterino_gui", 100, MESSAGE_SIZE); - auto document = QJsonDocument::fromJson( - QByteArray::fromRawData(buf.get(), retSize)); + if (!error.isEmpty()) + { + qCDebug(chatterinoNativeMessage) + << "Failed to create message queue:" << error; - this->handleMessage(document.object()); - } - catch (ipc::interprocess_exception &ex) - { - qCDebug(chatterinoNativeMessage) - << "received from gui process:" << ex.what(); - } - } + nmIpcError().set(error); + return; } - catch (ipc::interprocess_exception &ex) + + while (true) { - qCDebug(chatterinoNativeMessage) - << "run ipc message queue:" << ex.what(); + auto buf = messageQueue->receive(); + if (buf.isEmpty()) + { + continue; + } + auto document = QJsonDocument::fromJson(buf); - nmIpcError().set(QString::fromLatin1(ex.what())); + this->handleMessage(document.object()); } } void NativeMessagingServer::ReceiverThread::handleMessage( const QJsonObject &root) { - auto app = getApp(); - QString action = root.value("action").toString(); + QString action = root["action"_L1].toString(); - if (action.isNull()) + if (action == "select") { - qCDebug(chatterinoNativeMessage) << "NM action was null"; + this->handleSelect(root); return; } - - if (action == "select") + if (action == "detach") { - QString _type = root.value("type").toString(); - bool attach = root.value("attach").toBool(); - bool attachFullscreen = root.value("attach_fullscreen").toBool(); - QString name = root.value("name").toString(); + this->handleDetach(root); + return; + } + if (action == "sync") + { + this->handleSync(root); + return; + } + + qCDebug(chatterinoNativeMessage) << "NM unknown action" << action; +} + +// NOLINTBEGIN(readability-convert-member-functions-to-static) +void NativeMessagingServer::ReceiverThread::handleSelect( + const QJsonObject &root) +{ + QString type = root["type"_L1].toString(); + bool attach = root["attach"_L1].toBool(); + bool attachFullscreen = root["attach_fullscreen"_L1].toBool(); + QString name = root["name"_L1].toString(); #ifdef USEWINSDK - AttachedWindow::GetArgs args; - args.winId = root.value("winId").toString(); - args.yOffset = root.value("yOffset").toInt(-1); + const auto sizeObject = root["size"_L1].toObject(); + AttachedWindow::GetArgs args = { + .winId = root["winId"_L1].toString(), + .yOffset = root["yOffset"_L1].toInt(-1), + .x = sizeObject["x"_L1].toDouble(-1.0), + .pixelRatio = sizeObject["pixelRatio"_L1].toDouble(-1.0), + .width = sizeObject["width"_L1].toInt(-1), + .height = sizeObject["height"_L1].toInt(-1), + .fullscreen = attachFullscreen, + }; - { - const auto sizeObject = root.value("size").toObject(); - args.x = sizeObject.value("x").toDouble(-1.0); - args.pixelRatio = sizeObject.value("pixelRatio").toDouble(-1.0); - args.width = sizeObject.value("width").toInt(-1); - args.height = sizeObject.value("height").toInt(-1); - } + qCDebug(chatterinoNativeMessage) + << args.x << args.pixelRatio << args.width << args.height << args.winId; - args.fullscreen = attachFullscreen; + if (args.winId.isNull()) + { + qCDebug(chatterinoNativeMessage) << "winId in select is missing"; + return; + } +#endif - qCDebug(chatterinoNativeMessage) - << args.x << args.pixelRatio << args.width << args.height - << args.winId; + if (type != u"twitch"_s) + { + qCDebug(chatterinoNativeMessage) << "NM unknown channel type"; + return; + } - if (_type.isNull() || args.winId.isNull()) + postToThread([=] { + auto *app = getApp(); + + if (!name.isEmpty()) { - qCDebug(chatterinoNativeMessage) - << "NM type, name or winId missing"; - attach = false; - attachFullscreen = false; - return; + auto channel = app->twitch->getOrAddChannel(name); + if (app->twitch->watchingChannel.get() != channel) + { + app->twitch->watchingChannel.reset(channel); + } } -#endif - if (_type == "twitch") + if (attach || attachFullscreen) { - postToThread([=] { - if (!name.isEmpty()) - { - auto channel = app->twitch->getOrAddChannel(name); - if (app->twitch->watchingChannel.get() != channel) - { - app->twitch->watchingChannel.reset(channel); - } - } - - if (attach || attachFullscreen) - { #ifdef USEWINSDK - // if (args.height != -1) { - auto *window = - AttachedWindow::get(::GetForegroundWindow(), args); - if (!name.isEmpty()) - { - window->setChannel(app->twitch->getOrAddChannel(name)); - } -// } -// window->show(); + auto *window = AttachedWindow::getForeground(args); + if (!name.isEmpty()) + { + window->setChannel(app->twitch->getOrAddChannel(name)); + } #endif - } - }); - } - else - { - qCDebug(chatterinoNativeMessage) << "NM unknown channel type"; } - } - else if (action == "detach") - { - QString winId = root.value("winId").toString(); + }); +} - if (winId.isNull()) - { - qCDebug(chatterinoNativeMessage) << "NM winId missing"; - return; - } +void NativeMessagingServer::ReceiverThread::handleDetach( + const QJsonObject &root) +{ + QString winId = root["winId"_L1].toString(); + + if (winId.isNull()) + { + qCDebug(chatterinoNativeMessage) << "NM winId missing"; + return; + } #ifdef USEWINSDK - postToThread([winId] { - qCDebug(chatterinoNativeMessage) << "NW detach"; - AttachedWindow::detach(winId); - }); + postToThread([winId] { + qCDebug(chatterinoNativeMessage) << "NW detach"; + AttachedWindow::detach(winId); + }); #endif - } - else +} +// NOLINTEND(readability-convert-member-functions-to-static) + +void NativeMessagingServer::ReceiverThread::handleSync(const QJsonObject &root) +{ + // Structure: + // { action: 'sync', twitchChannels?: string[] } + postToThread([&parent = this->parent_, + twitch = root["twitchChannels"_L1].toArray()] { + parent.syncChannels(twitch); + }); +} + +void NativeMessagingServer::syncChannels(const QJsonArray &twitchChannels) +{ + assertInGuiThread(); + + auto *app = getApp(); + + std::vector updated; + updated.reserve(twitchChannels.size()); + for (const auto &value : twitchChannels) { - qCDebug(chatterinoNativeMessage) << "NM unknown action " + action; + auto name = value.toString(); + if (name.isEmpty()) + { + continue; + } + // the deduping is done on the extension side + updated.emplace_back(app->twitch->getOrAddChannel(name)); } + + // This will destroy channels that aren't used anymore. + this->channelWarmer_ = std::move(updated); } -Atomic> &nmIpcError() +Atomic> &nmIpcError() { - static Atomic> x; + static Atomic> x; return x; } diff --git a/src/singletons/NativeMessaging.hpp b/src/singletons/NativeMessaging.hpp index 9c46a08a1c8..5d0633358db 100644 --- a/src/singletons/NativeMessaging.hpp +++ b/src/singletons/NativeMessaging.hpp @@ -2,43 +2,69 @@ #include "common/Atomic.hpp" -#include #include #include +#include +#include + namespace chatterino { class Application; class Paths; +class Channel; -void registerNmHost(Paths &paths); -std::string &getNmQueueName(Paths &paths); +using ChannelPtr = std::shared_ptr; -Atomic> &nmIpcError(); +void registerNmHost(const Paths &paths); +std::string &getNmQueueName(const Paths &paths); + +Atomic> &nmIpcError(); + +namespace nm::client { -class NativeMessagingClient final -{ -public: void sendMessage(const QByteArray &array); void writeToCout(const QByteArray &array); -}; + +} // namespace nm::client class NativeMessagingServer final { public: + NativeMessagingServer(); + NativeMessagingServer(const NativeMessagingServer &) = delete; + NativeMessagingServer(NativeMessagingServer &&) = delete; + NativeMessagingServer &operator=(const NativeMessagingServer &) = delete; + NativeMessagingServer &operator=(NativeMessagingServer &&) = delete; + void start(); private: class ReceiverThread : public QThread { public: + ReceiverThread(NativeMessagingServer &parent); + void run() override; private: void handleMessage(const QJsonObject &root); + void handleSelect(const QJsonObject &root); + void handleDetach(const QJsonObject &root); + void handleSync(const QJsonObject &root); + + NativeMessagingServer &parent_; }; + void syncChannels(const QJsonArray &twitchChannels); + ReceiverThread thread; + + /// This vector contains all channels that are open the user's browser. + /// These channels are joined to be able to switch channels more quickly. + std::vector channelWarmer_; + + friend ReceiverThread; }; } // namespace chatterino diff --git a/src/singletons/Paths.cpp b/src/singletons/Paths.cpp index 8fd6b13cbea..4d0ac30dbad 100644 --- a/src/singletons/Paths.cpp +++ b/src/singletons/Paths.cpp @@ -15,12 +15,8 @@ using namespace std::literals; namespace chatterino { -Paths *Paths::instance = nullptr; - Paths::Paths() { - this->instance = this; - this->initAppFilePathHash(); this->initCheckPortable(); @@ -33,12 +29,12 @@ bool Paths::createFolder(const QString &folderPath) return QDir().mkpath(folderPath); } -bool Paths::isPortable() +bool Paths::isPortable() const { return Modes::instance().isPortable; } -QString Paths::cacheDirectory() +QString Paths::cacheDirectory() const { static const auto pathSetting = [] { QStringSetting cachePathSetting("/cache/path"); @@ -83,14 +79,14 @@ void Paths::initCheckPortable() void Paths::initRootDirectory() { - assert(this->portable_.is_initialized()); + assert(this->portable_.has_value()); // Root path = %APPDATA%/Chatterino or the folder that the executable // resides in this->rootAppDataDirectory = [&]() -> QString { // portable - if (this->isPortable()) + if (Modes::instance().isPortable) { return QCoreApplication::applicationDirPath(); } @@ -122,9 +118,8 @@ void Paths::initSubDirectories() // create settings subdirectories and validate that they are created // properly - auto makePath = [&](const std::string &name) -> QString { - auto path = combinePath(this->rootAppDataDirectory, - QString::fromStdString(name)); + auto makePath = [&](const QString &name) -> QString { + auto path = combinePath(this->rootAppDataDirectory, name); if (!QDir().mkpath(path)) { @@ -140,14 +135,11 @@ void Paths::initSubDirectories() this->cacheDirectory_ = makePath("Cache"); this->messageLogDirectory = makePath("Logs"); this->miscDirectory = makePath("Misc"); - this->twitchProfileAvatars = makePath("ProfileAvatars"); + this->twitchProfileAvatars = + makePath(combinePath("ProfileAvatars", "twitch")); + this->pluginsDirectory = makePath("Plugins"); + this->themesDirectory = makePath("Themes"); this->crashdumpDirectory = makePath("Crashes"); - //QDir().mkdir(this->twitchProfileAvatars + "/twitch"); -} - -Paths *getPaths() -{ - return Paths::instance; } } // namespace chatterino diff --git a/src/singletons/Paths.hpp b/src/singletons/Paths.hpp index 7ff5f8e1772..bfabd1b1ea7 100644 --- a/src/singletons/Paths.hpp +++ b/src/singletons/Paths.hpp @@ -1,15 +1,14 @@ #pragma once -#include #include +#include + namespace chatterino { class Paths { public: - static Paths *instance; - Paths(); // Root directory for the configuration files. %APPDATA%/chatterino or @@ -31,13 +30,20 @@ class Paths // Hash of QCoreApplication::applicationFilePath() QString applicationFilePathHash; - // Profile avatars for Twitch /cache/twitch + // Profile avatars for Twitch /ProfileAvatars/twitch QString twitchProfileAvatars; + // Plugin files live here. /Plugins + QString pluginsDirectory; + + // Custom themes live here. /Themes + QString themesDirectory; + bool createFolder(const QString &folderPath); - bool isPortable(); + [[deprecated("use Modes::instance().portable instead")]] bool isPortable() + const; - QString cacheDirectory(); + QString cacheDirectory() const; private: void initAppFilePathHash(); @@ -45,12 +51,10 @@ class Paths void initRootDirectory(); void initSubDirectories(); - boost::optional portable_; + std::optional portable_; // Directory for cache files. Same as /Misc QString cacheDirectory_; }; -Paths *getPaths(); - } // namespace chatterino diff --git a/src/singletons/Settings.cpp b/src/singletons/Settings.cpp index a4cf20f9632..baac324cb78 100644 --- a/src/singletons/Settings.cpp +++ b/src/singletons/Settings.cpp @@ -1,5 +1,6 @@ #include "singletons/Settings.hpp" +#include "Application.hpp" #include "controllers/filters/FilterRecord.hpp" #include "controllers/highlights/HighlightBadge.hpp" #include "controllers/highlights/HighlightBlacklistUser.hpp" @@ -7,66 +8,76 @@ #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/moderationactions/ModerationAction.hpp" #include "controllers/nicknames/Nickname.hpp" +#include "util/Clamp.hpp" #include "util/PersistSignalVector.hpp" #include "util/WindowsHelper.hpp" +#include + +namespace { + +using namespace chatterino; + +template +void initializeSignalVector(pajlada::Signals::SignalHolder &signalHolder, + ChatterinoSetting> &setting, + SignalVector &vec) +{ + // Fill the SignalVector up with initial values + for (auto &&item : setting.getValue()) + { + vec.append(item); + } + + // Set up a signal to + signalHolder.managedConnect(vec.delayedItemsChanged, [&] { + setting.setValue(vec.raw()); + }); +} + +} // namespace + namespace chatterino { -ConcurrentSettings *concurrentInstance_{}; - -ConcurrentSettings::ConcurrentSettings() - // NOTE: these do not get deleted - : highlightedMessages(*new SignalVector()) - , highlightedUsers(*new SignalVector()) - , highlightedBadges(*new SignalVector()) - , blacklistedUsers(*new SignalVector()) - , ignoredMessages(*new SignalVector()) - , mutedChannels(*new SignalVector()) - , filterRecords(*new SignalVector()) - , nicknames(*new SignalVector()) - , moderationActions(*new SignalVector) - , loggedChannels(*new SignalVector) -{ - persist(this->highlightedMessages, "/highlighting/highlights"); - persist(this->blacklistedUsers, "/highlighting/blacklist"); - persist(this->highlightedBadges, "/highlighting/badges"); - persist(this->highlightedUsers, "/highlighting/users"); - persist(this->ignoredMessages, "/ignore/phrases"); - persist(this->mutedChannels, "/pings/muted"); - persist(this->filterRecords, "/filtering/filters"); - persist(this->nicknames, "/nicknames"); - // tagged users? - persist(this->moderationActions, "/moderation/actions"); - persist(this->loggedChannels, "/logging/channels"); -} - -bool ConcurrentSettings::isHighlightedUser(const QString &username) +std::vector> _settings; + +void _actuallyRegisterSetting( + std::weak_ptr setting) +{ + _settings.push_back(std::move(setting)); +} + +bool Settings::isHighlightedUser(const QString &username) { auto items = this->highlightedUsers.readOnly(); for (const auto &highlightedUser : *items) { if (highlightedUser.isMatch(username)) + { return true; + } } return false; } -bool ConcurrentSettings::isBlacklistedUser(const QString &username) +bool Settings::isBlacklistedUser(const QString &username) { auto items = this->blacklistedUsers.readOnly(); for (const auto &blacklistedUser : *items) { if (blacklistedUser.isMatch(username)) + { return true; + } } return false; } -bool ConcurrentSettings::isMutedChannel(const QString &channelName) +bool Settings::isMutedChannel(const QString &channelName) { auto items = this->mutedChannels.readOnly(); @@ -80,12 +91,27 @@ bool ConcurrentSettings::isMutedChannel(const QString &channelName) return false; } -void ConcurrentSettings::mute(const QString &channelName) +std::optional Settings::matchNickname(const QString &usernameText) +{ + auto nicknames = this->nicknames.readOnly(); + + for (const auto &nickname : *nicknames) + { + if (auto nicknameText = nickname.match(usernameText)) + { + return nicknameText; + } + } + + return std::nullopt; +} + +void Settings::mute(const QString &channelName) { mutedChannels.append(channelName); } -void ConcurrentSettings::unmute(const QString &channelName) +void Settings::unmute(const QString &channelName) { for (std::vector::size_type i = 0; i != mutedChannels.raw().size(); i++) @@ -98,7 +124,7 @@ void ConcurrentSettings::unmute(const QString &channelName) } } -bool ConcurrentSettings::toggleMutedChannel(const QString &channelName) +bool Settings::toggleMutedChannel(const QString &channelName) { if (this->isMutedChannel(channelName)) { @@ -112,21 +138,45 @@ bool ConcurrentSettings::toggleMutedChannel(const QString &channelName) } } -ConcurrentSettings &getCSettings() -{ - // `concurrentInstance_` gets assigned in Settings ctor. - assert(concurrentInstance_); - - return *concurrentInstance_; -} - Settings *Settings::instance_ = nullptr; Settings::Settings(const QString &settingsDirectory) - : ABSettings(settingsDirectory) + : prevInstance_(Settings::instance_) { + QString settingsPath = settingsDirectory + "/settings.json"; + + // get global instance of the settings library + auto settingsInstance = pajlada::Settings::SettingManager::getInstance(); + + settingsInstance->load(qPrintable(settingsPath)); + + settingsInstance->setBackupEnabled(true); + settingsInstance->setBackupSlots(9); + settingsInstance->saveMethod = + pajlada::Settings::SettingManager::SaveMethod::SaveOnExit; + + initializeSignalVector(this->signalHolder, this->highlightedMessagesSetting, + this->highlightedMessages); + initializeSignalVector(this->signalHolder, this->highlightedUsersSetting, + this->highlightedUsers); + initializeSignalVector(this->signalHolder, this->highlightedBadgesSetting, + this->highlightedBadges); + initializeSignalVector(this->signalHolder, this->blacklistedUsersSetting, + this->blacklistedUsers); + initializeSignalVector(this->signalHolder, this->ignoredMessagesSetting, + this->ignoredMessages); + initializeSignalVector(this->signalHolder, this->mutedChannelsSetting, + this->mutedChannels); + initializeSignalVector(this->signalHolder, this->filterRecordsSetting, + this->filterRecords); + initializeSignalVector(this->signalHolder, this->nicknamesSetting, + this->nicknames); + initializeSignalVector(this->signalHolder, this->moderationActionsSetting, + this->moderationActions); + initializeSignalVector(this->signalHolder, this->loggedChannelsSetting, + this->loggedChannels); + instance_ = this; - concurrentInstance_ = this; #ifdef USEWINSDK this->autorun = isRegisteredForStartup(); @@ -136,10 +186,95 @@ Settings::Settings(const QString &settingsDirectory) }, false); #endif + this->enableStreamerMode.connect( + []() { + getApp()->streamerModeChanged.invoke(); + }, + false); +} + +Settings::~Settings() +{ + Settings::instance_ = this->prevInstance_; +} + +void Settings::saveSnapshot() +{ + rapidjson::Document *d = new rapidjson::Document(rapidjson::kObjectType); + rapidjson::Document::AllocatorType &a = d->GetAllocator(); + + for (const auto &weakSetting : _settings) + { + auto setting = weakSetting.lock(); + if (!setting) + { + continue; + } + + rapidjson::Value key(setting->getPath().c_str(), a); + auto *curVal = setting->unmarshalJSON(); + if (curVal == nullptr) + { + continue; + } + + rapidjson::Value val; + val.CopyFrom(*curVal, a); + d->AddMember(key.Move(), val.Move(), a); + } + + // log("Snapshot state: {}", rj::stringify(*d)); + + this->snapshot_.reset(d); +} + +void Settings::restoreSnapshot() +{ + if (!this->snapshot_) + { + return; + } + + const auto &snapshot = *(this->snapshot_.get()); + + if (!snapshot.IsObject()) + { + return; + } + + for (const auto &weakSetting : _settings) + { + auto setting = weakSetting.lock(); + if (!setting) + { + continue; + } + + const char *path = setting->getPath().c_str(); + + if (!snapshot.HasMember(path)) + { + continue; + } + + setting->marshalJSON(snapshot[path]); + } +} + +float Settings::getClampedUiScale() const +{ + return clamp(this->uiScale.getValue(), 0.2f, 10); +} + +void Settings::setClampedUiScale(float value) +{ + this->uiScale.setValue(clamp(value, 0.2f, 10)); } Settings &Settings::instance() { + assert(instance_ != nullptr); + return *instance_; } diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 8b0b32f7739..c66fcb2187d 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -1,59 +1,33 @@ #pragma once -#include "BaseSettings.hpp" #include "common/Channel.hpp" +#include "common/ChatterinoSetting.hpp" +#include "common/enums/MessageOverflow.hpp" #include "common/SignalVector.hpp" +#include "controllers/filters/FilterRecord.hpp" +#include "controllers/highlights/HighlightBadge.hpp" +#include "controllers/highlights/HighlightBlacklistUser.hpp" +#include "controllers/highlights/HighlightPhrase.hpp" +#include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/logging/ChannelLog.hpp" +#include "controllers/moderationactions/ModerationAction.hpp" +#include "controllers/nicknames/Nickname.hpp" +#include "controllers/sound/ISoundController.hpp" #include "singletons/Toasts.hpp" #include "util/RapidJsonSerializeQString.hpp" #include "util/StreamerMode.hpp" #include "widgets/Notebook.hpp" -#include "widgets/splits/SplitInput.hpp" #include #include +#include using TimeoutButton = std::pair; namespace chatterino { -class HighlightPhrase; -class HighlightBlacklistUser; -class IgnorePhrase; -class FilterRecord; -using FilterRecordPtr = std::shared_ptr; -class Nickname; -class HighlightBadge; -class ModerationAction; - -/// Settings which are available for reading on all threads. -class ConcurrentSettings -{ -public: - ConcurrentSettings(); - - SignalVector &highlightedMessages; - SignalVector &highlightedUsers; - SignalVector &highlightedBadges; - SignalVector &blacklistedUsers; - SignalVector &ignoredMessages; - SignalVector &mutedChannels; - SignalVector &filterRecords; - SignalVector &nicknames; - SignalVector &moderationActions; - SignalVector &loggedChannels; - - bool isHighlightedUser(const QString &username); - bool isBlacklistedUser(const QString &username); - bool isMutedChannel(const QString &channelName); - bool toggleMutedChannel(const QString &channelName); - -private: - void mute(const QString &channelName); - void unmute(const QString &channelName); -}; - -ConcurrentSettings &getCSettings(); +void _actuallyRegisterSetting( + std::weak_ptr setting); enum UsernameDisplayMode : int { Username = 1, // Username @@ -74,17 +48,42 @@ enum HelixTimegateOverride : int { AlwaysUseHelix = 3, }; +enum ThumbnailPreviewMode : int { + DontShow = 0, + + AlwaysShow = 1, + + ShowOnShift = 2, +}; + +enum UsernameRightClickBehavior : int { + Reply = 0, + Mention = 1, + Ignore = 2, +}; + /// Settings which are availlable for reading and writing on the gui thread. // These settings are still accessed concurrently in the code but it is bad practice. -class Settings : public ABSettings, public ConcurrentSettings +class Settings { static Settings *instance_; + Settings *prevInstance_ = nullptr; public: Settings(const QString &settingsDirectory); + ~Settings(); static Settings &instance(); + void saveSnapshot(); + void restoreSnapshot(); + + FloatSetting uiScale = {"/appearance/uiScale2", 1}; + BoolSetting windowTopMost = {"/appearance/windowAlwaysOnTop", false}; + + float getClampedUiScale() const; + void setClampedUiScale(float value); + /// Appearance BoolSetting showTimestamps = {"/appearance/messages/showTimestamps", true}; BoolSetting animationsWhenFocused = { @@ -117,6 +116,10 @@ class Settings : public ABSettings, public ConcurrentSettings EnumSetting tabDirection = {"/appearance/tabDirection", NotebookTabLocation::Top}; + EnumSetting tabVisibility = { + "/appearance/tabVisibility", + NotebookTabVisibility::AllTabs, + }; // BoolSetting collapseLongMessages = // {"/appearance/messages/collapseLongMessages", false}; @@ -181,6 +184,24 @@ class Settings : public ABSettings, public ConcurrentSettings BoolSetting autoCloseUserPopup = {"/behaviour/autoCloseUserPopup", true}; BoolSetting autoCloseThreadPopup = {"/behaviour/autoCloseThreadPopup", false}; + + EnumSetting usernameRightClickBehavior = { + "/behaviour/usernameRightClickBehavior", + UsernameRightClickBehavior::Mention, + }; + EnumSetting usernameRightClickModifierBehavior = + { + "/behaviour/usernameRightClickBehaviorWithModifier", + UsernameRightClickBehavior::Reply, + }; + EnumSetting usernameRightClickModifier = { + "/behaviour/usernameRightClickModifier", + Qt::KeyboardModifier::ShiftModifier}; + + BoolSetting autoSubToParticipatedThreads = { + "/behaviour/autoSubToParticipatedThreads", + true, + }; // BoolSetting twitchSeperateWriteConnection = // {"/behaviour/twitchSeperateWriteConnection", false}; @@ -197,6 +218,10 @@ class Settings : public ABSettings, public ConcurrentSettings "/behaviour/autocompletion/emoteCompletionWithColon", true}; BoolSetting showUsernameCompletionMenu = { "/behaviour/autocompletion/showUsernameCompletionMenu", true}; + BoolSetting useSmartEmoteCompletion = { + "/experiments/useSmartEmoteCompletion", + false, + }; FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0}; EnumSetting pauseChatModifier = { @@ -213,10 +238,10 @@ class Settings : public ABSettings, public ConcurrentSettings false}; BoolSetting enableEmoteImages = {"/emotes/enableEmoteImages", true}; BoolSetting animateEmotes = {"/emotes/enableGifAnimations", true}; + BoolSetting enableZeroWidthEmotes = {"/emotes/enableZeroWidthEmotes", true}; FloatSetting emoteScale = {"/emotes/scale", 1.f}; BoolSetting showUnlistedSevenTVEmotes = { "/emotes/showUnlistedSevenTVEmotes", false}; - QStringSetting emojiSet = {"/emotes/emojiSet", "Twitter"}; BoolSetting stackBits = {"/emotes/stackBits", false}; @@ -231,6 +256,7 @@ class Settings : public ABSettings, public ConcurrentSettings BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true}; BoolSetting enableSevenTVChannelEmotes = {"/emotes/seventv/channel", true}; BoolSetting enableSevenTVEventAPI = {"/emotes/seventv/eventapi", true}; + BoolSetting sendSevenTVActivity = {"/emotes/seventv/sendActivity", true}; /// Links BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false}; @@ -349,6 +375,23 @@ class Settings : public ABSettings, public ConcurrentSettings ""}; QStringSetting subHighlightColor = {"/highlighting/subHighlightColor", ""}; + BoolSetting enableAutomodHighlight = { + "/highlighting/automod/enabled", + true, + }; + BoolSetting enableAutomodHighlightSound = { + "/highlighting/automod/enableSound", + false, + }; + BoolSetting enableAutomodHighlightTaskbar = { + "/highlighting/automod/enableTaskbarFlashing", + false, + }; + QStringSetting automodHighlightSoundUrl = { + "/highlighting/automod/soundUrl", + "", + }; + BoolSetting enableThreadHighlight = { "/highlighting/thread/nameIsHighlightKeyword", true}; BoolSetting showThreadHighlightInMentions = { @@ -477,11 +520,13 @@ class Settings : public ABSettings, public ConcurrentSettings HelixTimegateOverride::Timegate, }; - IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; + EnumSetting emotesTooltipPreview = { + "/misc/emotesTooltipPreview", + ThumbnailPreviewMode::AlwaysShow, + }; QStringSetting cachePath = {"/cache/path", ""}; - BoolSetting restartOnCrash = {"/misc/restartOnCrash", false}; BoolSetting attachExtensionToAnyProcess = { "/misc/attachExtensionToAnyProcess", false}; BoolSetting askOnImageUpload = {"/misc/askOnImageUpload", true}; @@ -498,6 +543,8 @@ class Settings : public ABSettings, public ConcurrentSettings IntSetting lastSelectChannelTab = {"/ui/lastSelectChannelTab", 0}; IntSetting lastSelectIrcConn = {"/ui/lastSelectIrcConn", 0}; + BoolSetting showSendButton = {"/ui/showSendButton", false}; + // Similarity BoolSetting similarityEnabled = {"/similarity/similarityEnabled", false}; BoolSetting colorSimilarDisabled = {"/similarity/colorSimilarDisabled", @@ -527,8 +574,66 @@ class Settings : public ABSettings, public ConcurrentSettings {"d", 1}, {"w", 1}}}; + BoolSetting pluginsEnabled = {"/plugins/supportEnabled", false}; + ChatterinoSetting> enabledPlugins = { + "/plugins/enabledPlugins", {}}; + + // Advanced + EnumStringSetting soundBackend = { + "/sound/backend", + SoundBackend::Miniaudio, + }; + private: + ChatterinoSetting> highlightedMessagesSetting = + {"/highlighting/highlights"}; + ChatterinoSetting> highlightedUsersSetting = { + "/highlighting/users"}; + ChatterinoSetting> highlightedBadgesSetting = { + "/highlighting/badges"}; + ChatterinoSetting> + blacklistedUsersSetting = {"/highlighting/blacklist"}; + ChatterinoSetting> ignoredMessagesSetting = { + "/ignore/phrases"}; + ChatterinoSetting> mutedChannelsSetting = { + "/pings/muted"}; + ChatterinoSetting> filterRecordsSetting = { + "/filtering/filters"}; + ChatterinoSetting> nicknamesSetting = {"/nicknames"}; + ChatterinoSetting> moderationActionsSetting = + {"/moderation/actions"}; + ChatterinoSetting> loggedChannelsSetting = { + "/logging/channels"}; + +public: + SignalVector highlightedMessages; + SignalVector highlightedUsers; + SignalVector highlightedBadges; + SignalVector blacklistedUsers; + SignalVector ignoredMessages; + SignalVector mutedChannels; + SignalVector filterRecords; + SignalVector nicknames; + SignalVector moderationActions; + SignalVector loggedChannels; + + bool isHighlightedUser(const QString &username); + bool isBlacklistedUser(const QString &username); + bool isMutedChannel(const QString &channelName); + bool toggleMutedChannel(const QString &channelName); + std::optional matchNickname(const QString &username); + +private: + void mute(const QString &channelName); + void unmute(const QString &channelName); + void updateModerationActions(); + + std::unique_ptr snapshot_; + + pajlada::Signals::SignalHolder signalHolder; }; +Settings *getSettings(); + } // namespace chatterino diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index e13df1cd2ac..e89c31f9630 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -2,279 +2,445 @@ #include "singletons/Theme.hpp" #include "Application.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "singletons/Paths.hpp" #include "singletons/Resources.hpp" +#include "singletons/WindowManager.hpp" #include +#include +#include +#include +#include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) +# include +#endif #include -#define LOOKUP_COLOR_COUNT 360 - namespace { -double getMultiplierByTheme(const QString &themeName) + +using namespace chatterino; +using namespace literals; + +void parseInto(const QJsonObject &obj, QLatin1String key, QColor &color) +{ + const auto &jsonValue = obj[key]; + if (!jsonValue.isString()) [[unlikely]] + { + qCWarning(chatterinoTheme) << key + << "was expected but not found in the " + "current theme - using previous value."; + return; + } + QColor parsed = {jsonValue.toString()}; + if (!parsed.isValid()) [[unlikely]] + { + qCWarning(chatterinoTheme).nospace() + << "While parsing " << key << ": '" << jsonValue.toString() + << "' isn't a valid color."; + return; + } + color = parsed; +} + +// NOLINTBEGIN(cppcoreguidelines-macro-usage) +#define _c2StringLit(s, ty) s##ty +#define parseColor(to, from, key) \ + parseInto(from, _c2StringLit(#key, _L1), (to).from.key) +// NOLINTEND(cppcoreguidelines-macro-usage) + +void parseWindow(const QJsonObject &window, chatterino::Theme &theme) +{ + parseColor(theme, window, background); + parseColor(theme, window, text); +} + +void parseTabs(const QJsonObject &tabs, chatterino::Theme &theme) +{ + const auto parseTabColors = [](const auto &json, auto &tab) { + parseInto(json, "text"_L1, tab.text); + { + const auto backgrounds = json["backgrounds"_L1].toObject(); + parseColor(tab, backgrounds, regular); + parseColor(tab, backgrounds, hover); + parseColor(tab, backgrounds, unfocused); + } + { + const auto line = json["line"_L1].toObject(); + parseColor(tab, line, regular); + parseColor(tab, line, hover); + parseColor(tab, line, unfocused); + } + }; + parseColor(theme, tabs, dividerLine); + parseTabColors(tabs["regular"_L1].toObject(), theme.tabs.regular); + parseTabColors(tabs["newMessage"_L1].toObject(), theme.tabs.newMessage); + parseTabColors(tabs["highlighted"_L1].toObject(), theme.tabs.highlighted); + parseTabColors(tabs["selected"_L1].toObject(), theme.tabs.selected); +} + +void parseMessages(const QJsonObject &messages, chatterino::Theme &theme) { - if (themeName == "Light") { - return 0.8; + const auto textColors = messages["textColors"_L1].toObject(); + parseColor(theme.messages, textColors, regular); + parseColor(theme.messages, textColors, caret); + parseColor(theme.messages, textColors, link); + parseColor(theme.messages, textColors, system); + parseColor(theme.messages, textColors, chatPlaceholder); } - else if (themeName == "White") { - return 1.0; + const auto backgrounds = messages["backgrounds"_L1].toObject(); + parseColor(theme.messages, backgrounds, regular); + parseColor(theme.messages, backgrounds, alternate); } - else if (themeName == "Black") + parseColor(theme, messages, disabled); + parseColor(theme, messages, selection); + parseColor(theme, messages, highlightAnimationStart); + parseColor(theme, messages, highlightAnimationEnd); +} + +void parseScrollbars(const QJsonObject &scrollbars, chatterino::Theme &theme) +{ + parseColor(theme, scrollbars, background); + parseColor(theme, scrollbars, thumb); + parseColor(theme, scrollbars, thumbSelected); +} + +void parseSplits(const QJsonObject &splits, chatterino::Theme &theme) +{ + parseColor(theme, splits, messageSeperator); + parseColor(theme, splits, background); + parseColor(theme, splits, dropPreview); + parseColor(theme, splits, dropPreviewBorder); + parseColor(theme, splits, dropTargetRect); + parseColor(theme, splits, dropTargetRectBorder); + parseColor(theme, splits, resizeHandle); + parseColor(theme, splits, resizeHandleBackground); + { - return -1.0; + const auto header = splits["header"_L1].toObject(); + parseColor(theme.splits, header, border); + parseColor(theme.splits, header, focusedBorder); + parseColor(theme.splits, header, background); + parseColor(theme.splits, header, focusedBackground); + parseColor(theme.splits, header, text); + parseColor(theme.splits, header, focusedText); } - else if (themeName == "Dark") { - return -0.8; + const auto input = splits["input"_L1].toObject(); + parseColor(theme.splits, input, background); + parseColor(theme.splits, input, text); } - /* - else if (themeName == "Custom") - { - return getSettings()->customThemeMultiplier.getValue(); - } - */ +} + +void parseColors(const QJsonObject &root, chatterino::Theme &theme) +{ + const auto colors = root["colors"_L1].toObject(); + + parseInto(colors, "accent"_L1, theme.accent); - return -0.8; + parseWindow(colors["window"_L1].toObject(), theme); + parseTabs(colors["tabs"_L1].toObject(), theme); + parseMessages(colors["messages"_L1].toObject(), theme); + parseScrollbars(colors["scrollbars"_L1].toObject(), theme); + parseSplits(colors["splits"_L1].toObject(), theme); } +#undef parseColor +#undef _c2StringLit + +std::optional loadThemeFromPath(const QString &path) +{ + QFile file(path); + if (!file.open(QFile::ReadOnly)) + { + qCWarning(chatterinoTheme) + << "Failed to open" << file.fileName() << "at" << path; + return std::nullopt; + } + + QJsonParseError error{}; + auto json = QJsonDocument::fromJson(file.readAll(), &error); + if (!json.isObject()) + { + qCWarning(chatterinoTheme) << "Failed to parse" << file.fileName() + << "error:" << error.errorString(); + return std::nullopt; + } + + // TODO: Validate JSON schema? + + return json.object(); +} + +/** + * Load the given theme descriptor from its path + * + * Returns a JSON object containing theme data if the theme is valid, otherwise nullopt + * + * NOTE: No theme validation is done by this function + **/ +std::optional loadTheme(const ThemeDescriptor &theme) +{ + return loadThemeFromPath(theme.path); +} + } // namespace namespace chatterino { +const std::vector Theme::builtInThemes{ + { + .key = "White", + .path = ":/themes/White.json", + .name = "White", + }, + { + .key = "Light", + .path = ":/themes/Light.json", + .name = "Light", + }, + { + .key = "Dark", + .path = ":/themes/Dark.json", + .name = "Dark", + }, + { + .key = "Black", + .path = ":/themes/Black.json", + .name = "Black", + }, +}; + +// Dark is our default & fallback theme +const ThemeDescriptor Theme::fallbackTheme = Theme::builtInThemes.at(2); + bool Theme::isLightTheme() const { return this->isLight_; } -QColor Theme::blendColors(const QColor &color1, const QColor &color2, - qreal ratio) +bool Theme::isSystemTheme() const { - int r = int(color1.red() * (1 - ratio) + color2.red() * ratio); - int g = int(color1.green() * (1 - ratio) + color2.green() * ratio); - int b = int(color1.blue() * (1 - ratio) + color2.blue() * ratio); - - return QColor(r, g, b, 255); + return this->themeName == u"System"_s; } -Theme::Theme() +void Theme::initialize(Settings &settings, const Paths &paths) { - this->update(); - - this->themeName.connectSimple( - [this](auto) { + this->themeName.connect( + [this](auto themeName) { + qCInfo(chatterinoTheme) << "Theme updated to" << themeName; this->update(); }, false); - this->themeHue.connectSimple( - [this](auto) { + auto updateIfSystem = [this](const auto &) { + if (this->isSystemTheme()) + { this->update(); - }, - false); + } + }; + this->darkSystemThemeName.connect(updateIfSystem, false); + this->lightSystemThemeName.connect(updateIfSystem, false); + + this->loadAvailableThemes(paths); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + QObject::connect(qApp->styleHints(), &QStyleHints::colorSchemeChanged, + &this->lifetime_, [this] { + if (this->isSystemTheme()) + { + this->update(); + getIApp()->getWindows()->forceLayoutChannelViews(); + } + }); +#endif + + this->update(); } void Theme::update() { - this->actuallyUpdate(this->themeHue, - getMultiplierByTheme(this->themeName.getValue())); + auto currentTheme = [&]() -> QString { +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + if (this->isSystemTheme()) + { + switch (qApp->styleHints()->colorScheme()) + { + case Qt::ColorScheme::Light: + return this->lightSystemThemeName; + case Qt::ColorScheme::Unknown: + case Qt::ColorScheme::Dark: + return this->darkSystemThemeName; + } + } +#endif + return this->themeName; + }; - this->updated.invoke(); -} + auto oTheme = this->findThemeByKey(currentTheme()); -// hue: theme color (0 - 1) -// multiplier: 1 = white, 0.8 = light, -0.8 dark, -1 black -void Theme::actuallyUpdate(double hue, double multiplier) -{ - this->isLight_ = multiplier > 0; - bool lightWin = isLight_; + constexpr const double nsToMs = 1.0 / 1000000.0; + QElapsedTimer timer; + timer.start(); - // QColor themeColor = QColor::fromHslF(hue, 0.43, 0.5); - QColor themeColor = QColor::fromHslF(hue, 0.8, 0.5); - QColor themeColorNoSat = QColor::fromHslF(hue, 0, 0.5); + std::optional themeJSON; + QString themePath; + if (!oTheme) + { + qCWarning(chatterinoTheme) + << "Theme" << this->themeName + << "not found, falling back to the fallback theme"; - const auto sat = qreal(0); - const auto isLight = this->isLightTheme(); - const auto flat = isLight; + themeJSON = loadTheme(fallbackTheme); + themePath = fallbackTheme.path; + } + else + { + const auto &theme = *oTheme; - auto getColor = [multiplier](double h, double s, double l, double a = 1.0) { - return QColor::fromHslF(h, s, ((l - 0.5) * multiplier) + 0.5, a); - }; + themeJSON = loadTheme(theme); + themePath = theme.path; + + if (!themeJSON) + { + qCWarning(chatterinoTheme) + << "Theme" << this->themeName + << "not valid, falling back to the fallback theme"; - /// WINDOW + // Parsing the theme failed, fall back + themeJSON = loadTheme(fallbackTheme); + themePath = fallbackTheme.path; + } + } + auto loadTs = double(timer.nsecsElapsed()) * nsToMs; + + if (!themeJSON) { -#ifdef Q_OS_LINUX - this->window.background = lightWin ? "#fff" : QColor(61, 60, 56); -#else - this->window.background = lightWin ? "#fff" : "#111"; -#endif + qCWarning(chatterinoTheme) + << "Failed to load" << this->themeName << "or the fallback theme"; + return; + } - QColor fg = this->window.text = lightWin ? "#000" : "#eee"; - this->window.borderFocused = lightWin ? "#ccc" : themeColor; - this->window.borderUnfocused = lightWin ? "#ccc" : themeColorNoSat; + if (this->isAutoReloading() && this->currentThemeJson_ == *themeJSON) + { + return; + } - // Ubuntu style - // TODO: add setting for this - // TabText = QColor(210, 210, 210); - // TabBackground = QColor(61, 60, 56); - // TabHoverText = QColor(210, 210, 210); - // TabHoverBackground = QColor(73, 72, 68); + this->parseFrom(*themeJSON); + this->currentThemePath_ = themePath; - // message (referenced later) - this->messages.textColors.caret = // - this->messages.textColors.regular = isLight_ ? "#000" : "#fff"; + auto parseTs = double(timer.nsecsElapsed()) * nsToMs; - QColor highlighted = lightWin ? QColor("#ff0000") : QColor("#ee6166"); + this->updated.invoke(); + auto updateTs = double(timer.nsecsElapsed()) * nsToMs; + qCDebug(chatterinoTheme).nospace().noquote() + << "Updated theme in " << QString::number(updateTs, 'f', 2) + << "ms (load: " << QString::number(loadTs, 'f', 2) + << "ms, parse: " << QString::number(parseTs - loadTs, 'f', 2) + << "ms, update: " << QString::number(updateTs - parseTs, 'f', 2) + << "ms)"; + + if (this->isAutoReloading()) + { + this->currentThemeJson_ = *themeJSON; + } +} + +std::vector> Theme::availableThemes() const +{ + std::vector> packagedThemes; - /// TABS - if (lightWin) + for (const auto &theme : this->availableThemes_) + { + if (theme.custom) { - this->tabs.regular = { - QColor("#444"), - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {QColor("#fff"), QColor("#fff"), QColor("#fff")}}; - this->tabs.newMessage = { - QColor("#222"), - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {QColor("#bbb"), QColor("#bbb"), QColor("#bbb")}}; - this->tabs.highlighted = { - fg, - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {highlighted, highlighted, highlighted}}; - this->tabs.selected = { - QColor("#000"), - {QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")}, - {this->accent, this->accent, this->accent}}; + auto p = std::make_pair( + QStringLiteral("Custom: %1").arg(theme.name), theme.key); + + packagedThemes.emplace_back(p); } else { - this->tabs.regular = { - QColor("#aaa"), - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {QColor("#444"), QColor("#444"), QColor("#444")}}; - this->tabs.newMessage = { - fg, - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {QColor("#888"), QColor("#888"), QColor("#888")}}; - this->tabs.highlighted = { - fg, - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {highlighted, highlighted, highlighted}}; - - this->tabs.selected = { - QColor("#fff"), - {QColor("#555555"), QColor("#555555"), QColor("#555555")}, - {this->accent, this->accent, this->accent}}; - } + auto p = std::make_pair(theme.name, theme.key); - // scrollbar - this->scrollbars.highlights.highlight = QColor("#ee6166"); - this->scrollbars.highlights.subscription = QColor("#C466FF"); - - // this->tabs.newMessage = { - // fg, - // {QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), - // QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), - // QBrush(blendColors(themeColorNoSat, "#ccc", 0.9), - // Qt::FDiagPattern)}}; - - // this->tabs.newMessage = { - // fg, - // {QBrush(blendColors(themeColor, "#666", 0.7), - // Qt::FDiagPattern), - // QBrush(blendColors(themeColor, "#666", 0.5), - // Qt::FDiagPattern), - // QBrush(blendColors(themeColorNoSat, "#666", 0.7), - // Qt::FDiagPattern)}}; - // this->tabs.highlighted = {fg, {QColor("#777"), - // QColor("#777"), QColor("#666")}}; - - this->tabs.dividerLine = - this->tabs.selected.backgrounds.regular.color(); + packagedThemes.emplace_back(p); + } } - // Message - this->messages.textColors.link = - isLight_ ? QColor(66, 134, 244) : QColor(66, 134, 244); - this->messages.textColors.system = QColor(140, 127, 127); - this->messages.textColors.chatPlaceholder = - isLight_ ? QColor(175, 159, 159) : QColor(93, 85, 85); - - this->messages.backgrounds.regular = getColor(0, sat, 1); - this->messages.backgrounds.alternate = getColor(0, sat, 0.96); - - // this->messages.backgrounds.resub - // this->messages.backgrounds.whisper - this->messages.disabled = getColor(0, sat, 1, 0.6); - // this->messages.seperator = - // this->messages.seperatorInner = - - int complementaryGray = this->isLightTheme() ? 20 : 230; - this->messages.highlightAnimationStart = - QColor(complementaryGray, complementaryGray, complementaryGray, 110); - this->messages.highlightAnimationEnd = - QColor(complementaryGray, complementaryGray, complementaryGray, 0); - - // Scrollbar - this->scrollbars.background = QColor(0, 0, 0, 0); - // this->scrollbars.background = splits.background; - // this->scrollbars.background.setAlphaF(qreal(0.2)); - this->scrollbars.thumb = getColor(0, sat, 0.70); - this->scrollbars.thumbSelected = getColor(0, sat, 0.65); - - // tooltip - this->tooltip.background = QColor(0, 0, 0); - this->tooltip.text = QColor(255, 255, 255); - - // Selection - this->messages.selection = - isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); + return packagedThemes; +} - if (this->isLightTheme()) +void Theme::loadAvailableThemes(const Paths &paths) +{ + this->availableThemes_ = Theme::builtInThemes; + + auto dir = QDir(paths.themesDirectory); + for (const auto &info : + dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot, QDir::Name)) { - this->splits.dropTargetRect = QColor(255, 255, 255, 0x00); - this->splits.dropTargetRectBorder = QColor(0, 148, 255, 0x00); + if (!info.isFile()) + { + continue; + } + + if (!info.fileName().endsWith(".json")) + { + continue; + } - this->splits.resizeHandle = QColor(0, 148, 255, 0xff); - this->splits.resizeHandleBackground = QColor(0, 148, 255, 0x50); + auto themeName = info.baseName(); + + auto themeDescriptor = ThemeDescriptor{ + info.fileName(), info.absoluteFilePath(), themeName, true}; + + auto theme = loadTheme(themeDescriptor); + if (!theme) + { + qCWarning(chatterinoTheme) << "Failed to parse theme at" << info; + continue; + } + + this->availableThemes_.emplace_back(std::move(themeDescriptor)); } - else - { - this->splits.dropTargetRect = QColor(0, 148, 255, 0x00); - this->splits.dropTargetRectBorder = QColor(0, 148, 255, 0x00); +} - this->splits.resizeHandle = QColor(0, 148, 255, 0x70); - this->splits.resizeHandleBackground = QColor(0, 148, 255, 0x20); +std::optional Theme::findThemeByKey(const QString &key) +{ + for (const auto &theme : this->availableThemes_) + { + if (theme.key == key) + { + return theme; + } } - this->splits.header.background = getColor(0, sat, flat ? 1 : 0.9); - this->splits.header.border = getColor(0, sat, flat ? 1 : 0.85); - this->splits.header.text = this->messages.textColors.regular; - this->splits.header.focusedBackground = - getColor(0, sat, isLight ? 0.95 : 0.79); - this->splits.header.focusedBorder = getColor(0, sat, isLight ? 0.90 : 0.78); - this->splits.header.focusedText = QColor::fromHsvF( - 0.58388, isLight ? 1.0 : 0.482, isLight ? 0.6375 : 1.0); - - this->splits.input.background = getColor(0, sat, flat ? 0.95 : 0.95); - this->splits.input.border = getColor(0, sat, flat ? 1 : 1); - this->splits.input.text = this->messages.textColors.regular; - this->splits.input.styleSheet = - "background:" + this->splits.input.background.name() + ";" + - "border:" + this->tabs.selected.backgrounds.regular.color().name() + - ";" + "color:" + this->messages.textColors.regular.name() + ";" + - "selection-background-color:" + - (isLight ? "#68B1FF" - : this->tabs.selected.backgrounds.regular.color().name()); - - this->splits.input.focusedLine = this->tabs.highlighted.line.regular; - - this->splits.messageSeperator = - isLight ? QColor(127, 127, 127) : QColor(60, 60, 60); - this->splits.background = getColor(0, sat, 1); - this->splits.dropPreview = QColor(0, 148, 255, 0x30); - this->splits.dropPreviewBorder = QColor(0, 148, 255, 0xff); - - // Copy button + return std::nullopt; +} + +void Theme::parseFrom(const QJsonObject &root) +{ + parseColors(root, *this); + + this->isLight_ = + root["metadata"_L1]["iconTheme"_L1].toString() == u"dark"_s; + + this->splits.input.styleSheet = uR"( + background: %1; + border: %2; + color: %3; + selection-background-color: %4; + )"_s.arg( + this->splits.input.background.name(QColor::HexArgb), + this->tabs.selected.backgrounds.regular.name(QColor::HexArgb), + this->messages.textColors.regular.name(QColor::HexArgb), + this->isLightTheme() + ? u"#68B1FF"_s + : this->tabs.selected.backgrounds.regular.name(QColor::HexArgb)); + + // Usercard buttons if (this->isLightTheme()) { this->buttons.copy = getResources().buttons.copyDark; @@ -287,7 +453,36 @@ void Theme::actuallyUpdate(double hue, double multiplier) } } -void Theme::normalizeColor(QColor &color) +bool Theme::isAutoReloading() const +{ + return this->themeReloadTimer_ != nullptr; +} + +void Theme::setAutoReload(bool autoReload) +{ + if (autoReload == this->isAutoReloading()) + { + return; + } + + if (!autoReload) + { + this->themeReloadTimer_.reset(); + this->currentThemeJson_ = {}; + return; + } + + this->themeReloadTimer_ = std::make_unique(); + QObject::connect(this->themeReloadTimer_.get(), &QTimer::timeout, [this]() { + this->update(); + }); + this->themeReloadTimer_->setInterval(Theme::AUTO_RELOAD_INTERVAL_MS); + this->themeReloadTimer_->start(); + + qCDebug(chatterinoTheme) << "Enabled theme watcher"; +} + +void Theme::normalizeColor(QColor &color) const { if (this->isLightTheme()) { @@ -326,7 +521,7 @@ void Theme::normalizeColor(QColor &color) Theme *getTheme() { - return getApp()->themes; + return getIApp()->getThemes(); } } // namespace chatterino diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index f99ba169def..d67ff8a9eb3 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -5,26 +5,55 @@ #include "util/RapidJsonSerializeQString.hpp" #include -#include #include +#include +#include +#include +#include +#include + +#include +#include +#include namespace chatterino { class WindowManager; +struct ThemeDescriptor { + QString key; + + // Path to the theme on disk + // Can be a Qt resource path + QString path; + + // Name of the theme + QString name; + + bool custom{}; +}; + class Theme final : public Singleton { public: - Theme(); + static const std::vector builtInThemes; + + // The built in theme that will be used if some theme parsing fails + static const ThemeDescriptor fallbackTheme; + + static const int AUTO_RELOAD_INTERVAL_MS = 500; + + void initialize(Settings &settings, const Paths &paths) final; bool isLightTheme() const; + bool isSystemTheme() const; struct TabColors { QColor text; struct { - QBrush regular; - QBrush hover; - QBrush unfocused; + QColor regular; + QColor hover; + QColor unfocused; } backgrounds; struct { QColor regular; @@ -39,8 +68,6 @@ class Theme final : public Singleton struct { QColor background; QColor text; - QColor borderUnfocused; - QColor borderFocused; } window; /// TABS @@ -49,7 +76,6 @@ class Theme final : public Singleton TabColors newMessage; TabColors highlighted; TabColors selected; - QColor border; QColor dividerLine; } tabs; @@ -66,12 +92,9 @@ class Theme final : public Singleton struct { QColor regular; QColor alternate; - // QColor whisper; } backgrounds; QColor disabled; - // QColor seperator; - // QColor seperatorInner; QColor selection; QColor highlightAnimationStart; @@ -83,18 +106,8 @@ class Theme final : public Singleton QColor background; QColor thumb; QColor thumbSelected; - struct { - QColor highlight; - QColor subscription; - } highlights; } scrollbars; - /// TOOLTIP - struct { - QColor text; - QColor background; - } tooltip; - /// SPLITS struct { QColor messageSeperator; @@ -113,17 +126,12 @@ class Theme final : public Singleton QColor focusedBackground; QColor text; QColor focusedText; - // int margin; } header; struct { - QColor border; QColor background; - QColor selection; - QColor focusedLine; QColor text; QString styleSheet; - // int margin; } input; } splits; @@ -132,18 +140,46 @@ class Theme final : public Singleton QPixmap pin; } buttons; - void normalizeColor(QColor &color); + void normalizeColor(QColor &color) const; void update(); - QColor blendColors(const QColor &color1, const QColor &color2, qreal ratio); + + bool isAutoReloading() const; + void setAutoReload(bool autoReload); + + /** + * Return a list of available themes + **/ + std::vector> availableThemes() const; pajlada::Signals::NoArgSignal updated; QStringSetting themeName{"/appearance/theme/name", "Dark"}; - DoubleSetting themeHue{"/appearance/theme/hue", 0.0}; + QStringSetting lightSystemThemeName{"/appearance/theme/lightSystem", + "Light"}; + QStringSetting darkSystemThemeName{"/appearance/theme/darkSystem", "Dark"}; private: bool isLight_ = false; - void actuallyUpdate(double hue, double multiplier); + + std::vector availableThemes_; + + QString currentThemePath_; + std::unique_ptr themeReloadTimer_; + // This will only be populated when auto-reloading themes + QJsonObject currentThemeJson_; + + QObject lifetime_; + + /** + * Figure out which themes are available in the Themes directory + * + * NOTE: This is currently not built to be reloadable + **/ + void loadAvailableThemes(const Paths &paths); + + std::optional findThemeByKey(const QString &key); + + void parseFrom(const QJsonObject &root); pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp index 09fbf8fbcc0..aab01b51dd7 100644 --- a/src/singletons/Toasts.cpp +++ b/src/singletons/Toasts.cpp @@ -1,12 +1,11 @@ #include "Toasts.hpp" #include "Application.hpp" -#include "common/DownloadManager.hpp" -#include "common/NetworkRequest.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "common/Version.hpp" #include "controllers/notifications/NotificationController.hpp" #include "providers/twitch/api/Helix.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" @@ -14,32 +13,65 @@ #include "widgets/helper/CommonTexts.hpp" #ifdef Q_OS_WIN - # include - #endif #include #include #include #include +#include #include -#include +#include + +namespace { + +using namespace chatterino; +using namespace literals; + +QString avatarFilePath(const QString &channelName) +{ + // TODO: cleanup channel (to be used as a file) and use combinePath + return getIApp()->getPaths().twitchProfileAvatars % '/' % channelName % + u".png"; +} + +bool hasAvatarForChannel(const QString &channelName) +{ + QFileInfo avatarFile(avatarFilePath(channelName)); + return avatarFile.exists() && avatarFile.isFile(); +} + +/// A job that downlaods a twitch avatar and saves it to a file +class AvatarDownloader : public QObject +{ + Q_OBJECT +public: + AvatarDownloader(const QString &avatarURL, const QString &channelName); + +private: + QNetworkAccessManager manager_; + QFile file_; + QNetworkReply *reply_{}; + +signals: + void downloadComplete(); +}; + +} // namespace namespace chatterino { -std::map Toasts::reactionToString = { - {ToastReaction::OpenInBrowser, OPEN_IN_BROWSER}, - {ToastReaction::OpenInPlayer, OPEN_PLAYER_IN_BROWSER}, - {ToastReaction::OpenInStreamlink, OPEN_IN_STREAMLINK}, - {ToastReaction::DontOpen, DONT_OPEN}}; +#ifdef Q_OS_WIN +using WinToastLib::WinToast; +using WinToastLib::WinToastTemplate; +#endif bool Toasts::isEnabled() { #ifdef Q_OS_WIN - return WinToastLib::WinToast::isCompatible() && - getSettings()->notificationToast && + return WinToast::isCompatible() && getSettings()->notificationToast && !(isInStreamerMode() && getSettings()->streamerModeSuppressLiveNotifications); #else @@ -49,24 +81,31 @@ bool Toasts::isEnabled() QString Toasts::findStringFromReaction(const ToastReaction &reaction) { - auto iterator = Toasts::reactionToString.find(reaction); - if (iterator != Toasts::reactionToString.end()) + // The constants are macros right now, but we want to avoid ASCII casts, + // so we're concatenating them with a QString literal - effectively making them part of it. + switch (reaction) { - return iterator->second; - } - else - { - return DONT_OPEN; + case ToastReaction::OpenInBrowser: + return OPEN_IN_BROWSER u""_s; + case ToastReaction::OpenInPlayer: + return OPEN_PLAYER_IN_BROWSER u""_s; + case ToastReaction::OpenInStreamlink: + return OPEN_IN_STREAMLINK u""_s; + case ToastReaction::DontOpen: + default: + return DONT_OPEN u""_s; } } QString Toasts::findStringFromReaction( - const pajlada::Settings::Setting &value) + const pajlada::Settings::Setting &reaction) { - int i = static_cast(value); - return Toasts::findStringFromReaction(static_cast(i)); + static_assert(std::is_same_v, int>); + int value = reaction; + return Toasts::findStringFromReaction(static_cast(value)); } +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) void Toasts::sendChannelNotification(const QString &channelName, const QString &channelTitle, Platform p) { @@ -75,16 +114,15 @@ void Toasts::sendChannelNotification(const QString &channelName, this->sendWindowsNotification(channelName, channelTitle, p); }; #else + (void)channelTitle; auto sendChannelNotification = [] { - // Unimplemented for OSX and Linux + // Unimplemented for macOS and Linux }; #endif // Fetch user profile avatar if (p == Platform::Twitch) { - QFileInfo check_file(getPaths()->twitchProfileAvatars + "/twitch/" + - channelName + ".png"); - if (check_file.exists() && check_file.isFile()) + if (hasAvatarForChannel(channelName)) { sendChannelNotification(); } @@ -93,10 +131,11 @@ void Toasts::sendChannelNotification(const QString &channelName, getHelix()->getUserByName( channelName, [channelName, sendChannelNotification](const auto &user) { - DownloadManager *manager = new DownloadManager(); - manager->setFile(user.profileImageUrl, channelName); - manager->connect(manager, - &DownloadManager::downloadComplete, + // gets deleted when finished + auto *downloader = + new AvatarDownloader(user.profileImageUrl, channelName); + QObject::connect(downloader, + &AvatarDownloader::downloadComplete, sendChannelNotification); }, [] { @@ -116,13 +155,12 @@ class CustomHandler : public WinToastLib::IWinToastHandler public: CustomHandler(QString channelName, Platform p) - : channelName_(channelName) + : channelName_(std::move(channelName)) , platform_(p) { } - void toastActivated() const + void toastActivated() const override { - QString link; auto toastReaction = static_cast(getSettings()->openFromToast.getValue()); @@ -131,51 +169,74 @@ class CustomHandler : public WinToastLib::IWinToastHandler case ToastReaction::OpenInBrowser: if (platform_ == Platform::Twitch) { - link = "https://www.twitch.tv/" + channelName_; + QDesktopServices::openUrl( + QUrl(u"https://www.twitch.tv/" % channelName_)); } - QDesktopServices::openUrl(QUrl(link)); break; case ToastReaction::OpenInPlayer: if (platform_ == Platform::Twitch) { - link = - "https://player.twitch.tv/?parent=twitch.tv&channel=" + - channelName_; + QDesktopServices::openUrl(QUrl( + u"https://player.twitch.tv/?parent=twitch.tv&channel=" % + channelName_)); } - QDesktopServices::openUrl(QUrl(link)); break; case ToastReaction::OpenInStreamlink: { openStreamlinkForChannel(channelName_); break; } - // the fourth and last option is "don't open" - // in this case obviously nothing should happen + case ToastReaction::DontOpen: + // nothing should happen + break; } } - void toastActivated(int actionIndex) const + void toastActivated(int actionIndex) const override { } - void toastFailed() const + void toastFailed() const override { } - void toastDismissed(WinToastDismissalReason state) const + void toastDismissed(WinToastDismissalReason state) const override { } }; +void Toasts::ensureInitialized() +{ + if (this->initialized_) + { + return; + } + this->initialized_ = true; + + auto *instance = WinToast::instance(); + instance->setAppName(L"Chatterino2"); + instance->setAppUserModelId( + WinToast::configureAUMI(L"", L"Chatterino 2", L"", + Version::instance().version().toStdWString())); + instance->setShortcutPolicy(WinToast::SHORTCUT_POLICY_IGNORE); + WinToast::WinToastError error{}; + instance->initialize(&error); + + if (error != WinToast::NoError) + { + qCDebug(chatterinoNotification) + << "Failed to initialize WinToast - error:" << error; + } +} + void Toasts::sendWindowsNotification(const QString &channelName, const QString &channelTitle, Platform p) { - WinToastLib::WinToastTemplate templ = WinToastLib::WinToastTemplate( - WinToastLib::WinToastTemplate::ImageAndText03); - QString str = channelName + " is live!"; - std::string utf8_text = str.toUtf8().constData(); - std::wstring widestr = std::wstring(utf8_text.begin(), utf8_text.end()); + this->ensureInitialized(); + + WinToastTemplate templ(WinToastTemplate::ImageAndText03); + QString str = channelName % u" is live!"; - templ.setTextField(widestr, WinToastLib::WinToastTemplate::FirstLine); + templ.setTextField(str.toStdWString(), WinToastTemplate::FirstLine); if (static_cast(getSettings()->openFromToast.getValue()) != ToastReaction::DontOpen) { @@ -183,43 +244,68 @@ void Toasts::sendWindowsNotification(const QString &channelName, Toasts::findStringFromReaction(getSettings()->openFromToast); mode = mode.toLower(); - templ.setTextField(QString("%1 \nClick to %2") - .arg(channelTitle) - .arg(mode) - .toStdWString(), - WinToastLib::WinToastTemplate::SecondLine); + templ.setTextField( + u"%1 \nClick to %2"_s.arg(channelTitle).arg(mode).toStdWString(), + WinToastTemplate::SecondLine); } - QString Path; + QString avatarPath; if (p == Platform::Twitch) { - Path = getPaths()->twitchProfileAvatars + "/twitch/" + channelName + - ".png"; + avatarPath = avatarFilePath(channelName); } - std::string temp_Utf8 = Path.toUtf8().constData(); - std::wstring imagePath = std::wstring(temp_Utf8.begin(), temp_Utf8.end()); - templ.setImagePath(imagePath); + templ.setImagePath(avatarPath.toStdWString()); if (getSettings()->notificationPlaySound) { - templ.setAudioOption( - WinToastLib::WinToastTemplate::AudioOption::Silent); + templ.setAudioOption(WinToastTemplate::AudioOption::Silent); + } + + WinToast::WinToastError error = WinToast::NoError; + WinToast::instance()->showToast(templ, new CustomHandler(channelName, p), + &error); + if (error != WinToast::NoError) + { + qCWarning(chatterinoNotification) << "Failed to show toast:" << error; } - WinToastLib::WinToast::instance()->setAppName(L"Chatterino2"); - int mbstowcs(wchar_t * aumi_version, const char *CHATTERINO_VERSION, - size_t size); - std::string(CHATTERINO_VERSION); - std::wstring aumi_version = - std::wstring(CHATTERINO_VERSION.begin(), CHATTERINO_VERSION.end()); - WinToastLib::WinToast::instance()->setAppUserModelId( - WinToastLib::WinToast::configureAUMI(L"", L"Chatterino 2", L"", - aumi_version)); - WinToastLib::WinToast::instance()->setShortcutPolicy( - WinToastLib::WinToast::SHORTCUT_POLICY_IGNORE); - WinToastLib::WinToast::instance()->initialize(); - WinToastLib::WinToast::instance()->showToast( - templ, new CustomHandler(channelName, p)); } #endif } // namespace chatterino + +namespace { + +AvatarDownloader::AvatarDownloader(const QString &avatarURL, + const QString &channelName) + : file_(avatarFilePath(channelName)) +{ + if (!this->file_.open(QFile::WriteOnly | QFile::Truncate)) + { + qCWarning(chatterinoNotification) + << "Failed to open avatar file" << this->file_.errorString(); + } + + this->reply_ = this->manager_.get(QNetworkRequest(avatarURL)); + + connect(this->reply_, &QNetworkReply::readyRead, this, [this] { + this->file_.write(this->reply_->readAll()); + }); + connect(this->reply_, &QNetworkReply::finished, this, [this] { + if (this->reply_->error() != QNetworkReply::NoError) + { + qCWarning(chatterinoNotification) + << "Failed to download avatar" << this->reply_->errorString(); + } + + if (this->file_.isOpen()) + { + this->file_.close(); + } + emit downloadComplete(); + this->deleteLater(); + }); +} + +#include "Toasts.moc" + +} // namespace diff --git a/src/singletons/Toasts.hpp b/src/singletons/Toasts.hpp index a47a52b5ca5..83e64a08143 100644 --- a/src/singletons/Toasts.hpp +++ b/src/singletons/Toasts.hpp @@ -24,14 +24,16 @@ class Toasts final : public Singleton static QString findStringFromReaction(const ToastReaction &reaction); static QString findStringFromReaction( const pajlada::Settings::Setting &reaction); - static std::map reactionToString; static bool isEnabled(); private: #ifdef Q_OS_WIN + void ensureInitialized(); void sendWindowsNotification(const QString &channelName, const QString &channelTitle, Platform p); + + bool initialized_ = false; #endif }; } // namespace chatterino diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index 44e8b492b77..0a7351491b1 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -1,9 +1,8 @@ #include "Updates.hpp" #include "common/Modes.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" #include "Settings.hpp" @@ -11,6 +10,7 @@ #include "util/CombinePath.hpp" #include "util/PostToThread.hpp" +#include #include #include #include @@ -26,21 +26,14 @@ namespace { } // namespace -Updates::Updates() - : currentVersion_(CHATTERINO_VERSION) +Updates::Updates(const Paths &paths_) + : paths(paths_) + , currentVersion_(CHATTERINO_VERSION) , updateGuideLink_("https://chatterino.com") { qCDebug(chatterinoUpdate) << "init UpdateManager"; } -Updates &Updates::instance() -{ - // fourtf: don't add this class to the application class - static Updates instance; - - return instance; -} - /// Checks if the online version is newer or older than the current version. bool Updates::isDowngradeOf(const QString &online, const QString ¤t) { @@ -97,7 +90,7 @@ void Updates::installUpdates() box->exec(); QDesktopServices::openUrl(this->updateGuideLink_); #elif defined Q_OS_WIN - if (getPaths()->isPortable()) + if (Modes::instance().isPortable) { QMessageBox *box = new QMessageBox(QMessageBox::Information, "Chatterino Update", @@ -121,10 +114,22 @@ void Updates::installUpdates() box->raise(); }); }) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { + if (result.status() != 200) + { + auto *box = new QMessageBox( + QMessageBox::Information, "Chatterino Update", + QStringLiteral("The update couldn't be downloaded " + "(Error: %1).") + .arg(result.formatError())); + box->setAttribute(Qt::WA_DeleteOnClose); + box->exec(); + return; + } + QByteArray object = result.getData(); auto filename = - combinePath(getPaths()->miscDirectory, "update.zip"); + combinePath(this->paths.miscDirectory, "update.zip"); QFile file(filename); file.open(QIODevice::Truncate | QIODevice::WriteOnly); @@ -132,7 +137,7 @@ void Updates::installUpdates() if (file.write(object) == -1) { this->setStatus_(WriteFileFailed); - return Failure; + return; } file.flush(); file.close(); @@ -143,7 +148,6 @@ void Updates::installUpdates() {filename, "restart"}); QApplication::exit(0); - return Success; }) .execute(); this->setStatus_(Downloading); @@ -170,10 +174,22 @@ void Updates::installUpdates() box->setAttribute(Qt::WA_DeleteOnClose); box->exec(); }) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { + if (result.status() != 200) + { + auto *box = new QMessageBox( + QMessageBox::Information, "Chatterino Update", + QStringLiteral("The update couldn't be downloaded " + "(Error: %1).") + .arg(result.formatError())); + box->setAttribute(Qt::WA_DeleteOnClose); + box->exec(); + return; + } + QByteArray object = result.getData(); auto filePath = - combinePath(getPaths()->miscDirectory, "Update.exe"); + combinePath(this->paths.miscDirectory, "Update.exe"); QFile file(filePath); file.open(QIODevice::Truncate | QIODevice::WriteOnly); @@ -191,7 +207,7 @@ void Updates::installUpdates() box->exec(); QDesktopServices::openUrl(this->updateExe_); - return Failure; + return; } file.flush(); file.close(); @@ -214,8 +230,6 @@ void Updates::installUpdates() QDesktopServices::openUrl(this->updateExe_); } - - return Success; }) .execute(); this->setStatus_(Downloading); @@ -225,6 +239,7 @@ void Updates::installUpdates() void Updates::checkForUpdates() { +#ifndef CHATTERINO_DISABLE_UPDATER auto version = Version::instance(); if (!version.isSupportedOS()) @@ -253,53 +268,57 @@ void Updates::checkForUpdates() NetworkRequest(url) .timeout(60000) - .onSuccess([this](auto result) -> Outcome { - auto object = result.parseJson(); + .onSuccess([this](auto result) { + const auto object = result.parseJson(); /// Version available on every platform - QJsonValue version_val = object.value("version"); + auto version = object["version"]; - if (!version_val.isString()) + if (!version.isString()) { this->setStatus_(SearchFailed); - qCDebug(chatterinoUpdate) << "error updating"; - return Failure; + qCDebug(chatterinoUpdate) + << "error checking version - missing 'version'" << object; + return; } -#if defined Q_OS_WIN || defined Q_OS_MACOS +# if defined Q_OS_WIN || defined Q_OS_MACOS /// Downloads an installer for the new version - QJsonValue updateExe_val = object.value("updateexe"); - if (!updateExe_val.isString()) + auto updateExeUrl = object["updateexe"]; + if (!updateExeUrl.isString()) { this->setStatus_(SearchFailed); - qCDebug(chatterinoUpdate) << "error updating"; - return Failure; + qCDebug(chatterinoUpdate) + << "error checking version - missing 'updateexe'" << object; + return; } - this->updateExe_ = updateExe_val.toString(); + this->updateExe_ = updateExeUrl.toString(); -# ifdef Q_OS_WIN +# ifdef Q_OS_WIN /// Windows portable - QJsonValue portable_val = object.value("portable_download"); - if (!portable_val.isString()) + auto portableUrl = object["portable_download"]; + if (!portableUrl.isString()) { this->setStatus_(SearchFailed); - qCDebug(chatterinoUpdate) << "error updating"; - return Failure; + qCDebug(chatterinoUpdate) + << "error checking version - missing 'portable_download'" + << object; + return; } - this->updatePortable_ = portable_val.toString(); -# endif + this->updatePortable_ = portableUrl.toString(); +# endif -#elif defined Q_OS_LINUX - QJsonValue updateGuide_val = object.value("updateguide"); - if (updateGuide_val.isString()) +# elif defined Q_OS_LINUX + QJsonValue updateGuide = object.value("updateguide"); + if (updateGuide.isString()) { - this->updateGuideLink_ = updateGuide_val.toString(); + this->updateGuideLink_ = updateGuide.toString(); } -#else - return Failure; -#endif +# else + return; +# endif /// Current version - this->onlineVersion_ = version_val.toString(); + this->onlineVersion_ = version.toString(); /// Update available :) if (this->currentVersion_ != this->onlineVersion_) @@ -312,10 +331,10 @@ void Updates::checkForUpdates() { this->setStatus_(NoUpdateAvailable); } - return Failure; }) .execute(); this->setStatus_(Searching); +#endif } Updates::Status Updates::getStatus() const diff --git a/src/singletons/Updates.hpp b/src/singletons/Updates.hpp index e08b313bac5..8a063f345ba 100644 --- a/src/singletons/Updates.hpp +++ b/src/singletons/Updates.hpp @@ -5,11 +5,19 @@ namespace chatterino { +class Paths; + +/** + * To check for updates, use the `checkForUpdates` method. + * The class by itself does not start any automatic updates. + */ class Updates { - Updates(); + const Paths &paths; public: + explicit Updates(const Paths &paths_); + enum Status { None, Searching, @@ -21,9 +29,6 @@ class Updates WriteFileFailed, }; - // fourtf: don't add this class to the application class - static Updates &instance(); - static bool isDowngradeOf(const QString &online, const QString ¤t); void checkForUpdates(); diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 15053ccd1f2..bf75f0ed4da 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -24,7 +24,6 @@ #include "widgets/splits/SplitContainer.hpp" #include "widgets/Window.hpp" -#include #include #include #include @@ -34,13 +33,14 @@ #include #include +#include namespace chatterino { namespace { - boost::optional &shouldMoveOutOfBoundsWindow() + std::optional &shouldMoveOutOfBoundsWindow() { - static boost::optional x; + static std::optional x; return x; } @@ -50,12 +50,11 @@ const QString WindowManager::WINDOW_LAYOUT_FILENAME( QStringLiteral("window-layout.json")); using SplitNode = SplitContainer::Node; -using SplitDirection = SplitContainer::Direction; void WindowManager::showSettingsDialog(QWidget *parent, SettingsDialogPreference preference) { - if (getArgs().dontSaveSettings) + if (getApp()->getArgs().dontSaveSettings) { QMessageBox::critical(parent, "Chatterino - Editing Settings Forbidden", "Settings cannot be edited when running with\n" @@ -87,19 +86,18 @@ void WindowManager::showAccountSelectPopup(QPoint point) w->refresh(); - QPoint buttonPos = point; - w->move(buttonPos.x() - 30, buttonPos.y()); + w->moveTo(point - QPoint(30, 0), widgets::BoundsChecking::CursorPosition); w->show(); w->setFocus(); } -WindowManager::WindowManager() - : windowLayoutFilePath(combinePath(getPaths()->settingsDirectory, +WindowManager::WindowManager(const Paths &paths) + : windowLayoutFilePath(combinePath(paths.settingsDirectory, WindowManager::WINDOW_LAYOUT_FILENAME)) { qCDebug(chatterinoWindowmanager) << "init WindowManager"; - auto settings = getSettings(); + auto *settings = getSettings(); this->wordFlagsListener_.addSetting(settings->showTimestamps); this->wordFlagsListener_.addSetting(settings->showBadgesGlobalAuthority); @@ -123,13 +121,7 @@ WindowManager::WindowManager() this->saveTimer->setSingleShot(true); QObject::connect(this->saveTimer, &QTimer::timeout, [] { - getApp()->windows->save(); - }); - - this->miscUpdateTimer_.start(100); - - QObject::connect(&this->miscUpdateTimer_, &QTimer::timeout, [this] { - this->miscUpdate.invoke(); + getIApp()->getWindows()->save(); }); } @@ -143,7 +135,7 @@ MessageElementFlags WindowManager::getWordFlags() void WindowManager::updateWordTypeMask() { using MEF = MessageElementFlag; - auto settings = getSettings(); + auto *settings = getSettings(); // text auto flags = MessageElementFlags(MEF::Text); @@ -219,6 +211,11 @@ void WindowManager::forceLayoutChannelViews() this->layoutChannelViews(nullptr); } +void WindowManager::invalidateChannelViewBuffers(Channel *channel) +{ + this->invalidateBuffersRequested.invoke(channel); +} + void WindowManager::repaintVisibleChatWidgets(Channel *channel) { this->layoutRequested.invoke(channel); @@ -243,11 +240,15 @@ Window &WindowManager::getMainWindow() return *this->mainWindow_; } -Window &WindowManager::getSelectedWindow() +Window *WindowManager::getLastSelectedWindow() const { assertInGuiThread(); + if (this->selectedWindow_ == nullptr) + { + return this->mainWindow_; + } - return *this->selectedWindow_; + return this->selectedWindow_; } Window &WindowManager::createWindow(WindowType type, bool show, QWidget *parent) @@ -342,34 +343,45 @@ void WindowManager::setEmotePopupPos(QPoint pos) this->emotePopupPos_ = pos; } -void WindowManager::initialize(Settings &settings, Paths &paths) +void WindowManager::initialize(Settings &settings, const Paths &paths) { + (void)paths; assertInGuiThread(); - getApp()->themes->repaintVisibleChatWidgets_.connect([this] { - this->repaintVisibleChatWidgets(); - }); + // We can safely ignore this signal connection since both Themes and WindowManager + // share the Application state lifetime + // NOTE: APPLICATION_LIFETIME + std::ignore = + getIApp()->getThemes()->repaintVisibleChatWidgets_.connect([this] { + this->repaintVisibleChatWidgets(); + }); assert(!this->initialized_); { WindowLayout windowLayout; - if (getArgs().customChannelLayout) + if (getApp()->getArgs().customChannelLayout) { - windowLayout = getArgs().customChannelLayout.value(); + windowLayout = getApp()->getArgs().customChannelLayout.value(); } else { windowLayout = this->loadWindowLayoutFromFile(); } + auto desired = getIApp()->getArgs().activateChannel; + if (desired) + { + windowLayout.activateOrAddChannel(desired->provider, desired->name); + } + this->emotePopupPos_ = windowLayout.emotePopupPos_; this->applyWindowLayout(windowLayout); } - if (getArgs().isFramelessEmbed) + if (getApp()->getArgs().isFramelessEmbed) { this->framelessEmbedWindow_.reset(new FramelessEmbedWindow); this->framelessEmbedWindow_->show(); @@ -382,7 +394,7 @@ void WindowManager::initialize(Settings &settings, Paths &paths) this->mainWindow_->getNotebook().addPage(true); // TODO: don't create main window if it's a frameless embed - if (getArgs().isFramelessEmbed) + if (getApp()->getArgs().isFramelessEmbed) { this->mainWindow_->hide(); } @@ -400,10 +412,10 @@ void WindowManager::initialize(Settings &settings, Paths &paths) this->forceLayoutChannelViews(); }); settings.alternateMessages.connect([this](auto, auto) { - this->forceLayoutChannelViews(); + this->invalidateChannelViewBuffers(); }); settings.separateMessages.connect([this](auto, auto) { - this->forceLayoutChannelViews(); + this->invalidateChannelViewBuffers(); }); settings.collpseMessagesMinLines.connect([this](auto, auto) { this->forceLayoutChannelViews(); @@ -417,11 +429,18 @@ void WindowManager::initialize(Settings &settings, Paths &paths) void WindowManager::save() { - if (getArgs().dontSaveSettings) + if (getApp()->getArgs().dontSaveSettings) { return; } - qCDebug(chatterinoWindowmanager) << "[WindowManager] Saving"; + + if (this->shuttingDown_) + { + qCDebug(chatterinoWindowmanager) << "Skipping save (shutting down)"; + return; + } + + qCDebug(chatterinoWindowmanager) << "Saving"; assertInGuiThread(); QJsonDocument document; @@ -602,6 +621,10 @@ void WindowManager::encodeChannel(IndirectChannel channel, QJsonObject &obj) obj.insert("name", channel.get()->getName()); } break; + case Channel::Type::TwitchAutomod: { + obj.insert("type", "automod"); + } + break; case Channel::Type::TwitchMentions: { obj.insert("type", "mentions"); } @@ -619,7 +642,7 @@ void WindowManager::encodeChannel(IndirectChannel channel, QJsonObject &obj) } break; case Channel::Type::Irc: { - if (auto ircChannel = + if (auto *ircChannel = dynamic_cast(channel.get().get())) { obj.insert("type", "irc"); @@ -631,6 +654,10 @@ void WindowManager::encodeChannel(IndirectChannel channel, QJsonObject &obj) } } break; + case Channel::Type::Misc: { + obj.insert("type", "misc"); + obj.insert("name", channel.get()->getName()); + } } } @@ -649,7 +676,7 @@ IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) { assertInGuiThread(); - auto app = getApp(); + auto *app = getApp(); if (descriptor.type_ == "twitch") { @@ -671,11 +698,19 @@ IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) { return app->twitch->liveChannel; } + else if (descriptor.type_ == "automod") + { + return app->twitch->automodChannel; + } else if (descriptor.type_ == "irc") { return Irc::instance().getOrAddChannel(descriptor.server_, descriptor.channelName_); } + else if (descriptor.type_ == "misc") + { + return app->twitch->getChannelOrEmpty(descriptor.channelName_); + } return Channel::getEmpty(); } @@ -684,6 +719,9 @@ void WindowManager::closeAll() { assertInGuiThread(); + qCDebug(chatterinoWindowmanager) << "Shutting down (closing windows)"; + this->shuttingDown_ = true; + for (Window *window : windows_) { window->close(); @@ -707,7 +745,7 @@ WindowLayout WindowManager::loadWindowLayoutFromFile() const void WindowManager::applyWindowLayout(const WindowLayout &layout) { - if (getArgs().dontLoadMainWindow) + if (getApp()->getArgs().dontLoadMainWindow) { return; } @@ -733,7 +771,7 @@ void WindowManager::applyWindowLayout(const WindowLayout &layout) // out of bounds windows auto screens = qApp->screens(); bool outOfBounds = - !getenv("I3SOCK") && + !qEnvironmentVariableIsSet("I3SOCK") && std::none_of(screens.begin(), screens.end(), [&](QScreen *screen) { return screen->availableGeometry().intersects( diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index ba7cc5a4c93..927a5712977 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -37,9 +37,14 @@ class WindowManager final : public Singleton public: static const QString WINDOW_LAYOUT_FILENAME; - WindowManager(); + explicit WindowManager(const Paths &paths); ~WindowManager() override; + WindowManager(const WindowManager &) = delete; + WindowManager(WindowManager &&) = delete; + WindowManager &operator=(const WindowManager &) = delete; + WindowManager &operator=(WindowManager &&) = delete; + static void encodeTab(SplitContainer *tab, bool isSelected, QJsonObject &obj); static void encodeChannel(IndirectChannel channel, QJsonObject &obj); @@ -61,11 +66,22 @@ class WindowManager final : public Singleton // This is called, for example, when the emote scale or timestamp format has // changed void forceLayoutChannelViews(); + + // Tell a channel (or all channels if channel is nullptr) to invalidate all paint buffers + void invalidateChannelViewBuffers(Channel *channel = nullptr); + void repaintVisibleChatWidgets(Channel *channel = nullptr); void repaintGifEmotes(); Window &getMainWindow(); - Window &getSelectedWindow(); + + // Returns a pointer to the last selected window. + // Edge cases: + // - If the application was not focused since the start, this will return a pointer to the main window. + // - If the window was closed this points to the main window. + // - If the window was unfocused since being selected, this function will still return it. + Window *getLastSelectedWindow() const; + Window &createWindow(WindowType type, bool show = true, QWidget *parent = nullptr); @@ -86,8 +102,8 @@ class WindowManager final : public Singleton QPoint emotePopupPos(); void setEmotePopupPos(QPoint pos); - virtual void initialize(Settings &settings, Paths &paths) override; - virtual void save() override; + void initialize(Settings &settings, const Paths &paths) override; + void save() override; void closeAll(); int getGeneration() const; @@ -112,13 +128,12 @@ class WindowManager final : public Singleton // This signal fires whenever views rendering a channel, or all views if the // channel is a nullptr, need to redo their layout pajlada::Signals::Signal layoutRequested; + // This signal fires whenever views rendering a channel, or all views if the + // channel is a nullptr, need to invalidate their paint buffers + pajlada::Signals::Signal invalidateBuffersRequested; pajlada::Signals::NoArgSignal wordFlagsChanged; - // This signal fires every 100ms and can be used to trigger random things that require a recheck. - // It is currently being used by the "Tooltip Preview Image" system to recheck if an image is ready to be rendered. - pajlada::Signals::NoArgSignal miscUpdate; - pajlada::Signals::Signal selectSplit; pajlada::Signals::Signal selectSplitContainer; pajlada::Signals::Signal scrollToMessageSignal; @@ -137,6 +152,7 @@ class WindowManager final : public Singleton const QString windowLayoutFilePath; bool initialized_ = false; + bool shuttingDown_ = false; QPoint emotePopupPos_; @@ -152,7 +168,8 @@ class WindowManager final : public Singleton pajlada::SettingListener wordFlagsListener_; QTimer *saveTimer; - QTimer miscUpdateTimer_; + + friend class Window; // this is for selectedWindow_ }; } // namespace chatterino diff --git a/src/singletons/helper/GifTimer.cpp b/src/singletons/helper/GifTimer.cpp index 6f567ea7461..9eaf1162fb1 100644 --- a/src/singletons/helper/GifTimer.cpp +++ b/src/singletons/helper/GifTimer.cpp @@ -13,19 +13,25 @@ void GIFTimer::initialize() getSettings()->animateEmotes.connect([this](bool enabled, auto) { if (enabled) + { this->timer.start(); + } else + { this->timer.stop(); + } }); QObject::connect(&this->timer, &QTimer::timeout, [this] { if (getSettings()->animationsWhenFocused && qApp->activeWindow() == nullptr) + { return; + } this->position_ += GIF_FRAME_LENGTH; this->signal.invoke(); - getApp()->windows->repaintGifEmotes(); + getIApp()->getWindows()->repaintGifEmotes(); }); } diff --git a/src/singletons/helper/LoggingChannel.cpp b/src/singletons/helper/LoggingChannel.cpp index 24da8d20349..f3a6fbb792d 100644 --- a/src/singletons/helper/LoggingChannel.cpp +++ b/src/singletons/helper/LoggingChannel.cpp @@ -1,7 +1,9 @@ #include "LoggingChannel.hpp" +#include "Application.hpp" #include "common/QLogging.hpp" #include "messages/Message.hpp" +#include "messages/MessageThread.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" @@ -28,6 +30,10 @@ LoggingChannel::LoggingChannel(const QString &_channelName, { this->subDirectory = "Live"; } + else if (channelName.startsWith("/automod")) + { + this->subDirectory = "AutoMod"; + } else { this->subDirectory = @@ -39,8 +45,9 @@ LoggingChannel::LoggingChannel(const QString &_channelName, QDir::separator() + this->subDirectory; getSettings()->logPath.connect([this](const QString &logPath, auto) { - this->baseDirectory = - logPath.isEmpty() ? getPaths()->messageLogDirectory : logPath; + this->baseDirectory = logPath.isEmpty() + ? getIApp()->getPaths().messageLogDirectory + : logPath; this->openLogFile(); }); } @@ -95,7 +102,8 @@ void LoggingChannel::addMessage(MessagePtr message) } QString str; - if (channelName.startsWith("/mentions")) + if (channelName.startsWith("/mentions") || + channelName.startsWith("/automod")) { str.append("#" + message->channelName + " "); } @@ -104,7 +112,48 @@ void LoggingChannel::addMessage(MessagePtr message) str.append(now.toString("HH:mm:ss")); str.append("] "); - str.append(message->searchText); + QString messageText; + if (message->loginName.isEmpty()) + { + // This accounts for any messages not explicitly sent by a user, like + // system messages, parts of announcements, subs etc. + messageText = message->messageText; + } + else + { + if (message->localizedName.isEmpty()) + { + messageText = message->loginName + ": " + message->messageText; + } + else + { + messageText = message->localizedName + " " + message->loginName + + ": " + message->messageText; + } + } + + if ((message->flags.has(MessageFlag::ReplyMessage) && + getSettings()->stripReplyMention) && + !getSettings()->hideReplyContext) + { + qsizetype colonIndex = messageText.indexOf(':'); + if (colonIndex != -1) + { + QString rootMessageChatter; + if (message->replyParent) + { + rootMessageChatter = message->replyParent->loginName; + } + else + { + // we actually want to use 'reply-parent-user-login' tag here, + // but it's not worth storing just for this edge case + rootMessageChatter = message->replyThread->root()->loginName; + } + messageText.insert(colonIndex + 1, " @" + rootMessageChatter); + } + } + str.append(messageText); str.append(endline); this->appendLine(str); diff --git a/src/singletons/helper/LoggingChannel.hpp b/src/singletons/helper/LoggingChannel.hpp index 9970baaefcd..753a63b9126 100644 --- a/src/singletons/helper/LoggingChannel.hpp +++ b/src/singletons/helper/LoggingChannel.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -13,13 +12,20 @@ class Logging; struct Message; using MessagePtr = std::shared_ptr; -class LoggingChannel : boost::noncopyable +class LoggingChannel { explicit LoggingChannel(const QString &_channelName, const QString &platform); public: ~LoggingChannel(); + + LoggingChannel(const LoggingChannel &) = delete; + LoggingChannel &operator=(const LoggingChannel &) = delete; + + LoggingChannel(LoggingChannel &&) = delete; + LoggingChannel &operator=(LoggingChannel &&) = delete; + void addMessage(MessagePtr message); private: diff --git a/src/util/AbandonObject.hpp b/src/util/AbandonObject.hpp new file mode 100644 index 00000000000..b0588449a2f --- /dev/null +++ b/src/util/AbandonObject.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace chatterino { + +/// Guard to call `deleteLater` on a QObject when destroyed. +class AbandonObject +{ +public: + AbandonObject(QObject *obj) + : obj_(obj) + { + } + + ~AbandonObject() + { + if (this->obj_) + { + this->obj_->deleteLater(); + } + } + + AbandonObject(const AbandonObject &) = delete; + AbandonObject(AbandonObject &&) = delete; + AbandonObject &operator=(const AbandonObject &) = delete; + AbandonObject &operator=(AbandonObject &&) = delete; + +private: + QObject *obj_; +}; + +} // namespace chatterino diff --git a/src/util/AttachToConsole.cpp b/src/util/AttachToConsole.cpp index 5e5ca77ff8d..41689c699af 100644 --- a/src/util/AttachToConsole.cpp +++ b/src/util/AttachToConsole.cpp @@ -3,7 +3,7 @@ #ifdef USEWINSDK # include -# include +# include #endif namespace chatterino { @@ -13,8 +13,8 @@ void attachToConsole() #ifdef USEWINSDK if (AttachConsole(ATTACH_PARENT_PROCESS)) { - freopen("CONOUT$", "w", stdout); - freopen("CONOUT$", "w", stderr); + std::ignore = freopen_s(nullptr, "CONOUT$", "w", stdout); + std::ignore = freopen_s(nullptr, "CONOUT$", "w", stderr); } #endif } diff --git a/src/util/CancellationToken.hpp b/src/util/CancellationToken.hpp new file mode 100644 index 00000000000..12c26f6b1af --- /dev/null +++ b/src/util/CancellationToken.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +namespace chatterino { + +/// The CancellationToken is a thread-safe way for worker(s) +/// to know if the task they want to continue doing should be cancelled. +class CancellationToken +{ +public: + CancellationToken() = default; + explicit CancellationToken(bool isCancelled) + : isCancelled_(new std::atomic(isCancelled)) + { + } + + CancellationToken(const CancellationToken &) = default; + CancellationToken(CancellationToken &&other) + : isCancelled_(std::move(other.isCancelled_)){}; + + CancellationToken &operator=(CancellationToken &&other) + { + this->isCancelled_ = std::move(other.isCancelled_); + return *this; + } + CancellationToken &operator=(const CancellationToken &) = default; + + void cancel() + { + if (this->isCancelled_ != nullptr) + { + this->isCancelled_->store(true, std::memory_order_release); + } + } + + bool isCancelled() const + { + return this->isCancelled_ == nullptr || + this->isCancelled_->load(std::memory_order_acquire); + } + +private: + std::shared_ptr> isCancelled_; +}; + +/// The ScopedCancellationToken is a way to automatically cancel a CancellationToken when it goes out of scope +class ScopedCancellationToken +{ +public: + ScopedCancellationToken() = default; + ScopedCancellationToken(CancellationToken &&backingToken) + : backingToken_(std::move(backingToken)) + { + } + ScopedCancellationToken(CancellationToken backingToken) + : backingToken_(std::move(backingToken)) + { + } + + ~ScopedCancellationToken() + { + this->backingToken_.cancel(); + } + + ScopedCancellationToken(const ScopedCancellationToken &) = delete; + ScopedCancellationToken(ScopedCancellationToken &&other) + : backingToken_(std::move(other.backingToken_)){}; + ScopedCancellationToken &operator=(ScopedCancellationToken &&other) + { + this->backingToken_ = std::move(other.backingToken_); + return *this; + } + ScopedCancellationToken &operator=(const ScopedCancellationToken &) = + delete; + +private: + CancellationToken backingToken_; +}; + +} // namespace chatterino diff --git a/src/util/ChannelHelpers.hpp b/src/util/ChannelHelpers.hpp new file mode 100644 index 00000000000..f53f3aa2332 --- /dev/null +++ b/src/util/ChannelHelpers.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include "common/Channel.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "singletons/Settings.hpp" + +namespace chatterino { + +/// Adds a timeout or replaces a previous one sent in the last 20 messages and in the last 5s. +/// This function accepts any buffer to store the messsages in. +/// @param replaceMessage A function of type `void (int index, MessagePtr toReplace, MessagePtr replacement)` +/// - replace `buffer[i]` (=toReplace) with `replacement` +/// @param addMessage A function of type `void (MessagePtr message)` +/// - adds the `message`. +/// @param disableUserMessages If set, disables all message by the timed out user. +template +void addOrReplaceChannelTimeout(const Buf &buffer, MessagePtr message, + QTime now, Replace replaceMessage, + Add addMessage, bool disableUserMessages) +{ + // NOTE: This function uses the messages PARSE time to figure out whether they should be replaced + // This works as expected for incoming messages, but not for historic messages. + // This has never worked before, but would be nice in the future. + // For this to work, we need to make sure *all* messages have a "server received time". + + auto snapshotLength = static_cast(buffer.size()); + + auto end = std::max(0, snapshotLength - 20); + + bool shouldAddMessage = true; + + QTime minimumTime = now.addSecs(-5); + + auto timeoutStackStyle = static_cast( + getSettings()->timeoutStackStyle.getValue()); + + for (auto i = snapshotLength - 1; i >= end; --i) + { + const MessagePtr &s = buffer[i]; + + if (s->parseTime < minimumTime) + { + break; + } + + if (s->flags.has(MessageFlag::Untimeout) && + s->timeoutUser == message->timeoutUser) + { + break; + } + + if (timeoutStackStyle == TimeoutStackStyle::DontStackBeyondUserMessage) + { + if (s->loginName == message->timeoutUser && + s->flags.hasNone({MessageFlag::Disabled, MessageFlag::Timeout, + MessageFlag::Untimeout})) + { + break; + } + } + + if (s->flags.has(MessageFlag::Timeout) && + s->timeoutUser == message->timeoutUser) + { + if (message->flags.has(MessageFlag::PubSub) && + !s->flags.has(MessageFlag::PubSub)) + { + replaceMessage(i, s, message); + shouldAddMessage = false; + break; + } + if (!message->flags.has(MessageFlag::PubSub) && + s->flags.has(MessageFlag::PubSub)) + { + shouldAddMessage = + timeoutStackStyle == TimeoutStackStyle::DontStack; + break; + } + + uint32_t count = s->count + 1; + + MessageBuilder replacement(timeoutMessage, message->timeoutUser, + message->loginName, message->searchText, + count); + + replacement->timeoutUser = message->timeoutUser; + replacement->count = count; + replacement->flags = message->flags; + + replaceMessage(i, s, replacement.release()); + + shouldAddMessage = false; + break; + } + } + + // disable the messages from the user + if (disableUserMessages) + { + for (qsizetype i = 0; i < snapshotLength; i++) + { + auto &s = buffer[i]; + if (s->loginName == message->timeoutUser && + s->flags.hasNone({MessageFlag::Timeout, MessageFlag::Untimeout, + MessageFlag::Whisper})) + { + // FOURTF: disabled for now + // PAJLADA: Shitty solution described in Message.hpp + s->flags.set(MessageFlag::Disabled); + } + } + } + + if (shouldAddMessage) + { + addMessage(message); + } +} + +} // namespace chatterino diff --git a/src/util/Clipboard.cpp b/src/util/Clipboard.cpp index 8cd60c5a632..9f28d12b9ba 100644 --- a/src/util/Clipboard.cpp +++ b/src/util/Clipboard.cpp @@ -7,7 +7,7 @@ namespace chatterino { void crossPlatformCopy(const QString &text) { - auto clipboard = QApplication::clipboard(); + auto *clipboard = QApplication::clipboard(); clipboard->setText(text); diff --git a/src/util/DebugCount.cpp b/src/util/DebugCount.cpp index 331aa605a2a..1fe915cf2f5 100644 --- a/src/util/DebugCount.cpp +++ b/src/util/DebugCount.cpp @@ -1,7 +1,114 @@ -#include "DebugCount.hpp" +#include "util/DebugCount.hpp" + +#include "common/UniqueAccess.hpp" + +#include +#include + +#include + +namespace { + +using namespace chatterino; + +struct Count { + int64_t value = 0; + DebugCount::Flags flags = DebugCount::Flag::None; +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +UniqueAccess> COUNTS; + +} // namespace namespace chatterino { -UniqueAccess> DebugCount::counts_; +void DebugCount::configure(const QString &name, Flags flags) +{ + auto counts = COUNTS.access(); + + auto it = counts->find(name); + if (it == counts->end()) + { + counts->emplace(name, Count{.flags = flags}); + } + else + { + it->second.flags = flags; + } +} + +void DebugCount::set(const QString &name, const int64_t &amount) +{ + auto counts = COUNTS.access(); + + auto it = counts->find(name); + if (it == counts->end()) + { + counts->emplace(name, Count{amount}); + } + else + { + it->second.value = amount; + } +} + +void DebugCount::increase(const QString &name, const int64_t &amount) +{ + auto counts = COUNTS.access(); + + auto it = counts->find(name); + if (it == counts->end()) + { + counts->emplace(name, Count{amount}); + } + else + { + it->second.value += amount; + } +} + +void DebugCount::decrease(const QString &name, const int64_t &amount) +{ + auto counts = COUNTS.access(); + + auto it = counts->find(name); + if (it == counts->end()) + { + counts->emplace(name, Count{-amount}); + } + else + { + it->second.value -= amount; + } +} + +QString DebugCount::getDebugText() +{ +#if QT_VERSION > QT_VERSION_CHECK(5, 13, 0) + static const QLocale locale(QLocale::English); +#else + static QLocale locale(QLocale::English); +#endif + + auto counts = COUNTS.access(); + + QString text; + for (const auto &[key, count] : *counts) + { + QString formatted; + if (count.flags.has(Flag::DataSize)) + { + formatted = locale.formattedDataSize(count.value); + } + else + { + formatted = locale.toString(static_cast(count.value)); + } + + text += key % ": " % formatted % '\n'; + } + return text; +} } // namespace chatterino diff --git a/src/util/DebugCount.hpp b/src/util/DebugCount.hpp index 629cb5fbece..ef23639840b 100644 --- a/src/util/DebugCount.hpp +++ b/src/util/DebugCount.hpp @@ -1,95 +1,38 @@ #pragma once -#include "common/UniqueAccess.hpp" +#include "common/FlagsEnum.hpp" -#include #include -#include -#include - namespace chatterino { class DebugCount { public: - static void increase(const QString &name) - { - auto counts = counts_.access(); + enum class Flag : uint16_t { + None = 0, + /// The value is a data size in bytes + DataSize = 1 << 0, + }; + using Flags = FlagsEnum; - auto it = counts->find(name); - if (it == counts->end()) - { - counts->insert(name, 1); - } - else - { - reinterpret_cast(it.value())++; - } - } - static void increase(const QString &name, const int64_t &amount) - { - auto counts = counts_.access(); + static void configure(const QString &name, Flags flags); - auto it = counts->find(name); - if (it == counts->end()) - { - counts->insert(name, amount); - } - else - { - reinterpret_cast(it.value()) += amount; - } - } - - static void decrease(const QString &name) - { - auto counts = counts_.access(); + static void set(const QString &name, const int64_t &amount); - auto it = counts->find(name); - if (it == counts->end()) - { - counts->insert(name, -1); - } - else - { - reinterpret_cast(it.value())--; - } - } - static void decrease(const QString &name, const int64_t &amount) - { - auto counts = counts_.access(); - - auto it = counts->find(name); - if (it == counts->end()) - { - counts->insert(name, -amount); - } - else - { - reinterpret_cast(it.value()) -= amount; - } - } - - static QString getDebugText() + static void increase(const QString &name, const int64_t &amount); + static void increase(const QString &name) { - auto counts = counts_.access(); - - QString text; - for (auto it = counts->begin(); it != counts->end(); it++) - { - text += it.key() + ": " + QString::number(it.value()) + "\n"; - } - return text; + DebugCount::increase(name, 1); } - QString toString() + static void decrease(const QString &name, const int64_t &amount); + static void decrease(const QString &name) { - return ""; + DebugCount::decrease(name, 1); } -private: - static UniqueAccess> counts_; + static QString getDebugText(); }; } // namespace chatterino diff --git a/src/util/FormatTime.cpp b/src/util/FormatTime.cpp index 6d6f2e52526..1bc0e07b6b8 100644 --- a/src/util/FormatTime.cpp +++ b/src/util/FormatTime.cpp @@ -1,4 +1,7 @@ -#include "FormatTime.hpp" +#include "util/FormatTime.hpp" + +#include +#include namespace chatterino { @@ -57,4 +60,15 @@ QString formatTime(QString totalSecondsString) return "n/a"; } +QString formatTime(std::chrono::seconds totalSeconds) +{ + auto count = totalSeconds.count(); + + return formatTime(static_cast(std::clamp( + count, + static_cast(std::numeric_limits::min()), + static_cast( + std::numeric_limits::max())))); +} + } // namespace chatterino diff --git a/src/util/FormatTime.hpp b/src/util/FormatTime.hpp index 0e4eb272583..c9bb12cae59 100644 --- a/src/util/FormatTime.hpp +++ b/src/util/FormatTime.hpp @@ -2,10 +2,13 @@ #include +#include + namespace chatterino { // format: 1h 23m 42s QString formatTime(int totalSeconds); QString formatTime(QString totalSecondsString); +QString formatTime(std::chrono::seconds totalSeconds); } // namespace chatterino diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index a913d4fc071..f81ac9b792d 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -11,7 +11,7 @@ namespace chatterino { namespace _helpers_internal { - int skipSpace(const QStringRef &view, int startPos) + SizeType skipSpace(StringView view, SizeType startPos) { while (startPos < view.length() && view.at(startPos).isSpace()) { @@ -20,26 +20,26 @@ namespace _helpers_internal { return startPos - 1; } - bool matchesIgnorePlural(const QStringRef &word, const QString &singular) + bool matchesIgnorePlural(StringView word, const QString &expected) { - if (!word.startsWith(singular)) + if (!word.startsWith(expected)) { return false; } - if (word.length() == singular.length()) + if (word.length() == expected.length()) { return true; } - return word.length() == singular.length() + 1 && + return word.length() == expected.length() + 1 && word.at(word.length() - 1).toLatin1() == 's'; } - std::pair findUnitMultiplierToSec(const QStringRef &view, - int &pos) + std::pair findUnitMultiplierToSec(StringView view, + SizeType &pos) { // Step 1. find end of unit - int startIdx = pos; - int endIdx = view.length(); + auto startIdx = pos; + auto endIdx = view.length(); for (; pos < view.length(); pos++) { auto c = view.at(pos); @@ -162,18 +162,6 @@ QString shortenString(const QString &str, unsigned maxWidth) return shortened; } -QString localizeNumbers(const int &number) -{ - QLocale locale; - return locale.toString(number); -} - -QString localizeNumbers(unsigned int number) -{ - QLocale locale; - return locale.toString(number); -} - QString kFormatNumbers(const int &number) { return QString("%1K").arg(number / 1000); @@ -219,16 +207,19 @@ int64_t parseDurationToSeconds(const QString &inputString, return -1; } - // TODO(QT6): use QStringView - QStringRef input(&inputString); +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 2) + StringView input(inputString); +#else + StringView input(&inputString); +#endif input = input.trimmed(); uint64_t currentValue = 0; bool visitingNumber = true; // input must start with a number - int numberStartIdx = 0; + SizeType numberStartIdx = 0; - for (int pos = 0; pos < input.length(); pos++) + for (SizeType pos = 0; pos < input.length(); pos++) { QChar c = input.at(pos); @@ -283,4 +274,17 @@ int64_t parseDurationToSeconds(const QString &inputString, return (int64_t)currentValue; } +bool compareEmoteStrings(const QString &a, const QString &b) +{ + // try comparing insensitively, if they are the same then sensitively + // (fixes order of LuL and LUL) + int k = QString::compare(a, b, Qt::CaseInsensitive); + if (k == 0) + { + return a > b; + } + + return k < 0; +} + } // namespace chatterino diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index f165902828f..56e25082a9c 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -1,10 +1,15 @@ #pragma once #include +#include #include -#include + +#if QT_VERSION < QT_VERSION_CHECK(5, 15, 2) +# include +#endif #include +#include #include namespace chatterino { @@ -12,6 +17,13 @@ namespace chatterino { // only qualified for tests namespace _helpers_internal { +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 2) + using StringView = QStringView; +#else + using StringView = QStringRef; +#endif + using SizeType = StringView::size_type; + /** * Skips all spaces. * The caller must guarantee view.at(startPos).isSpace(). @@ -20,7 +32,7 @@ namespace _helpers_internal { * @param startPos The starting position (there must be a space in the view). * @return The position of the last space. */ - int skipSpace(const QStringRef &view, int startPos); + SizeType skipSpace(StringView view, SizeType startPos); /** * Checks if `word` equals `expected` (singular) or `expected` + 's' (plural). @@ -29,7 +41,7 @@ namespace _helpers_internal { * @param expected Singular of the expected word. * @return true if `word` is singular or plural of `expected`. */ - bool matchesIgnorePlural(const QStringRef &word, const QString &expected); + bool matchesIgnorePlural(StringView word, const QString &expected); /** * Tries to find the unit starting at `pos` and returns its multiplier so @@ -46,8 +58,8 @@ namespace _helpers_internal { * if it's a valid unit, undefined otherwise. * @return (multiplier, ok) */ - std::pair findUnitMultiplierToSec(const QStringRef &view, - int &pos); + std::pair findUnitMultiplierToSec(StringView view, + SizeType &pos); } // namespace _helpers_internal @@ -72,8 +84,12 @@ QString formatRichNamedLink(const QString &url, const QString &name, QString shortenString(const QString &str, unsigned maxWidth = 50); -QString localizeNumbers(const int &number); -QString localizeNumbers(unsigned int number); +template +QString localizeNumbers(T number) +{ + QLocale locale; + return locale.toString(number); +} QString kFormatNumbers(const int &number); @@ -148,4 +164,30 @@ std::vector splitListIntoBatches(const T &list, int batchSize = 100) return batches; } +bool compareEmoteStrings(const QString &a, const QString &b); + +template +constexpr std::optional makeConditionedOptional(bool condition, + const T &value) +{ + if (condition) + { + return value; + } + + return std::nullopt; +} + +template +constexpr std::optional> makeConditionedOptional(bool condition, + T &&value) +{ + if (condition) + { + return std::optional>(std::forward(value)); + } + + return std::nullopt; +} + } // namespace chatterino diff --git a/src/util/IncognitoBrowser.cpp b/src/util/IncognitoBrowser.cpp index 074f07c1b9e..93ae2983bb4 100644 --- a/src/util/IncognitoBrowser.cpp +++ b/src/util/IncognitoBrowser.cpp @@ -1,88 +1,93 @@ #include "util/IncognitoBrowser.hpp" #ifdef USEWINSDK # include "util/WindowsHelper.hpp" +#elif defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) +# include "util/XDGHelper.hpp" #endif #include -#include #include namespace { using namespace chatterino; -#ifdef USEWINSDK -QString injectPrivateSwitch(QString command) +QString getPrivateSwitch(const QString &browserExecutable) { // list of command line switches to turn on private browsing in browsers static auto switches = std::vector>{ - {"firefox", "-private-window"}, {"librewolf", "-private-window"}, - {"waterfox", "-private-window"}, {"icecat", "-private-window"}, - {"chrome", "-incognito"}, {"vivaldi", "-incognito"}, - {"opera", "-newprivatetab"}, {"opera\\\\launcher", "--private"}, - {"iexplore", "-private"}, {"msedge", "-inprivate"}, + {"firefox", "-private-window"}, {"librewolf", "-private-window"}, + {"waterfox", "-private-window"}, {"icecat", "-private-window"}, + {"chrome", "-incognito"}, {"vivaldi", "-incognito"}, + {"opera", "-newprivatetab"}, {"opera\\launcher", "--private"}, + {"iexplore", "-private"}, {"msedge", "-inprivate"}, + {"firefox-esr", "-private-window"}, {"chromium", "-incognito"}, }; - // transform into regex and replacement string - std::vector> replacers; - for (const auto &switch_ : switches) + // compare case-insensitively + auto lowercasedBrowserExecutable = browserExecutable.toLower(); + +#ifdef Q_OS_WINDOWS + if (lowercasedBrowserExecutable.endsWith(".exe")) { - replacers.emplace_back( - QRegularExpression("(" + switch_.first + "\\.exe\"?).*", - QRegularExpression::CaseInsensitiveOption), - "\\1 " + switch_.second); + lowercasedBrowserExecutable.chop(4); } +#endif - // try to find matching regex and apply it - for (const auto &replacement : replacers) + for (const auto &switch_ : switches) { - if (replacement.first.match(command).hasMatch()) + if (lowercasedBrowserExecutable.endsWith(switch_.first)) { - command.replace(replacement.first, replacement.second); - return command; + return switch_.second; } } // couldn't match any browser -> unknown browser - return QString(); + return {}; } -QString getCommand() +QString getDefaultBrowserExecutable() { +#ifdef USEWINSDK // get default browser start command, by protocol if possible, falling back to extension if not QString command = - getAssociatedCommand(AssociationQueryType::Protocol, L"http"); + getAssociatedExecutable(AssociationQueryType::Protocol, L"http"); if (command.isNull()) { // failed to fetch default browser by protocol, try by file extension instead - command = - getAssociatedCommand(AssociationQueryType::FileExtension, L".html"); + command = getAssociatedExecutable(AssociationQueryType::FileExtension, + L".html"); } if (command.isNull()) { // also try the equivalent .htm extension - command = - getAssociatedCommand(AssociationQueryType::FileExtension, L".htm"); - } - - if (command.isNull()) - { - // failed to find browser command - return QString(); - } - - // inject switch to enable private browsing - command = injectPrivateSwitch(command); - if (command.isNull()) - { - return QString(); + command = getAssociatedExecutable(AssociationQueryType::FileExtension, + L".htm"); } return command; -} +#elif defined(Q_OS_UNIX) && !defined(Q_OS_DARWIN) + static QString defaultBrowser = []() -> QString { + auto desktopFile = getDefaultBrowserDesktopFile(); + if (desktopFile.has_value()) + { + auto entry = desktopFile->getEntries("Desktop Entry"); + auto exec = entry.find("Exec"); + if (exec != entry.end()) + { + return parseDesktopExecProgram(exec->second.trimmed()); + } + } + return {}; + }(); + + return defaultBrowser; +#else + return {}; #endif +} } // namespace @@ -90,23 +95,15 @@ namespace chatterino { bool supportsIncognitoLinks() { -#ifdef USEWINSDK - return !getCommand().isNull(); -#else - return false; -#endif + auto browserExe = getDefaultBrowserExecutable(); + return !browserExe.isNull() && !getPrivateSwitch(browserExe).isNull(); } bool openLinkIncognito(const QString &link) { -#ifdef USEWINSDK - auto command = getCommand(); - - // TODO: split command into program path and incognito argument - return QProcess::startDetached(command, {link}); -#else - return false; -#endif + auto browserExe = getDefaultBrowserExecutable(); + return QProcess::startDetached(browserExe, + {getPrivateSwitch(browserExe), link}); } } // namespace chatterino diff --git a/src/util/InitUpdateButton.cpp b/src/util/InitUpdateButton.cpp index a8ff6bc6498..349fb78bb6f 100644 --- a/src/util/InitUpdateButton.cpp +++ b/src/util/InitUpdateButton.cpp @@ -1,5 +1,6 @@ -#include "InitUpdateButton.hpp" +#include "util/InitUpdateButton.hpp" +#include "Application.hpp" #include "widgets/dialogs/UpdateDialog.hpp" #include "widgets/helper/Button.hpp" @@ -12,7 +13,7 @@ void initUpdateButton(Button &button, // show update prompt when clicking the button QObject::connect(&button, &Button::leftClicked, [&button] { - auto dialog = new UpdateDialog(); + auto *dialog = new UpdateDialog(); dialog->setActionOnFocusLoss(BaseWindow::Delete); auto globalPoint = button.mapToGlobal( @@ -24,11 +25,15 @@ void initUpdateButton(Button &button, globalPoint.setX(0); } - dialog->move(globalPoint); + dialog->moveTo(globalPoint, widgets::BoundsChecking::DesiredPosition); dialog->show(); dialog->raise(); - dialog->buttonClicked.connect([&button](auto buttonType) { + // We can safely ignore the signal connection because the dialog will always + // be destroyed before the button is destroyed, since it is destroyed on focus loss + // + // The button is either attached to a Notebook, or a Window frame + std::ignore = dialog->buttonClicked.connect([&button](auto buttonType) { switch (buttonType) { case UpdateDialog::Dismiss: { @@ -36,7 +41,7 @@ void initUpdateButton(Button &button, } break; case UpdateDialog::Install: { - Updates::instance().installUpdates(); + getIApp()->getUpdates().installUpdates(); } break; } @@ -48,17 +53,17 @@ void initUpdateButton(Button &button, // update image when state changes auto updateChange = [&button](auto) { - button.setVisible(Updates::instance().shouldShowUpdateButton()); + button.setVisible(getIApp()->getUpdates().shouldShowUpdateButton()); - auto imageUrl = Updates::instance().isError() - ? ":/buttons/updateError.png" - : ":/buttons/update.png"; + const auto *imageUrl = getIApp()->getUpdates().isError() + ? ":/buttons/updateError.png" + : ":/buttons/update.png"; button.setPixmap(QPixmap(imageUrl)); }; - updateChange(Updates::instance().getStatus()); + updateChange(getIApp()->getUpdates().getStatus()); - signalHolder.managedConnect(Updates::instance().statusUpdated, + signalHolder.managedConnect(getIApp()->getUpdates().statusUpdated, [updateChange](auto status) { updateChange(status); }); diff --git a/src/util/IpcQueue.cpp b/src/util/IpcQueue.cpp new file mode 100644 index 00000000000..0efc878fc59 --- /dev/null +++ b/src/util/IpcQueue.cpp @@ -0,0 +1,87 @@ +#include "util/IpcQueue.hpp" + +#include "common/QLogging.hpp" + +#include +#include +#include +#include + +namespace boost_ipc = boost::interprocess; + +namespace chatterino::ipc { + +void sendMessage(const char *name, const QByteArray &data) +{ + try + { + boost_ipc::message_queue messageQueue(boost_ipc::open_only, name); + + messageQueue.try_send(data.data(), size_t(data.size()), 1); + } + catch (boost_ipc::interprocess_exception &ex) + { + qCDebug(chatterinoNativeMessage) + << "Failed to send message:" << ex.what(); + } +} + +class IpcQueuePrivate +{ +public: + IpcQueuePrivate(const char *name, size_t maxMessages, size_t maxMessageSize) + : queue(boost_ipc::open_or_create, name, maxMessages, maxMessageSize) + { + } + + boost_ipc::message_queue queue; +}; + +IpcQueue::IpcQueue(IpcQueuePrivate *priv) + : private_(priv){}; +IpcQueue::~IpcQueue() = default; + +std::pair, QString> IpcQueue::tryReplaceOrCreate( + const char *name, size_t maxMessages, size_t maxMessageSize) +{ + try + { + boost_ipc::message_queue::remove(name); + return std::make_pair( + std::unique_ptr(new IpcQueue( + new IpcQueuePrivate(name, maxMessages, maxMessageSize))), + QString()); + } + catch (boost_ipc::interprocess_exception &ex) + { + return {nullptr, QString::fromLatin1(ex.what())}; + } +} + +QByteArray IpcQueue::receive() +{ + try + { + auto *d = this->private_.get(); + + QByteArray buf; + // The new storage is uninitialized + buf.resize(static_cast(d->queue.get_max_msg_size())); + + size_t messageSize = 0; + unsigned int priority = 0; + d->queue.receive(buf.data(), buf.size(), messageSize, priority); + + // truncate to the initialized storage + buf.truncate(static_cast(messageSize)); + return buf; + } + catch (boost_ipc::interprocess_exception &ex) + { + qCDebug(chatterinoNativeMessage) + << "Failed to receive message:" << ex.what(); + } + return {}; +} + +} // namespace chatterino::ipc diff --git a/src/util/IpcQueue.hpp b/src/util/IpcQueue.hpp new file mode 100644 index 00000000000..467aa287328 --- /dev/null +++ b/src/util/IpcQueue.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +class QByteArray; +class QString; + +namespace chatterino::ipc { + +void sendMessage(const char *name, const QByteArray &data); + +class IpcQueuePrivate; +class IpcQueue +{ +public: + ~IpcQueue(); + + static std::pair, QString> tryReplaceOrCreate( + const char *name, size_t maxMessages, size_t maxMessageSize); + + // TODO: use std::expected + /// Try to receive a message. + /// In the case of an error, the buffer is empty. + QByteArray receive(); + +private: + IpcQueue(IpcQueuePrivate *priv); + + std::unique_ptr private_; + + friend class IpcQueuePrivate; +}; + +} // namespace chatterino::ipc diff --git a/src/util/LayoutHelper.cpp b/src/util/LayoutHelper.cpp index 43987d7ef16..c3a71690e02 100644 --- a/src/util/LayoutHelper.cpp +++ b/src/util/LayoutHelper.cpp @@ -7,14 +7,14 @@ namespace chatterino { QWidget *wrapLayout(QLayout *layout) { - auto widget = new QWidget; + auto *widget = new QWidget; widget->setLayout(layout); return widget; } QScrollArea *makeScrollArea(WidgetOrLayout item) { - auto area = new QScrollArea(); + auto *area = new QScrollArea(); switch (item.which()) { diff --git a/src/util/LayoutHelper.hpp b/src/util/LayoutHelper.hpp index fe8e46145dd..d40bab0f3a5 100644 --- a/src/util/LayoutHelper.hpp +++ b/src/util/LayoutHelper.hpp @@ -19,7 +19,7 @@ T *makeLayout(std::initializer_list items) { auto t = new T; - for (auto &item : items) + for (const auto &item : items) { switch (item.which()) { diff --git a/src/util/NuulsUploader.hpp b/src/util/NuulsUploader.hpp deleted file mode 100644 index e830b45054f..00000000000 --- a/src/util/NuulsUploader.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace chatterino { - -class ResizingTextEdit; -class Channel; -using ChannelPtr = std::shared_ptr; - -struct RawImageData { - QByteArray data; - QString format; - QString filePath; -}; - -void upload(QByteArray imageData, ChannelPtr channel, - ResizingTextEdit &textEdit, std::string format); -void upload(RawImageData imageData, ChannelPtr channel, - ResizingTextEdit &textEdit); -void upload(const QMimeData *source, ChannelPtr channel, - ResizingTextEdit &outputTextEdit); - -} // namespace chatterino diff --git a/src/util/PersistSignalVector.hpp b/src/util/PersistSignalVector.hpp index 34aafdf283c..9d6d8fd92d9 100644 --- a/src/util/PersistSignalVector.hpp +++ b/src/util/PersistSignalVector.hpp @@ -13,7 +13,9 @@ inline void persist(SignalVector &vec, const std::string &name) auto setting = std::make_unique>>(name); for (auto &&item : setting->getValue()) + { vec.append(item); + } vec.delayedItemsChanged.connect([setting = setting.get(), vec = &vec] { setting->setValue(vec->raw()); diff --git a/src/util/PostToThread.hpp b/src/util/PostToThread.hpp index 4f7f872d940..afeb34d06b0 100644 --- a/src/util/PostToThread.hpp +++ b/src/util/PostToThread.hpp @@ -17,11 +17,11 @@ class LambdaRunnable : public QRunnable { public: LambdaRunnable(std::function action) + : action_(std::move(action)) { - this->action_ = std::move(action); } - void run() + void run() override { this->action_(); } diff --git a/src/util/QObjectRef.hpp b/src/util/QObjectRef.hpp deleted file mode 100644 index 07444d08535..00000000000 --- a/src/util/QObjectRef.hpp +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace chatterino { -/// Holds a pointer to a QObject and resets it to nullptr if the QObject -/// gets destroyed. -template -class QObjectRef -{ -public: - QObjectRef() - { - static_assert(std::is_base_of_v); - } - - explicit QObjectRef(T *t) - { - static_assert(std::is_base_of_v); - - this->set(t); - } - - QObjectRef(const QObjectRef &other) - { - this->set(other.t_); - } - - ~QObjectRef() - { - this->set(nullptr); - } - - QObjectRef &operator=(T *t) - { - this->set(t); - - return *this; - } - - operator bool() - { - return t_; - } - - T *operator->() - { - return t_; - } - - T *get() - { - return t_; - } - -private: - void set(T *other) - { - // old - if (this->conn_) - { - QObject::disconnect(this->conn_); - } - - // new - if (other) - { - // the cast here should absolutely not be necessary, but gcc still requires it - this->conn_ = - QObject::connect((QObject *)other, &QObject::destroyed, qApp, - [this](QObject *) { - this->set(nullptr); - }, - Qt::DirectConnection); - } - - this->t_ = other; - } - - std::atomic t_{}; - QMetaObject::Connection conn_; -}; -} // namespace chatterino diff --git a/src/util/SampleData.cpp b/src/util/SampleData.cpp index f68305a1b0b..953646139bd 100644 --- a/src/util/SampleData.cpp +++ b/src/util/SampleData.cpp @@ -107,8 +107,14 @@ const QStringList &getSampleMiscMessages() // mod announcement R"(@badge-info=subscriber/47;badges=broadcaster/1,subscriber/3012,twitchconAmsterdam2020/1;color=#FF0000;display-name=Supinic;emotes=;flags=;id=8c26e1ab-b50c-4d9d-bc11-3fd57a941d90;login=supinic;mod=0;msg-id=announcement;msg-param-color=PRIMARY;room-id=31400525;subscriber=1;system-msg=;tmi-sent-ts=1648762219962;user-id=31400525;user-type= :tmi.twitch.tv USERNOTICE #supinic :mm test lol)", - // Elevated Message (Paid option for keeping a message in chat longer) + // Hype Chat (Paid option for keeping a message in chat longer) + // no level R"(@badge-info=subscriber/3;badges=subscriber/0,bits-charity/1;color=#0000FF;display-name=SnoopyTheBot;emotes=;first-msg=0;flags=;id=8779a9e5-cf1b-47b3-b9fe-67a5b1b605f6;mod=0;pinned-chat-paid-amount=500;pinned-chat-paid-canonical-amount=5;pinned-chat-paid-currency=USD;pinned-chat-paid-exponent=2;returning-chatter=0;room-id=36340781;subscriber=1;tmi-sent-ts=1664505974154;turbo=0;user-id=136881249;user-type= :snoopythebot!snoopythebot@snoopythebot.tmi.twitch.tv PRIVMSG #pajlada :-$5)", + // level 1 + R"(@pinned-chat-paid-level=ONE;mod=0;flags=;pinned-chat-paid-amount=1400;pinned-chat-paid-exponent=2;tmi-sent-ts=1687970631828;subscriber=1;user-type=;color=#9DA364;emotes=;badges=predictions/blue-1,subscriber/60,twitchconAmsterdam2020/1;pinned-chat-paid-canonical-amount=1400;turbo=0;user-id=26753388;id=e6681ba0-cdc6-4482-93a3-515b74361e8b;room-id=36340781;first-msg=0;returning-chatter=0;pinned-chat-paid-currency=NOK;pinned-chat-paid-is-system-message=0;badge-info=predictions/Day\s53/53\sforsenSmug,subscriber/67;display-name=matrHS :matrhs!matrhs@matrhs.tmi.twitch.tv PRIVMSG #pajlada :Title: Beating the record. but who is recordingLOL)", + R"(@flags=;pinned-chat-paid-amount=8761;turbo=0;user-id=35669184;pinned-chat-paid-level=ONE;user-type=;pinned-chat-paid-canonical-amount=8761;badge-info=subscriber/2;badges=subscriber/2,sub-gifter/1;emotes=;pinned-chat-paid-exponent=2;subscriber=1;mod=0;room-id=36340781;returning-chatter=0;id=289b614d-1837-4cff-ac22-ce33a9735323;first-msg=0;tmi-sent-ts=1687631719188;color=#00FF7F;pinned-chat-paid-currency=RUB;display-name=Danis;pinned-chat-paid-is-system-message=0 :danis!danis@danis.tmi.twitch.tv PRIVMSG #pajlada :-1 lulw)", + // level 2 + R"(@room-id=36340781;tmi-sent-ts=1687970634371;flags=;id=39a80a3d-c16e-420f-9bbb-faba4976a3bb;badges=subscriber/6,premium/1;emotes=;display-name=rickharrisoncoc;pinned-chat-paid-level=TWO;turbo=0;pinned-chat-paid-amount=500;pinned-chat-paid-is-system-message=0;color=#FF69B4;subscriber=1;user-type=;first-msg=0;pinned-chat-paid-currency=USD;pinned-chat-paid-canonical-amount=500;user-id=518404689;badge-info=subscriber/10;pinned-chat-paid-exponent=2;returning-chatter=0;mod=0 :rickharrisoncoc!rickharrisoncoc@rickharrisoncoc.tmi.twitch.tv PRIVMSG #pajlada :forsen please read my super chat. Please.)", }; return list; } diff --git a/src/util/SplitCommand.cpp b/src/util/SplitCommand.cpp index 09c35afc100..3d71e040f03 100644 --- a/src/util/SplitCommand.cpp +++ b/src/util/SplitCommand.cpp @@ -70,7 +70,9 @@ QStringList chatterino::splitCommand(QStringView command) if (quoteCount) { if (quoteCount == 1) + { inQuote = !inQuote; + } quoteCount = 0; } if (!inQuote && command.at(i).isSpace()) @@ -87,7 +89,9 @@ QStringList chatterino::splitCommand(QStringView command) } } if (!tmp.isEmpty()) + { args += tmp; + } return args; } diff --git a/src/util/StandardItemHelper.hpp b/src/util/StandardItemHelper.hpp index 4e62c91e7f0..4f8d0826be3 100644 --- a/src/util/StandardItemHelper.hpp +++ b/src/util/StandardItemHelper.hpp @@ -5,7 +5,7 @@ namespace chatterino { -static auto defaultItemFlags(bool selectable) +inline auto defaultItemFlags(bool selectable) { return Qt::ItemIsEnabled | (selectable ? Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | @@ -13,7 +13,7 @@ static auto defaultItemFlags(bool selectable) : Qt::ItemFlag()); } -static void setBoolItem(QStandardItem *item, bool value, +inline void setBoolItem(QStandardItem *item, bool value, bool userCheckable = true, bool selectable = true) { item->setFlags( @@ -22,7 +22,7 @@ static void setBoolItem(QStandardItem *item, bool value, item->setCheckState(value ? Qt::Checked : Qt::Unchecked); } -static void setStringItem(QStandardItem *item, const QString &value, +inline void setStringItem(QStandardItem *item, const QString &value, bool editable = true, bool selectable = true) { item->setData(value, Qt::EditRole); @@ -30,7 +30,7 @@ static void setStringItem(QStandardItem *item, const QString &value, (editable ? (Qt::ItemIsEditable) : 0))); } -static void setFilePathItem(QStandardItem *item, const QUrl &value, +inline void setFilePathItem(QStandardItem *item, const QUrl &value, bool selectable = true) { item->setData(value, Qt::UserRole); @@ -40,7 +40,7 @@ static void setFilePathItem(QStandardItem *item, const QUrl &value, (selectable ? Qt::ItemIsSelectable : Qt::NoItemFlags))); } -static void setColorItem(QStandardItem *item, const QColor &value, +inline void setColorItem(QStandardItem *item, const QColor &value, bool selectable = true) { item->setData(value, Qt::DecorationRole); @@ -49,7 +49,7 @@ static void setColorItem(QStandardItem *item, const QColor &value, (selectable ? Qt::ItemIsSelectable : Qt::NoItemFlags))); } -static QStandardItem *emptyItem() +inline QStandardItem *emptyItem() { auto *item = new QStandardItem(); item->setFlags(Qt::ItemFlags()); diff --git a/src/util/StreamLink.cpp b/src/util/StreamLink.cpp index 379c2699ae3..06452c496b1 100644 --- a/src/util/StreamLink.cpp +++ b/src/util/StreamLink.cpp @@ -15,119 +15,92 @@ #include #include #include +#include #include -namespace chatterino { - namespace { - const char *getBinaryName() +using namespace chatterino; + +QString getStreamlinkPath() +{ + if (getSettings()->streamlinkUseCustomPath) { -#ifdef _WIN32 - return "streamlink.exe"; -#else - return "streamlink"; -#endif + const QString path = getSettings()->streamlinkPath; + return path.trimmed() % "/" % STREAMLINK_BINARY_NAME; } - const char *getDefaultBinaryPath() + return STREAMLINK_BINARY_NAME.toString(); +} + +void showStreamlinkNotFoundError() +{ + static auto *msg = new QErrorMessage; + msg->setWindowTitle("Chatterino - streamlink not found"); + + if (getSettings()->streamlinkUseCustomPath) { -#ifdef _WIN32 - return "C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe"; -#else - return "/usr/bin/streamlink"; -#endif + msg->showMessage("Unable to find Streamlink executable\nMake sure " + "your custom path is pointing to the DIRECTORY " + "where the streamlink executable is located"); } - - bool checkStreamlinkPath(const QString &path) + else { - QFileInfo fileinfo(path); + msg->showMessage( + "Unable to find Streamlink executable.\nIf you have Streamlink " + "installed, you might need to enable the custom path option"); + } +} - if (!fileinfo.exists()) - { - return false; - // throw Exception(fS("Streamlink path ({}) is invalid, file does - // not exist", path)); - } +QProcess *createStreamlinkProcess() +{ + auto *p = new QProcess; - return fileinfo.isExecutable(); - } + const auto path = getStreamlinkPath(); - void showStreamlinkNotFoundError() + if (Version::instance().isFlatpak()) { - static QErrorMessage *msg = new QErrorMessage; - msg->setWindowTitle("Chatterino - streamlink not found"); - - if (getSettings()->streamlinkUseCustomPath) - { - msg->showMessage("Unable to find Streamlink executable\nMake sure " - "your custom path is pointing to the DIRECTORY " - "where the streamlink executable is located"); - } - else - { - msg->showMessage( - "Unable to find Streamlink executable.\nIf you have Streamlink " - "installed, you might need to enable the custom path option"); - } + p->setProgram("flatpak-spawn"); + p->setArguments({"--host", path}); } - - QProcess *createStreamlinkProcess() + else { - auto p = new QProcess; - - const QString path = [] { - if (getSettings()->streamlinkUseCustomPath) - { - return getSettings()->streamlinkPath + "/" + getBinaryName(); - } - else - { - return QString{getBinaryName()}; - } - }(); + p->setProgram(path); + } - if (Version::instance().isFlatpak()) + QObject::connect(p, &QProcess::errorOccurred, [=](auto err) { + if (err == QProcess::FailedToStart) { - p->setProgram("flatpak-spawn"); - p->setArguments({"--host", path}); + showStreamlinkNotFoundError(); } else { - p->setProgram(path); + qCWarning(chatterinoStreamlink) << "Error occurred" << err; } - QObject::connect(p, &QProcess::errorOccurred, [=](auto err) { - if (err == QProcess::FailedToStart) - { - showStreamlinkNotFoundError(); - } - else - { - qCWarning(chatterinoStreamlink) << "Error occurred" << err; - } + p->deleteLater(); + }); + QObject::connect( + p, + static_cast( + &QProcess::finished), + [=](int /*exitCode*/, QProcess::ExitStatus /*exitStatus*/) { p->deleteLater(); }); - QObject::connect( - p, - static_cast( - &QProcess::finished), - [=](int /*exitCode*/, QProcess::ExitStatus /*exitStatus*/) { - p->deleteLater(); - }); - - return p; - } + return p; +} } // namespace +namespace chatterino { + void getStreamQualities(const QString &channelURL, std::function cb) { - auto p = createStreamlinkProcess(); + auto *p = createStreamlinkProcess(); QObject::connect( p, @@ -147,7 +120,7 @@ void getStreamQualities(const QString &channelURL, QStringList split = lastLine.right(lastLine.length() - 19).split(", "); - for (int i = split.length() - 1; i >= 0; i--) + for (auto i = split.length() - 1; i >= 0; i--) { QString option = split.at(i); if (option == "best)") @@ -187,7 +160,7 @@ void getStreamQualities(const QString &channelURL, void openStreamlink(const QString &channelURL, const QString &quality, QStringList extraArguments) { - auto proc = createStreamlinkProcess(); + auto *proc = createStreamlinkProcess(); auto arguments = proc->arguments() << extraArguments << channelURL << quality; @@ -211,12 +184,15 @@ void openStreamlinkForChannel(const QString &channel) { static const QString INFO_TEMPLATE("Opening %1 in Streamlink ..."); - auto *currentPage = dynamic_cast( - getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); + auto *currentPage = dynamic_cast(getIApp() + ->getWindows() + ->getMainWindow() + .getNotebook() + .getSelectedPage()); if (currentPage != nullptr) { - if (auto currentSplit = currentPage->getSelectedSplit(); - currentSplit != nullptr) + auto *currentSplit = currentPage->getSelectedSplit(); + if (currentSplit != nullptr) { currentSplit->getChannel()->addMessage( makeSystemMessage(INFO_TEMPLATE.arg(channel))); diff --git a/src/util/StreamLink.hpp b/src/util/StreamLink.hpp index 99dbb95b923..8ebcc617bc2 100644 --- a/src/util/StreamLink.hpp +++ b/src/util/StreamLink.hpp @@ -14,6 +14,12 @@ class Exception : public std::runtime_error using std::runtime_error::runtime_error; }; +#ifdef Q_OS_WIN +constexpr inline QStringView STREAMLINK_BINARY_NAME = u"streamlink.exe"; +#else +constexpr inline QStringView STREAMLINK_BINARY_NAME = u"streamlink"; +#endif + // Open streamlink for given channel, quality and extra arguments // the "Additional arguments" are fetched and added at the beginning of the // streamlink call diff --git a/src/util/StreamerMode.cpp b/src/util/StreamerMode.cpp index 03f3f59aae8..c905b88a03a 100644 --- a/src/util/StreamerMode.cpp +++ b/src/util/StreamerMode.cpp @@ -71,6 +71,7 @@ bool isInStreamerMode() p.exitStatus() == QProcess::NormalExit) { cache = (p.exitCode() == 0); + getApp()->streamerModeChanged.invoke(); return (p.exitCode() == 0); } @@ -89,6 +90,7 @@ bool isInStreamerMode() qCWarning(chatterinoStreamerMode) << "pgrep execution timed out!"; cache = false; + getApp()->streamerModeChanged.invoke(); return false; #endif @@ -122,6 +124,7 @@ bool isInStreamerMode() if (broadcastingBinaries().contains(processName)) { cache = true; + getApp()->streamerModeChanged.invoke(); return true; } } @@ -133,6 +136,7 @@ bool isInStreamerMode() } cache = false; + getApp()->streamerModeChanged.invoke(); #endif return false; } diff --git a/src/util/Twitch.cpp b/src/util/Twitch.cpp index 12e7519848a..fa21c8583c7 100644 --- a/src/util/Twitch.cpp +++ b/src/util/Twitch.cpp @@ -62,6 +62,33 @@ void stripChannelName(QString &channelName) } } +std::pair parseUserNameOrID(const QString &input) +{ + if (input.startsWith("id:")) + { + return { + {}, + input.mid(3), + }; + } + + QString userName = input; + + if (userName.startsWith('@') || userName.startsWith('#')) + { + userName.remove(0, 1); + } + if (userName.endsWith(',')) + { + userName.chop(1); + } + + return { + userName, + {}, + }; +} + QRegularExpression twitchUserNameRegexp() { static QRegularExpression re( diff --git a/src/util/Twitch.hpp b/src/util/Twitch.hpp index c3bb346a9d0..367b6cc98c5 100644 --- a/src/util/Twitch.hpp +++ b/src/util/Twitch.hpp @@ -16,6 +16,16 @@ void stripUserName(QString &userName); // stripChannelName removes any @ prefix or , suffix to make it more suitable for command use void stripChannelName(QString &channelName); +using ParsedUserName = QString; +using ParsedUserID = QString; + +/** + * Parse the given input into either a user name or a user ID + * + * User IDs take priority and are parsed if the input starts with `id:` + */ +std::pair parseUserNameOrID(const QString &input); + // Matches a strict Twitch user login. // May contain lowercase a-z, 0-9, and underscores // Must contain between 1 and 25 characters diff --git a/src/util/Variant.hpp b/src/util/Variant.hpp new file mode 100644 index 00000000000..8e7a2b09398 --- /dev/null +++ b/src/util/Variant.hpp @@ -0,0 +1,28 @@ +#pragma once + +namespace chatterino::variant { + +/// Compile-time safe visitor for std and boost variants. +/// +/// From https://en.cppreference.com/w/cpp/utility/variant/visit +/// +/// Usage: +/// +/// ``` +/// std::variant v; +/// std::visit(variant::Overloaded{ +/// [](double) { qDebug() << "double"; }, +/// [](int) { qDebug() << "int"; } +/// }, v); +/// ``` +template +struct Overloaded : Ts... { + using Ts::operator()...; +}; + +// Technically, we shouldn't need this, as we're on C++ 20, +// but not all of our compilers support CTAD for aggregates yet. +template +Overloaded(Ts...) -> Overloaded; + +} // namespace chatterino::variant diff --git a/src/util/WidgetHelpers.cpp b/src/util/WidgetHelpers.cpp new file mode 100644 index 00000000000..b5e6fa9a303 --- /dev/null +++ b/src/util/WidgetHelpers.cpp @@ -0,0 +1,95 @@ +#include "util/WidgetHelpers.hpp" + +#include +#include +#include +#include +#include + +namespace { + +/// Move the `window` into the `screen` geometry if it's not already in there. +void moveWithinScreen(QWidget *window, QScreen *screen, QPoint point) +{ + if (screen == nullptr) + { + screen = QGuiApplication::primaryScreen(); + } + + const QRect bounds = screen->availableGeometry(); + + bool stickRight = false; + bool stickBottom = false; + + const auto w = window->frameGeometry().width(); + const auto h = window->frameGeometry().height(); + + if (point.x() < bounds.left()) + { + point.setX(bounds.left()); + } + if (point.y() < bounds.top()) + { + point.setY(bounds.top()); + } + if (point.x() + w > bounds.right()) + { + stickRight = true; + point.setX(bounds.right() - w); + } + if (point.y() + h > bounds.bottom()) + { + stickBottom = true; + point.setY(bounds.bottom() - h); + } + + if (stickRight && stickBottom) + { + const QPoint globalCursorPos = QCursor::pos(); + point.setY(globalCursorPos.y() - window->height() - 16); + } + + window->move(point); +} + +} // namespace + +namespace chatterino::widgets { + +void moveWindowTo(QWidget *window, QPoint position, BoundsChecking mode) +{ + switch (mode) + { + case BoundsChecking::Off: { + window->move(position); + } + break; + + case BoundsChecking::CursorPosition: { + moveWithinScreen(window, QGuiApplication::screenAt(QCursor::pos()), + position); + } + break; + + case BoundsChecking::DesiredPosition: { + moveWithinScreen(window, QGuiApplication::screenAt(position), + position); + } + break; + } +} + +void showAndMoveWindowTo(QWidget *window, QPoint position, BoundsChecking mode) +{ +#ifdef Q_OS_WINDOWS + window->show(); + + moveWindowTo(window, position, mode); +#else + moveWindowTo(window, position, mode); + + window->show(); +#endif +} + +} // namespace chatterino::widgets diff --git a/src/util/WidgetHelpers.hpp b/src/util/WidgetHelpers.hpp new file mode 100644 index 00000000000..b09e93d0b95 --- /dev/null +++ b/src/util/WidgetHelpers.hpp @@ -0,0 +1,39 @@ +#pragma once + +class QWidget; +class QPoint; +class QScreen; + +namespace chatterino::widgets { + +enum class BoundsChecking { + /// Don't do any bounds checking (equivalent to `QWidget::move`). + Off, + + /// Attempt to keep the window within bounds of the screen the cursor is on. + CursorPosition, + + /// Attempt to keep the window within bounds of the screen the desired position is on. + DesiredPosition, +}; + +/// Moves the `window` to the (global) `position` +/// while doing bounds-checking according to `mode` to ensure the window stays on one screen. +/// +/// @param window The window to move. +/// @param position The global position to move the window to. +/// @param mode The desired bounds checking. +void moveWindowTo(QWidget *window, QPoint position, + BoundsChecking mode = BoundsChecking::DesiredPosition); + +/// Moves the `window` to the (global) `position` +/// while doing bounds-checking according to `mode` to ensure the window stays on one screen. +/// Will also call show on the `window`, order is dependant on platform. +/// +/// @param window The window to move. +/// @param position The global position to move the window to. +/// @param mode The desired bounds checking. +void showAndMoveWindowTo(QWidget *window, QPoint position, + BoundsChecking mode = BoundsChecking::DesiredPosition); + +} // namespace chatterino::widgets diff --git a/src/util/WindowsHelper.cpp b/src/util/WindowsHelper.cpp index d46d29158ad..6584604db23 100644 --- a/src/util/WindowsHelper.cpp +++ b/src/util/WindowsHelper.cpp @@ -1,29 +1,28 @@ -#include "WindowsHelper.hpp" +#include "util/WindowsHelper.hpp" -#include +#include "common/Literals.hpp" + +#include +#include #include #include #ifdef USEWINSDK +# include +# include # include # include namespace chatterino { -typedef enum MONITOR_DPI_TYPE { - MDT_EFFECTIVE_DPI = 0, - MDT_ANGULAR_DPI = 1, - MDT_RAW_DPI = 2, - MDT_DEFAULT = MDT_EFFECTIVE_DPI -} MONITOR_DPI_TYPE; +using namespace literals; -typedef HRESULT(CALLBACK *GetDpiForMonitor_)(HMONITOR, MONITOR_DPI_TYPE, UINT *, - UINT *); -typedef HRESULT(CALLBACK *AssocQueryString_)(ASSOCF, ASSOCSTR, LPCWSTR, LPCWSTR, - LPWSTR, DWORD *); +using GetDpiForMonitor_ = HRESULT(CALLBACK *)(HMONITOR, MONITOR_DPI_TYPE, + UINT *, UINT *); -boost::optional getWindowDpi(HWND hwnd) +// TODO: This should be changed to `GetDpiForWindow`. +std::optional getWindowDpi(HWND hwnd) { static HINSTANCE shcore = LoadLibrary(L"Shcore.dll"); if (shcore != nullptr) @@ -34,45 +33,38 @@ boost::optional getWindowDpi(HWND hwnd) HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - UINT xScale, yScale; - + UINT xScale = 96; + UINT yScale = 96; getDpiForMonitor(monitor, MDT_DEFAULT, &xScale, &yScale); return xScale; } } - return boost::none; + return std::nullopt; } -typedef HRESULT(CALLBACK *OleFlushClipboard_)(); - void flushClipboard() { - static HINSTANCE ole32 = LoadLibrary(L"Ole32.dll"); - if (ole32 != nullptr) + if (QApplication::clipboard()->ownsClipboard()) { - if (auto oleFlushClipboard = - OleFlushClipboard_(GetProcAddress(ole32, "OleFlushClipboard"))) - { - oleFlushClipboard(); - } + OleFlushClipboard(); } } -constexpr const char *runKey = - "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const QString RUN_KEY = + uR"(HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run)"_s; bool isRegisteredForStartup() { - QSettings settings(runKey, QSettings::NativeFormat); + QSettings settings(RUN_KEY, QSettings::NativeFormat); return !settings.value("Chatterino").toString().isEmpty(); } void setRegisteredForStartup(bool isRegistered) { - QSettings settings(runKey, QSettings::NativeFormat); + QSettings settings(RUN_KEY, QSettings::NativeFormat); if (isRegistered) { @@ -88,21 +80,8 @@ void setRegisteredForStartup(bool isRegistered) } } -QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) +QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query) { - static HINSTANCE shlwapi = LoadLibrary(L"shlwapi"); - if (shlwapi == nullptr) - { - return QString(); - } - - static auto assocQueryString = - AssocQueryString_(GetProcAddress(shlwapi, "AssocQueryStringW")); - if (assocQueryString == nullptr) - { - return QString(); - } - // always error out instead of returning a truncated string when the // buffer is too small - avoids race condition when the user changes their // default browser between calls to AssocQueryString @@ -117,28 +96,28 @@ QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query) } else { - return QString(); + return {}; } } DWORD resultSize = 0; - if (FAILED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, - nullptr, &resultSize))) + if (FAILED(AssocQueryStringW(flags, ASSOCSTR_EXECUTABLE, query, nullptr, + nullptr, &resultSize))) { - return QString(); + return {}; } if (resultSize <= 1) { // resultSize includes the null terminator. if resultSize is 1, the // returned value would be the empty string. - return QString(); + return {}; } QString result; - auto buf = new wchar_t[resultSize]; - if (SUCCEEDED(assocQueryString(flags, ASSOCSTR_COMMAND, query, nullptr, buf, - &resultSize))) + auto *buf = new wchar_t[resultSize]; + if (SUCCEEDED(AssocQueryStringW(flags, ASSOCSTR_EXECUTABLE, query, nullptr, + buf, &resultSize))) { // QString::fromWCharArray expects the length in characters *not // including* the null terminator, but AssocQueryStringW calculates diff --git a/src/util/WindowsHelper.hpp b/src/util/WindowsHelper.hpp index 478368b81b3..af719996b75 100644 --- a/src/util/WindowsHelper.hpp +++ b/src/util/WindowsHelper.hpp @@ -2,20 +2,22 @@ #ifdef USEWINSDK -# include +# include # include +# include + namespace chatterino { enum class AssociationQueryType { Protocol, FileExtension }; -boost::optional getWindowDpi(HWND hwnd); +std::optional getWindowDpi(HWND hwnd); void flushClipboard(); bool isRegisteredForStartup(); void setRegisteredForStartup(bool isRegistered); -QString getAssociatedCommand(AssociationQueryType queryType, LPCWSTR query); +QString getAssociatedExecutable(AssociationQueryType queryType, LPCWSTR query); } // namespace chatterino diff --git a/src/util/XDGDesktopFile.cpp b/src/util/XDGDesktopFile.cpp new file mode 100644 index 00000000000..886a921dc45 --- /dev/null +++ b/src/util/XDGDesktopFile.cpp @@ -0,0 +1,118 @@ +#include "util/XDGDesktopFile.hpp" + +#include "util/XDGDirectory.hpp" + +#include +#include + +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +namespace chatterino { + +XDGDesktopFile::XDGDesktopFile(const QString &filename) +{ + QFile file(filename); + if (!file.open(QIODevice::ReadOnly)) + { + return; + } + this->valid = true; + + std::optional> entries; + + while (!file.atEnd()) + { + auto lineBytes = file.readLine().trimmed(); + + // Ignore comments & empty lines + if (lineBytes.startsWith('#') || lineBytes.size() == 0) + { + continue; + } + + auto line = QString::fromUtf8(lineBytes); + + if (line.startsWith('[')) + { + // group header + auto end = line.indexOf(']', 1); + if (end == -1 || end == 1) + { + // malformed header - either empty or no closing bracket + continue; + } + auto groupName = line.mid(1, end - 1); + + // it is against spec for the group name to already exist, but the + // parsing behavior for that case is not specified. operator[] will + // result in duplicate groups being merged, which makes the most + // sense for a read-only parser + entries = this->groups[groupName]; + + continue; + } + + // group entry + if (!entries.has_value()) + { + // no group header yet, entry before a group header is against spec + // and should be ignored + continue; + } + + auto delimiter = line.indexOf('='); + if (delimiter == -1) + { + // line is not a group header or a key value pair, ignore it + continue; + } + + auto key = QStringView(line).left(delimiter).trimmed().toString(); + // QStringView.mid() does not do bounds checking before qt 5.15, so + // we have to do it ourselves + auto valueStart = delimiter + 1; + QString value; + if (valueStart < line.size()) + { + value = QStringView(line).mid(valueStart).trimmed().toString(); + } + + // existing keys are against spec, so we can overwrite them with + // wild abandon + entries->get().emplace(key, value); + } +} + +XDGEntries XDGDesktopFile::getEntries(const QString &groupHeader) const +{ + auto group = this->groups.find(groupHeader); + if (group != this->groups.end()) + { + return group->second; + } + + return {}; +} + +std::optional XDGDesktopFile::findDesktopFile( + const QString &desktopFileID) +{ + for (const auto &dataDir : getXDGDirectories(XDGDirectoryType::Data)) + { + auto fileName = + QDir::cleanPath(dataDir + QDir::separator() + "applications" + + QDir::separator() + desktopFileID); + XDGDesktopFile desktopFile(fileName); + if (desktopFile.isValid()) + { + return desktopFile; + } + } + return {}; +} + +} // namespace chatterino + +#endif diff --git a/src/util/XDGDesktopFile.hpp b/src/util/XDGDesktopFile.hpp new file mode 100644 index 00000000000..d61705c804c --- /dev/null +++ b/src/util/XDGDesktopFile.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "util/QStringHash.hpp" + +#include +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +namespace chatterino { + +// See https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#group-header +using XDGEntries = std::unordered_map; + +class XDGDesktopFile +{ +public: + // Read the file at `filename` as an XDG desktop file, parsing its groups & their entries + // + // Use the `isValid` function to check if the file was read properly + explicit XDGDesktopFile(const QString &filename); + + /// Returns a map of entries for the given group header + XDGEntries getEntries(const QString &groupHeader) const; + + /// isValid returns true if the file exists and is readable + bool isValid() const + { + return valid; + } + + /// Find the first desktop file based on the given desktop file ID + /// + /// This will look through all Data XDG directories + /// + /// Can return std::nullopt if no desktop file was found for the given desktop file ID + /// + /// References: https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id + static std::optional findDesktopFile( + const QString &desktopFileID); + +private: + bool valid{}; + std::unordered_map groups; +}; + +} // namespace chatterino + +#endif diff --git a/src/util/XDGDirectory.cpp b/src/util/XDGDirectory.cpp new file mode 100644 index 00000000000..979e58170c8 --- /dev/null +++ b/src/util/XDGDirectory.cpp @@ -0,0 +1,79 @@ +#include "util/XDGDirectory.hpp" + +#include "util/CombinePath.hpp" +#include "util/Qt.hpp" + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +QStringList getXDGDirectories(XDGDirectoryType directory) +{ + // User XDG directory environment variables with defaults + // Defaults fetched from https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 2023-08-05 + static std::unordered_map> + userDirectories = { + { + XDGDirectoryType::Config, + { + "XDG_CONFIG_HOME", + combinePath(QDir::homePath(), ".config/"), + }, + }, + { + XDGDirectoryType::Data, + { + "XDG_DATA_HOME", + combinePath(QDir::homePath(), ".local/share/"), + }, + }, + }; + + // Base (or system) XDG directory environment variables with defaults + // Defaults fetched from https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 2023-08-05 + static std::unordered_map> + baseDirectories = { + { + XDGDirectoryType::Config, + { + "XDG_CONFIG_DIRS", + {"/etc/xdg"}, + }, + }, + { + XDGDirectoryType::Data, + { + "XDG_DATA_DIRS", + {"/usr/local/share/", "/usr/share/"}, + }, + }, + }; + + QStringList paths; + + const auto &[userEnvVar, userDefaultValue] = userDirectories.at(directory); + auto userEnvPath = qEnvironmentVariable(userEnvVar, userDefaultValue); + paths.push_back(userEnvPath); + + const auto &[baseEnvVar, baseDefaultValue] = baseDirectories.at(directory); + auto baseEnvPaths = + qEnvironmentVariable(baseEnvVar).split(':', Qt::SkipEmptyParts); + if (baseEnvPaths.isEmpty()) + { + paths.append(baseDefaultValue); + } + else + { + paths.append(baseEnvPaths); + } + + return paths; +} + +#endif + +} // namespace chatterino diff --git a/src/util/XDGDirectory.hpp b/src/util/XDGDirectory.hpp new file mode 100644 index 00000000000..9a18ea25f95 --- /dev/null +++ b/src/util/XDGDirectory.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +enum class XDGDirectoryType { + Config, + Data, +}; + +/// getXDGDirectories returns a list of directories given a directory type +/// +/// This will attempt to read the relevant environment variable (e.g. XDG_CONFIG_HOME and XDG_CONFIG_DIRS) and merge them, with sane defaults +QStringList getXDGDirectories(XDGDirectoryType directory); + +#endif + +} // namespace chatterino diff --git a/src/util/XDGHelper.cpp b/src/util/XDGHelper.cpp new file mode 100644 index 00000000000..588c4616648 --- /dev/null +++ b/src/util/XDGHelper.cpp @@ -0,0 +1,259 @@ +#include "util/XDGHelper.hpp" + +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "util/CombinePath.hpp" +#include "util/Qt.hpp" +#include "util/XDGDesktopFile.hpp" +#include "util/XDGDirectory.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +using namespace chatterino::literals; + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoXDG; + +using namespace chatterino; + +const auto HTTPS_MIMETYPE = u"x-scheme-handler/https"_s; + +/// Read the given mimeapps file and try to find an association for the HTTPS_MIMETYPE +/// +/// If the mimeapps file is invalid (i.e. wasn't read), return nullopt +/// If the file is valid, look for the default Desktop File ID handler for the HTTPS_MIMETYPE +/// If no default Desktop File ID handler is found, populate `associations` +/// and `denyList` with Desktop File IDs from "Added Associations" and "Removed Associations" respectively +std::optional processMimeAppsList( + const QString &mimeappsPath, QStringList &associations, + std::unordered_set &denyList) +{ + XDGDesktopFile mimeappsFile(mimeappsPath); + if (!mimeappsFile.isValid()) + { + return {}; + } + + // get the list of Desktop File IDs for the given mimetype under the "Default + // Applications" group in the mimeapps.list file + auto defaultGroup = mimeappsFile.getEntries("Default Applications"); + auto defaultApps = defaultGroup.find(HTTPS_MIMETYPE); + if (defaultApps != defaultGroup.cend()) + { + // for each desktop ID in the list: + auto desktopIds = defaultApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + auto desktopId = entry.trimmed(); + + // if a valid desktop file is found, verify that it is associated + // with the type. being in the default list gives it an implicit + // association, so just check that it's not in the denylist + if (!denyList.contains(desktopId)) + { + auto desktopFile = XDGDesktopFile::findDesktopFile(desktopId); + // if a valid association is found, we have found the default + // application + if (desktopFile.has_value()) + { + return desktopFile; + } + } + } + } + + // no definitive default application found. process added and removed + // associations, then return empty + + // load any removed associations into the denylist + auto removedGroup = mimeappsFile.getEntries("Removed Associations"); + auto removedApps = removedGroup.find(HTTPS_MIMETYPE); + if (removedApps != removedGroup.end()) + { + auto desktopIds = removedApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + denyList.insert(entry.trimmed()); + } + } + + // append any created associations to the associations list + auto addedGroup = mimeappsFile.getEntries("Added Associations"); + auto addedApps = addedGroup.find(HTTPS_MIMETYPE); + if (addedApps != addedGroup.end()) + { + auto desktopIds = addedApps->second.split(';', Qt::SkipEmptyParts); + for (const auto &entry : desktopIds) + { + associations.push_back(entry.trimmed()); + } + } + + return {}; +} + +std::optional searchMimeAppsListsInDirectory( + const QString &directory, QStringList &associations, + std::unordered_set &denyList) +{ + static auto desktopNames = qEnvironmentVariable("XDG_CURRENT_DESKTOP") + .split(':', Qt::SkipEmptyParts); + static const QString desktopFilename = QStringLiteral("%1-mimeapps.list"); + static const QString nonDesktopFilename = QStringLiteral("mimeapps.list"); + + // try desktop specific mimeapps.list files first + for (const auto &desktopName : desktopNames) + { + auto fileName = + combinePath(directory, desktopFilename.arg(desktopName)); + auto defaultApp = processMimeAppsList(fileName, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // try the generic mimeapps.list + auto fileName = combinePath(directory, nonDesktopFilename); + auto defaultApp = processMimeAppsList(fileName, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + + // no definitive default application found + return {}; +} + +} // namespace + +namespace chatterino { + +/// Try to figure out the most reasonably default web browser to use +/// +/// If the `xdg-settings` program is available, use that +/// If not, read through all possible mimapps files in the order specified here: https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-1.0.1.html#file +/// If no mimeapps file has a default, try to use the Added Associations in those files +std::optional getDefaultBrowserDesktopFile() +{ + // no xdg-utils, find it manually by searching mimeapps.list files + QStringList associations; + std::unordered_set denyList; + + // config dirs first + for (const auto &configDir : getXDGDirectories(XDGDirectoryType::Config)) + { + auto defaultApp = + searchMimeAppsListsInDirectory(configDir, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // data dirs for backwards compatibility + for (const auto &dataDir : getXDGDirectories(XDGDirectoryType::Data)) + { + auto appsDir = combinePath(dataDir, "applications"); + auto defaultApp = + searchMimeAppsListsInDirectory(appsDir, associations, denyList); + if (defaultApp.has_value()) + { + return defaultApp; + } + } + + // no mimeapps.list has an explicit default, use the most preferred added + // association that exists. We could search here for one we support... + if (!associations.empty()) + { + for (const auto &desktopId : associations) + { + auto desktopFile = XDGDesktopFile::findDesktopFile(desktopId); + if (desktopFile.has_value()) + { + return desktopFile; + } + } + } + + // use xdg-settings if installed + QProcess xdgSettings; + xdgSettings.start("xdg-settings", {"get", "default-web-browser"}, + QIODevice::ReadOnly); + xdgSettings.waitForFinished(1000); + if (xdgSettings.exitStatus() == QProcess::ExitStatus::NormalExit && + xdgSettings.error() == QProcess::UnknownError && + xdgSettings.exitCode() == 0) + { + return XDGDesktopFile::findDesktopFile( + xdgSettings.readAllStandardOutput().trimmed()); + } + + return {}; +} + +QString parseDesktopExecProgram(const QString &execKey) +{ + static const QRegularExpression unescapeReservedCharacters( + R"(\\(["`$\\]))"); + + QString program = execKey; + + // string values in desktop files escape all backslashes. This is an + // independent escaping scheme that must be processed first + program.replace(u"\\\\"_s, u"\\"_s); + + if (!program.startsWith('"')) + { + // not quoted, trim after the first space (if any) + auto end = program.indexOf(' '); + if (end != -1) + { + program = program.left(end); + } + } + else + { + // quoted + auto endQuote = program.indexOf('"', 1); + if (endQuote == -1) + { + // No end quote found, the returned program might be malformed + program = program.mid(1); + qCWarning(LOG).noquote().nospace() + << "Malformed desktop entry key " << program << ", originally " + << execKey << ", you might run into issues"; + } + else + { + // End quote found + program = program.mid(1, endQuote - 1); + } + } + + // program now contains the first token of the command line. + // this is either the program name with an absolute path, or just the program name + // denoting it's a relative path. Either will be handled by QProcess cleanly + // now, there is a second escaping scheme specific to the + // exec key that must be applied. + program.replace(unescapeReservedCharacters, "\\1"); + + return program; +} + +} // namespace chatterino + +#endif diff --git a/src/util/XDGHelper.hpp b/src/util/XDGHelper.hpp new file mode 100644 index 00000000000..c862af936c7 --- /dev/null +++ b/src/util/XDGHelper.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "util/XDGDesktopFile.hpp" + +#include + +namespace chatterino { + +#if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN) + +std::optional getDefaultBrowserDesktopFile(); + +/// Parses the given `execKey` and returns the resulting program name, ignoring all arguments +/// +/// Parsing is done in accordance to https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html +/// +/// Note: We do *NOT* support field codes +QString parseDesktopExecProgram(const QString &execKey); + +#endif + +} // namespace chatterino diff --git a/src/widgets/AccountSwitchPopup.cpp b/src/widgets/AccountSwitchPopup.cpp index a50969289af..f94b94f7a91 100644 --- a/src/widgets/AccountSwitchPopup.cpp +++ b/src/widgets/AccountSwitchPopup.cpp @@ -1,5 +1,7 @@ #include "widgets/AccountSwitchPopup.hpp" +#include "common/Literals.hpp" +#include "singletons/Theme.hpp" #include "widgets/AccountSwitchWidget.hpp" #include "widgets/dialogs/SettingsDialog.hpp" @@ -9,6 +11,8 @@ namespace chatterino { +using namespace literals; + AccountSwitchPopup::AccountSwitchPopup(QWidget *parent) : BaseWindow({BaseWindow::TopMost, BaseWindow::Frameless, BaseWindow::DisableLayoutSave}, @@ -25,8 +29,8 @@ AccountSwitchPopup::AccountSwitchPopup(QWidget *parent) this->ui_.accountSwitchWidget->setFocusPolicy(Qt::NoFocus); vbox->addWidget(this->ui_.accountSwitchWidget); - auto hbox = new QHBoxLayout(); - auto manageAccountsButton = new QPushButton(this); + auto *hbox = new QHBoxLayout(); + auto *manageAccountsButton = new QPushButton(this); manageAccountsButton->setText("Manage Accounts"); manageAccountsButton->setFocusPolicy(Qt::NoFocus); hbox->addWidget(manageAccountsButton); @@ -39,6 +43,48 @@ AccountSwitchPopup::AccountSwitchPopup(QWidget *parent) this->getLayoutContainer()->setLayout(vbox); this->setScaleIndependantSize(200, 200); + this->themeChangedEvent(); +} + +void AccountSwitchPopup::themeChangedEvent() +{ + BaseWindow::themeChangedEvent(); + + auto *t = getTheme(); + auto color = [](const QColor &c) { + return c.name(QColor::HexArgb); + }; + this->setStyleSheet(uR"( + QListView { + color: %1; + background: %2; + } + QListView::item:hover { + background: %3; + } + QListView::item:selected { + background: %4; + } + + QPushButton { + background: %5; + color: %1; + } + QPushButton:hover { + background: %3; + } + QPushButton:pressed { + background: %6; + } + + chatterino--AccountSwitchPopup { + background: %7; + } + )"_s.arg(color(t->window.text), color(t->splits.header.background), + color(t->splits.header.focusedBackground), color(t->accent), + color(t->tabs.regular.backgrounds.regular), + color(t->tabs.selected.backgrounds.regular), + color(t->window.background))); } void AccountSwitchPopup::refresh() diff --git a/src/widgets/AccountSwitchPopup.hpp b/src/widgets/AccountSwitchPopup.hpp index 801b1a463c8..68d7b69b833 100644 --- a/src/widgets/AccountSwitchPopup.hpp +++ b/src/widgets/AccountSwitchPopup.hpp @@ -21,6 +21,8 @@ class AccountSwitchPopup : public BaseWindow void focusOutEvent(QFocusEvent *event) final; void paintEvent(QPaintEvent *event) override; + void themeChangedEvent() override; + private: struct { AccountSwitchWidget *accountSwitchWidget = nullptr; diff --git a/src/widgets/AccountSwitchWidget.cpp b/src/widgets/AccountSwitchWidget.cpp index 21afb7be2c2..19dbc678b50 100644 --- a/src/widgets/AccountSwitchWidget.cpp +++ b/src/widgets/AccountSwitchWidget.cpp @@ -11,31 +11,33 @@ namespace chatterino { AccountSwitchWidget::AccountSwitchWidget(QWidget *parent) : QListWidget(parent) { - auto app = getApp(); + auto *app = getApp(); this->addItem(ANONYMOUS_USERNAME_LABEL); - for (const auto &userName : app->accounts->twitch.getUsernames()) + for (const auto &userName : app->getAccounts()->twitch.getUsernames()) { this->addItem(userName); } - app->accounts->twitch.userListUpdated.connect([=, this]() { - this->blockSignals(true); + this->managedConnections_.managedConnect( + app->getAccounts()->twitch.userListUpdated, [=, this]() { + this->blockSignals(true); - this->clear(); + this->clear(); - this->addItem(ANONYMOUS_USERNAME_LABEL); + this->addItem(ANONYMOUS_USERNAME_LABEL); - for (const auto &userName : app->accounts->twitch.getUsernames()) - { - this->addItem(userName); - } + for (const auto &userName : + app->getAccounts()->twitch.getUsernames()) + { + this->addItem(userName); + } - this->refreshSelection(); + this->refreshSelection(); - this->blockSignals(false); - }); + this->blockSignals(false); + }); this->refreshSelection(); @@ -46,11 +48,11 @@ AccountSwitchWidget::AccountSwitchWidget(QWidget *parent) if (newUsername.compare(ANONYMOUS_USERNAME_LABEL, Qt::CaseInsensitive) == 0) { - app->accounts->twitch.currentUsername = ""; + app->getAccounts()->twitch.currentUsername = ""; } else { - app->accounts->twitch.currentUsername = newUsername; + app->getAccounts()->twitch.currentUsername = newUsername; } } }); @@ -68,9 +70,9 @@ void AccountSwitchWidget::refreshSelection() // Select the currently logged in user if (this->count() > 0) { - auto app = getApp(); + auto *app = getApp(); - auto currentUser = app->accounts->twitch.getCurrent(); + auto currentUser = app->getAccounts()->twitch.getCurrent(); if (currentUser->isAnon()) { diff --git a/src/widgets/AccountSwitchWidget.hpp b/src/widgets/AccountSwitchWidget.hpp index 8d236766d0d..97a0fdd9478 100644 --- a/src/widgets/AccountSwitchWidget.hpp +++ b/src/widgets/AccountSwitchWidget.hpp @@ -1,5 +1,7 @@ #pragma once +#include "pajlada/signals/signalholder.hpp" + #include namespace chatterino { @@ -15,6 +17,8 @@ class AccountSwitchWidget : public QListWidget private: void refreshSelection(); + + pajlada::Signals::SignalHolder managedConnections_; }; } // namespace chatterino diff --git a/src/widgets/AttachedWindow.cpp b/src/widgets/AttachedWindow.cpp index 57a879641e6..5ce232a1f2f 100644 --- a/src/widgets/AttachedWindow.cpp +++ b/src/widgets/AttachedWindow.cpp @@ -2,7 +2,6 @@ #include "Application.hpp" #include "common/QLogging.hpp" -#include "ForwardDecl.hpp" #include "singletons/Settings.hpp" #include "util/DebugCount.hpp" #include "widgets/splits/Split.hpp" @@ -136,6 +135,13 @@ AttachedWindow *AttachedWindow::get(void *target, const GetArgs &args) return window; } +#ifdef USEWINSDK +AttachedWindow *AttachedWindow::getForeground(const GetArgs &args) +{ + return AttachedWindow::get(::GetForegroundWindow(), args); +} +#endif + void AttachedWindow::detach(const QString &winId) { for (Item &item : items) @@ -266,7 +272,7 @@ void AttachedWindow::updateWindowRect(void *_attachedPtr) float scale = 1.f; if (auto dpi = getWindowDpi(attached)) { - scale = dpi.get() / 96.f; + scale = *dpi / 96.f; for (auto w : this->ui_.split->findChildren()) { diff --git a/src/widgets/AttachedWindow.hpp b/src/widgets/AttachedWindow.hpp index 2f477450299..3f863cc3208 100644 --- a/src/widgets/AttachedWindow.hpp +++ b/src/widgets/AttachedWindow.hpp @@ -28,22 +28,25 @@ class AttachedWindow : public QWidget bool fullscreen = false; }; - virtual ~AttachedWindow() override; + ~AttachedWindow() override; static AttachedWindow *get(void *target_, const GetArgs &args); +#ifdef USEWINSDK + static AttachedWindow *getForeground(const GetArgs &args); +#endif static void detach(const QString &winId); void setChannel(ChannelPtr channel); protected: - virtual void showEvent(QShowEvent *) override; + void showEvent(QShowEvent *) override; // virtual void nativeEvent(const QByteArray &eventType, void *message, // long *result) override; private: struct { Split *split; - } ui_; + } ui_{}; struct Item { void *hwnd; @@ -58,7 +61,7 @@ class AttachedWindow : public QWidget void *target_; int yOffset_; - int currentYOffset_; + int currentYOffset_{}; double x_ = -1; double pixelRatio_ = -1; int width_ = 360; diff --git a/src/widgets/BaseWidget.cpp b/src/widgets/BaseWidget.cpp index c98c425c3c3..5302d039745 100644 --- a/src/widgets/BaseWidget.cpp +++ b/src/widgets/BaseWidget.cpp @@ -1,6 +1,6 @@ #include "widgets/BaseWidget.hpp" -#include "BaseSettings.hpp" +#include "Application.hpp" #include "common/QLogging.hpp" #include "controllers/hotkeys/HotkeyController.hpp" #include "singletons/Theme.hpp" @@ -18,10 +18,8 @@ namespace chatterino { BaseWidget::BaseWidget(QWidget *parent, Qt::WindowFlags f) : QWidget(parent, f) + , theme(getIApp()->getThemes()) { - // REMOVED - this->theme = getTheme(); - this->signalHolder_.managedConnect(this->theme->updated, [this]() { this->themeChangedEvent(); @@ -30,7 +28,7 @@ BaseWidget::BaseWidget(QWidget *parent, Qt::WindowFlags f) } void BaseWidget::clearShortcuts() { - for (auto shortcut : this->shortcuts_) + for (auto *shortcut : this->shortcuts_) { shortcut->setKey(QKeySequence()); shortcut->removeEventFilter(this); @@ -43,16 +41,15 @@ float BaseWidget::scale() const { if (this->overrideScale_) { - return this->overrideScale_.get(); + return *this->overrideScale_; } - else if (auto baseWidget = dynamic_cast(this->window())) + + if (auto *baseWidget = dynamic_cast(this->window())) { return baseWidget->scale_; } - else - { - return 1.f; - } + + return 1.F; } void BaseWidget::setScale(float value) @@ -66,13 +63,13 @@ void BaseWidget::setScale(float value) this->setScaleIndependantSize(this->scaleIndependantSize()); } -void BaseWidget::setOverrideScale(boost::optional value) +void BaseWidget::setOverrideScale(std::optional value) { this->overrideScale_ = value; this->setScale(this->scale()); } -boost::optional BaseWidget::overrideScale() const +std::optional BaseWidget::overrideScale() const { return this->overrideScale_; } @@ -125,7 +122,7 @@ void BaseWidget::setScaleIndependantHeight(int value) float BaseWidget::qtFontScale() const { - if (auto window = dynamic_cast(this->window())) + if (auto *window = dynamic_cast(this->window())) { // ensure no div by 0 return this->scale() / std::max(0.01f, window->nativeScale_); @@ -141,7 +138,7 @@ void BaseWidget::childEvent(QChildEvent *event) if (event->added()) { // add element if it's a basewidget - if (auto widget = dynamic_cast(event->child())) + if (auto *widget = dynamic_cast(event->child())) { this->widgets_.push_back(widget); } diff --git a/src/widgets/BaseWidget.hpp b/src/widgets/BaseWidget.hpp index 8aee5881fdc..2e9c0472813 100644 --- a/src/widgets/BaseWidget.hpp +++ b/src/widgets/BaseWidget.hpp @@ -1,11 +1,12 @@ #pragma once -#include #include #include #include #include +#include + namespace chatterino { class Theme; @@ -22,8 +23,8 @@ class BaseWidget : public QWidget virtual float scale() const; pajlada::Signals::Signal scaleChanged; - boost::optional overrideScale() const; - void setOverrideScale(boost::optional); + std::optional overrideScale() const; + void setOverrideScale(std::optional); QSize scaleIndependantSize() const; int scaleIndependantWidth() const; @@ -36,8 +37,8 @@ class BaseWidget : public QWidget float qtFontScale() const; protected: - virtual void childEvent(QChildEvent *) override; - virtual void showEvent(QShowEvent *) override; + void childEvent(QChildEvent *) override; + void showEvent(QShowEvent *) override; virtual void scaleChangedEvent(float newScale); virtual void themeChangedEvent(); @@ -56,7 +57,7 @@ class BaseWidget : public QWidget private: float scale_{1.f}; - boost::optional overrideScale_; + std::optional overrideScale_; QSize scaleIndependantSize_; std::vector widgets_; diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 60365081e67..592f2b97ee8 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -8,8 +8,9 @@ #include "util/PostToThread.hpp" #include "util/WindowsHelper.hpp" #include "widgets/helper/EffectLabel.hpp" +#include "widgets/helper/TitlebarButtons.hpp" #include "widgets/Label.hpp" -#include "widgets/TooltipWidget.hpp" +#include "widgets/Window.hpp" #include #include @@ -56,7 +57,7 @@ BaseWindow::BaseWindow(FlagsEnum _flags, QWidget *parent) this->setWindowFlags(Qt::ToolTip); #else this->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | - Qt::X11BypassWindowManagerHint | + Qt::WindowDoesNotAcceptFocus | Qt::BypassWindowManagerHint); #endif } @@ -117,7 +118,7 @@ float BaseWindow::scale() const float BaseWindow::qtFontScale() const { - return this->scale() / std::max(0.01, this->nativeScale_); + return this->scale() / std::max(0.01F, this->nativeScale_); } void BaseWindow::init() @@ -179,9 +180,8 @@ void BaseWindow::init() this->close(); }); - this->ui_.minButton = _minButton; - this->ui_.maxButton = _maxButton; - this->ui_.exitButton = _exitButton; + this->ui_.titlebarButtons = new TitleBarButtons( + this, _minButton, _maxButton, _exitButton); this->ui_.buttons.push_back(_minButton); this->ui_.buttons.push_back(_maxButton); @@ -240,18 +240,6 @@ void BaseWindow::init() #endif } -void BaseWindow::setStayInScreenRect(bool value) -{ - this->stayInScreenRect_ = value; - - this->moveIntoDesktopRect(this->pos()); -} - -bool BaseWindow::getStayInScreenRect() const -{ - return this->stayInScreenRect_; -} - void BaseWindow::setActionOnFocusLoss(ActionOnFocusLoss value) { this->actionOnFocusLoss_ = value; @@ -385,12 +373,12 @@ void BaseWindow::mousePressEvent(QMouseEvent *event) if (this->flags_.has(FramelessDraggable)) { this->movingRelativePos = event->localPos(); - if (auto widget = + if (auto *widget = this->childAt(event->localPos().x(), event->localPos().y())) { std::function recursiveCheckMouseTracking; recursiveCheckMouseTracking = [&](QWidget *widget) { - if (widget == nullptr) + if (widget == nullptr || widget->isHidden()) { return false; } @@ -479,18 +467,10 @@ EffectLabel *BaseWindow::addTitleBarLabel(std::function onClicked) void BaseWindow::changeEvent(QEvent *) { - if (this->isVisible()) - { - TooltipWidget::instance()->hide(); - } - #ifdef USEWINSDK - if (this->ui_.maxButton) + if (this->ui_.titlebarButtons) { - this->ui_.maxButton->setButtonStyle( - this->windowState() & Qt::WindowMaximized - ? TitleBarButtonStyle::Unmaximize - : TitleBarButtonStyle::Maximize); + this->ui_.titlebarButtons->updateMaxButton(); } if (this->isVisible() && this->hasCustomWindowFrame()) @@ -511,18 +491,16 @@ void BaseWindow::changeEvent(QEvent *) void BaseWindow::leaveEvent(QEvent *) { - TooltipWidget::instance()->hide(); } -void BaseWindow::moveTo(QWidget *parent, QPoint point, bool offset) +void BaseWindow::moveTo(QPoint point, widgets::BoundsChecking mode) { - if (offset) - { - point.rx() += 16; - point.ry() += 16; - } + widgets::moveWindowTo(this, point, mode); +} - this->moveIntoDesktopRect(point); +void BaseWindow::showAndMoveTo(QPoint point, widgets::BoundsChecking mode) +{ + widgets::showAndMoveWindowTo(this, point, mode); } void BaseWindow::resizeEvent(QResizeEvent *) @@ -530,7 +508,7 @@ void BaseWindow::resizeEvent(QResizeEvent *) // Queue up save because: Window resized if (!flags_.has(DisableLayoutSave)) { - getApp()->windows->queueSave(); + getIApp()->getWindows()->queueSave(); } #ifdef USEWINSDK @@ -562,7 +540,7 @@ void BaseWindow::moveEvent(QMoveEvent *event) #ifdef CHATTERINO if (!flags_.has(DisableLayoutSave)) { - getApp()->windows->queueSave(); + getIApp()->getWindows()->queueSave(); } #endif @@ -576,59 +554,12 @@ void BaseWindow::closeEvent(QCloseEvent *) void BaseWindow::showEvent(QShowEvent *) { - this->moveIntoDesktopRect(this->pos()); - if (this->frameless_) - { - QTimer::singleShot(30, this, [this] { - this->moveIntoDesktopRect(this->pos()); - }); - } -} - -void BaseWindow::moveIntoDesktopRect(QPoint point) -{ - if (!this->stayInScreenRect_) - { - return; - } - - // move the widget into the screen geometry if it's not already in there - auto *screen = QApplication::screenAt(point); - if (screen == nullptr) - { - screen = QApplication::primaryScreen(); - } - const QRect bounds = screen->availableGeometry(); - - bool stickRight = false; - bool stickBottom = false; - - if (point.x() < bounds.left()) - { - point.setX(bounds.left()); - } - if (point.y() < bounds.top()) - { - point.setY(bounds.top()); - } - if (point.x() + this->width() > bounds.right()) - { - stickRight = true; - point.setX(bounds.right() - this->width()); - } - if (point.y() + this->height() > bounds.bottom()) - { - stickBottom = true; - point.setY(bounds.bottom() - this->height()); - } - - if (stickRight && stickBottom) +#ifdef Q_OS_WIN + if (this->flags_.has(BoundsCheckOnShow)) { - const QPoint globalCursorPos = QCursor::pos(); - point.setY(globalCursorPos.y() - this->height() - 16); + this->moveTo(this->pos(), widgets::BoundsChecking::CursorPosition); } - - this->move(point); +#endif } #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) @@ -644,6 +575,11 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, bool returnValue = false; + auto isHoveringTitlebarButton = [&]() { + auto ht = msg->wParam; + return ht == HTMAXBUTTON || ht == HTMINBUTTON || ht == HTCLOSE; + }; + switch (msg->message) { case WM_DPICHANGED: @@ -671,6 +607,91 @@ bool BaseWindow::nativeEvent(const QByteArray &eventType, void *message, returnValue = this->handleNCHITTEST(msg, result); break; + case WM_NCMOUSEHOVER: + case WM_NCMOUSEMOVE: { + // WM_NCMOUSEMOVE/WM_NCMOUSEHOVER gets sent when the mouse is + // moving/hovering in the non-client area + // - (mostly) the edges and the titlebar. + // We only need to handle the event for the titlebar buttons, + // as Qt doesn't create mouse events for these events. + if (!this->ui_.titlebarButtons) + { + // we don't consume the event if we don't have custom buttons + break; + } + + if (isHoveringTitlebarButton()) + { + *result = 0; + returnValue = true; + long x = GET_X_LPARAM(msg->lParam); + long y = GET_Y_LPARAM(msg->lParam); + + RECT winrect; + GetWindowRect(HWND(winId()), &winrect); + QPoint globalPos(x, y); + this->ui_.titlebarButtons->hover(msg->wParam, globalPos); + this->lastEventWasNcMouseMove_ = true; + } + else + { + this->ui_.titlebarButtons->leave(); + } + } + break; + + case WM_MOUSEMOVE: { + if (!this->lastEventWasNcMouseMove_) + { + break; + } + this->lastEventWasNcMouseMove_ = false; + // Windows doesn't send WM_NCMOUSELEAVE in some cases, + // so the buttons show as hovered even though they're not hovered. + [[fallthrough]]; + } + case WM_NCMOUSELEAVE: { + // WM_NCMOUSELEAVE gets sent when the mouse leaves any + // non-client area. In case we have titlebar buttons, + // we want to ensure they're deselected. + if (this->ui_.titlebarButtons) + { + this->ui_.titlebarButtons->leave(); + } + } + break; + + case WM_NCLBUTTONDOWN: + case WM_NCLBUTTONUP: { + // WM_NCLBUTTON{DOWN, UP} gets called when the left mouse button + // was pressed in a non-client area. + // We simulate a mouse down/up event for the titlebar buttons + // as Qt doesn't create an event in that case. + if (!this->ui_.titlebarButtons || !isHoveringTitlebarButton()) + { + break; + } + returnValue = true; + *result = 0; + + auto ht = msg->wParam; + long x = GET_X_LPARAM(msg->lParam); + long y = GET_Y_LPARAM(msg->lParam); + + RECT winrect; + GetWindowRect(HWND(winId()), &winrect); + QPoint globalPos(x, y); + if (msg->message == WM_NCLBUTTONDOWN) + { + this->ui_.titlebarButtons->mousePress(ht, globalPos); + } + else + { + this->ui_.titlebarButtons->mouseRelease(ht, globalPos); + } + } + break; + default: return QWidget::nativeEvent(eventType, message, result); } @@ -710,11 +731,11 @@ void BaseWindow::updateScale() auto scale = this->nativeScale_ * (this->flags_.has(DisableCustomScaling) ? 1 - : getABSettings()->getClampedUiScale()); + : getSettings()->getClampedUiScale()); this->setScale(scale); - for (auto child : this->findChildren()) + for (auto *child : this->findChildren()) { child->setScale(scale); } @@ -727,29 +748,21 @@ void BaseWindow::calcButtonsSizes() return; } - if (this->frameless_) + if (this->frameless_ || !this->ui_.titlebarButtons) { return; } - if ((this->width() / this->scale()) < 300) +#ifdef USEWINSDK + if ((static_cast(this->width()) / this->scale()) < 300) { - if (this->ui_.minButton) - this->ui_.minButton->setScaleIndependantSize(30, 30); - if (this->ui_.maxButton) - this->ui_.maxButton->setScaleIndependantSize(30, 30); - if (this->ui_.exitButton) - this->ui_.exitButton->setScaleIndependantSize(30, 30); + this->ui_.titlebarButtons->setSmallSize(); } else { - if (this->ui_.minButton) - this->ui_.minButton->setScaleIndependantSize(46, 30); - if (this->ui_.maxButton) - this->ui_.maxButton->setScaleIndependantSize(46, 30); - if (this->ui_.exitButton) - this->ui_.exitButton->setScaleIndependantSize(46, 30); + this->ui_.titlebarButtons->setRegularSize(); } +#endif } void BaseWindow::drawCustomWindowFrame(QPainter &painter) @@ -798,7 +811,7 @@ bool BaseWindow::handleSHOWWINDOW(MSG *msg) if (auto dpi = getWindowDpi(msg->hwnd)) { - float currentScale = (float)dpi.get() / 96.F; + float currentScale = (float)dpi.value() / 96.F; if (currentScale != this->nativeScale_) { this->nativeScale_ = currentScale; @@ -1002,26 +1015,55 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) if (*result == 0) { - bool client = false; - - for (QWidget *widget : this->ui_.buttons) - { - if (widget->geometry().contains(point)) - { - client = true; - } - } - + // Check the main layout first, as it's the largest area if (this->ui_.layoutBase->geometry().contains(point)) { - client = true; + *result = HTCLIENT; } - if (client) + // Check the titlebar buttons + if (*result == 0 && + this->ui_.titlebarBox->geometry().contains(point)) { - *result = HTCLIENT; + for (const auto *widget : this->ui_.buttons) + { + if (!widget->isVisible() || + !widget->geometry().contains(point)) + { + continue; + } + + if (const auto *btn = + dynamic_cast(widget)) + { + switch (btn->getButtonStyle()) + { + case TitleBarButtonStyle::Minimize: { + *result = HTMINBUTTON; + break; + } + case TitleBarButtonStyle::Unmaximize: + case TitleBarButtonStyle::Maximize: { + *result = HTMAXBUTTON; + break; + } + case TitleBarButtonStyle::Close: { + *result = HTCLOSE; + break; + } + default: { + *result = HTCLIENT; + break; + } + } + break; + } + *result = HTCLIENT; + break; + } } - else + + if (*result == 0) { *result = HTCAPTION; } @@ -1029,16 +1071,17 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) return true; } - else if (this->flags_.has(FramelessDraggable)) + + if (this->flags_.has(FramelessDraggable)) { *result = 0; bool client = false; - if (auto widget = this->childAt(point)) + if (auto *widget = this->childAt(point)) { std::function recursiveCheckMouseTracking; recursiveCheckMouseTracking = [&](QWidget *widget) { - if (widget == nullptr) + if (widget == nullptr || widget->isHidden()) { return false; } @@ -1048,6 +1091,11 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) return true; } + if (widget == this) + { + return false; + } + return recursiveCheckMouseTracking(widget->parentWidget()); }; @@ -1068,6 +1116,8 @@ bool BaseWindow::handleNCHITTEST(MSG *msg, long *result) return true; } + + // don't handle the message return false; #else return false; diff --git a/src/widgets/BaseWindow.hpp b/src/widgets/BaseWindow.hpp index 106cc1a3e5d..d55b5bd6d16 100644 --- a/src/widgets/BaseWindow.hpp +++ b/src/widgets/BaseWindow.hpp @@ -1,6 +1,7 @@ #pragma once #include "common/FlagsEnum.hpp" +#include "util/WidgetHelpers.hpp" #include "widgets/BaseWidget.hpp" #include @@ -17,6 +18,7 @@ namespace chatterino { class Button; class EffectLabel; class TitleBarButton; +class TitleBarButtons; enum class TitleBarButtonStyle; class BaseWindow : public BaseWidget @@ -26,14 +28,15 @@ class BaseWindow : public BaseWidget public: enum Flags { None = 0, - EnableCustomFrame = 1, - Frameless = 2, - TopMost = 4, - DisableCustomScaling = 8, - FramelessDraggable = 16, - DontFocus = 32, - Dialog = 64, - DisableLayoutSave = 128, + EnableCustomFrame = 1 << 0, + Frameless = 1 << 1, + TopMost = 1 << 2, + DisableCustomScaling = 1 << 3, + FramelessDraggable = 1 << 4, + DontFocus = 1 << 5, + Dialog = 1 << 6, + DisableLayoutSave = 1 << 7, + BoundsCheckOnShow = 1 << 8, }; enum ActionOnFocusLoss { Nothing, Delete, Close, Hide }; @@ -51,15 +54,18 @@ class BaseWindow : public BaseWidget std::function onClicked); EffectLabel *addTitleBarLabel(std::function onClicked); - void setStayInScreenRect(bool value); - bool getStayInScreenRect() const; - void setActionOnFocusLoss(ActionOnFocusLoss value); ActionOnFocusLoss getActionOnFocusLoss() const; - void moveTo(QWidget *widget, QPoint point, bool offset = true); + void moveTo(QPoint point, widgets::BoundsChecking mode); + + /** + * Moves the window to the given point and does bounds checking according to `mode` + * Depending on the platform, either the move or the show will take place first + **/ + void showAndMoveTo(QPoint point, widgets::BoundsChecking mode); - virtual float scale() const override; + float scale() const override; float qtFontScale() const; pajlada::Signals::NoArgSignal closing; @@ -74,20 +80,20 @@ class BaseWindow : public BaseWidget bool nativeEvent(const QByteArray &eventType, void *message, long *result) override; #endif - virtual void scaleChangedEvent(float) override; + void scaleChangedEvent(float) override; - virtual void paintEvent(QPaintEvent *) override; + void paintEvent(QPaintEvent *) override; - virtual void changeEvent(QEvent *) override; - virtual void leaveEvent(QEvent *) override; - virtual void resizeEvent(QResizeEvent *) override; - virtual void moveEvent(QMoveEvent *) override; - virtual void closeEvent(QCloseEvent *) override; - virtual void showEvent(QShowEvent *) override; + void changeEvent(QEvent *) override; + void leaveEvent(QEvent *) override; + void resizeEvent(QResizeEvent *) override; + void moveEvent(QMoveEvent *) override; + void closeEvent(QCloseEvent *) override; + void showEvent(QShowEvent *) override; - virtual void themeChangedEvent() override; - virtual bool event(QEvent *event) override; - virtual void wheelEvent(QWheelEvent *event) override; + void themeChangedEvent() override; + bool event(QEvent *event) override; + void wheelEvent(QWheelEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; @@ -97,11 +103,11 @@ class BaseWindow : public BaseWidget void updateScale(); - boost::optional overrideBackgroundColor_; + std::optional overrideBackgroundColor_; private: void init(); - void moveIntoDesktopRect(QPoint point); + void calcButtonsSizes(); void drawCustomWindowFrame(QPainter &painter); void onFocusLost(); @@ -121,7 +127,6 @@ class BaseWindow : public BaseWidget bool enableCustomFrame_; ActionOnFocusLoss actionOnFocusLoss_ = Nothing; bool frameless_; - bool stayInScreenRect_ = false; bool shown_ = false; FlagsEnum flags_; float nativeScale_ = 1; @@ -131,9 +136,7 @@ class BaseWindow : public BaseWidget QLayout *windowLayout = nullptr; QHBoxLayout *titlebarBox = nullptr; QWidget *titleLabel = nullptr; - TitleBarButton *minButton = nullptr; - TitleBarButton *maxButton = nullptr; - TitleBarButton *exitButton = nullptr; + TitleBarButtons *titlebarButtons = nullptr; QWidget *layoutBase = nullptr; std::vector