1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 12:55:55 +00:00

4.0.0 Beta 1

This commit is contained in:
SirStendec 2017-11-13 01:23:39 -05:00
parent c2688646af
commit 262757a20d
187 changed files with 22878 additions and 38882 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
node_modules
npm-debug.log
build
dist
Extension Building
Old Files
badges

View file

@ -1,7 +1,7 @@
FrankerFaceZ
============
Copyright (c) 2016 Dan Salvato LLC
Copyright (c) 2017 Dan Salvato LLC
Licensed under the Apache License, Version 2.0. See LICENSE.
@ -12,26 +12,18 @@ Developing
FrankerFaceZ uses node.js to manage development dependencies and to run an HTTP
server for development. To get everything you need:
1. Install node.js
2. Run ```npm install -g gulp``` to install the ```gulp``` command line utility.
3. Run ```npm install``` within the FrankerFaceZ directory.
1. Install node.js and npm
2. Run ```npm install`` within the FrankerFaceZ directory.
From there, you can use gulp to build the extension from source simply by
running ```gulp```. For development, you can instruct gulp to watch the source
files for changes and re-build automatically with ```gulp watch```
From there, you can use npm to build the extension from source simply by
running ```npm run build```. For development, you can instruct gulp to watch
the source files for changes and re-build automatically with ```npm start```
FrankerFaceZ comes with a local development server that listens on port 8000
and it serves up local development copies of files, falling back to the CDN
when a local copy of a file isn't present. To start the server,
run ```gulp server```
when a local copy of a file isn't present.
For convenience, the server is run automatically along with ```gulp watch```
Use the command ```/ffz developer_mode on``` or ```/ffz developer_mode off```
in Twitch chat to toggle developer mode on or off. You must then refresh the
page for changes to take effect. If FFZ is not working or the command otherwise
fails to work, you can open the JavaScript console on twitch.tv and run
```localStorage.ffzDebugMode = true;``` or
```localStorage.ffzDebugMode = false;``` to enable or disable the feature.
To make FrankerFaceZ load from your local development server, you must set
the local storage variable ```ffzDebugMode``` to true. Just run the following
in your console on Twitch: ```localStorage.ffzDebugMode = true;```

View file

@ -1,70 +1,7 @@
<div class="list-header">3.5.536 <time datetime="2017-11-07">(2017-11-07)</time></div>
<div class="list-header">4.0.0-beta1 <time datetime="2017-11-12">(2017-11-12)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Enable Transparent (Colored) badges in non-Webkit browsers. Note: The feature may not function correctly in all browsers.</li>
</ul>
<div class="list-header">3.5.535 <time datetime="2017-11-02">(2017-11-02)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Raid notices not rendering in chat.</li>
</ul>
<div class="list-header">3.5.534 <time datetime="2017-10-04">(2017-10-04)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Reset Player button not getting added to player because the menu element was renamed.</li>
</ul>
<div class="list-header">3.5.533 <time datetime="2017-10-01">(2017-10-01)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Experimental new feature, promoted messages.</li>
<li>&nbsp;</li>
<li>Moderators can promote a message to have it appear in Recent Highlights for users that
have the feature enabled. To promote a message, a moderator must use the command
<code>/ffz promote &lt;id&gt;</code> with the appropriate message id. The easiest
way to do this is to add a custom in-line moderation icon with the command
<code>/ffz promote {id}</code></li>
</ul>
<div class="list-header">3.5.532 <time datetime="2017-09-30">(2017-09-30)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: And the tooltip rendering tweaks keep coming</li>
</ul>
<div class="list-header">3.5.531 <time datetime="2017-09-29">(2017-09-29)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Properly calculate tooltip positions when added to an element other than the document root.</li>
</ul>
<div class="list-header">3.5.530 <time datetime="2017-09-29">(2017-09-29)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Make sure the dashboard module is loaded before trying to modify dashboard widgets.</li>
</ul>
<div class="list-header">3.5.529 <time datetime="2017-09-28">(2017-09-28)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Rewrite tooltip rendering to try making them better positioned.</li>
<li>Added: Clickable links in the Following tooltip, as a result of the rewrite.</li>
</ul>
<div class="list-header">3.5.528 <time datetime="2017-09-26">(2017-09-26)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Bug loading emote data from the server.</li>
<li>Fixed: Initialize the full Ember modifications when loading the settings pages and products page that has been brought into the Ember app.</li>
</ul>
<div class="list-header">3.5.527 <time datetime="2017-09-25">(2017-09-25)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: More tweaks to tooltip rendering to support backend improvements.</li>
</ul>
<div class="list-header">3.5.526 <time datetime="2017-09-22">(2017-09-22)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Minor tweaks to tooltip rendering to support improvements to the backend.</li>
</ul>
<div class="list-header">3.5.525 <time datetime="2017-09-20">(2017-09-20)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Twitch's native dark theme breaking the re-colored site header.</li>
<li>Fixed: Take control of Twitch's native dark theme when enabling or disabling FFZ's dark theme.</li>
<li>This is the initial release of the complete rewrite, FrankerFaceZ v4.</li>
<li>Changed: Every single thing.</li>
</ul>
<div class="list-header" id="ffz-old-news-button"><a href="#">View Older</a></div>

2216
dark.css

File diff suppressed because it is too large Load diff

View file

@ -1,292 +0,0 @@
// Dependencies
var fs = require('fs'),
gulp = require('gulp'),
browserify = require('gulp-browserify'),
header = require('gulp-header'),
footer = require('gulp-footer'),
concat = require('gulp-concat'),
clean = require('gulp-clean'),
util = require('gulp-util'),
rename = require('gulp-rename'),
uglify = require('gulp-uglify');
// Templates
var jsEscape = require('gulp-js-escape'),
wrap = require('gulp-wrap'),
declare = require('gulp-declare'),
cleanCSS = require('gulp-clean-css');
// LESS
var less = require('gulp-less'),
sourcemaps = require('gulp-sourcemaps');
// Deploy Dependencies
var ftp = require('vinyl-ftp'),
request = require('request');
// Server Dependencies
var http = require("http"),
https = require("https"),
net = require('net'),
path = require("path"),
request = require("request"),
url = require("url");
var server_version = "0.1.1";
// Tasks
gulp.task('clean', function() {
return gulp.src('build', {read:false})
.pipe(clean());
});
gulp.task('prepare', ['clean'], function() {
return gulp.src(['src/**/*'])
.pipe(gulp.dest('build/'));
});
//gulp.task('templates', ['prepare'], function() {
// return gulp.src(['build/templates/**/*.hbs'])
// .pipe(jsEscape())
// .pipe(wrap('Handlebars.compile(<%= contents %>)'))
// .pipe(declare({
// root: 'exports',
// noRedeclare: true,
// processName: function(filePath) {
// var match = filePath.match(/build[\\\/]templates[\\\/](.*)\.hbs$/);
// return declare.processNameByPath((match && match.length > 1) ? match[1] : filePath);
// }
// }))
// .pipe(concat('templates.js'))
// .pipe(gulp.dest('build/'))
// .on('error', util.log);
//});
gulp.task('styles', ['prepare'], function() {
//return;
return gulp.src(['build/less/*.less', '!build/less/style.less'])
.pipe(sourcemaps.init())
.pipe(less())
.pipe(sourcemaps.write())
.pipe(gulp.dest(__dirname))
.on('error', util.log);
});
gulp.task('embedded_styles', ['prepare'], function() {
return gulp.src(['build/styles/**/*.css'])
.pipe(cleanCSS())
.pipe(jsEscape())
.pipe(declare({
root: 'exports',
noRedeclare: true,
processName: function(filePath) {
var match = filePath.match(/build[\\\/]styles[\\\/](.*)\.css$/);
return declare.processNameByPath((match && match.length > 1) ? match[1] : filePath);
}
}))
.pipe(concat('compiled_styles.js'))
.pipe(gulp.dest('build/'))
.on('error', util.log)
});
gulp.task('scripts', ['embedded_styles'], function() {
return gulp.src(['build/main.js'])
.pipe(browserify())
.pipe(concat('script.js'))
.pipe(header('(function(window) {'))
.pipe(footer(';window.ffz = new FrankerFaceZ()}(window));'))
.pipe(gulp.dest(__dirname))
.on('error', util.log);
});
gulp.task('watch', ['default', 'server'], function() {
return gulp.watch('src/**/*', ['default']);
});
gulp.task('default', ['styles', 'scripts']);
// Deploy
gulp.task('minify_script', ['scripts'], function() {
return gulp.src(['*.js', '!*.min.js', '!gulpfile.js'])
.pipe(uglify())
.pipe(rename(function(path) {
path.basename += '.min';
}))
.pipe(gulp.dest(__dirname))
.on('error', util.log);
});
gulp.task('minify_style', function() {
return gulp.src(['*.css', '!*.min.css'])
.pipe(cleanCSS())
.pipe(rename(function(path) {
path.basename += '.min';
}))
.pipe(gulp.dest(__dirname))
.on('error', util.log);
});
gulp.task('minify', ['minify_script', 'minify_style']);
gulp.task('upload', ['minify'], function() {
// Load credentials from an external file.
var contents = fs.readFileSync('credentials.json', 'utf8'),
cred = JSON.parse(contents);
cred.log = util.log;
// Create the connection.
var conn = ftp.create(cred);
// What we're transfering.
var ftp_path = cred.remote_path,
globs = [
"script.min.js",
"style.min.css",
"dark.min.css",
"changelog.html"
];
util.log(cred.remote_path);
return gulp.src(globs, {base: '.', buffer: false})
.pipe(conn.newerOrDifferentSize(ftp_path))
.pipe(conn.dest(ftp_path))
.on('error', util.log);
});
gulp.task('clear_cache', ['upload'], function(cb) {
// Load credentials from an external file.
var contents = fs.readFileSync('credentials.json', 'utf8'),
cred = JSON.parse(contents);
// Build the URLs.
var base = "://cdn.frankerfacez.com/script/",
files = [],
globs = [
"script.min.js",
"style.min.css",
"dark.min.css",
"changelog.html"
];
for(var i=0; i < globs.length; i++) {
files.push("http" + base + globs[i]);
files.push("https" + base + globs[i]);
}
request({
method: 'DELETE',
uri: "https://api.cloudflare.com/client/v4/zones/" + cred.cloudflare_zone + "/purge_cache",
headers: {
"X-Auth-Email": cred.cloudflare_email,
"X-Auth-Key": cred.cloudflare_key
},
json: {
"files": files
}
}, function(error, request, body) {
if ( error )
return util.log("[FAIL] Error: " + error);
else if ( request.statusCode !== 200 )
return util.log("[FAIL] Non-200 Status: " + request.statusCode);
util.log("[SUCCESS] Cache cleared.");
cb();
});
});
gulp.task('deploy', ['upload', 'clear_cache']);
// Server
gulp.task('server', function() {
var handle_req = function(req, res) {
var uri = url.parse(req.url).pathname,
lpath = path.join(uri).split(path.sep);
if ( uri == "/dev_server" ) {
util.log("[" + util.colors.cyan("HTTP") + "] " + util.colors.green("200") + " GET " + util.colors.magenta(uri));
res.writeHead(200, {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"});
return res.end(JSON.stringify({path: process.cwd(), version: server_version}));
}
if ( ! lpath[0] )
lpath.shift();
if ( lpath[0] == "script" )
lpath.shift();
else
lpath.splice(0, 0, "cdn");
var file = path.join(process.cwd(), lpath.join(path.sep));
fs.exists(file, function(exists) {
if ( ! exists ) {
util.log("[" + util.colors.cyan("HTTP") + "] " + util.colors.bold.blue("CDN") + " GET " + util.colors.magenta(uri));
return request.get("http://cdn.frankerfacez.com/" + uri).on('error', function(err) { res.end() }).pipe(res);
}
var headers = {"Access-Control-Allow-Origin": "*"};
if ( fs.lstatSync(file).isDirectory() ) {
util.log("[" + util.colors.cyan("HTTP") + "] " + util.colors.red("403") + " GET " + util.colors.magenta(uri));
res.writeHead(403, headers);
res.write('403 Forbidden');
return res.end();
}
if ( file.substr(file.length-4) === ".svg" )
headers['Content-Type'] = 'image/svg+xml';
util.log("[" + util.colors.cyan("HTTP") + "] " + util.colors.green("200") + " GET " + util.colors.magenta(uri));
res.writeHead(200, headers);
fs.createReadStream(file).pipe(res);
});
};
if ( fs.existsSync("dev_key.pem") ) {
var https_options = {
key: fs.readFileSync("dev_key.pem"),
cert: fs.readFileSync("dev_cert.pem")
};
http.createServer(handle_req).listen(8001, "localhost");
https.createServer(https_options, handle_req).listen(8002, "localhost");
net.createServer(function(conn) {
conn.on('error', function(e) {
util.log("[" + util.colors.cyan("HTTP") + "] Connection Error: " + util.colors.magenta('' + e));
});
conn.once('data', function(buf) {
var address = (buf[0] === 22) ? 8002 : 8001;
var proxy = net.createConnection(address, function() {
proxy.write(buf);
conn.pipe(proxy).pipe(conn);
});
});
}).listen(8000);
util.log("[" + util.colors.cyan("HTTPS") + "] Listening on Port: " + util.colors.magenta("8000"));
} else {
http.createServer(handle_req).listen(8000, "localhost");
util.log("[" + util.colors.cyan("HTTP") + "] Listening on Port: " + util.colors.magenta("8000"));
}
});

View file

@ -1,3 +1,72 @@
<div class="list-header">3.5.536 <time datetime="2017-11-07">(2017-11-07)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Enable Transparent (Colored) badges in non-Webkit browsers. Note: The feature may not function correctly in all browsers.</li>
</ul>
<div class="list-header">3.5.535 <time datetime="2017-11-02">(2017-11-02)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Raid notices not rendering in chat.</li>
</ul>
<div class="list-header">3.5.534 <time datetime="2017-10-04">(2017-10-04)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Reset Player button not getting added to player because the menu element was renamed.</li>
</ul>
<div class="list-header">3.5.533 <time datetime="2017-10-01">(2017-10-01)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Experimental new feature, promoted messages.</li>
<li>&nbsp;</li>
<li>Moderators can promote a message to have it appear in Recent Highlights for users that
have the feature enabled. To promote a message, a moderator must use the command
<code>/ffz promote &lt;id&gt;</code> with the appropriate message id. The easiest
way to do this is to add a custom in-line moderation icon with the command
<code>/ffz promote {id}</code></li>
</ul>
<div class="list-header">3.5.532 <time datetime="2017-09-30">(2017-09-30)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: And the tooltip rendering tweaks keep coming</li>
</ul>
<div class="list-header">3.5.531 <time datetime="2017-09-29">(2017-09-29)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Properly calculate tooltip positions when added to an element other than the document root.</li>
</ul>
<div class="list-header">3.5.530 <time datetime="2017-09-29">(2017-09-29)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Make sure the dashboard module is loaded before trying to modify dashboard widgets.</li>
</ul>
<div class="list-header">3.5.529 <time datetime="2017-09-28">(2017-09-28)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Rewrite tooltip rendering to try making them better positioned.</li>
<li>Added: Clickable links in the Following tooltip, as a result of the rewrite.</li>
</ul>
<div class="list-header">3.5.528 <time datetime="2017-09-26">(2017-09-26)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Bug loading emote data from the server.</li>
<li>Fixed: Initialize the full Ember modifications when loading the settings pages and products page that has been brought into the Ember app.</li>
</ul>
<div class="list-header">3.5.527 <time datetime="2017-09-25">(2017-09-25)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: More tweaks to tooltip rendering to support backend improvements.</li>
</ul>
<div class="list-header">3.5.526 <time datetime="2017-09-22">(2017-09-22)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Changed: Minor tweaks to tooltip rendering to support improvements to the backend.</li>
</ul>
<div class="list-header">3.5.525 <time datetime="2017-09-20">(2017-09-20)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Twitch's native dark theme breaking the re-colored site header.</li>
<li>Fixed: Take control of Twitch's native dark theme when enabling or disabling FFZ's dark theme.</li>
</ul>
<div class="list-header">3.5.524 <time datetime="2017-09-15">(2017-09-15)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Tooltips for global emotes not saying where they're from.</li>

7387
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,47 @@
{
"name": "FrankerFaceZ",
"author": "Stendec",
"version": "3.0.0",
"description": "FrankerFaceZ gives Twitch users custom emoticons.",
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.0.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"main": "script.js",
"scripts": {
"start": "webpack-dev-server --config webpack.web.dev.js",
"build": "webpack --config webpack.web.prod.js --define process.env.NODE_ENV='production'",
"build:prod": "webpack --config webpack.web.prod.js --define process.env.NODE_ENV='production'",
"build:dev": "webpack --config webpack.web.dev.js"
},
"devDependencies": {
"gulp": "^3.9.1",
"gulp-browserify": "^0.5.1",
"gulp-changed": "^1.3.2",
"gulp-clean": "^0.3.2",
"gulp-clean-css": "^2.0.12",
"gulp-concat": "^2.6.0",
"gulp-declare": "^0.3.0",
"gulp-filesize": "0.0.6",
"gulp-footer": "^1.0.5",
"gulp-header": "^1.8.8",
"gulp-js-escape": "^1.0.1",
"gulp-less": "^3.1.0",
"gulp-rename": "^1.2.2",
"gulp-sourcemaps": "^1.6.0",
"gulp-uglify": "^2.0.0",
"gulp-util": "^3.0.7",
"gulp-wrap": "^0.13.0",
"request": "^2.74.0",
"vinyl-ftp": "^0.5.0"
"clean-webpack-plugin": "^0.1.17",
"copy-webpack-plugin": "^4.1.0",
"css-loader": "^0.28.7",
"eslint": "^4.9.0",
"extract-loader": "^1.0.1",
"file-loader": "^1.1.5",
"less": "^2.7.2",
"node-sass": "^4.5.3",
"raw-loader": "^0.5.1",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"to-string-loader": "^1.1.5",
"uglifyjs-webpack-plugin": "^1.0.0-beta.2",
"vue-loader": "^13.0.5",
"webpack": "^3.6.0",
"webpack-dev-middleware": "^1.12.0",
"webpack-dev-server": "^2.9.1",
"webpack-manifest-plugin": "^1.3.2",
"webpack-merge": "^4.1.0"
},
"repository": {
"type": "git",
"url": "https://bitbucket.org/stendec/frankerfacez.git"
"url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git"
},
"dependencies": {
"displacejs": "^1.2.4",
"path-to-regexp": "^2.0.0",
"popper.js": "^1.12.6",
"sortablejs": "^1.6.1",
"vue": "^2.5.2",
"vue-clickaway": "^2.1.0",
"vue-template-compiler": "^2.5.2"
}
}

BIN
res/font/ffz-fontello.eot Normal file

Binary file not shown.

66
res/font/ffz-fontello.svg Normal file
View file

@ -0,0 +1,66 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2017 by original authors @ fontello.com</metadata>
<defs>
<font id="ffz-fontello" horiz-adv-x="1000" >
<font-face font-family="ffz-fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="cancel" unicode="&#xe800;" d="M724 112q0-22-15-38l-76-76q-16-15-38-15t-38 15l-164 165-164-165q-16-15-38-15t-38 15l-76 76q-16 16-16 38t16 38l164 164-164 164q-16 16-16 38t16 38l76 76q16 16 38 16t38-16l164-164 164 164q16 16 38 16t38-16l76-76q15-15 15-38t-15-38l-164-164 164-164q15-15 15-38z" horiz-adv-x="785.7" />
<glyph glyph-name="zreknarf" unicode="&#xe801;" d="M620 841c21 1 43 4 63-4 18-9 36-21 56-26 11-18 32-28 42-46 19-30 37-60 43-95 8-48 36-91 65-130 18-39 10-84 18-126 12-26 21-53 35-78 6-11 23-4 25 6 6 37-13 72-11 108 2 19-4 39 2 57 14 19 23 41 38 59 18 28 53 43 86 40 11-15 26-27 39-41 14-22 33-41 50-61 10-14 16-29 23-44 14-25 21-54 34-80 11-31 29-61 28-94 3-10 4-21 5-31 5-21 14-44 3-64-12-27-36-46-59-63-31-24-60-52-95-71-20-13-42-26-65-36-18-8-31-26-50-31-12-9-21-24-36-26-23-7-46 19-69 6-16-9-32-16-48-23-44-29-93-49-144-60-43 1-86-3-129-2-18 9-39 5-57 14-32 8-56 31-86 44-14 7-28 17-41 26-13 10-22 25-36 33-20 1-33-18-52-20-12-2-24-2-36-2-18 19-29 45-54 56-26 18-55 34-77 57-12 13-26 25-42 35-24 21-40 49-58 75-16 23-27 50-24 78 3 41 3 83 13 123 21 54 57 99 93 144 19 27 51 52 86 43 17-8 29-23 37-40 6-14 17-26 19-41 5-30 9-61 4-91-9-32-15-65-12-97 0-15 15-28 30-24 16 4 20 21 27 34 9 20 22 39 34 57 19 27 27 60 40 89 10 24 8 50 13 74 4 40-3 80 3 119 7 19 10 40 20 58 15 19 35 34 50 53 9 11 22 17 34 23 40 14 80 35 123 36z" horiz-adv-x="1277" />
<glyph glyph-name="search" unicode="&#xe802;" d="M643 386q0 103-73 176t-177 74-177-74-73-176 73-177 177-73 177 73 73 177z m286-465q0-29-22-50t-50-21q-30 0-50 21l-191 191q-100-69-223-69-80 0-153 31t-125 84-84 125-31 153 31 152 84 126 125 84 153 31 153-31 125-84 84-126 31-152q0-123-69-223l191-191q21-21 21-51z" horiz-adv-x="928.6" />
<glyph glyph-name="clock" unicode="&#xe803;" d="M500 546v-250q0-7-5-12t-13-5h-178q-8 0-13 5t-5 12v36q0 8 5 13t13 5h125v196q0 8 5 13t12 5h36q8 0 13-5t5-13z m232-196q0 83-41 152t-110 111-152 41-153-41-110-111-41-152 41-152 110-111 153-41 152 41 110 111 41 152z m125 0q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="star" unicode="&#xe804;" d="M929 489q0-12-15-27l-202-197 48-279q0-4 0-12 0-11-6-19t-17-9q-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" />
<glyph glyph-name="star-empty" unicode="&#xe805;" d="M635 290l170 166-235 34-106 213-105-213-236-34 171-166-41-235 211 111 211-111z m294 199q0-12-15-27l-202-197 48-279q0-4 0-12 0-28-23-28-10 0-22 7l-251 132-250-132q-12-7-23-7-11 0-17 9t-6 19q0 4 1 12l48 279-203 197q-14 15-14 27 0 21 31 26l280 40 126 254q11 23 27 23t28-23l125-254 280-40q32-5 32-26z" horiz-adv-x="928.6" />
<glyph glyph-name="down-dir" unicode="&#xe806;" d="M571 457q0-14-10-25l-250-250q-11-11-25-11t-25 11l-250 250q-11 11-11 25t11 25 25 11h500q14 0 25-11t10-25z" horiz-adv-x="571.4" />
<glyph glyph-name="right-dir" unicode="&#xe807;" d="M321 350q0-14-10-25l-250-250q-11-11-25-11t-25 11-11 25v500q0 15 11 25t25 11 25-11l250-250q10-10 10-25z" horiz-adv-x="357.1" />
<glyph glyph-name="attention" unicode="&#xe808;" d="M571 83v106q0 8-5 13t-12 5h-108q-7 0-12-5t-5-13v-106q0-8 5-13t12-6h108q7 0 12 6t5 13z m-1 208l10 257q0 6-5 10-7 6-14 6h-122q-6 0-14-6-5-4-5-12l9-255q0-5 6-9t13-3h103q8 0 14 3t5 9z m-7 522l428-786q20-35-1-70-9-17-26-26t-35-10h-858q-18 0-35 10t-26 26q-21 35-1 70l429 786q9 17 26 27t36 10 36-10 27-27z" horiz-adv-x="1000" />
<glyph glyph-name="ok" unicode="&#xe809;" d="M933 534q0-22-16-38l-404-404-76-76q-16-15-38-15t-38 15l-76 76-202 202q-15 16-15 38t15 38l76 76q16 16 38 16t38-16l164-165 366 367q16 16 38 16t38-16l76-76q16-15 16-38z" horiz-adv-x="1000" />
<glyph glyph-name="cog" unicode="&#xe80a;" d="M571 350q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" />
<glyph glyph-name="plus" unicode="&#xe80b;" d="M786 439v-107q0-22-16-38t-38-15h-232v-233q0-22-16-37t-38-16h-107q-22 0-38 16t-15 37v233h-232q-23 0-38 15t-16 38v107q0 23 16 38t38 16h232v232q0 22 15 38t38 16h107q23 0 38-16t16-38v-232h232q23 0 38-16t16-38z" horiz-adv-x="785.7" />
<glyph glyph-name="folder-open" unicode="&#xe80c;" d="M1049 319q0-17-18-37l-187-221q-24-28-67-48t-81-20h-607q-19 0-33 7t-15 24q0 17 17 37l188 221q24 28 67 48t80 20h607q19 0 34-7t15-24z m-192 192v-90h-464q-53 0-110-26t-92-67l-188-221-2-3q0 2-1 7t0 7v536q0 51 37 88t88 37h179q51 0 88-37t37-88v-18h303q52 0 88-37t37-88z" horiz-adv-x="1071.4" />
<glyph glyph-name="download" unicode="&#xe80d;" d="M714 100q0 15-10 25t-25 11-25-11-11-25 11-25 25-11 25 11 10 25z m143 0q0 15-10 25t-26 11-25-11-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-37t-38-16h-821q-23 0-38 16t-16 37v179q0 22 16 38t38 16h259l75-76q33-32 76-32t76 32l76 76h259q22 0 38-16t16-38z m-182 318q10-23-8-39l-250-250q-10-11-25-11t-25 11l-250 250q-17 16-8 39 10 21 33 21h143v250q0 15 11 25t25 11h143q14 0 25-11t10-25v-250h143q24 0 33-21z" horiz-adv-x="928.6" />
<glyph glyph-name="upload" unicode="&#xe80e;" d="M714 29q0 14-10 25t-25 10-25-10-11-25 11-25 25-11 25 11 10 25z m143 0q0 14-10 25t-26 10-25-10-10-25 10-25 25-11 26 11 10 25z m72 125v-179q0-22-16-38t-38-16h-821q-23 0-38 16t-16 38v179q0 22 16 38t38 15h238q12-31 39-51t62-20h143q34 0 61 20t40 51h238q22 0 38-15t16-38z m-182 361q-9-22-33-22h-143v-250q0-15-10-25t-25-11h-143q-15 0-25 11t-11 25v250h-143q-23 0-33 22-9 22 8 39l250 250q10 10 25 10t25-10l250-250q18-17 8-39z" horiz-adv-x="928.6" />
<glyph glyph-name="floppy" unicode="&#xe80f;" d="M214-7h429v214h-429v-214z m500 0h72v500q0 8-6 21t-11 20l-157 156q-5 6-19 12t-22 5v-232q0-22-15-38t-38-16h-322q-22 0-37 16t-16 38v232h-72v-714h72v232q0 22 16 38t37 16h465q22 0 38-16t15-38v-232z m-214 518v178q0 8-5 13t-13 5h-107q-7 0-13-5t-5-13v-178q0-7 5-13t13-5h107q7 0 13 5t5 13z m357-18v-518q0-22-15-38t-38-16h-750q-23 0-38 16t-16 38v750q0 22 16 38t38 16h517q23 0 50-12t42-26l156-157q16-15 27-42t11-49z" horiz-adv-x="857.1" />
<glyph glyph-name="twitter" unicode="&#xf099;" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />
<glyph glyph-name="gauge" unicode="&#xf0e4;" d="M214 207q0 30-21 51t-50 21-51-21-21-51 21-50 51-21 50 21 21 50z m107 250q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m239-268l57 213q3 14-5 27t-21 16-27-3-17-22l-56-213q-33-3-60-25t-35-55q-11-43 11-81t66-50 81 11 50 66q9 33-4 65t-40 51z m369 18q0 30-21 51t-51 21-50-21-21-51 21-50 50-21 51 21 21 50z m-358 357q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m250-107q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m179-250q0-145-79-269-10-17-30-17h-782q-20 0-30 17-79 123-79 269 0 102 40 194t106 160 160 107 194 39 194-39 160-107 106-160 40-194z" horiz-adv-x="1000" />
<glyph glyph-name="download-cloud" unicode="&#xf0ed;" d="M714 332q0 8-5 13t-13 5h-125v196q0 8-5 13t-12 5h-108q-7 0-12-5t-5-13v-196h-125q-8 0-13-5t-5-13q0-8 5-13l196-196q5-5 13-5t13 5l196 196q5 6 5 13z m357-125q0-89-62-151t-152-63h-607q-103 0-177 73t-73 177q0 72 39 134t105 92q-1 17-1 24 0 118 84 202t202 84q87 0 159-49t105-129q40 35 93 35 59 0 101-42t42-101q0-43-23-77 72-17 119-76t46-133z" horiz-adv-x="1071.4" />
<glyph glyph-name="upload-cloud" unicode="&#xf0ee;" d="M714 368q0 8-5 13l-196 196q-5 5-13 5t-13-5l-196-196q-5-6-5-13 0-8 5-13t13-5h125v-196q0-8 5-13t12-5h108q7 0 12 5t5 13v196h125q8 0 13 5t5 13z m357-161q0-89-62-151t-152-63h-607q-103 0-177 73t-73 177q0 72 39 134t105 92q-1 17-1 24 0 118 84 202t202 84q87 0 159-49t105-129q40 35 93 35 59 0 101-42t42-101q0-43-23-77 72-17 119-76t46-133z" horiz-adv-x="1071.4" />
<glyph glyph-name="keyboard" unicode="&#xf11c;" d="M214 198v-53q0-9-9-9h-53q-9 0-9 9v53q0 9 9 9h53q9 0 9-9z m72 143v-53q0-9-9-9h-125q-9 0-9 9v53q0 9 9 9h125q9 0 9-9z m-72 143v-54q0-9-9-9h-53q-9 0-9 9v54q0 9 9 9h53q9 0 9-9z m572-286v-53q0-9-9-9h-482q-9 0-9 9v53q0 9 9 9h482q9 0 9-9z m-357 143v-53q0-9-9-9h-54q-9 0-9 9v53q0 9 9 9h54q9 0 9-9z m-72 143v-54q0-9-9-9h-53q-9 0-9 9v54q0 9 9 9h53q9 0 9-9z m214-143v-53q0-9-8-9h-54q-9 0-9 9v53q0 9 9 9h54q8 0 8-9z m-71 143v-54q0-9-9-9h-53q-9 0-9 9v54q0 9 9 9h53q9 0 9-9z m214-143v-53q0-9-9-9h-53q-9 0-9 9v53q0 9 9 9h53q9 0 9-9z m215-143v-53q0-9-9-9h-54q-9 0-9 9v53q0 9 9 9h54q9 0 9-9z m-286 286v-54q0-9-9-9h-54q-9 0-9 9v54q0 9 9 9h54q9 0 9-9z m143 0v-54q0-9-9-9h-54q-9 0-9 9v54q0 9 9 9h54q9 0 9-9z m143 0v-196q0-9-9-9h-125q-9 0-9 9v53q0 9 9 9h62v134q0 9 9 9h54q9 0 9-9z m71-420v500h-929v-500h929z m71 500v-500q0-29-20-50t-51-21h-929q-29 0-50 21t-21 50v500q0 30 21 51t50 21h929q30 0 51-21t20-51z" horiz-adv-x="1071.4" />
<glyph glyph-name="ellipsis-vert" unicode="&#xf142;" d="M214 154v-108q0-22-15-37t-38-16h-107q-23 0-38 16t-16 37v108q0 22 16 38t38 15h107q22 0 38-15t15-38z m0 285v-107q0-22-15-38t-38-15h-107q-23 0-38 15t-16 38v107q0 23 16 38t38 16h107q22 0 38-16t15-38z m0 286v-107q0-22-15-38t-38-16h-107q-23 0-38 16t-16 38v107q0 22 16 38t38 16h107q22 0 38-16t15-38z" horiz-adv-x="214.3" />
<glyph glyph-name="twitch" unicode="&#xf1e8;" d="M500 608v-242h-81v242h81z m222 0v-242h-81v242h81z m0-424l141 141v444h-666v-585h182v-121l121 121h222z m222 666v-565l-242-242h-182l-121-122h-121v122h-222v646l61 161h827z" horiz-adv-x="1000" />
<glyph glyph-name="trash" unicode="&#xf1f8;" d="M286 82v393q0 8-5 13t-13 5h-36q-8 0-13-5t-5-13v-393q0-8 5-13t13-5h36q8 0 13 5t5 13z m143 0v393q0 8-5 13t-13 5h-36q-8 0-13-5t-5-13v-393q0-8 5-13t13-5h36q8 0 13 5t5 13z m142 0v393q0 8-5 13t-12 5h-36q-8 0-13-5t-5-13v-393q0-8 5-13t13-5h36q7 0 12 5t5 13z m-303 554h250l-27 65q-4 5-9 6h-177q-6-1-10-6z m518-18v-36q0-8-5-13t-13-5h-54v-529q0-46-26-80t-63-34h-464q-37 0-63 33t-27 79v531h-53q-8 0-13 5t-5 13v36q0 8 5 13t13 5h172l39 93q9 21 31 35t44 15h178q23 0 44-15t30-35l39-93h173q8 0 13-5t5-13z" horiz-adv-x="785.7" />
<glyph glyph-name="window-maximize" unicode="&#xf2d0;" d="M143 64h714v429h-714v-429z m857 625v-678q0-37-26-63t-63-27h-822q-36 0-63 27t-26 63v678q0 37 26 63t63 27h822q37 0 63-27t26-63z" horiz-adv-x="1000" />
<glyph glyph-name="window-minimize" unicode="&#xf2d1;" d="M1000 118v-107q0-37-26-63t-63-27h-822q-36 0-63 27t-26 63v107q0 37 26 63t63 26h822q37 0 63-26t26-63z" horiz-adv-x="1000" />
<glyph glyph-name="window-restore" unicode="&#xf2d2;" d="M143-7h428v286h-428v-286z m571 286h286v428h-429v-143h54q37 0 63-26t26-63v-196z m429 482v-536q0-37-26-63t-63-26h-340v-197q0-37-26-63t-63-26h-536q-36 0-63 26t-26 63v536q0 37 26 63t63 26h340v197q0 37 26 63t63 26h536q36 0 63-26t26-63z" horiz-adv-x="1142.9" />
<glyph glyph-name="window-close" unicode="&#xf2d3;" d="M656 113l81 81q6 6 6 13t-6 13l-130 130 130 130q6 6 6 13t-6 13l-81 81q-6 6-13 6t-13-6l-130-130-130 130q-6 6-13 6t-13-6l-81-81q-6-6-6-13t6-13l130-130-130-130q-6-6-6-13t6-13l81-81q6-6 13-6t13 6l130 130 130-130q6-6 13-6t13 6z m344 576v-678q0-37-26-63t-63-27h-822q-36 0-63 27t-26 63v678q0 37 26 63t63 27h822q37 0 63-27t26-63z" horiz-adv-x="1000" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

BIN
res/font/ffz-fontello.ttf Normal file

Binary file not shown.

BIN
res/font/ffz-fontello.woff Normal file

Binary file not shown.

BIN
res/font/ffz-fontello.woff2 Normal file

Binary file not shown.

View file

@ -1,188 +0,0 @@
/* FileSaver.js
* A saveAs() FileSaver implementation.
* 1.3.2
* 2016-06-16 18:25:19
*
* By Eli Grey, http://eligrey.com
* License: MIT
* See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md
*/
/*global self */
/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */
/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */
var saveAs = saveAs || (function(view) {
"use strict";
// IE <10 is explicitly unsupported
if (typeof view === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) {
return;
}
var
doc = view.document
// only get URL when necessary in case Blob.js hasn't overridden it yet
, get_URL = function() {
return view.URL || view.webkitURL || view;
}
, save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a")
, can_use_save_link = "download" in save_link
, click = function(node) {
var event = new MouseEvent("click");
node.dispatchEvent(event);
}
, is_safari = /constructor/i.test(view.HTMLElement) || view.safari
, is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent)
, throw_outside = function(ex) {
(view.setImmediate || view.setTimeout)(function() {
throw ex;
}, 0);
}
, force_saveable_type = "application/octet-stream"
// the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to
, arbitrary_revoke_timeout = 1000 * 40 // in ms
, revoke = function(file) {
var revoker = function() {
if (typeof file === "string") { // file is an object URL
get_URL().revokeObjectURL(file);
} else { // file is a File
file.remove();
}
};
setTimeout(revoker, arbitrary_revoke_timeout);
}
, dispatch = function(filesaver, event_types, event) {
event_types = [].concat(event_types);
var i = event_types.length;
while (i--) {
var listener = filesaver["on" + event_types[i]];
if (typeof listener === "function") {
try {
listener.call(filesaver, event || filesaver);
} catch (ex) {
throw_outside(ex);
}
}
}
}
, auto_bom = function(blob) {
// prepend BOM for UTF-8 XML and text/* types (including HTML)
// note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF
if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type});
}
return blob;
}
, FileSaver = function(blob, name, no_auto_bom) {
if (!no_auto_bom) {
blob = auto_bom(blob);
}
// First try a.download, then web filesystem, then object URLs
var
filesaver = this
, type = blob.type
, force = type === force_saveable_type
, object_url
, dispatch_all = function() {
dispatch(filesaver, "writestart progress write writeend".split(" "));
}
// on any filesys errors revert to saving with object URLs
, fs_error = function() {
if ((is_chrome_ios || (force && is_safari)) && view.FileReader) {
// Safari doesn't allow downloading of blob urls
var reader = new FileReader();
reader.onloadend = function() {
var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;');
var popup = view.open(url, '_blank');
if(!popup) view.location.href = url;
url=undefined; // release reference before dispatching
filesaver.readyState = filesaver.DONE;
dispatch_all();
};
reader.readAsDataURL(blob);
filesaver.readyState = filesaver.INIT;
return;
}
// don't create more object URLs than needed
if (!object_url) {
object_url = get_URL().createObjectURL(blob);
}
if (force) {
view.location.href = object_url;
} else {
var opened = view.open(object_url, "_blank");
if (!opened) {
// Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html
view.location.href = object_url;
}
}
filesaver.readyState = filesaver.DONE;
dispatch_all();
revoke(object_url);
}
;
filesaver.readyState = filesaver.INIT;
if (can_use_save_link) {
object_url = get_URL().createObjectURL(blob);
setTimeout(function() {
save_link.href = object_url;
save_link.download = name;
click(save_link);
dispatch_all();
revoke(object_url);
filesaver.readyState = filesaver.DONE;
});
return;
}
fs_error();
}
, FS_proto = FileSaver.prototype
, saveAs = function(blob, name, no_auto_bom) {
return new FileSaver(blob, name || blob.name || "download", no_auto_bom);
}
;
// IE 10+ (native saveAs)
if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) {
return function(blob, name, no_auto_bom) {
name = name || blob.name || "download";
if (!no_auto_bom) {
blob = auto_bom(blob);
}
return navigator.msSaveOrOpenBlob(blob, name);
};
}
FS_proto.abort = function(){};
FS_proto.readyState = FS_proto.INIT = 0;
FS_proto.WRITING = 1;
FS_proto.DONE = 2;
FS_proto.error =
FS_proto.onwritestart =
FS_proto.onprogress =
FS_proto.onwrite =
FS_proto.onabort =
FS_proto.onerror =
FS_proto.onwriteend =
null;
return saveAs;
}(
typeof self !== "undefined" && self
|| typeof window !== "undefined" && window
|| this.content
));
// `self` is undefined in Firefox for Android content script context
// while `this` is nsIContentFrameMessageManager
// with an attribute `content` that corresponds to the window
if (typeof module !== "undefined" && module.exports) {
module.exports.saveAs = saveAs;
} else if ((typeof define !== "undefined" && define !== null) && (define.amd !== null)) {
define("FileSaver.js", function() {
return saveAs;
});
}

View file

@ -1,21 +0,0 @@
String.prototype.tokens=function(h){h=h?h:!1;var a,g,b=0,k=this.length,f,c,e=[],l="and;or;not;in;not in;is;is not;".split(";"),d=function(a,c,d){if(!d)return{type:a,value:c,position:[g,b]}};if(this){for(a=this.charAt(b);a;)if(g=b," ">=a)b+=1,a=this.charAt(b);else if("a"<=a&&"z">=a||"A"<=a&&"Z">=a||"_"==a){c=a;for(b+=1;;)if(a=this.charAt(b),"a"<=a&&"z">=a||"A"<=a&&"Z">=a||"0"<=a&&"9">=a||"_"===a)c+=a,b+=1;else break;0<=l.indexOf(c)?(f=e[e.length-1],"not"===c&&f&&"is"===f.value?(c=d("op","is not"),
c.position[0]=f.position[0],e[e.length-1]=c):"in"===c&&f&&"not"===e[e.length-1].value?(c=d("op","not in"),c.position[0]=f.position[0],e[e.length-1]=c):e.push(d("op",c))):e.push(d("name",c))}else if("0"<=a&&"9">=a){c=a;for(b+=1;;){a=this.charAt(b);if("0">a||"9"<a)break;b+=1;c+=a}if("."===a)for(b+=1,c+=a;;){a=this.charAt(b);if("0">a||"9"<a)break;b+=1;c+=a}if("e"===a||"E"===a){b+=1;c+=a;a=this.charAt(b);if("-"===a||"+"===a)b+=1,c+=a,a=this.charAt(b);("0">a||"9"<a)&&d("number",c,"Bad exponent");do b+=
1,c+=a,a=this.charAt(b);while("0"<=a&&"9">=a)}"a"<=a&&"z">=a&&(c+=a,b+=1,d("number",c,"Bad number"));f=+c;isFinite(f)?e.push(d("number",f)):d("number",c,"Bad number")}else if("'"===a||'"'===a){c="";f=a;for(b+=1;;){a=this.charAt(b);" ">a&&d("str",c,"\n"===a||"\r"===a||""===a?"Unterminated string.":"Control character in string.",d("",c));if(a===f)break;if("\\"===a)switch(b+=1,b>=k&&d("str",c,"Unterminated string"),a=this.charAt(b),a){case "b":a="\b";break;case "f":a="\f";break;case "n":a="\n";break;
case "r":a="\r";break;case "t":a="\t";break;case "u":b>=k&&d("str",c,"Unterminated string"),a=parseInt(this.substr(b+1,4),16),(!isFinite(a)||0>a)&&d("str",c,"Unterminated string"),a=String.fromCharCode(a),b+=4}c+=a;b+=1}b+=1;e.push(d("str",c));a=this.charAt(b)}else 0<="<>.".indexOf(a)?(c=a,b+=1,a=this.charAt(b),"<"===c&&"="===a?c="<=":">"===c&&"="===a?c=">=":"."===c&&"."===a&&(c=".."),1<c.length&&(b+=1,a=this.charAt(b)),e.push(d("op",c))):(b+=1,"$"===a?e.push(d("(root)",a)):"@"===a?e.push(d("(current)",
a)):"!"===a?e.push(d("(context)",a)):0<="+-*/%<>:[,]{}:()".indexOf(a)?e.push(d("op",a)):d("op",a,a+" is not an ObjectPath operator!"),a=this.charAt(b));h&&clog("}tokens with",e);return e}};
var makeTree=function(){var h={},e,n,l,u=["true","t"],v=["false","f"],w=["none","null","n","nil"],m=function(){return this},f=function(a){var b,c,d;a&&e.id!==a&&e.error("Expected '"+a+"', got '"+e.id+"'.");if(l>=n.length)e=h["(end)"];else return c=n[l],l+=1,d=c.value,a=c.type,"name"===a?(0<=v.indexOf(d.toLowerCase())&&(d="false"),0<=u.indexOf(d.toLowerCase())&&(d="true"),0<=w.indexOf(d.toLowerCase())&&(d="null"),(b=h[d])||(b=h["(name)"])):"(root)"===a?b=h["(root)"]:"(current)"===a?b=h["(current)"]:
"(context)"===a?b=h["(context)"]:"op"===a?(b=h[d])||c.error("Unknown operator."):"str"===a||"number"===a?(b=h["(literal)"],a="literal"):c.error("Unexpected token."),e=Object.create(b),e.position=c.position,e.value=d,e.arity=a,e.error=function(a){},e},k=function(a){var b,c=e;a="undefined"===typeof a?0:a;f();for(b=c.nud();a<e.lbp;)c=e,f(),b=c.led(b);return b},x={nud:function(){this.error("Undefined nud().")},led:function(a){this.error("Missing operator.")}},d=function(a,b){var c=h[a];b=b||0;c?b>=c.lbp&&
(c.lbp=b):(c=Object.create(x),c.id=c.value=a,c.lbp=b,h[a]=c);return c},p=function(a,b){var c=d(a);c.nud=function(){this.value=h[this.id].value;this.arity="literal";this.id="(literal)";return this};c.value=b;return c},g=function(a,b,c){a=d(a,b);a.led=c||function(a){this.first=a;this.second=k(b);this.arity="binary";return this};return a},r=function(a,b,c){a=d(a,b);a.led=c||function(a){this.first=a;this.second=k(b-1);this.arity="binary";return this};return a},q=function(a,b,c){a=d(a);a.nud=c||function(){this.first=
k(b);this.arity="unary";return this};return a},t=function(a){this.first=a;0>["(name)","*"].indexOf(e.id)&&SyntaxError("Expected an attribute name.");"*"===e.id&&(e.arity="wildcard");this.second=e;f();return this};d("(end)");d("(name)").nud=m;d("(literal)").nud=m;d("(root)").nud=m;d("(current)").nud=m;d("(context)").nud=m;d(":");d(",");p("true",!0);p("false",!1);p("null",null);r("or",30);r("and",40);q("not",50);g(":",120);g("in",60);g("not in",60);g("is",60);g("is not",60);g("<",60);g("<=",60);g(">",
60);g(">=",60);g("+",110);g("-",110);g("*",120);g("/",120);g("%",120);q("-",130);q("+",130);d(".",150).led=t;d("..",150).led=t;d("]");g("[",150,function(a){this.first=a;this.second=k(0);this.arity="binary";f("]");return this});d("[").led=function(a){this.first=a;this.second=k();f("]");return this};d("[").nud=function(){var a=[];if("]"!==e.id)for(;;){a.push(k());if(","!==e.id)break;f(",")}f("]");this.first=a;this.arity="unary";return this};d(")");d("(",150).led=function(a){var b=[];this.arity="binary";
this.id="fn";this.first=a.value;if(")"!==e.id)for(;;){b.push(k());if(","!==e.id)break;f(",")}f(")");this.second=b;return this};d("(",150).nud=function(){var a=k();f(")");return a};d("}");d("{").nud=function(){var a=[],b,c;if("}"!==e.id)for(;;){b=k();f(":");c=k();c.key=b;a.push(c);if(","!==e.id)break;f(",")}f("}");this.first=a;this.arity="unary";return this};return function(a,b){var c=this.D=b&&b.debug||!1;n="string"===typeof a?a.tokens(c):a;l=0;f();c=k();f("(end)");return c}},parse=makeTree();
module.exports = ObjectPath=function(a,h){this.exprCache=[];this._init_(a,h);return this};
ObjectPath.prototype={D:!1,current:null,data:null,SELECTOR_OPS:"is;>;<;is not;>=;<=;in;not in;:;and;or".split(";"),simpleTypes:["string","number"],_init_:function(a,h){this.setData(a);h&&this.setDebug(h.debug||this.D)},setCurrent:function(a){this.current=a},resetCurrent:function(){this.current=null},setContext:function(a){this.context=a},compile:function(a){return this.exprCache.hasOwnProperty(a)?this.exprCache[a]:this.exprCache[a]=parse.call(this,a,{debug:this.D})},setData:function(a){return 0>["object",
"array"].indexOf(typeof a)?(this.log(a+" is not object nor array! Data not changed."),a):this.data=a},setDebug:function(a){this.D=a},flatten:function(a){var h=[],l=function(a){if(Array.isArray(a))for(var g=0;g<a.length;g++)l(a[g]);else if("object"===typeof a)for(g in h.push(a),a)l(a[g])};l(a);return h},execute:function(a){var h=this,l=this.flatten,m=this.simpleTypes,g,f=function(b){if(Array.isArray(b)){for(var c=[],e=0;e<b.length;e++)c.push(f(b[e]));return c}var a=b.id;if(0<="+;-;*;%;/;>;>=;<;<=;in;not in;is;is not".split(";").indexOf(b.id))var c=
"object"===typeof b.first&&b.first.id?f(b.first):null,d="object"===typeof b.second&&b.second.id?f(b.second):null,g=typeof c,k=typeof d;switch(a){case "(literal)":return b.value;case "+":if("number"===g&&"number"!==k)d=parseInt(d);else if(Array.isArray(c)){if(Array.isArray(d))return c.concat(d);c.push(d)}return c+d;case "-":return d?c-d:-c;case "*":return"wildcard"!=b.arity?c*d:null;case "%":return c%d;case "/":return c/d;case ">":return c>d;case "<":return c<d;case ">=":return c>=d;case "<=":return c<=
d;case "not":return!f(b.first);case "or":return f(b.first)||f(b.second);case "and":return f(b.first)&&f(b.second);case "in":case "not in":return e=null,"string"===k?e=0<=d.search(c.toString()):Array.isArray(d)?e=0<=d.indexOf(c):"object"===k&&(e=d.hasOwnProperty(c)),"in"===a?e:!e;case "is":case "is not":e=null;if(0<=m.indexOf(g))e=c==d;else if("array"===g||"object"===g)try{throw e=JSON.stringify(c)==JSON.stringify(d),{name:"comparison Error",message:"JSONStringifyNotAvailable. Your web browser doesn't support JSON.stringify(). Vote for support at"};
}catch(n){}return"is"===a?e:!e;case "(root)":return h.data;case "(current)":return h.current;case "(context)":return h.context;case ":":return b;case "(name)":return b.value;case "[":if("unary"===b.arity){a=[];for(e in b.first)a.push(f(b.first[e]));return a}if("op"===b.arity){c=f(b.first);if(!c)return c;if("string"===typeof c||Array.isArray(c)){d=f(b.second);k=typeof d;if(d&&":"===d.id)return c.slice(f(d.first),f(d.second));if("number"===k)return-1===d?c.slice(-1)[0]:0>d?c.slice(d,d+1)[0]:c[d];if("string"===
k){a=[];for(e=0;e<c.length;e++)c[e][d]&&a.push(c[e][d]);return a}if("object"===typeof b.second&&0<=h.SELECTOR_OPS.indexOf(b.second.id)){b=b.second;a=[];b=Object.create(b);for(e in c)d=c[e],h.current=d,b.first=d,f(b)&&a.push(d);return a}programmingError("left is array and right is not number")}else if("object"===g&&("(name)"===b.second.id||"string"===k))return c[d];return 1}return null;case "(":switch(b.first.value){case "str":return e=f(b.second),"object"===typeof e?JSON.stringify(e):e.toString}return null;
case "{":case "":throw{error:"NotImplementedYet",message:a+" is not implemented yet!",data:b};case "..":c=l(f(b.first));case ".":c=c||f(b.first);if("*"===b.second.id)return c;if(c){if(Array.isArray(c)){a=[];d=f(b.second);for(e=0;e<c.length;e++)c[e][d]&&a.push(c[e][d]);return a}return c[b.second.value]}return null;case "fn":switch(b.first){case "float":case "int":return parseFloat(f(b.second));case "str":return f(b.second).toString();case "array":return e=f(b.second),Array.isArray(e[0])?e[0]:"string"===
typeof e[0]?e[0].split(""):[];case "replace":return a=f(b.second),a[0]?a[0].replace(new RegExp(a[1],"g"),a[2]):"";case "join":a=f(b.second);try{return a[0].join(a[1])}catch(p){return null}case "split":return a=f(b.second),a[0]?a[0].split(a[1]):"";case "max":return Math.max.apply(null,f(b.second)[0]);case "min":return Math.min.apply(null,f(b.second)[0])}throw{error:"WrongFunction",message:"Function "+a+" is not proper ObjectPath function.",data:b};}throw{error:"WrongOperator",message:"Operator "+a+
" is not proper ObjectPath operator.",data:b};};if(!a)return a;"string"===typeof a&&(g=this.compile(a));return f(g)}};

View file

@ -1,847 +0,0 @@
var FFZ = window.FrankerFaceZ,
constants = require('./constants'),
utils = require('./utils'),
WEBKIT = constants.IS_WEBKIT ? '-webkit-' : '',
SPECIAL_BADGES = ['staff', 'admin', 'global_mod'],
OTHER_KNOWN = ['turbo', 'bits', 'premium', 'partner'],
CSS_BADGES = {
staff: { 1: { color: "#200f33", use_svg: true } },
admin: { 1: { color: "#faaf19", use_svg: true } },
global_mod: { 1: { color: "#0c6f20", use_svg: true } },
broadcaster: { 1: { color: "#e71818", use_svg: true } },
moderator: { 1: { color: "#34ae0a", use_svg: true } },
twitchbot: { 1: { color: "#34ae0a" } },
partner: { 1: { color: "transparent", has_trans: true, trans_color: "#6441a5" } },
turbo: { 1: { color: "#6441a5", use_svg: true } },
premium: { 1: { color: "#009cdc" } },
subscriber: { 0: { color: "#6441a4" }, 1: { color: "#6441a4" }},
bits: {
1: { color: "#cbc8d0" },
100: { color: "#ca7eff" },
1000: { color: "#3ed8b3" },
5000: { color: "#49acff" },
10000: { color: "#ff271e" },
25000: { color: "#f560ab" },
50000: { color: "#ff881f" },
75000: { color: "#16d03d" },
100000: { color: "#ffcb13" },
200000: { color: "#cbc8d0" },
300000: { color: "#c97ffd" },
400000: { color: "#3dd8b3" },
500000: { color: "#48acfe" },
600000: { color: "#ff281f" },
700000: { color: "#f560ab" },
800000: { color: "#ff881f" },
900000: { color: "#16d03d" },
1000000: { color: "#fecb11" }
}
},
NO_INVERT_BADGES = ['ssubscriber', 'ffz-badge-1'],
INVERT_INVERT_BADGES = ['bits'],
TRANSPARENT_BADGES = [], //'subscriber'],
BTTV_TYPE_REPLACEMENTS = {
'global-moderator': 'global_mod'
},
BADGE_POSITIONS = {
'broadcaster': 0,
'staff': 0,
'admin': 0,
'global_mod': 0,
'mod': 1,
'moderator': 1,
'twitchbot': 1,
'subscriber': 10,
},
BADGE_NAMES = {
'global_mod': 'Global Moderator'
},
BADGE_KLASSES = {
'global_mod': 'global-moderator'
};
// --------------------
// Settings
// --------------------
FFZ.settings_info.show_badges = {
type: "boolean",
value: true,
category: "Chat Appearance",
name: "Additional Badges",
help: "Show additional badges for bots, FrankerFaceZ donors, and other special users."
};
FFZ.settings_info.loyalty_badges = {
type: "boolean",
value: true,
category: "Chat Appearance",
name: "Display Subscriber Loyalty Badges",
help: "Show different badge images for users that have been subscribed 3, 6, 12, and 24 months in supported channels.",
on_update: function(val) {
utils.toggle_cls('ffz-no-loyalty')(!val);
}
};
FFZ.settings_info.hidden_badges = {
type: "button",
value: [],
category: "Chat Appearance",
name: "Hidden Badges",
help: "Any badges added to this list will not be displayed in chat.",
on_update: function(val) {
if ( this.has_bttv_6 )
return;
var controller = utils.ember_lookup('controller:chat'),
messages = controller && controller.get('currentRoom.messages');
if ( ! messages )
return;
for(var i=0; i < messages.length; i++)
messages[i]._line && messages[i]._line.ffzUpdateBadges();
},
method: function() {
var f = this,
service = utils.ember_lookup('service:badges'),
badgeCollection = service && service.badgeCollection,
old_val = f.settings.hidden_badges.join(", "),
values = [];
if ( badgeCollection ) {
if ( badgeCollection.global )
for(var badge in badgeCollection.global)
if ( badgeCollection.global.hasOwnProperty(badge) && badge !== 'broadcasterName' ) {
var badge_data = badgeCollection.global[badge] || {},
version = badge_data.versions && Object.keys(badge_data.versions)[0];
if ( version )
values.push([badge, f.render_badges(f.get_twitch_badges(badge + "/" + version))]);
}
if ( badgeCollection.channel )
for(var badge in badgeCollection.channel)
if ( badgeCollection.channel.hasOwnProperty(badge) && badge !== 'broadcasterName' ) {
var badge_data = badgeCollection.channel[badge] || {},
version = badge_data.versions && Object.keys(badge_data.versions)[0];
if ( version )
values.push([badge, f.render_badges(f.get_twitch_badges(badge + "/" + version))]);
}
}
for(var badge_id in f.badges) {
if ( ! f.badges.hasOwnProperty(badge_id) )
continue;
var badge = f.badges[badge_id],
hide_key = (badge.source_ext ? f._apis[badge.source_ext].name_key : 'ffz') + '-' + (badge.name || badge.id),
render_badge = {};
render_badge[badge.slot] = f._get_badge_object({}, badge);
values.push([hide_key, f.render_badges(render_badge)]);
}
if ( this.has_bttv_6 && window.BetterTTV ) {
try {
for(var badge_id in BetterTTV.chat.store.__badgeTypes)
values.push(['bttv-' + badge_id, null]);
values.push(['bot', null]);
} catch(err) {
this.error("Unable to load known BetterTTV badges.", err);
}
}
var already_used = [],
output = [];
for(var i=0; i < values.length; i++) {
var badge = values[i];
if ( already_used.indexOf(badge[0]) !== -1 )
continue;
already_used.push(badge[0]);
output.push((badge[1] ? '<div class="ffz-hidden-badges badges">' + badge[1] + '</div>' : '') + '<code>' + badge[0] + '</code>');
}
utils.prompt(
"Hidden Badges",
"Please enter a comma-separated list of badges that you would like to be hidden in chat. You can use the special value <code>game</code> to hide all the game-specific badges at once.</p><p><b>Possible Values:</b> " + output.join(", "),
old_val,
function(new_val) {
if ( new_val === null || new_val === undefined )
return;
f.settings.set("hidden_badges", _.unique(new_val.trim().toLowerCase().split(/\s*,\s*/)).without(""));
}, 600
)
}
};
FFZ.settings_info.sub_notice_badges = {
type: "boolean",
value: false,
category: "Chat Appearance",
name: "Old-Style Subscriber Notice Badges",
no_bttv: true,
help: "Display a subscriber badge on old-style chat messages about new subscribers.",
on_update: function(val) {
this.toggle_style('badges-sub-notice', ! this.has_bttv && ! val);
this.toggle_style('badges-sub-notice-on', ! this.has_bttv && val);
}
};
FFZ.settings_info.legacy_badges = {
type: "select",
options: {
0: "Default",
1: "Moderator Only",
2: "Mod + Turbo",
3: "All Legacy Badges"
},
value: 0,
category: "Chat Appearance",
name: "Legacy Badges",
help: "Use the old, pre-vector chat badges from Twitch in place of the new.",
process_value: utils.process_int(0, 0, 3),
on_update: function(val) {
this.toggle_style('badges-legacy', val === 3);
this.toggle_style('badges-legacy-mod', val !== 0);
this.toggle_style('badges-legacy-turbo', val > 1);
}
};
FFZ.settings_info.transparent_badges = {
type: "select",
options: {
0: "Default",
1: "Rounded",
2: "Circular",
3: "Circular (Color Only)",
4: "Circular (Color Only, Small)",
5: "Transparent",
6: "Transparent (Colored)"
},
value: 0,
category: "Chat Appearance",
no_bttv: 6,
name: "Badge Style",
help: "Make badges appear rounded, completely circular, or transparent with no background at all.",
process_value: utils.process_int(0, 0, 5),
on_update: function(val) {
if ( this.has_bttv_6 )
return;
this.toggle_style('badges-rounded', val === 1);
this.toggle_style('badges-circular', val === 2 || val === 3 || val === 4);
this.toggle_style('badges-blank', val === 3 || val === 4);
this.toggle_style('badges-circular-small', val === 4);
this.toggle_style('badges-transparent', val >= 5);
utils.toggle_cls('ffz-transparent-badges')(val >= 5);
utils.toggle_cls('ffz-blank-badges')(val === 3 || val === 4);
// Update existing chat lines.
var CL = utils.ember_resolve('component:chat/chat-line'),
CW = utils.ember_resolve('component:twitch-conversations/conversation-window'),
DP = utils.ember_resolve('component:chat/from-display-preview'),
views = (CL || CW || DP) ? utils.ember_views() : [];
for(var vid in views) {
var view = views[vid];
if ( CL && view instanceof CL && view.buildBadgesHTML )
view.$('.badges').replaceWith(view.buildBadgesHTML());
else if ( DP && view instanceof DP && view.ffzRenderBadges )
view.ffzRenderBadges();
else if ( CW && view instanceof CW && view.ffzReplaceBadges )
view.ffzReplaceBadges();
}
}
};
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_badges = function() {
this.log("Preparing badge system.");
if ( ! this.has_bttv_6 ) {
var val = this.settings.transparent_badges;
this.toggle_style('badges-rounded', val === 1);
this.toggle_style('badges-circular', val === 2 || val === 3 || val === 4);
this.toggle_style('badges-blank', val === 3 || val === 4);
this.toggle_style('badges-circular-small', val === 4);
this.toggle_style('badges-transparent', val >= 5);
utils.toggle_cls('ffz-transparent-badges')(val >= 5);
utils.toggle_cls('ffz-blank-badges')(val === 3 || val === 4);
utils.toggle_cls('ffz-no-loyalty')(!this.settings.loyalty_badges);
}
if ( ! this.has_bttv ) {
this.toggle_style('badges-sub-notice', ! this.settings.sub_notice_badges);
this.toggle_style('badges-sub-notice-on', this.settings.sub_notice_badges);
}
this.toggle_style('badges-legacy', this.settings.legacy_badges === 3);
this.toggle_style('badges-legacy-mod', this.settings.legacy_badges !== 0);
this.toggle_style('badges-legacy-turbo', this.settings.legacy_badges > 1);
this.log("Creating badge style element.");
var s = this._badge_style = document.createElement('style');
s.id = "ffz-badge-css";
document.head.appendChild(s);
this.log("Generating CSS for existing API badges.");
for(var badge_id in this.badges)
if ( this.badges.hasOwnProperty(badge_id) )
utils.update_css(s, badge_id, utils.badge_css(this.badges[badge_id]));
this.log("Generating CSS for existing Twitch badges.");
for(var badge_id in CSS_BADGES) {
var badge_data = CSS_BADGES[badge_id],
klass = BADGE_KLASSES[badge_id] || badge_id;
for(var version in badge_data)
utils.update_css(s, 'twitch-' + badge_id + '-' + version, utils.cdn_badge_css(klass, version, badge_data[version]));
}
this.log("Loading badges.");
this.load_badges();
}
// --------------------
// Reloading Badges
// --------------------
FFZ.ws_commands.reload_badges = function() {
this.load_badges();
}
FFZ.ws_commands.set_badge = function(data) {
var user_id = data[0],
slot = data[1],
badge = data[2],
user = this.users[user_id] = this.users[user_id] || {},
badges = user.badges = user.badges || {};
if ( typeof badge === "number" )
badge = {id: badge};
if ( badge === undefined || badge === null )
badges[slot] = null;
else
badges[slot] = badge;
}
// --------------------
// Badge Selection
// --------------------
FFZ.prototype.get_badges = function(user, room_id, badges, msg) {
var data = this.users[user],
room = this.rooms[room_id],
room_data = room && room.users && room.users[user],
hidden_badges = this.settings.hidden_badges,
badge_data = data && data.badges || {};
if ( room_data && room_data.badges )
badge_data = _.extend({}, badge_data, room_data.badges);
if ( ! badge_data || ! this.settings.show_badges )
return badges;
for(var slot in badge_data) {
var badge = badge_data[slot];
if ( ! badge_data.hasOwnProperty(slot) || ! badge )
continue;
var badge_id = badge.real_id || badge.id,
full_badge = this.badges[badge_id] || {},
full_badge_id = full_badge.real_id || full_badge.id,
old_badge = badges[slot],
hide_key = (full_badge.source_ext ? this._apis[full_badge.source_ext].name_key : 'ffz') + '-' + (full_badge.name || full_badge.id);
if ( hidden_badges.indexOf(hide_key) !== -1 )
continue;
if ( full_badge.visible !== undefined ) {
var visible = full_badge.visible;
if ( typeof visible === "function" )
visible = visible.call(this, room_id, user, msg, badges);
if ( ! visible )
continue;
}
if ( old_badge ) {
var replaces = badge.hasOwnProperty('replaces') ? badge.replaces : full_badge.replaces,
replace_mode = badge.replace_mode || full_badge.replace_mode || 'merge';
if ( ! replaces )
continue;
if ( replace_mode === 'merge' ) {
old_badge.image = badge.image || null;
old_badge.klass += ' ffz-badge-replacement ffz-replacer-ffz-badge-' + (badge_id || full_badge_id);
old_badge.title += ', ' + (badge.title || full_badge.title);
continue;
} else if ( replace_mode === 'keep_title' ) {
var b = badges[slot] = this._get_badge_object(badge, full_badge);
b.title = old_badge.title + ', ' + b.title;
continue;
} else if ( replace_mode === 'title_only' ) {
old_badge.title += ', ' + (badge.title || full_badge.title);
continue;
}
}
badges[slot] = this._get_badge_object(badge, full_badge);
}
return badges;
}
FFZ.prototype._get_badge_object = function(badge, full_badge) {
var id = badge.real_id || badge.id || full_badge.real_id || full_badge.id;
return {
id: id,
klass: 'ffz-badge-' + id,
title: badge.title || full_badge.title || ('Unknown FFZ Badge\nID: ' + id),
image: badge.image,
full_image: full_badge.image,
color: badge.color,
no_tooltip: badge.no_tooltip || full_badge.no_tooltip,
click_action: badge.click_action || full_badge.click_action,
click_url: badge.click_url || full_badge.click_url,
no_invert: badge.no_invert || full_badge.no_invert,
no_color: badge.no_color || full_badge.no_color,
invert_invert: badge.invert_invert || full_badge.invert_invert,
transparent: badge.transparent || full_badge.transparent || (badge.color || full_badge.color) === "transparent",
extra_css: (badge.extra_css || full_badge.extra_css)
}
}
FFZ.prototype.get_line_badges = function(msg) {
var room = msg.get && msg.get('room') || msg.room,
from = msg.get && msg.get('from') || msg.from,
tags = msg.get && msg.get('tags') || msg.tags || {},
badge_tag = tags.badges || {};
// Twitch Badges
var badges = this.get_twitch_badges(badge_tag, room);
// FFZ Badges
return this.get_badges(from, room, badges, msg);
}
FFZ.prototype.get_twitch_badges = function(badge_tag, room_id) {
var badges = {},
hidden_badges = this.settings.hidden_badges,
last_id = -1,
had_last = false,
service = utils.ember_lookup('service:badges'),
badgeCollection = service && service.badgeCollection,
globals = badgeCollection && badgeCollection.global || {},
channel = badgeCollection && badgeCollection.channel || {};
// Is this the right channel?
if ( room_id && room_id !== channel.broadcasterName ) {
var ffz_room = this.rooms && this.rooms[room_id];
channel = ffz_room && ffz_room.badges || {};
}
// Whisper Chat Lines have a non-associative array for some reason.
if ( Array.isArray(badge_tag) ) {
var val = badge_tag;
badge_tag = {};
for(var i=0; i < val.length; i++)
badge_tag[val[i].id] = val[i].version;
}
// VoD Chat lines don't have the badges pre-parsed for some reason.
else if ( typeof badge_tag === 'string' )
badge_tag = utils.parse_badge_tag(badge_tag);
for(var badge in badge_tag) {
var version = badge_tag[badge];
if ( ! badge_tag.hasOwnProperty(badge) || version === undefined || version === null )
continue;
var versions = channel[badge] || globals[badge],
binfo = versions && versions.versions && versions.versions[version],
is_game = badge.substr(-2) === '_1';
if ( hidden_badges.indexOf(badge) !== -1 || (is_game && hidden_badges.indexOf('game') !== -1) )
continue;
if ( BADGE_POSITIONS.hasOwnProperty(badge) )
last_id = BADGE_POSITIONS[badge];
else {
last_id = had_last ? last_id + 1 : 15;
had_last = true;
}
var is_known = BADGE_POSITIONS.hasOwnProperty(badge) || OTHER_KNOWN.indexOf(badge) !== -1;
badges[last_id] = {
klass: (BADGE_KLASSES[badge] || badge) + (is_known ? '' : ' unknown-badge') + ' version-' + version,
title: binfo && binfo.title || BADGE_NAMES[badge] || badge.capitalize(),
click_url: binfo && binfo.click_action === 'visit_url' && binfo.click_url,
no_invert: ! (versions && versions.allow_invert) && NO_INVERT_BADGES.indexOf(badge) !== -1,
no_color: ! CSS_BADGES.hasOwnProperty(badge),
invert_invert: (versions && versions.invert_invert) || INVERT_INVERT_BADGES.indexOf(badge) !== -1,
transparent: TRANSPARENT_BADGES.indexOf(badge) !== -1
};
if ( ! is_known && binfo ) {
badges[last_id].image = binfo.image_url_1x;
if ( binfo.image_url_2x || binfo.image_url_4x )
badges[last_id].srcSet = 'url("' + binfo.image_url_1x + '") 1x' + (binfo.image_url_2x ? ', url("' + binfo.image_url_2x + '") 2x' : '') + (binfo.image_url_4x ? ', url("' + binfo.image_url_4x + '") 4x' : '');
}
}
return badges;
}
// --------------------
// Render Badge
// --------------------
FFZ.prototype.render_badges = function(badges) {
var out = [],
setting = this.settings.transparent_badges;
for(var key in badges) {
var badge = badges[key],
klass = badge.klass,
css = '',
is_colored = !(badge.no_color !== undefined ? badge.no_color : badge.transparent);
if ( badge.image )
if ( is_colored && setting === 6 )
css += WEBKIT + 'mask-image:url("' + utils.quote_attr(badge.image) + '");';
else
css += 'background-image:url("' + utils.quote_attr(badge.image) + '");';
if ( badge.srcSet )
if ( is_colored && setting === 6 )
css += WEBKIT + 'mask-image:' + WEBKIT + 'image-set(' + badge.srcSet + ');';
else
css += 'background-image:' + WEBKIT + 'image-set(' + badge.srcSet + ');';
if ( badge.color )
if ( is_colored && setting === 6 )
css += 'background: linear-gradient(' + badge.color + ',' + badge.color + ');';
else
css += 'background-color:' + badge.color + ';'
if ( badge.extra_css )
css += badge.extra_css;
if ( badge.click_url )
klass += ' click_url';
if ( badge.click_action )
klass += ' click_action';
if ( badge.no_invert )
klass += ' no-invert';
if ( badge.invert_invert )
klass += ' invert-invert';
if ( is_colored && setting === 6 )
klass += ' colored';
if ( badge.transparent )
klass += ' transparent';
out.push('<div class="badge ' + (badge.no_tooltip ? '' : 'html-tooltip ') + utils.quote_attr(klass) + '"' + (badge.id ? ' data-badge-id="' + badge.id + '"' : '') + (badge.click_url ? ' data-url="' + utils.quote_attr(badge.click_url) + '"' : '') + (css ? ' style="' + utils.quote_attr(css) + '"' : '') + ' title="' + utils.quote_attr(badge.title) + '"></div>');
}
return out.join("");
}
// --------------------
// Extension Support
// --------------------
FFZ.prototype.bttv_badges = function(data) {
if ( ! this.settings.show_badges )
return;
var user_id = data.sender,
user = this.users[user_id],
room = this.rooms[data.room],
room_data = room && room.users && room.users[user_id],
badges_out = [],
insert_at = -1,
hidden_badges = this.settings.hidden_badges,
alpha = BetterTTV.settings.get('alphaTags');
if ( ! data.badges )
data.badges = [];
// Determine where in the list to insert these badges.
// Also, strip out banned badges while we're at it.
for(var i=0; i < data.badges.length; i++) {
var badge = data.badges[i],
space_ind = badge.type.indexOf(' '),
hidden_key = space_ind !== -1 ? badge.type.substr(0, space_ind) : badge.type;
if ( hidden_key.indexOf('twitch-') === 0 )
hidden_key = hidden_key.substr(7);
if ( BTTV_TYPE_REPLACEMENTS.hasOwnProperty(hidden_key) )
hidden_key = BTTV_TYPE_REPLACEMENTS[hidden_key];
else {
var ind = hidden_key.indexOf('-');
if ( ind !== -1 )
hidden_key = hidden_key.substr(0, ind);
}
var is_game = hidden_key.substr(-2) === '_1';
if ( hidden_badges.indexOf(hidden_key) !== -1 || (is_game && hidden_badges.indexOf('game') !== -1) ) {
data.badges.splice(i, 1);
i--;
continue;
}
if ( insert_at === -1 && (badge.type === "subscriber" || badge.type === "turbo" || badge.type.substr(0, 7) === 'twitch-') )
insert_at = i;
}
var badge_data = user && user.badges || {};
if ( room_data && room_data.badges )
badge_data = _.extend({}, badge_data, room_data.badges);
// If there's no user, we're done now.
if ( ! badge_data )
return;
// We have a user. Start replacing badges.
for (var slot in badge_data) {
var badge = badge_data[slot];
if ( ! badge_data.hasOwnProperty(slot) || ! badge )
continue;
var badge_id = badge.real_id || badge.id,
full_badge = this.badges[badge_id] || {},
full_badge_id = full_badge.real_id || full_badge.id,
desc = badge.title || full_badge.title,
style = "",
klass = 'ffz-badge-' + badge_id + (alpha ? ' alpha' : ''),
hide_key = (full_badge.source_ext ? this._apis[full_badge.source_ext].name_key : 'ffz') + '-' + (full_badge.name || full_badge.id);
if ( hidden_badges.indexOf(hide_key) !== -1 )
continue;
if ( full_badge.visible !== undefined ) {
var visible = full_badge.visible;
if ( typeof visible === "function" )
visible = visible.call(this, data.room, user_id);
if ( ! visible )
continue;
}
if ( alpha && badge.transparent_image )
style += 'background-image: url("' + badge.transparent_image + '");';
else if ( badge.image )
style += 'background-image: url("' + badge.image + '");';
if ( badge.color && ! alpha )
style += 'background-color: ' + badge.color + '; ';
if ( badge.extra_css )
style += badge.extra_css;
if ( style )
desc += '" style="' + utils.quote_attr(style);
var replaces = badge.hasOwnProperty('replaces') ? badge.replaces : full_badge.replaces,
replace_mode = badge.replace_mode || full_badge.replace_mode || 'merge';
if ( replaces ) {
var replaced = false;
for(var i=0; i < data.badges.length; i++) {
var b = data.badges[i];
if ( b.type === full_badge.replaces_type ) {
if ( replace_mode === 'merge' ) {
b.type += " ffz-badge-replacement ffz-replacer-ffz-badge-" + (badge_id || full_badge_id);
b.description += ", " + (badge.title || full_badge.title) +
(badge.image ? '" style="background-image: url(' + utils.quote_attr('"' + badge.image + '"') + ')' : '');
} else if ( replace_mode === 'keep_title' ) {
data.badges[i] = {
type: klass,
name: '',
description: b.description + ', ' + desc
};
} else if ( replace_mode === 'title_only' ) {
b.description += ', ' + (badge.title || full_badge.title);
} else {
data.badges[i] = {type: klass, name: '', description: desc};
}
replaced = true;
break;
}
}
if ( replaced )
continue;
}
badges_out.push([(insert_at == -1 ? 1 : -1) * slot, {type: klass, name: "", description: desc}]);
}
badges_out.sort(function(a,b){return a[0] - b[0]});
if ( insert_at == -1 ) {
while(badges_out.length)
data.badges.push(badges_out.shift()[1]);
} else {
while(badges_out.length)
data.badges.insertAt(insert_at, badges_out.shift()[1]);
}
}
// --------------------
// Badge Loading
// --------------------
FFZ.bttv_known_bots = ["nightbot","moobot","sourbot","xanbot","manabot","mtgbot","ackbot","baconrobot","tardisbot","deejbot","valuebot","stahpbot"];
FFZ.prototype.load_badges = function(callback, tries) {
var f = this;
jQuery.getJSON(constants.API_SERVER + "v1/badges")
.done(function(data) {
var badge_total = 0,
badge_count = 0,
badge_data = {};
for(var i=0; i < data.badges.length; i++) {
var badge = data.badges[i];
if ( badge && badge.name ) {
f._load_badge_json(badge.id, badge);
badge_total++;
}
}
if ( data.users )
for(var badge_id in data.users)
if ( data.users.hasOwnProperty(badge_id) && f.badges[badge_id] ) {
var badge = f.badges[badge_id],
users = data.users[badge_id];
badge_data[badge.name] = users.length;
for(var i=0; i < users.length; i++) {
var user = users[i],
ud = f.users[user] = f.users[user] || {},
badges = ud.badges = ud.badges || {};
badge_count++;
badges[badge.slot] = {id: badge.id};
}
f.log('Added "' + badge.name + '" badge to ' + utils.number_commas(users.length) + ' users.');
}
// Special Badges
var zw = f.users.zenwan = f.users.zenwan || {},
badges = zw.badges = zw.badges || {};
if ( ! badges[1] )
badge_count++;
badges[1] = {id: 2, image: "//cdn.frankerfacez.com/script/momiglee_badge.png", title: "WAN"};
f.log("Loaded " + utils.number_commas(badge_count) + " total badges across " + badge_total + " types.");
typeof callback === "function" && callback(true, badge_count, badge_total, badge_data);
}).fail(function(data) {
if ( data.status === 404 )
return typeof callback === "function" && callback(false);
tries = (tries || 0) + 1;
if ( tries < 10 )
return setTimeout(f.load_badges.bind(f, callback, tries), 500 + 500*tries);
f.error("Unable to load badge data. [HTTP Status " + data.status + "]", data);
typeof callback === "function" && callback(false);
});
}
FFZ.prototype._load_badge_json = function(badge_id, data) {
this.badges[badge_id] = data;
if ( data.replaces ) {
data.replaces_type = data.replaces;
data.replaces = true;
}
if ( data.name === 'bot' )
data.visible = function(r,user) { return !(this.has_bttv && FFZ.bttv_known_bots.indexOf(user)!==-1); };
if ( data.name === 'developer' || data.name === 'supporter' )
data.click_url = 'https://www.frankerfacez.com/donate';
utils.update_css(this._badge_style, badge_id, utils.badge_css(data));
}

View file

@ -1,531 +0,0 @@
var FFZ = window.FrankerFaceZ,
constants = require('./constants'),
utils = require('./utils'),
KNOWN_COMMANDS = ['ffz', 'unban', 'ban', 'timeout', 'r9kbeta', 'r9kbetaoff', 'slow', 'slowoff', 'subscribers', 'subscribersoff', 'mod', 'unmod', 'me', 'emoteonly', 'emoteonlyoff', 'host', 'unhost', 'commercial'],
STATUS_CODES = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
500: "Internal Server Error"
},
format_result = function(response) {
if ( typeof response === "string" )
return response;
else if ( Array.isArray(response) )
return _.map(response, format_result).join(", ");
return JSON.stringify(response);
},
ObjectPath = require('./ObjectPath');
FFZ.ObjectPath = ObjectPath;
// -----------------
// Settings
// -----------------
FFZ.settings_info.command_aliases = {
type: "button",
value: [],
category: "Chat Moderation",
no_bttv: 6,
name: "Command Aliases",
help: "Define custom commands for chat that are shortcuts for other commands or messages to send in chat.",
on_update: function() {
this.cache_command_aliases();
},
method: function() {
var f = this,
old_val = [],
input = utils.createElement('textarea');
input.style.marginBottom = '20px';
for(var i=0; i < this.settings.command_aliases.length; i++) {
var pair = this.settings.command_aliases[i],
name = pair[0],
command = pair[1],
label = pair[2];
old_val.push(name + (label ? ' ' + label : '') + '=' + command);
}
utils.prompt(
"Command Aliases",
"Please enter a list of custom commands that you would like to use in Twitch chat. " +
"One item per line. To send multiple commands, separate them with <code>&lt;LINE&gt;</code>. " +
"Variables, such as arguments you provide running the custom command, can be inserted into the output.<hr>" +
"All custom commands require names. Names go at the start of each line, and are separated from " +
"the actual command by an equals sign. Do not include the leading slash or dot. Those are automatically included. " +
"You can also include a description of the arguments after the name but before the equals-sign " +
"to include a helpful reminder when using tab-completion with the command.<br>" +
"<strong>Example:</strong> <code>boop &lt;user&gt;=/timeout {0} 15 Boop!</code><hr>" +
"<code>{0}</code>, <code>{1}</code>, <code>{2}</code>, etc. will be replaced with any arguments you've supplied. " +
"Follow an argument index with a <code>$</code> to also include all remaining arguments.<br>" +
"<strong>Example:</strong> <code>boop=/timeout {0} 15 {1$}</code><hr>" +
"<strong>Allowed Variables</strong><br><table><tbody>" +
"<tr><td><code>{room}</code></td><td>chat room's name</td>" +
"<td><code>{room_name}</code></td><td>chat room's name</td></tr>" +
"<tr><td><code>{room_display_name}</code></td><td>chat room's display name</td>" +
"<td><code>{room_id}</code></td><td>chat room's numeric ID</td></tr>" +
"</tbody></table>",
old_val.join("\n"),
function(new_val) {
if ( new_val === null || new_val === undefined )
return;
var vals = new_val.trim().split(/\s*\n\s*/g),
output = [];
for(var i=0; i < vals.length; i++) {
var cmd = vals[i];
if ( cmd.charAt(0) === '.' || cmd.charAt(0) === '/' )
cmd = cmd.substr(1);
var name,
label,
name_match = /^([^=]+)=/.exec(cmd);
if ( ! cmd || ! cmd.length )
continue;
if ( name_match ) {
var ind = name_match[1].indexOf(' ');
if ( ind === -1 ) {
name = name_match[1].toLowerCase();
label = null;
} else {
name = name_match[1].substr(0,ind).toLowerCase();
label = name_match[1].substr(ind).trim();
}
cmd = cmd.substr(name_match[0].length);
}
output.push([name, cmd, label]);
}
f.settings.set("command_aliases", output);
}, 600, input);
}
};
FFZ.prototype._command_aliases = {};
FFZ.prototype.cache_command_aliases = function() {
var aliases = this._command_aliases = {};
for(var i=0; i < this.settings.command_aliases.length; i++) {
var pair = this.settings.command_aliases[i],
name = pair[0],
command = pair[1],
label = pair[2];
// Skip taken/invalid names.
if ( ! name || ! name.length || aliases[name] || KNOWN_COMMANDS.indexOf(name) !== -1 )
continue;
aliases[name] = [command, label];
}
if ( this._inputv )
Ember.propertyDidChange(this._inputv, 'ffz_commands');
}
// -----------------
// Log Export
// -----------------
FFZ.ffz_commands.log = function(room, args) {
var f = this;
this.get_debugging_info().then(function(result) {
f._pastebin(result).then(function(url) {
f.room_message(room, "Your FrankerFaceZ logs have been pasted to: " + url);
}).catch(function() {
f.room_message(room, "An error occured uploading the logs to a pastebin.");
});
});
};
// -----------------
// Data Reload
// -----------------
FFZ.ffz_commands.reload = function(room, args) {
var f = this,
promises = [];
// Feature Friday. There's no feedback possible so don't use a promise.
this.check_ff();
// Badge Information
promises.push(new Promise(function(done, fail) {
f.load_badges(function(success, badge_count, badge_total, badge_data) {
done(success ? [badge_count, badge_total, badge_data] : [0, 0, {}]);
});
}));
// Emote Sets
for(var set_id in this.emote_sets) {
var es = this.emote_sets[set_id];
if ( ! es || es.hasOwnProperty('source_ext') )
continue;
promises.push(new Promise(function(done, fail) {
f.load_set(set_id, done);
}));
}
// Do it!
Promise.all(promises).then(function(results) {
try {
var success = 0,
badge_count = results[0][0],
badge_total = results[0][1],
badges = results[0][2],
total = results.length - 1,
badge_string = [];
if ( results.length > 1 ) {
for(var i=1; i < results.length; i++) {
if ( results[i] )
success++;
}
}
for(var key in badges) {
if ( badges.hasOwnProperty(key) )
badge_string.push(key + ': ' + badges[key])
}
f.room_message(room, "Loaded " + utils.number_commas(badge_count) + " badge" + utils.pluralize(badge_count) + " across " + utils.number_commas(badge_total) + " badge type" + utils.pluralize(badge_total) + (badge_string.length ? " (" + badge_string.join(", ") + ")" : "") + ". Successfully reloaded " + utils.number_commas(success) + " of " + utils.number_commas(total) + " emoticon set" + utils.pluralize(total) + ".");
} catch(err) {
f.room_message(room, "An error occured running the command.");
f.error("Error Running FFZ Reload", err);
}
})
}
// -----------------
// Moderation Cards
// -----------------
FFZ.chat_commands.card = function(room, args) {
if ( ! args || ! args.length || args.length > 1 )
return "Usage: /card <username>";
if ( ! this._roomv )
return "An error occured. (We don't have the Room View.)";
// Get the position of the input box.
var el = this._roomv.get('element'),
ta = el && el.querySelector('textarea'),
bounds = ta && ta.getBoundingClientRect(),
x = 0, y = 0, bottom, right;
if ( ! bounds )
bounds = el && el.getBoundingClientRect() || document.body.getBoundingClientRect();
if ( bounds ) {
if ( bounds.left > 400 ) {
right = bounds.left - 40;
bottom = bounds.top + bounds.height;
} else {
x = bounds.left - 20;
bottom = bounds.top - 20;
}
}
this._roomv.actions.showModOverlay.call(this._roomv, {
top: y,
left: x,
bottom: bottom,
right: right,
sender: args[0]
});
}
FFZ.chat_commands.card.label = '/card &lt;user&gt;';
FFZ.chat_commands.card.info = 'Open Moderation Card';
FFZ.chat_commands.rules = function(room, args) {
var f = this,
r = room.room;
r.waitForRoomProperties().then(function() {
var rules = r.get("roomProperties.chat_rules");
if ( ! rules || ! rules.length )
return f.room_message(room, "This chat room does not have rules set.");
r.set("chatRules", rules);
r.set("shouldDisplayChatRules", true);
});
}
FFZ.chat_commands.rules.info = 'Show Chat Room Rules';
FFZ.chat_commands.open_link = function(room, args) {
if ( ! args || ! args.length )
return "Usage: /open_link <url>";
var wnd = window.open(args.join(" "), "_blank");
wnd.opener = null;
}
FFZ.chat_commands.open_link.label = '/open_link &lt;url&gt;';
FFZ.chat_commands.open_link.info = 'Open URL in Tab';
FFZ.chat_commands.fetch_link = function(room, args) {
if ( ! args || ! args.length )
return "Usage: /fetch_link <url> [template]\nTemplates use http://objectpath.org/ to format data. Default Template is \"Response: #$#\"";
var f = this,
url = args.shift(),
headers = {};
if ( /https?:\/\/[^.]+\.twitch\.tv\//.test(url) )
headers['Client-ID'] = constants.CLIENT_ID;
jQuery.ajax({
url: url,
headers: headers,
success: function(data) {
f.log("Response Received", data);
args = (args && args.length) ? args.join(" ").split(/#/g) : ["Response: ", "$"];
if ( typeof data === "string" )
data = [data];
var is_special = true,
output = [],
op = new ObjectPath(data);
for(var i=0; i < args.length; i++) {
var segment = args[i];
is_special = !is_special;
if ( ! is_special )
output.push(segment);
else
try {
output.push(format_result(op.execute(segment)));
} catch(err) {
f.log("Error", err);
output.push("[Error: " + (err.message || err) + "]");
}
}
f.room_message(room, output.join(''));
},
error: function(xhr) {
f.log("Request Error", xhr);
f.room_message(room, "Request Failed: " + (xhr.status === 0 ? 'Unknown Error. ' + (url.indexOf('https') === -1 ? 'Please make sure you\'re making HTTPS requests.' : 'Likely a CORS problem. Check your browser\'s Networking console for more.') : xhr.status + ' ' + (STATUS_CODES[xhr.status] || '' )));
}
});
}
FFZ.chat_commands.fetch_link.label = '/fetch_link &lt;url&gt; <i>[template]</i>';
FFZ.chat_commands.fetch_link.info = 'Fetch URL and Display in Chat';
// --------------------------
// Message-Specific Deletion
// --------------------------
FFZ.chat_commands.timeout_message = function(room, args) {
var username = args.shift(),
target_message = args.shift(),
duration = args.shift(),
reason = args.join(' ');
if ( ! username || ! target_message )
return 'Usage: /timeout_message <username> <target-message-id> [duration=600] [reason]\nTimeout a user and provide the ID of a specific message to remove.';
room.room.sendTags('/timeout ' + username + ' ' + (duration || '600') + (reason ? ' ' + reason : ''), {
'target-message-id': target_message
});
}
// ---------------------
// Group Chat Renaming
// ---------------------
FFZ.chat_commands.renamegroup = function(room, args) {
var f = this,
new_name = args.join(' ');
if ( ! new_name.length )
return "Usage: /renamegroup <name>\nGroup owner only. Rename a group chat.";
// Check that the length of the arguments is less than 120 bytes
else if ( utils.utf8_encode(new_name).length > 120 )
return "You entered a room name that is too long.";
// Set the group name
room.room.tmiRoom.session._depotApi.put("/rooms/" + room.id, {
display_name: new_name
}).then(function(result) {
if ( result && result.room && result.room.display_name === new_name )
f.room_message(room, 'The room was renamed to: ' + new_name);
else
f.room_message(room, 'The room name was not changed successfully.')
});
}
FFZ.chat_commands.renamegroup.label = '/renamegroup &lt;name&gt;';
FFZ.chat_commands.renamegroup.info = 'Rename a group chat. Group owner only.'
FFZ.chat_commands.renamegroup.enabled = function(room) {
// Are we in a group chat and are we the owner?
return room && room.room && room.room.get('isGroupRoom') && room.room.get('isOwner');
}
// -----------------
// Promoted Messages
// -----------------
FFZ.ffz_commands.promote = function(room, args) {
args = args.join(" ").trim();
var f = this;
if ( ! args.length || args.indexOf(' ') !== -1 )
return "You must provide only a single message ID. This is easiest to add as a custom in-line moderation action.";
if ( ! this.ws_send("promote_message", [room.id, args], function(success, data) {
if ( ! success ) {
f.log("Promotion Error: " + JSON.stringify(data));
f.room_message(room, "There was an error promoting the message.");
}
}) )
return "There was an error communicating with the server.";
}
// -----------------
// Mass Moderation
// -----------------
FFZ.ffz_commands.massunmod = function(room, args) {
args = args.join(" ").trim();
if ( ! args.length )
return "You must provide a list of users to unmod.";
args = args.split(/\W*,\W*/);
var user = this.get_user();
if ( ! user || ! user.login == room.id )
return "You must be the broadcaster to use massunmod.";
if ( args.length > 50 )
return "Each user you unmod counts as a single message. To avoid being globally banned, please limit yourself to 50 at a time and wait between uses.";
var count = args.length;
while(args.length) {
var name = args.shift();
room.room.tmiRoom.sendMessage("/unmod " + name);
}
return "Sent unmod command for " + count + " users.";
}
FFZ.ffz_commands.massunmod.help = "Usage: /ffz massunmod <list, of, users>\nBroadcaster only. Unmod all the users in the provided list.";
FFZ.ffz_commands.massmod = function(room, args) {
args = args.join(" ").trim();
if ( ! args.length )
return "You must provide a list of users to mod.";
args = args.split(/\W*,\W*/);
var user = this.get_user();
if ( ! user || ! user.login == room.id )
return "You must be the broadcaster to use massmod.";
if ( args.length > 50 )
return "Each user you mod counts as a single message. To avoid being globally banned, please limit yourself to 50 at a time and wait between uses.";
var count = args.length;
while(args.length) {
var name = args.shift();
room.room.tmiRoom.sendMessage("/mod " + name);
}
return "Sent mod command for " + count + " users.";
}
FFZ.ffz_commands.massmod.help = "Usage: /ffz massmod <list, of, users>\nBroadcaster only. Mod all the users in the provided list.";
// -----------------
// Mass Unbanning
// -----------------
FFZ.prototype.get_banned_users = function() {
var f = this;
return new Promise(function(succeed, fail) {
var user = f.get_user();
if ( ! user )
return fail();
jQuery.get("/settings/channel").done(function(data) {
try {
var dom = new DOMParser().parseFromString(data, 'text/html'),
users = _.pluck(dom.querySelectorAll('.ban .obj'), 'textContent');
succeed(_.map(users, function(x) { return x.trim() }));
} catch(err) {
f.error("Failed to parse banned users", err);
fail();
}
}).fail(function(err) {
f.error("Failed to load banned users", err);
fail();
})
});
}
/*FFZ.ffz_commands.massunban = function(room, args) {
var user = this.get_user();
if ( ! user || (user.login !== room.id && ! user.is_admin && ! user.is_staff) )
return "You must be the broadcaster to use massunban.";
}*/
/*FFZ.ffz_commands.massunban = function(room, args) {
args = args.join(" ").trim();
}*/

File diff suppressed because one or more lines are too long

View file

@ -1,38 +0,0 @@
var FFZ = window.FrankerFaceZ;
// -----------------------
// Developer Mode
// -----------------------
FFZ.settings_info.developer_mode = {
type: "boolean",
value: false,
storage_key: "ffzDebugMode",
visible: function() { return this.settings.developer_mode || (Date.now() - parseInt(localStorage.ffzLastDevMode || "0")) < 604800000; },
category: "Debugging",
name: "Developer Mode",
help: "Load FrankerFaceZ from the local development server instead of the CDN. Please refresh after changing this setting.",
on_update: function() {
localStorage.ffzLastDevMode = Date.now();
}
};
FFZ.ffz_commands.developer_mode = function(room, args) {
var enabled, args = args && args.length ? args[0].toLowerCase() : null;
if ( args == "y" || args == "yes" || args == "true" || args == "on" )
enabled = true;
else if ( args == "n" || args == "no" || args == "false" || args == "off" )
enabled = false;
if ( enabled === undefined )
return "Developer Mode is currently " + (this.settings.developer_mode ? "enabled." : "disabled.");
this.settings.set("developer_mode", enabled);
return "Developer Mode is now " + (enabled ? "enabled" : "disabled") + ". Please refresh your browser.";
}
FFZ.ffz_commands.developer_mode.help = "Usage: /ffz developer_mode <on|off>\nEnable or disable Developer Mode. When Developer Mode is enabled, the script will be reloaded from //localhost:8000/script.js instead of from the CDN.";

View file

@ -1,335 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// --------------------
// Settings
// --------------------
FFZ.settings_info.collect_bits = {
type: "select",
options: {
0: "Disabled",
1: "Grouped by Type",
2: "All in One"
},
value: 0,
process_value: utils.process_int(0),
category: "Chat Appearance",
no_bttv: 6,
name: "Bits Stacking",
help: "Collect all the bits emoticons in a message into a single one at the start of the message."
};
FFZ.settings_info.bits_animated = {
type: "boolean",
value: true,
category: "Chat Appearance",
no_bttv: 6,
name: "Bits Animation",
help: "Display bits with animation.",
on_update: function() {
var bits = utils.ember_lookup('service:bits-emotes') ||
utils.ember_lookup('service:bits-rendering-config');
if ( bits && bits.ffz_has_css )
bits.ffz_update_css();
}
}
FFZ.settings_info.bits_tags_container = {
type: "boolean",
value: true,
category: "Chat Appearance",
no_bttv: 6,
name: "Bits Tag Display",
help: "Display competitive bits tags at the top of chats that have it enabled.",
on_update: utils.toggle_cls('ffz-show-bits-tags')
}
FFZ.settings_info.bits_pinned = {
type: "select",
options: {
0: "Disabled",
1: "Show Recent",
2: "Show Top",
3: "Show All (Default)"
},
value: 3,
process_value: utils.process_int(3, 0, 3),
category: "Chat Appearance",
name: "Display Pinned Cheers",
help: "Show pinned messages with bits at the top of chat in channels that have it enabled.",
on_update: function(val) {
var PinnedCheers = utils.ember_lookup('service:bits-pinned-cheers');
if ( val === 3 || ! PinnedCheers )
return;
if ( val !== 1 )
PinnedCheers.set('recentPinnedCheer', null);
if ( val !== 2 )
PinnedCheers.set('topPinnedCheer', null);
}
}
FFZ.settings_info.bits_redesign = {
type: "boolean",
value: false,
category: "Chat Appearance",
name: "Bits Redesign",
help: "Use the special cheering animations from April 1st, 2017.",
on_update: function() {
var bits = utils.ember_lookup('service:bits-emotes') ||
utils.ember_lookup('service:bits-rendering-config');
if ( bits && bits.ffz_has_css )
bits.ffz_update_css();
}
}
// --------------------
// Initialization
// --------------------
var redesign = function(x) { return x.replace('/actions/cheer/', '/actions/cheer-redesign/') };
FFZ.prototype.setup_bits = function() {
utils.toggle_cls('ffz-show-bits-tags')(this.settings.bits_tags_container);
this.update_views('component:bits/chat-token', this._modify_bits_token);
var f = this,
Service = utils.ember_lookup('service:bits-emotes'),
PinnedCheers = utils.ember_lookup('service:bits-pinned-cheers'),
image_css = function(images) {
var im_1 = images[1],
im_2 = images[2],
im_4 = images[4];
if ( f.settings.bits_redesign ) {
im_1 = redesign(im_1);
im_2 = redesign(im_2);
im_4 = redesign(im_4);
}
return 'background-image: url("' + im_1 + '");' +
'background-image: ' + (constants.IS_WEBKIT ? ' -webkit-' : '') + 'image-set(' +
'url("' + im_1 + '") 1x, url("' + im_2 + '") 2x, url("' + im_4 + '") 4x);';
},
tier_css = function(ind, prefix, tier) {
var selector = '.ffz-bit.bit-prefix-' + prefix + '.bit-tier-' + ind,
color = f._handle_color(tier.color),
animated = f.settings.bits_animated,
output;
output = selector + '{' +
'color: ' + color[0] + ';' +
this._ffz_image_css(tier.images.light[animated ? 'animated' : 'static']) +
'}';
return output + '.tipsy ' + selector + ',.dark ' + selector + ',.force-dark ' + selector + ',.theatre ' + selector + '{' +
'color: ' + color[1] + ';' +
this._ffz_image_css(tier.images.dark[animated ? 'animated' : 'static']) +
'}';
};
if ( PinnedCheers ) {
PinnedCheers.reopen({
_updatePinnedCheerData: function(data) {
var setting = f.settings.bits_pinned;
if ( setting < 2 )
data.top = null;
else if ( data.top )
data.top.is_pinned_cheer = 2;
if ( setting !== 3 && setting !== 1 )
data.recent = null;
else if ( data.recent )
data.recent.is_pinned_cheer = true;
return this._super(data);
}
});
FFZ.settings_info.bits_pinned.on_update.call(this, this.settings.bits_pinned);
}
if ( Service ) {
Service.reopen({
ffz_has_css: false,
ffz_get_tier: function(prefix, amount) {
if ( ! this.ffz_has_css )
this.ffz_update_css();
var config = this.getPrefixData(prefix) || {},
tiers = config.tiers || [],
tier = null,
index = null;
for(var i=0, l = tiers.length; i < l; i++) {
var t = tiers[i];
if ( amount < t.min_bits )
break;
tier = t;
index = i;
}
return [index, tier];
},
ffz_get_preview: function(prefix, amount) {
var src = this.getImageSrc(amount, prefix, true, !f.settings.bits_animated, 4);
return f.settings.bits_redesign ? redesign(src) : src;
},
_ffz_image_css: image_css,
_ffz_tier_css: tier_css,
ffz_update_css: function() {
var output = [],
prefixes = _.map(this.get('regexes') || [], function(x) {
return x && x.prefix || null;
});
for(var i=0; i < prefixes.length; i++) {
var prefix = prefixes[i],
data = prefix && this.getPrefixData(prefix);
if ( ! data )
continue;
var tiers = data && data.tiers || [];
for(var x=0; x < tiers.length; x++)
output.push(this._ffz_tier_css(x, prefix, tiers[x]));
}
utils.update_css(f._chat_style, 'bit-styles', output.join(''));
this.ffz_has_css = true;
}.observes('emoteConfig', 'regexes')
});
} else {
this.log("Unable to find the Ember service:bits-emotes. Falling back...");
Service = utils.ember_lookup('service:bits-rendering-config');
if ( ! Service )
return this.error("Unable to locate the Ember service:bits-rendering-config");
Service.reopen({
ffz_has_css: false,
ffz_get_tier: function(prefix, amount) {
if ( ! this.get('isLoaded') ) {
this._actionPromiseCache = false;
this.loadRenderConfig();
} else if ( ! this.ffz_has_css )
this.ffz_update_css();
var config = this._getConfigPrefix(prefix) || {},
tiers = config.tiers || [],
tier = null,
index = null;
for(var i=0, l = tiers.length; i < l; i++) {
var t = tiers[i];
if ( amount < t.min_bits )
break;
tier = t;
index = i;
}
return [index, tier];
},
ffz_get_preview: function(prefix, amount) {
var data = this.ffz_get_tier(prefix, amount),
tier = data && data[1],
src = tier ? this._constructImageSrc([4], tier, {
background: 'dark',
scale: 4,
state: f.settings.bits_animated ? 'animated' : 'static'
}).src : '';
return f.settings.bits_redesign ? redesign(src) : src;
},
_ffz_image_css: image_css,
_ffz_tier_css: tier_css,
ffz_update_css: function() {
var output = [],
config = this.get('config') || {prefixes: []};
for(var i=0; i < config.prefixes.length; i++) {
var prefix = config.prefixes[i],
data = this._getConfigPrefix(prefix),
tiers = data && data.tiers || [];
for(var x=0; x < tiers.length; x++)
output.push(this._ffz_tier_css(x, prefix, tiers[x]));
}
utils.update_css(f._chat_style, 'bit-styles', output.join(''));
this.ffz_has_css = true;
}.observes('config'),
loadRenderConfig: function() {
var out = this._super();
if ( ! this.get('config') )
this._actionPromiseCache = false;
return out;
}
});
}
if ( Service.get('isLoaded') && Service.loadRenderConfig )
Service.loadRenderConfig();
}
FFZ.prototype._modify_bits_token = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
this.ffzRender();
},
ffzRender: function() {
var el = this.get('element'),
prefix = this.get('prefix'),
amount = this.get('amount');
el.innerHTML = f.render_token(false, false, true, {
type: 'bits',
amount: amount,
prefix: prefix
});
}.observes('prefix', 'amount')
})
}

View file

@ -1,754 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_channel = function() {
// Style Stuff!
this.log("Creating channel style element.");
var s = this._channel_style = document.createElement("style");
s.id = "ffz-channel-css";
document.head.appendChild(s);
// Settings stuff!
document.body.classList.toggle("ffz-hide-view-count", !this.settings.channel_views);
document.body.classList.toggle('ffz-theater-stats', this.settings.theater_stats === 2);
document.body.classList.toggle('ffz-theater-basic-stats', this.settings.theater_stats > 0);
var banner_hidden = this.settings.hide_channel_banner;
banner_hidden = banner_hidden === 1 ? this.settings.channel_bar_bottom : banner_hidden > 0;
utils.toggle_cls('ffz-hide-channel-banner')(banner_hidden);
utils.toggle_cls('ffz-channel-bar-bottom')(this.settings.channel_bar_bottom);
utils.toggle_cls('ffz-minimal-channel-title')(this.settings.channel_title_top === 2);
utils.toggle_cls('ffz-channel-title-top')(this.settings.channel_title_top > 0);
utils.toggle_cls('ffz-minimal-channel-bar')(this.settings.channel_bar_collapse);
this.log("Hooking the Ember Channel Index redesign.");
this.update_views('component:channel-redesign', this.modify_channel_redesign);
this.update_views('component:channel-redesign/live', this.modify_channel_live);
this.update_views('component:share-box', this.modify_channel_share_box);
this.update_views('component:channel-options', this.modify_channel_options);
this.update_views('component:edit-broadcast-link', this.modify_channel_broadcast_link);
/*this.log("Hooking the Ember Channel Index component.");
if ( ! this.update_views('component:legacy-channel', this.modify_channel_index) )
return;*/
var f = this,
Channel = utils.ember_lookup('controller:channel');
if ( ! Channel )
return f.error("Unable to find the Ember Channel controller");
this.log("Hooking the Ember Channel controller.");
Channel.reopen({
ffzUpdateInfo: function() {
if ( this._ffz_update_timer )
clearTimeout(this._ffz_update_timer);
this._ffz_update_timer = setTimeout(this.ffzCheckUpdate.bind(this), 55000 + (Math.random() * 10000));
}.observes("channelModel", "channelModel.hostModeTarget"),
ffzCheckUpdate: function() {
this.ffzUpdateInfo();
this._ffzUpdateModel(this.get('channelModel'));
this._ffzUpdateModel(this.get('channelModel.hostModeTarget'), true);
},
_ffzUpdateModel: function(model, is_host) {
var channel_id = model && model.get('id');
if ( ! channel_id || model.get('isLoading') )
return;
utils.api.get("streams/" + channel_id, {}, {version: 3})
.done(function(data) {
// If there's no stream, we can't update much.
if ( ! data || ! data.stream ) {
model.set('stream.createdAt', null);
model.set('stream.viewers', 0);
return;
}
model.set('stream.createdAt', utils.parse_date(data.stream.created_at));
model.set('stream.viewers', data.stream.viewers || 0);
model.set('stream.game', data.stream.game);
if ( data.stream.channel ) {
var info = data.stream.channel;
model.set('game', info.game);
model.set('status', info.status);
model.set('views', info.views);
if ( model.get('followers.isFulfilled') )
model.set('followers.content.meta.total', info.followers);
}
if ( f._cindex )
if ( is_host )
f._cindex.ffzFixHostTitle();
else
f._cindex.ffzFixTitle();
});
},
ffzHostTarget: function() {
var target = this.get('channelModel.hostModeTarget'),
name = target && target.get('name'),
id = target && target.get('id'),
display_name = target && target.get('display_name');
if ( display_name && display_name !== 'jtv' )
FFZ.capitalization[name] = [display_name, Date.now()];
if ( f._chatv )
f._chatv.ffzUpdateHost(target);
if ( f._cindex )
f._cindex.ffzUpdateMetadata();
}.observes("channelModel.hostModeTarget")
});
Channel.ffzUpdateInfo();
}
// These have to be done in order to ensure the channel metadata all sorts correctly.
FFZ.prototype.modify_channel_share_box = function(view) {
utils.ember_reopen_view(view, {
ffz_init: function() {
this.get('element').classList.toggle('ffz-share-box', true)
}
});
}
FFZ.prototype.modify_channel_options = function(view) {
utils.ember_reopen_view(view, {
ffz_init: function() {
this.get('element').classList.toggle('ffz-channel-options', true)
}
});
}
FFZ.prototype.modify_channel_broadcast_link = function(view) {
utils.ember_reopen_view(view, {
ffz_init: function() {
this.get('element').classList.toggle('ffz-channel-broadcast-link', true)
}
});
}
// Channel Live
FFZ.prototype.modify_channel_live = function(view) {
var f = this;
utils.ember_reopen_view(view, {
ffz_host: null,
ffz_init: function() {
var channel_id = this.get("channel.id"),
el = this.get("element");
f._cindex = this;
f.ws_sub("channel." + channel_id);
this.ffzUpdateAttributes();
this.ffzFixTitle();
this.ffzFixHostTitle();
this.ffzUpdateMetadata();
if ( f.settings.auto_theater ) {
var layout = this.get('layout'),
func = function(tries) {
var player = f._player && f._player.get('player');
if ( ! player || typeof player.isLoading === 'function' && player.isLoading() )
return (tries||0) < 20 ? setTimeout(func.bind(this, (tries||0) + 1), 500) : null;
// In case this happens before the event bindings are in, we just set
// the layout into theater mode manually.
player.setTheatre(true);
layout.setTheatreMode(true);
}
func();
}
this.$().on("click", ".ffz-creative-tag-link", utils.transition_link(function(e) {
utils.transition('directory.creative.hashtag.index', this.getAttribute('data-tag'));
}));
var t = this;
this.$('.player-placeholder').on('click', function() { t.updatePlayerPosition() })
if ( this.updatePlayerPosition ) {
this._ffz_loaded = Date.now();
this._ffz_player_repositoner = setInterval(this.ffzUpdatePlayerPosition.bind(this), 250);
}
},
ffzUpdatePlayerPosition: function() {
if ( f.has_bttv || this._ffz_player_repositoner && Date.now() - this._ffz_loaded > 60000 ) {
clearInterval(this._ffz_player_repositoner);
this._ffz_player_repositoner = null;
if ( f.has_bttv )
return;
}
this.updatePlayerPosition();
},
ffzUpdateAttributes: function() {
var channel_id = this.get("channel.id"),
hosted_id = this.get("channel.hostModeTarget.id"),
el = this.get("element");
if ( hosted_id !== this.ffz_host ) {
if ( this.ffz_host )
f.ws_unsub("channel." + this.ffz_host);
if ( hosted_id )
f.ws_sub("channel." + hosted_id);
this.ffz_host = hosted_id;
// Destroy the popup if we have a metadata popup.
if ( f._popup && f._popup.id === 'ffz-metadata-popup' )
f.close_popup();
if ( hosted_id )
this.ffzFixHostTitle();
this.ffzUpdateMetadata();
}
el.classList.add('ffz-channel');
el.classList.toggle('ffz-host', hosted_id || false);
el.setAttribute('data-channel', channel_id || '');
el.setAttribute('data-hosted', hosted_id || '');
}.observes('channel.id', 'channel.hostModeTarget'),
ffz_destroy: function() {
var channel_id = this.get("channel.id"),
el = this.get("element");
if ( channel_id )
f.ws_unsub("channel." + channel_id);
if ( this.ffz_host ) {
f.ws_unsub("channel." + this.ffz_host);
this.ffz_host = null;
}
var timers = this.ffz_timers || {};
for(var key in timers) {
var to = timers[key];
if ( to )
clearTimeout(to);
}
var popup = el.querySelector('#ffz-metadata-popup');
if ( popup && popup === f._popup )
f.close_popup();
if ( f._cindex === this )
f._cindex = null;
if ( this._fix_host_timer ) {
clearTimeout(this._fix_host_timer);
this._fix_host_timer = null;
}
if ( this._ffz_player_repositoner ) {
clearInterval(this._ffz_player_repositoner);
this._ffz_player_repositoner = null;
}
utils.update_css(f._channel_style, channel_id, null);
},
ffzFixHostTitle: function() {
if ( this._fix_host_timer ) {
clearTimeout(this._fix_host_timer);
this._fix_host_timer = null;
}
var channel = this.get('channel.hostModeTarget');
if ( ! channel || channel.get('isLoading') )
return;
var el = this.get('element'),
container = el && el.querySelector('.cn-hosting--top .card');
if ( ! container ) {
// Wait for the host bar to appear.
this._fix_host_timer = setTimeout(this.ffzFixHostTitle.bind(this), 250);
return;
}
var old_ui = container.querySelector('.ffz.card__layout');
if ( old_ui )
container.removeChild(old_ui);
var image = utils.createElement('img'),
figure = utils.createElement('figure', 'card__img card__img--avatar', image),
avatar_link = utils.createElement('a', '', figure),
user_link = utils.createElement('a', '', utils.sanitize(channel.get('displayName'))),
card_title = utils.createElement('h3', 'card__title'),
card_info = utils.createElement('p', 'card__info', 'Hosting '),
card_body = utils.createElement('div', 'card__body', card_title),
layout = utils.createElement('div', 'ffz card__layout', avatar_link);
card_info.appendChild(user_link);
layout.appendChild(card_body);
card_body.appendChild(card_info);
container.classList.add('ffz-host-info');
container.appendChild(layout);
var channel_id = channel.get('id'),
status = channel.get('status'),
game = channel.get('game'),
tokens = f.tokenize_line(channel_id, channel_id, status, true);
if ( game === 'Creative' )
tokens = f.tokenize_ctags(tokens);
if ( game ) {
var game_link = utils.createElement('a', '', utils.sanitize(game));
card_info.appendChild(document.createTextNode(game === 'Creative' ? ' being ' : ' playing '));
card_info.appendChild(game_link);
game_link.href = Twitch.uri.game(game);
game_link.addEventListener('click', utils.transition_link(utils.transition_game.bind(this, game)));
}
avatar_link.href = user_link.href = '/' + channel_id;
var user_handler = utils.transition_link(utils.transition_user.bind(this, channel_id));
avatar_link.addEventListener('click', user_handler);
user_link.addEventListener('click', user_handler);
image.src = channel.get('logo') || constants.NO_LOGO;
card_title.innerHTML = f.render_tokens(tokens);
}.observes('channel.hostModeTarget', 'channel.hostModeTarget.isLoading', 'channel.hostModeTarget.id', 'channel.hostModeTarget.status', 'channel.hostModeTarget.game'),
ffzFixTitle: function() {
if ( ! f.settings.stream_title )
return;
var channel_id = this.get("channel.id"),
status = this.get("channel.status"),
game = this.get("channel.game"),
tokens = f.tokenize_line(channel_id, channel_id, status, true);
if ( game === 'Creative' )
tokens = f.tokenize_ctags(tokens);
var el = this.$(".cn-metabar > div:first-child .js-card__title");
el && el.html(f.render_tokens(tokens));
}.observes('channel.id', 'channel.status', 'channel.game'),
ffzUpdateMetadata: function(key) {
var t = this,
keys = key ? [key] : Object.keys(FFZ.channel_metadata),
is_hosting = !!this.get('channel.hostModeTarget'),
basic_info = [this, this.get(is_hosting ? 'channel.hostModeTarget' : 'channel'), is_hosting, this.get('channel')],
timers = this.ffz_timers = this.ffz_timers || {},
refresh_func = this.ffzUpdateMetadata.bind(this),
container = this.get('element'),
metabar = container && container.querySelector(is_hosting ? '.cn-hosting--bottom' : '.cn-metabar__more');
// Stop once this is destroyed.
if ( this.isDestroyed || ! metabar )
return;
for(var i=0; i < keys.length; i++)
f.render_metadata(keys[i], basic_info, metabar, timers, refresh_func, is_hosting);
},
ffzUpdateHostButton: function() {
this.set('ffz_host_updating', false);
return this.ffzUpdateMetadata('host');
}.observes('channel.id', 'channel.hostModeTarget.id')
});
}
FFZ.prototype.modify_channel_redesign = function(view) {
var f = this,
Layout = utils.ember_lookup('service:layout');
utils.ember_reopen_view(view, {
ffz_init: function() {
// Twitch y u make me do dis
// (If this isn't the outer channel-redesign abort)
if ( this.parentView instanceof view )
return;
var channel_id = this.get("channel.id"),
el = this.get("element");
f._credesign = this;
this.ffzUpdateCoverHeight();
el.setAttribute('data-channel', channel_id);
el.classList.add('ffz-channel-container');
},
ffz_destroy: function() {
var channel_id = this.get("channel.id"),
el = this.get("element");
el.setAttribute('data-channel', '');
el.classList.remove('ffz-channel-container');
if ( f._credesign === this )
f._credesign = null;
},
ffzUpdateCoverHeight: function() {
var old_height = this.channelCoverHeight,
setting = f.settings.hide_channel_banner,
banner_hidden = setting === 1 ? f.settings.channel_bar_bottom : setting > 0,
new_height = banner_hidden ? 0 : 380;
this.channelCoverHeight = new_height;
this.$("#channel").toggleClass('ffz-bar-fixed', this.get('isFixed'));
if ( this.$scrollContainer && old_height !== new_height )
this.scrollTo(this.$scrollContainer.scrollTop() + (new_height - old_height));
if ( this.updatePlayerPosition )
setTimeout(this.updatePlayerPosition.bind(this));
}.observes('isFixed')
})
}
// ---------------
// Settings
// ---------------
FFZ.settings_info.auto_theater = {
type: "boolean",
value: false,
category: "Appearance",
no_mobile: true,
no_bttv: true,
name: "Automatic Theater Mode",
help: "Automatically enter theater mode when opening a channel."
};
FFZ.settings_info.chatter_count = {
type: "boolean",
value: false,
no_mobile: true,
category: "Channel Metadata",
name: "Chatter Count",
help: "Display the current number of users connected to chat beneath the channel.",
on_update: function(val) {
if ( ! this.rooms )
return;
// Refresh the data.
for(var room_id in this.rooms) {
var r = this.rooms[room_id] && this.rooms[room_id].room;
r && r.ffzInitChatterCount();
}
if ( this._cindex )
this._cindex.ffzUpdateMetadata('chatters');
}
};
FFZ.settings_info.channel_views = {
type: "boolean",
value: true,
no_mobile: true,
category: "Channel Metadata",
name: "Channel Views",
help: 'Display the number of times the channel has been viewed beneath the stream.',
on_update: function(val) {
document.body.classList.toggle("ffz-hide-view-count", !val);
}
};
FFZ.settings_info.hosted_channels = {
type: "boolean",
value: true,
no_mobile: true,
category: "Channel Metadata",
name: "Channel Hosting",
help: "Display other channels that have been featured by the current channel.",
on_update: function(val) {
var cb = document.querySelector('input.ffz-setting-hosted-channels');
if ( cb )
cb.checked = val;
var Chat = utils.ember_lookup('controller:chat'),
room = Chat && Chat.get('currentChannelRoom');
if ( room )
room.setHostMode({
hostTarget: room.ffz_host_target,
recentlyJoined: true
});
}
};
FFZ.settings_info.stream_host_button = {
type: "boolean",
value: true,
no_mobile: true,
category: "Channel Metadata",
name: "Host This Channel Button",
help: "Display a button underneath streams that make it easy to host them with your own channel.",
on_update: function(val) {
if ( this._cindex )
this._cindex.ffzUpdateHostButton();
}
};
FFZ.settings_info.stream_uptime = {
type: "select",
options: {
0: "Disabled",
1: "Enabled",
2: "Enabled (with Seconds)",
3: "Enabled (Channel Only)",
4: "Enabled (Channel Only with Seconds)"
},
value: 1,
process_value: utils.process_int(1, 0, 2),
no_mobile: true,
category: "Channel Metadata",
name: "Stream Uptime",
help: 'Display the stream uptime under a channel by the viewer count.',
on_update: function(val) {
if ( this._cindex )
this._cindex.ffzUpdateMetadata('uptime');
}
};
FFZ.settings_info.stream_title = {
type: "boolean",
value: true,
no_bttv: 6,
no_mobile: true,
category: "Channel Metadata",
name: "Title Links",
help: "Make links in stream titles clickable.",
on_update: function(val) {
if ( this._cindex )
this._cindex.ffzFixTitle();
}
};
FFZ.settings_info.channel_bar_bottom = {
type: "boolean",
value: false,
no_bttv: true,
no_mobile: true,
category: "Appearance",
name: "Channel Bar on Bottom",
help: "Hide the profile banner and position the channel bar at the bottom of the screen.",
on_update: function(val) {
if ( this.has_bttv )
return;
var banner_hidden = this.settings.hide_channel_banner;
banner_hidden = banner_hidden === 1 ? val : banner_hidden > 0;
utils.toggle_cls('ffz-channel-bar-bottom')(val);
utils.toggle_cls('ffz-hide-channel-banner')(banner_hidden);
if ( this._credesign )
this._credesign.ffzUpdateCoverHeight();
var Layout = utils.ember_lookup('service:layout');
if ( Layout )
Ember.propertyDidChange(Layout, 'ffzExtraHeight');
}
}
FFZ.settings_info.hide_channel_banner = {
type: "select",
options: {
0: "Never",
1: "When Channel Bar is on Bottom",
2: "Always"
},
value: 1,
process_value: utils.process_int(1),
no_bttv: true,
no_mobile: true,
category: "Appearance",
name: "Hide Channel Banner",
help: "Hide the banner at the top of channel pages.",
on_update: function(val) {
if ( this.has_bttv )
return;
var is_hidden = val === 1 ? this.settings.channel_bar_bottom : val > 0;
utils.toggle_cls('ffz-hide-channel-banner')(is_hidden);
if ( this._credesign )
this._credesign.ffzUpdateCoverHeight();
var Layout = utils.ember_lookup('service:layout');
if ( Layout )
Ember.propertyDidChange(Layout, 'ffzExtraHeight');
}
}
FFZ.settings_info.channel_bar_collapse = {
type: "boolean",
value: false,
no_bttv: true,
no_mobile: true,
category: "Appearance",
name: "Minimal Channel Bar",
help: "Slide the channel bar mostly out of view when it's not being used.",
on_update: function(val) {
if ( this.has_bttv )
return;
utils.toggle_cls('ffz-minimal-channel-bar')(val);
var Layout = utils.ember_lookup('service:layout');
if ( Layout )
Ember.propertyDidChange(Layout, 'ffzExtraHeight');
}
}
FFZ.settings_info.channel_title_top = {
type: "select",
options: {
0: "Disabled",
1: "On Top",
2: "On Top, Minimal"
},
value: 0,
process_value: utils.process_int(0),
no_bttv: true,
no_mobile: true,
category: "Appearance",
name: "Channel Title on Top",
help: "Display the channel title and game above the player rather than below.",
on_update: function(val) {
if ( this.has_bttv )
return;
document.body.classList.toggle('ffz-minimal-channel-title', val === 2);
document.body.classList.toggle('ffz-channel-title-top', val > 0);
var Layout = utils.ember_lookup('service:layout');
if ( Layout )
Ember.propertyDidChange(Layout, 'ffzExtraHeight');
}
}
FFZ.settings_info.theater_stats = {
type: "select",
options: {
0: "Disabled",
1: "Basic",
2: "Full"
},
value: 2,
process_value: utils.process_int(2, 0, 2),
no_mobile: true,
category: "Channel Metadata",
name: "Display on Theater Mode Hover",
help: "Show the channel metadata and actions over the video player in theater mode when you hover it with your mouse.",
on_update: function(val) {
document.body.classList.toggle('ffz-theater-stats', val === 2);
document.body.classList.toggle('ffz-theater-basic-stats', val > 0);
}
};
FFZ.basic_settings.channel_info = {
type: "select",
options: {
0: "Disabled",
1: "Enabled",
2: "Enabled (with Seconds)",
3: "Enabled (Channel Only)",
4: "Enabled (Channel Only with Seconds)"
},
category: "General",
name: "Stream Uptime",
help: "Display the current stream's uptime under the player.",
get: function() {
return this.settings.stream_uptime;
},
set: function(val) {
if ( typeof val === 'string' )
val = parseInt(val || "0");
this.settings.set('stream_uptime', val);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,330 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// --------------------
// Settings
// --------------------
FFZ.settings_info.show_commerce = {
type: "select",
options: {
0: "Never",
1: "When Revenue is Shared",
2: "Always"
},
value: 2,
process_value: utils.process_int(0),
no_mobile: true,
category: "Commerce",
name: "Display Commerce Bar",
help: "Show the commerce bar under channels that allows you to purchase supported games.",
on_update: function(val) {
utils.toggle_cls('ffz-hide-purchase-game')(val === 0);
var views = utils.ember_views(),
ChannelBox = utils.ember_resolve('component:commerce/channel-box');
if ( ! ChannelBox )
return;
for(var key in views)
if ( views[key] instanceof ChannelBox )
try {
views[key].ffzUpdateVisibility();
} catch(err) { }
}
}
FFZ.settings_info.show_itad = {
type: "boolean",
value: true,
no_mobile: true,
category: "Commerce",
name: "Display Competitor Pricing",
help: "Add a button on the commerce bar with pricing from other stores.",
on_update: function(val) {
var views = utils.ember_views(),
BuyGameNow = utils.ember_resolve('component:commerce/buy-game-now');
if ( ! BuyGameNow )
return;
for(var key in views)
if ( views[key] instanceof BuyGameNow )
try {
views[key].ffzRenderPricing();
} catch(err) { }
}
}
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_commerce = function() {
this._itad_game_to_plain = {};
// Styles
utils.toggle_cls('ffz-hide-purchase-game')(this.settings.show_commerce === 0);
// Ember Modifications
this.update_views('component:commerce/channel-box', this.modify_commerce_box);
this.update_views('component:commerce/buy-game-now', this.modify_buy_game_now);
}
// --------------------
// Modifications
// --------------------
FFZ.prototype.modify_commerce_box = function(view) {
var f = this;
utils.ember_reopen_view(view, {
ffz_init: function() {
this.ffzUpdateVisibility();
},
ffzUpdateVisibility: function() {
var el = this.parentView.get('element'),
real_el = el && el.querySelector('.cmrc-channel-box');
if ( ! real_el )
! this.isDestroyed && setTimeout(this.ffzUpdateVisibility.bind(this), 250);
else
real_el.classList.toggle('hidden', f.settings.show_commerce === 1 && ! this.get('showSupports'));
}.observes('showSupports')
})
}
FFZ.prototype.modify_buy_game_now = function(view) {
var f = this;
utils.ember_reopen_view(view, {
itad_plain: null,
itad_price: null,
itad_country: null,
ffz_init: function() {
if ( ! this.$().parents('.cmrc-game-details-box,.cmrc-channel-box').length || ! this.$('button').length )
return;
//f.log("Buy-Game-New Component", this);
this.itad_count = 0;
this.ffzUpdateITADPlain();
var t = this;
f.get_location().then(function(data) {
t.set('itad_country', data && data.country);
});
},
ffzTitle: function() {
// Do this because Twitch's ToS say you're not allowed to use data collected from
// the API to show users commercial offers. This scrapes the game title from the
// page itself and not from any kind of JS API.
// Granted, they're probably more worried about automated chat spam and people
// sending spam to email addresses recovered from authenticated user profile requests.
var el;
if ( document.body.dataset.currentPath === 'directory.game-details' )
el = document.querySelector('.game-details__page-title');
else
el = document.querySelector('.js-card__info [data-tt_content="current_game"]');
var output = el ? _.pluck(_.filter(el.childNodes, function(x) { return x.nodeType === document.TEXT_NODE }), 'textContent').join(' ').trim() : null;
if ( ! output && this.itad_count < 50 ) {
var t = this;
setTimeout(function() {
t.itad_count += 1;
Ember.propertyDidChange(this, 'ffzTitle');
}, 250);
}
return output;
}.property(),
didReceiveAttrs: function() {
this._super();
Ember.propertyDidChange(this, 'ffzTitle');
},
ffzUpdateITADPlain: function() {
var title = this.get('ffzTitle'),
old_plain = this.get('itad_plain'),
plain = f._itad_game_to_plain[title] || null;
//f.log("Update ITAD Plain: " + title + " -- [" + plain + "]", this);
// If we already have the value, fetch it now.
if ( ! title || plain ) {
if ( old_plain !== plain )
this.set('itad_plain', plain);
return;
}
if ( old_plain )
this.set('itad_plain', null);
var t = this;
f.ws_send("get_itad_plain", title, function(success, data) {
if ( ! success ) return;
f._itad_game_to_plain[title] = data;
t.ffzUpdateITADPlain();
}, true);
}.observes('ffzTitle'),
ffzUpdateITADPrice: function() {
var t = this,
old_price = this.get('itad_price'),
country = this.get('itad_country'),
plain = this.get('itad_plain');
if ( old_price && old_price[0] === plain )
return;
if ( ! plain || ! country )
return this.set('itad_price', null);
this.set('itad_price', [plain, null]);
f.ws_send("get_itad_prices", [plain, country], function(success, data) {
if ( ! success ) return;
t.set('itad_price', [plain, data]);
});
}.observes('itad_plain', 'itad_country'),
ffzRenderPricing: function() {
var t = this,
el = this.get('element'),
cont = el && el.querySelector('.ffz-price-info'),
btn_price,
data = this.get('itad_price');
if ( ! f.settings.show_itad || ! data || ! data[1] || ! data[1].list || ! data[1].list.length ) {
if ( cont )
jQuery(cont).remove();
return;
}
if ( ! cont ) {
cont = utils.createElement('div', 'ffz-price-info mg-l-1 balloon-wrapper');
btn_price = utils.createElement('span', 'ffz-price-num button__num-block pd-x-1 mg-1-0');
var btn = utils.createElement('button', 'button itad-button button--dropmenu',
utils.createElement('span', 'ffz-price-label inline-block pd-r-1', 'ITAD'));
btn.appendChild(btn_price);
cont.appendChild(btn);
el.appendChild(cont);
btn.addEventListener('click', function(event) {
t.ffzRenderPopup(event);
});
} else
btn_price = cont.querySelector('.ffz-price-num');
// Determine the cheapest price.
var sales = data[1].list,
cheapest = sales[0].price_new,
currency = data[1].currency,
formatter = new Intl.NumberFormat(undefined, (currency && currency.code) ? {style: 'currency', currency: currency.code, minimumFractionDigits: 2} : {minimumFractionDigits: 2});
btn_price.textContent = formatter.format(cheapest);
}.observes('itad_price'),
ffzRenderPopup: function(e) {
if ( e.button !== 0 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey )
return;
e.preventDefault();
e.stopPropagation();
var popup = f._popup ? f.close_popup() : f._last_popup,
t = this,
data = t.get('itad_price'),
el = this.get('element'),
has_support = el && el.parentElement && (el.parentElement.querySelector('.cmrc-channel-box__support') || el.parentElement.querySelector('.cmrc-game-detail-box__support')),
cont = el && el.querySelector('.ffz-price-info');
if ( popup && popup.id === 'ffz-price-popup' || ! data || ! data[1] || ! data[1].list || ! data[1].list.length )
return;
var balloon = utils.createElement('div', 'itad-balloon balloon balloon--md show', '<table><thead><tr><th>Store</th><th>Price Cut</th><th>Current</th><th>Regular</th></tr></thead><tbody></tbody></table>'),
tbody = balloon.querySelector('tbody');
balloon.id = 'ffz-price-popup';
// Render the table.
var currency = data[1].currency,
formatter = new Intl.NumberFormat(undefined, (currency && currency.code) ? {style: 'currency', currency: currency.code, minimumFractionDigits: 2} : {minimumFractionDigits: 2});
var sales = data[1].list;
for(var i=0; i < sales.length; i++) {
var entry = sales[i],
row = utils.createElement('tr');
row.innerHTML = '<td><a class="store-link" rel="noreferrer" target="_blank" href="' + utils.quote_san(entry.url) + '">' + utils.sanitize(entry.shop.name) + '</a></td>' +
'<td>' + (entry.price_cut < 0 ? '' : '-') + utils.sanitize(entry.price_cut) + '%</td>' +
'<td>' + formatter.format(entry.price_new) + '</td>' +
'<td>' + formatter.format(entry.price_old) + '</td>';
tbody.appendChild(row);
}
if ( has_support )
jQuery('.store-link', tbody).click(function(e) {
var name = has_support.querySelector('strong').textContent,
link_text = e.target.textContent;
if ( ! confirm("By following this link and purchasing from " + link_text + " you will NOT be supporting " + name + ".\n\nAre you sure you wish to contune?") )
return false;
});
// Add a by-line for IsThereAnyDeal.
var url = data[1].urls && data[1].urls.game || "https://isthereanydeal.com",
by_line = utils.createElement('span', 'ffz-attributions',
'<hr>Source: <a rel="noreferrer" target="_blank" href="' + utils.quote_san(url) + '">IsThereAnyDeal.com</a><br><br>Any affiliate links in the provided data are the responsibility of IsThereAnyDeal and do not benefit FrankerFaceZ. You may consider visiting the store directly.' +
'<hr>Reminder: When you buy a game from other services, you miss out on the benefits of purchasing from Twitch directly including: supporting partnered streamers and earning <a target="_blank" href="https://blog.twitch.tv/twitch-crates-are-coming-soon-f50fa0cd4cdf">Twitch Crates</a> containing emotes and badges.');
balloon.appendChild(by_line);
// Now calculate the position and add the balloon to the DOM.
var container = document.querySelector('#main_col'),
outer = container.getBoundingClientRect(),
rect = cont.getBoundingClientRect();
var is_up = (rect.top - outer.top) > (outer.bottom - rect.bottom);
balloon.classList.add('balloon--' + (is_up ? 'up' : 'down'));
balloon.classList.toggle('balloon--right', (rect.left - outer.left) > (outer.right - rect.right));
f._popup_allow_parent = true;
f._popup = balloon;
cont.appendChild(balloon);
}
})
}

View file

@ -1,271 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants'),
createElement = utils.createElement;
// ---------------
// Settings
// ---------------
FFZ.settings_info.conv_focus_on_click = {
type: "boolean",
value: false,
no_mobile: true,
visible: false,
category: "Whispers",
name: "Focus Input on Click",
help: "Focus on a conversation's input box when you click it."
};
FFZ.settings_info.top_conversations = {
type: "boolean",
value: false,
no_mobile: true,
category: "Whispers",
name: "Position on Top",
help: "Display the whisper UI at the top of the window instead of the bottom.",
on_update: function(val) {
document.body.classList.toggle('ffz-top-conversations', val);
}
};
FFZ.settings_info.hide_whispers_in_embedded_chat = {
type: "boolean",
value: false,
no_bttv: 6,
category: "Whispers",
name: "Hide Whispers in Embedded Chat",
help: "Do not display whispers on the dashboard, in pop-out chat, or in chat embedded into other websites.",
on_update: function(val) {
if ( ! val || this.has_bttv_6 )
return;
for(var room_id in this.rooms) {
var room = this.rooms[room_id].room;
if ( ! room )
continue;
var messages = room.get('messages'),
length = messages && messages.length || 0,
i = length;
while(--i >= 0) {
if ( messages[i] && messages[i].style === 'whisper' )
messages.removeAt(i);
}
}
}
};
FFZ.settings_info.hide_conversations_in_theatre = {
type: "boolean",
value: false,
no_mobile: true,
category: "Whispers",
name: "Hide Whispers in Theater Mode",
help: "Hide the whisper UI when the page is in theater mode.",
on_update: function(val) {
document.body.classList.toggle('ffz-theatre-conversations', val);
}
};
FFZ.settings_info.minimize_conversations = {
type: "boolean",
value: false,
no_mobile: true,
category: "Whispers",
name: "Minimize Whisper UI",
help: "Slide the whisper UI mostly out of view when it's not being used and you have no unread messages.",
on_update: function(val) {
document.body.classList.toggle('ffz-minimize-conversations', val);
}
};
// ---------------
// Initialization
// ---------------
FFZ.prototype.setup_conversations = function() {
document.body.classList.toggle('ffz-top-conversations', this.settings.top_conversations);
document.body.classList.toggle('ffz-minimize-conversations', this.settings.minimize_conversations);
document.body.classList.toggle('ffz-theatre-conversations', this.settings.hide_conversations_in_theatre);
this.update_views('component:twitch-conversations/conversation-window', this.modify_conversation_window);
this.update_views('component:twitch-conversations/conversation-settings-menu', this.modify_conversation_menu);
this.update_views('component:twitch-conversations/conversation-line', this.modify_conversation_line);
}
FFZ.prototype.modify_conversation_menu = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
var user = this.get('thread.otherUsername'),
el = this.get('element'),
sections = el && el.querySelectorAll('.options-section');
if ( ! user || ! user.length || f.has_bttv_6 )
return;
if ( sections && sections.length )
el.appendChild(createElement('div', 'options-divider'));
var ffz_options = createElement('div', 'options-section'),
card_link = createElement('a', 'ffz-show-card', "Open Moderation Card");
card_link.addEventListener('click', function(e) {
el.parentElement.classList.add('hidden');
FFZ.chat_commands.card.call(f, null, [user]);
});
ffz_options.appendChild(card_link);
el.appendChild(ffz_options);
}
})
}
FFZ.prototype.modify_conversation_window = function(component) {
var f = this,
Layout = utils.ember_lookup('service:layout');
utils.ember_reopen_view(component, {
headerBadges: Ember.computed("thread.participants", "currentUsername", function() {
return [];
}),
ffzHeaderBadges: Ember.computed("thread.participants", "currentUsername", function() {
var e = this.get("otherUser");
return f.get_badges(e.get('username'), null, f.get_twitch_badges(e.get('badges')), null);
}),
ffzReplaceBadges: function() {
var el = this.get('element'),
badge_el = el && el.querySelector('.badges'),
badges = this.get('ffzHeaderBadges');
if ( ! el )
return;
if ( ! badge_el ) {
badge_el = createElement('span', 'badges');
var header = el && el.querySelector('.convoHeader .username');
if ( ! header )
return;
header.insertBefore(badge_el, header.firstChild);
}
badge_el.innerHTML = f.render_badges(badges);
}.observes('ffzHeaderBadges'),
ffz_init: function() {
var el = this.get('element'),
header = el && el.querySelector('.convoHeader'),
header_name = header && header.querySelector('.username'),
raw_color = this.get('otherUser.color'),
colors = raw_color && f._handle_color(raw_color),
is_dark = (Layout && Layout.get('isTheatreMode')) || f.settings.dark_twitch;
this.ffzReplaceBadges();
if ( header_name && raw_color ) {
header_name.style.color = (is_dark ? colors[1] : colors[0]);
header_name.classList.add('has-color');
header_name.setAttribute('data-color', raw_color);
}
jQuery('.badge', el).zipsy({gravity: utils.newtip_placement(constants.TOOLTIP_DISTANCE, 'n')});
}
});
}
FFZ.prototype.modify_conversation_line = function(component) {
var f = this,
Layout = utils.ember_lookup('service:layout');
utils.ember_reopen_view(component, {
tokenizedMessage: function() {
try {
return f.tokenize_conversation_line(this.get('message'));
} catch(err) {
f.error("convo-line tokenizedMessage: " + err);
return this._super();
}
}.property("message", "currentUsername"),
click: function(e) {
if ( e.target && e.target.classList.contains('deleted-link') )
return f._deleted_link_click.call(e.target, e);
if ( f._click_emote(e.target, e) )
return;
return this._super(e);
},
didUpdate: function() { this.ffzRender() },
ffz_init: function() { this.ffzRender() },
ffzRender: function() {
var el = this.get('element'),
e = [],
username = this.get('message.from.username').toLowerCase(),
raw_display = this.get('message.from.displayName'),
alias = f.aliases[username],
raw_color = this.get('message.from.color'),
is_dark = (Layout && Layout.get('isTheatreMode')) || f.settings.dark_twitch,
colors = raw_color && f._handle_color(raw_color),
style = colors ? 'color:' + (is_dark ? colors[1] : colors[0]) : '',
colored = colors ? ' has-color' : '',
results = f.format_display_name(raw_display, username),
myself = f.get_user(),
from_me = myself && myself.login === username;
e.push('<span class="from' +
(alias ? ' ffz-alias' : '') +
(results[1] ? ' html-tooltip' : '') +
colored +
'" style="' + style + '"' +
(colors ? ' data-color="' + raw_color + '"' : '') +
(results[1] ? ' title="' + utils.quote_attr(results[1]) + '"' : '') +
'>' + results[0] + '</span>');
e.push('<span class="colon">:</span> ');
if ( ! this.get('isActionMessage') ) {
style = '';
colored = '';
}
e.push('<span class="message' + colored + '" style="' + style + (colors ? '" data-color="' + raw_color : '') + '">');
e.push(f.render_tokens(this.get('tokenizedMessage'), true, f.settings.filter_whispered_links && !from_me));
e.push('</span>');
el.innerHTML = e.join('');
}
});
}

View file

@ -1,62 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// --------------------
// Settings
// --------------------
FFZ.settings_info.dash_widget_click_to_expand = {
type: "boolean",
value: true,
category: "Dashboard",
name: "Click-to-Expand Widgets",
help: "Expand and contract widgets when you click anywhere in their header, not just when you click the toggle button."
}
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_dashboard = function() {
// Standalone Mode
if ( location.search === '?standalone' )
utils.toggle_cls('ffz-minimal-dashboard')(true);
this.try_modify_dashboard();
//this.update_views('component:dashboards/live/stream-stats', this.modify_dashboard_stats);
//this.update_views('component:dashboards/live/stream-health', this.modify_dashboard_health);
}
FFZ.prototype.try_modify_dashboard = function() {
if ( this._dashboard_modified )
return;
var loaded = window.features && window.features.includes('dashboard');
if ( ! loaded )
return this.log('Dashboard still not loaded.');
this._dashboard_modified = true;
this.update_views('component:dashboards/live-widget', this.modify_dashboard_widget);
}
FFZ.prototype.modify_dashboard_widget = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
var t = this;
this.$(".dash-widget__header").click(function(e) {
if ( ! f.settings.dash_widget_click_to_expand || e.target.tagName === 'button' || jQuery(e.target).parents('button').length || jQuery(e.target).parents('.balloon-wrapper').length )
return;
t.actions.collapseWidget.call(t);
});
}
});
}

View file

@ -1,793 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// --------------------
// Settings
// --------------------
/*FFZ.settings_info.directory_creative_all_tags = {
type: "boolean",
value: false,
category: "Directory",
no_mobile: true,
name: "Display All Creative Tags",
help: "Alter the creative tags display to list them all in a cloud rather than having to scroll.",
on_update: function(val) {
document.body.classList.toggle('ffz-creative-tags', val);
}
};*/
FFZ.settings_info.directory_creative_showcase = {
type: "boolean",
value: true,
category: "Directory",
no_mobile: true,
name: "Creative Showcase",
help: "Display the showcase on the Creative directory page.",
on_update: function(val) {
document.body.classList.toggle('ffz-creative-showcase', val);
}
};
FFZ.settings_info.directory_logos = {
type: "boolean",
value: false,
category: "Directory",
no_mobile: true,
name: "Channel Logos",
help: "Display channel logos in the Twitch directory."
};
FFZ.settings_info.directory_group_hosts = {
type: "boolean",
value: true,
category:"Directory",
no_mobile: true,
name: "Group Hosts",
help: "Only show a given hosted channel once in the directory.",
on_update: function() {
var f = this,
HostModel = utils.ember_resolve('model:host'),
Following = HostModel && HostModel.collections[HostModel.collectionId("following")];
if ( ! Following )
return;
Following.clear();
Following.load();
}
};
FFZ.settings_info.enable_recommended_vods = {
type: "boolean",
value: true,
category: "Directory",
no_mobile: true,
experiment_warn: true,
name: 'Show Twitch\'s Recommended Videos',
help: 'Show the "Based on your Viewing History" section of the directory rather than <nobr>Most Recent Videos.</nobr>',
on_update: function(val) {
Ember.propertyDidChange(utils.ember_lookup('service:vod-coviews'), 'areVodsViewable');
}
}
FFZ.settings_info.recommended_above_hosts = {
type: "boolean",
value: function() { var s = utils.ember_lookup('service:vod-coviews'); return s && s.get('isFollowingAboveHost') },
category: "Directory",
no_mobile: true,
experiment_warn: true,
name: "Show Twitch's Recommended Videos above Hosts",
help: 'Enable this to place the "Based on your Viewing History" section above Live Hosts.',
on_update: function(val) {
Ember.propertyDidChange(utils.ember_lookup('service:vod-coviews'), 'isFollowingAboveHost');
//utils.ember_lookup('service:vod-coviews').set('isFollowingAboveHost', val);
}
}
FFZ.settings_info.banned_games = {
visible: false,
value: [],
on_update: function() {
var banned = this.settings.banned_games,
els = document.querySelectorAll('.ffz-directory-preview'),
SidebarThing = utils.ember_resolve('component:social-column/followed-channel'),
views = utils.ember_views();
if ( SidebarThing )
for(var key in views)
if ( views[key] instanceof SidebarThing )
try {
views[key].ffzUpdateVisibility();
} catch(err) { }
for(var i=0; i < els.length; i++) {
var el = els[i],
game = el.getAttribute('data-game');
el.classList.toggle('ffz-game-banned', banned.indexOf(game && game.toLowerCase()) !== -1);
}
}
}
FFZ.settings_info.spoiler_games = {
visible: false,
value: [],
on_update: function() {
var spoiled = this.settings.spoiler_games,
els = document.querySelectorAll('.ffz-directory-preview');
for(var i=0; i < els.length; i++) {
var el = els[i],
game = el.getAttribute('data-game');
el.classList.toggle('ffz-game-spoilered', spoiled.indexOf(game && game.toLowerCase()) !== -1);
}
}
}
FFZ.settings_info.directory_host_menus = {
type: "select",
options: {
0: "Disabled",
1: "When Multiple are Hosting",
2: "Always"
},
value: 1,
process_value: utils.process_int(1),
category: "Directory",
no_mobile: true,
name: "Hosted Channel Menus",
help: "Display a menu to select which channel to visit when clicking a hosted channel in the directory.",
on_update: function() {
var f = this,
HostModel = utils.ember_resolve('model:host'),
Following = HostModel && HostModel.collections[HostModel.collectionId("following")];
if ( ! Following )
return;
Following.clear();
Following.load();
}
};
FFZ.settings_info.directory_uploads_position = {
type: "select",
options: {
0: "Default",
1: "At Bottom",
2: "Hidden"
},
value: 0,
process_value: utils.process_int(0),
category: "Directory",
no_mobile: true,
name: "Display Latest Uploads",
help: "Choose where to display the Latest Uploads section.",
on_update: function(val) {
utils.toggle_cls('ffz-hide-directory-uploads')(val === 2);
}
};
FFZ.settings_info.directory_hide_info_on_hover = {
type: "boolean",
value: false,
category: "Directory",
no_mobile: true,
name: "Hide Boxart on Hover",
help: "Do not display boxart and other information over a stream or video's thumbnail when hovering over it.",
on_update: utils.toggle_cls('ffz-hide-thumb-info-on-hover')
}
// --------------------
// Initialization
// --------------------
FFZ._image_cache = {};
FFZ.prototype.setup_directory = function() {
utils.toggle_cls('ffz-creative-tags')(this.settings.directory_creative_all_tags);
utils.toggle_cls('ffz-creative-showcase')(this.settings.directory_creative_showcase);
utils.toggle_cls('ffz-hide-directory-uploads')(this.settings.directory_uploads_position === 2);
utils.toggle_cls('ffz-hide-thumb-info-on-hover')(this.settings.directory_hide_info_on_hover);
var f = this,
VodCoviews = utils.ember_lookup('service:vod-coviews');
if ( VodCoviews ) {
VodCoviews.reopen({
// checkExperiment likes setting this back. Don't let it.
isFollowingAboveHost: Ember.computed('_ffz', {
get: function(key) {
return f.settings.recommended_above_hosts;
},
set: function(key, val) {
return f.settings.recommended_above_hosts;
}
}),
areVodsViewable: function() {
var vods = this.get('recommendedVods');
return f.settings.enable_recommended_vods && vods && vods.length > 0;
}.property('recommendedVods')
});
Ember.propertyDidChange(VodCoviews, 'isFollowingAboveHost');
Ember.propertyDidChange(VodCoviews, 'areVodsViewable');
} else
this.log("Unable to locate the Ember service:vod-coviews");
this.log("Hooking the Ember Directory views.");
this.update_views('component:stream-preview', this.modify_directory_live, true);
this.update_views('component:creative-preview', this.modify_directory_live, true);
this.update_views('component:csgo-channel-preview', function(x) { this.modify_directory_live(x, 'channel.') }, true);
this.update_views('component:stream/lol-metadata', function(x) { this.modify_directory_live(x, 'content.') }, true);
this.update_views('component:twitch-carousel/stream-item', this.modify_directory_live, true);
this.update_views('component:stream/snapshot-card', this.modify_directory_live, true);
this.update_views('component:host-preview', this.modify_directory_host, true, true);
this.update_views('component:video-preview', this.modify_video_preview, true);
this.update_views('component:video/resumable-wrapper', this.modify_video_preview, true);
this.update_views("component:video/following-uploads", this.modify_following_uploads);
this.update_views('component:game-follow-button', this.modify_game_follow_button);
this.log("Attempting to modify the Following collection.");
this._modify_following();
}
FFZ.prototype.modify_following_uploads = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
var el = this.get('element');
el.classList.add('ffz-following-uploads');
if ( f.settings.directory_uploads_position === 1 ) {
var p = el.parentElement;
p.removeChild(el);
p.appendChild(el);
}
}
});
}
FFZ.prototype._modify_following = function() {
var HostModel = utils.ember_resolve('model:host'),
f = this;
if ( HostModel ) {
var Following = HostModel.collections[HostModel.collectionId("following")];
if ( Following ) {
this.log("Found Following model.");
Following.reopen({
ffz_streams: {},
ffz_hosts_for: {},
ffz_skipped: 0,
empty: function() {
this._super();
this.set("ffz_streams", {});
this.set("ffz_hosts_for", {});
this.set("ffz_skipped", 0);
},
request: function(e) {
// We have to override request with nearly the same logic
// to prevent infinitely trying to load more streams.
if (!Twitch.user.isLoggedIn() || window.App.get("disableFollowingDirectory")) return RSVP.resolve({
hosts: [], _total: 0
});
var t = {
limit: this.limit,
offset: this.get('content.length') + this.get('ffz_skipped')
};
// Don't use FFZ's Client ID because loading hosts is a normal part
// of the dashboard. We're just manipulating the logic a bit.
return this.get("api").request("get", "/api/users/:login/followed/hosting", t);
},
afterSuccess: function(e) {
var valid_hosts = [],
streams = this.get('ffz_streams'),
skipped = this.get('ffz_skipped'),
hosts_for = this.get('ffz_hosts_for'),
t = this;
for(var i=0; i < e.hosts.length; i++) {
var host = e.hosts[i],
target = host && host.target && host.target.id;
if ( host.rollbackData )
host.rollbackData = undefined;
if ( f.settings.directory_group_hosts && streams[target] ) {
skipped++;
//hosts_for[target] && hosts_for[target]
streams[target].ffz_hosts && streams[target].ffz_hosts.push({logo: host.logo, name: host.name, display_name: host.display_name});
continue;
}
streams[target] = host;
//hosts_for[target] = [{logo: host.logo, name: host.name, display_name: host.display_name}];
host.ffz_hosts = [{logo: host.logo, name: host.name, display_name: host.display_name}];
valid_hosts.push(host);
}
//f.log("Stuff!", [this, e, valid_hosts, skipped]);
this.set('ffz_skipped', skipped);
this.setContent(valid_hosts);
// We could get non-empty results even with no new hosts.
this.set('gotNonEmptyResults', e.hosts && e.hosts.length);
this.set('total', e._total - skipped);
}
});
// Filter the streams immediately.
if ( true && ! Following.get('isLoading') ) {
var content = Following.get('content'),
total = Following.get('total'),
host_copy = [];
// TODO: Something less stupid.
for(var i=0; i < content.length; i++) {
var host = content[i];
host_copy.push({
display_name: host.display_name,
game: host.game,
id: host.id,
logo: host.logo,
name: host.name,
target: {
_id: host.target._id,
channel: {
display_name: host.target.channel.display_name,
id: host.target.channel.id,
logo: host.target.channel.logo,
name: host.target.channel.name,
url: host.target.channel.url
},
id: host.target.id,
meta_game: host.target.meta_game,
preview: host.target.preview,
title: host.target.title,
url: host.target.url,
viewers: host.target.viewers
}
});
}
Following.clear();
Following.afterSuccess({hosts: host_copy, _total: total});
}
return;
}
}
// Couldn't find it. Reschedule.
setTimeout(this._modify_following.bind(this), 250);
}
FFZ.prototype.modify_game_follow_button = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
var el = this.get('element'),
game = this.get('game.id').toLowerCase(),
click_button = function(setting, update_func) {
return function(e) {
e.preventDefault();
var games = f.settings.get(setting),
ind = games.indexOf(game);
if ( ind === -1 )
games.push(game);
else
games.splice(ind, 1);
f.settings.set(setting, games);
update_func();
}
};
// Block Button
var block = utils.createElement('button', 'button tooltip ffz-block-button'),
update_block = function() {
var is_blocked = f.settings.banned_games.indexOf(game) !== -1;
block.classList.toggle('active', is_blocked);
block.innerHTML = (is_blocked ? 'Unblock' : 'Block');
block.title = 'Click to ' + (is_blocked ? 'unblock' : 'block') + " this game.\n\nBlocking a game hides all the streams and videos of the game when you're not viewing it directly.";
jQuery(block).trigger('mouseout').trigger('mouseover');
};
update_block();
block.addEventListener('click', click_button('banned_games', update_block));
el.appendChild(block);
// Spoiler Button
var spoiler = utils.createElement('button', 'button tooltip ffz-spoiler-button'),
update_spoiler = function() {
var is_spoiled = f.settings.spoiler_games.indexOf(game) !== -1;
spoiler.classList.toggle('active', is_spoiled);
spoiler.innerHTML = (is_spoiled ? 'Show Thumbnails' : 'Hide Thumbnails');
spoiler.title = 'Click to ' + (is_spoiled ? 'show' : 'hide') + " thumbnails for this game.\n\nHiding thumbnails for a game will help you avoid spoilers for a game that you haven't played yet.";
jQuery(spoiler).trigger('mouseout').trigger('mouseover');
}
update_spoiler();
spoiler.addEventListener('click', click_button('spoiler_games', update_spoiler));
el.appendChild(spoiler);
jQuery('.tooltip', el).zipsy();
}
})
}
FFZ.prototype.modify_directory_live = function(component, mode) {
var f = this,
pref = mode || 'stream.';
utils.ember_reopen_view(component, {
ffz_init: function() {
var el = this.get('element'),
meta = el && el.querySelector('.card__body'),
thumb = el && el.querySelector('.card__img'),
cap = thumb && thumb.querySelector('a:not(.card__boxpin)'),
uptime_setting = f.settings.stream_uptime,
channel_id = this.get(pref + 'channel.name'),
game = this.get(pref + 'game');
el.classList.add('ffz-directory-preview');
el.setAttribute('data-channel', channel_id);
el.setAttribute('data-game', game);
el.classList.toggle('ffz-game-banned', f.settings.banned_games.indexOf(game && game.toLowerCase()) !== -1);
el.classList.toggle('ffz-game-spoilered', f.settings.spoiler_games.indexOf(game && game.toLowerCase()) !== -1);
if ( uptime_setting && uptime_setting < 3 ) {
this._ffz_uptime_timer = setInterval(
this.ffzUpdateUptime.bind(this),
uptime_setting === 2 ? 1000 : 60000);
this.ffzUpdateUptime();
}
this._ffz_image_timer = setInterval(this.ffzRotateImage.bind(this), 30000);
this.ffzRotateImage();
if ( f.settings.directory_logos ) {
el.classList.add('ffz-directory-logo');
var link = document.createElement('a'),
logo = document.createElement('img'),
t = this;
logo.className = 'profile-photo';
logo.classList.toggle('is-csgo', mode === 'channel.');
logo.src = this.get(pref + 'channel.logo') || constants.NO_LOGO;
logo.alt = f.format_display_name(this.get(pref + 'channel.display_name'), channel_id, true, true)[0];
link.href = '/' + channel_id;
link.addEventListener('click', utils.transition_link(utils.transition_user.bind(this, channel_id)));
link.appendChild(logo);
meta.insertBefore(link, meta.firstChild);
}
},
ffz_destroy: function() {
if ( this._ffz_uptime ) {
jQuery(this._ffz_uptime).remove();
this._ffz_uptime = this._ffz_uptime_span = null;
}
if ( this._ffz_uptime_timer )
clearInterval(this._ffz_uptime_timer);
if ( this._ffz_image_timer )
clearInterval(this._ffz_image_timer);
},
ffzRotateImage: function() {
var url = this.get(pref + 'preview.medium'),
now = Math.round((new Date).getTime() / 150000);
if ( FFZ._image_cache[url] && FFZ._image_cache[url] !== now )
url += '?_=' + now;
else
FFZ._image_cache[url] = now;
this.$('.thumb .cap img').attr('src', url);
},
ffzUpdateUptime: function() {
var up_since = this.get(pref + 'created_at');
if ( typeof up_since === "string" )
up_since = utils.parse_date(up_since);
var now = Date.now() - (f._ws_server_offset || 0),
uptime = up_since && Math.floor((now - up_since.getTime()) / 1000) || 0;
if ( uptime > 0 ) {
if ( ! this._ffz_uptime ) {
var el = this.get('element'),
cont = el && el.querySelector('.card__img');
if ( ! cont )
return;
var t_el = this._ffz_uptime = utils.createElement('div', 'overlay_info length live', constants.CLOCK),
t_span = this._ffz_uptime_span = utils.createElement('span');
t_el.appendChild(t_span);
t_el.setAttribute('original-title', 'Stream Uptime <nobr>(since ' + up_since.toLocaleString() + ')</nobr>');
jQuery(t_el).zipsy({html: true, gravity: utils.newtip_placement(constants.TOOLTIP_DISTANCE, 's')});
cont.appendChild(t_el);
}
this._ffz_uptime_span.textContent = utils.time_to_string(uptime, false, false, false, f.settings.stream_uptime === 1);
} else if ( this._ffz_uptime ) {
jQuery(this._ffz_uptime).remove();
this._ffz_uptime = this._ffz_uptime_span = null;
}
}
});
}
FFZ.prototype.modify_video_preview = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
var el = this.get('element'),
game = this.get('video.game'),
thumb = el && el.querySelector('.thumb'),
boxart = thumb && thumb.querySelector('.boxart');
el.classList.add('ffz-directory-preview');
el.setAttribute('data-channel', this.get('video.channel.name'));
el.setAttribute('data-game', game);
el.classList.toggle('ffz-game-banned', f.settings.banned_games.indexOf(game && game.toLowerCase()) !== -1);
el.classList.toggle('ffz-game-spoilered', f.settings.spoiler_games.indexOf(game && game.toLowerCase()) !== -1);
if ( ! boxart && thumb && game ) {
var img = utils.createElement('img');
boxart = utils.createElement('a', 'boxart');
boxart.href = this.get('video.gameUrl');
boxart.setAttribute('original-title', game);
boxart.addEventListener('click', utils.transition_link(utils.transition_game.bind(this, game)));
img.src = this.get('video.gameBoxart');
boxart.appendChild(img);
thumb.appendChild(boxart);
}
}
});
}
FFZ.prototype.modify_directory_host = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffzVisitChannel: function(target, e) {
if ( e ) {
if ( e.button !== 0 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey )
return;
e.preventDefault();
e.stopPropagation();
}
f.close_popup();
utils.transition_user(target);
return false;
},
ffzShowHostMenu: function(e) {
if ( e.button !== 0 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey )
return;
e.preventDefault();
e.stopPropagation();
var hosts = this.get('stream.ffz_hosts'),
target = this.get('stream.target.channel.name');
if ( f.settings.directory_host_menus === 0 || ! hosts || (f.settings.directory_host_menus === 1 && hosts.length < 2) )
return this.ffzVisitChannel((hosts && hosts.length < 2) ? hosts[0].name : target);
var popup = f._popup ? f.close_popup() : f._last_popup,
t = this;
// Don't re-show the popup if we were clicking to show it.
if ( popup && popup.classList.contains('ffz-channel-selector') && popup.getAttribute('data-channel') === target )
return;
var menu = document.createElement('div'), hdr,
make_link = function(target) {
var link = document.createElement('a'),
results = f.format_display_name(target.display_name, target.name, true);
link.className = 'dropmenu_action';
link.setAttribute('data-channel', target.name);
link.href = '/' + target.name;
link.innerHTML = '<img class="image" src="' + utils.sanitize(target.logo || constants.NO_LOGO) + '"><span class="title' + (results[1] ? ' html-tooltip" title="' + utils.quote_attr(results[1]) : '') + '">' + results[0] + '</span>';
link.addEventListener('click', t.ffzVisitChannel.bind(t, target.name));
menu.appendChild(link);
return link;
};
menu.className = 'ffz-channel-selector dropmenu menu-like';
menu.setAttribute('data-channel', target);
hdr = document.createElement('div');
hdr.className = 'header';
hdr.textContent = 'Hosted Channel';
menu.appendChild(hdr);
make_link(this.get('stream.target.channel'));
hdr = document.createElement('div');
hdr.className = 'header';
hdr.textContent = 'Hosting Channels';
menu.appendChild(hdr);
for(var i=0; i < hosts.length; i++)
make_link(hosts[i]);
var cont = document.querySelector('#main_col > .tse-scroll-content > .tse-content'),
bounds = cont && cont.getBoundingClientRect(),
x = e.clientX - 60,
y = e.clientY - 60;
if ( bounds )
x = Math.max(bounds.left, Math.min(x, (bounds.left + bounds.width) - 302));
f.show_popup(menu, [x, y], document.querySelector('#main_col > .tse-scroll-content > .tse-content'));
},
ffzRotateImage: function() {
var url = this.get('stream.target.preview'),
now = Math.round((new Date).getTime() / 150000);
if ( FFZ._image_cache[url] && FFZ._image_cache[url] !== now )
url += '?_=' + now;
else
FFZ._image_cache[url] = now;
this.$('.thumb .cap img').attr('src', url);
},
ffz_destroy: function() {
var target = this.get('stream.target.channel');
if ( f._popup && f._popup.classList.contains('ffz-channel-selector') && f._popup.getAttribute('data-channel') === target )
f.close_popup();
if ( this._ffz_image_timer )
clearInterval(this._ffz_image_timer);
},
ffz_init: function() {
var el = this.get('element'),
meta = el && el.querySelector('.card__body'), //meta'),
thumb = el && el.querySelector('.card__img'), //thumb'),
cap = thumb && thumb.querySelector('a:not(.card__boxpin)'), //.cap'),
title = meta && meta.querySelector('.card__title a'),
target = this.get('stream.target.channel'),
game = this.get('stream.target.meta_game'),
hosts = this.get('stream.ffz_hosts');
el.classList.add('ffz-directory-preview');
el.setAttribute('data-channel', target.name);
el.setAttribute('data-game', game);
el.classList.toggle('ffz-game-banned', f.settings.banned_games.indexOf(game && game.toLowerCase()) !== -1);
el.classList.toggle('ffz-game-spoilered', f.settings.spoiler_games.indexOf(game && game.toLowerCase()) !== -1);
this._ffz_image_timer = setInterval(this.ffzRotateImage.bind(this), 30000);
this.ffzRotateImage();
if ( f.settings.directory_logos ) {
el.classList.add('ffz-directory-logo');
var logo = document.createElement('img'),
link = document.createElement('a');
logo.className = 'profile-photo';
logo.src = this.get('stream.target.channel.logo') || constants.NO_LOGO;
logo.alt = f.format_display_name(target.display_name, target.name, true, true)[0];
link.href = '/' + target.name;
link.addEventListener('click', this.ffzVisitChannel.bind(this, target.name));
link.appendChild(logo);
meta.insertBefore(link, meta.firstChild);
}
var update_links = f.settings.directory_host_menus === 2 || (hosts && hosts.length > 1);
if ( title ) {
if ( update_links ) {
title.href = '/' + target.name;
title.addEventListener('click', this.ffzShowHostMenu.bind(this));
}
if ( hosts && hosts.length > 1 ) {
title.textContent = utils.number_commas(hosts.length) + ' hosting ' + utils.sanitize(target.display_name);
title.title = _.sortBy(hosts, "name").mapBy("display_name").join(", ");
jQuery(title).zipsy({gravity: 's'});
}
}
if ( cap && update_links ) {
cap.href = '/' + target.name;
cap.addEventListener('click', this.ffzShowHostMenu.bind(this));
}
}
});
}

View file

@ -1,121 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants'),
parse_emotes = function(emotes) {
var output = {};
if ( ! emotes || ! emotes.length )
return output;
for(var i=0; i < emotes.length; i++) {
var emote = emotes[i];
if ( ! emote || ! emote.id )
continue;
var entries = output[emote.id] = output[emote.id] || [];
entries.push([emote.start, emote.end]);
}
return output;
};
FFZ.prototype.setup_feed_cards = function() {
this.update_views('component:twitch-feed/story-card', this.modify_feed_card);
this.update_views('component:twitch-feed/comment', this.modify_feed_comment);
this.rerender_feed_cards();
}
FFZ.prototype.rerender_feed_cards = function(for_set) {
var FeedCard = utils.ember_resolve('component:twitch-feed/story-card'),
FeedComment = utils.ember_resolve('component:twitch-feed/comment'),
views = utils.ember_views();
if ( ! FeedCard )
return;
for(var view_id in views) {
var view = views[view_id];
if ( view instanceof FeedCard ) {
try {
if ( ! view.ffz_init )
this.modify_feed_card(view);
view.ffz_init(for_set);
} catch(err) {
this.error("setup component:twitch-feed/story-card ffzInit", err)
}
}
if ( FeedComment && view instanceof FeedComment ) {
try {
if ( ! view.ffz_init )
this.modify_feed_comment(view);
view.ffz_init(for_set);
} catch(err) {
this.error("setup component:twitch-feed/comment ffzInit", err);
}
}
}
}
FFZ.prototype.modify_feed_card = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function(for_set) {
var el = this.get('element'),
message = this.get('post.body'),
emotes = parse_emotes(this.get('post.emotes')),
user_id = this.get('post.user.login'),
room_id = this.get('channelId') || user_id,
pbody = el && el.querySelector('.activity-body');
if ( ! message || ! el || ! pbody )
return;
// If this is for a specific emote set, only rerender if it matters.
if ( for_set && f.rooms && f.rooms[room_id] ) {
var sets = f.getEmotes(user_id, room_id);
if ( sets.indexOf(for_set) === -1 )
return;
}
var tokens = f.tokenize_feed_body(message, emotes, user_id, room_id),
output = f.render_tokens(tokens, true, false);
pbody.innerHTML = '<p>' + output + '</p>';
}
});
}
FFZ.prototype.modify_feed_comment = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function(for_set) {
var el = this.get('element'),
message = this.get('comment.body'),
emotes = parse_emotes(this.get('comment.emotes')),
user_id = this.get('comment.user.login'),
room_id = this.get('parentView.parentView.channelId') || this.get('parentView.parentView.post.user.login') || null,
pbody = el && el.querySelector('.activity-body');
if ( ! message || ! el || ! pbody )
return;
// If this is for a specific emote set, only rerender if it matters.
if ( for_set && f.rooms && f.rooms[room_id] ) {
var sets = f.getEmotes(user_id, room_id);
if ( sets.indexOf(for_set) === -1 )
return;
}
var tokens = f.tokenize_feed_body(message, emotes, user_id, room_id),
output = f.render_tokens(tokens, true, false);
pbody.innerHTML = '<p>' + output + '</p>';
}
})
}

View file

@ -1,336 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants'),
createElement = utils.createElement,
FOLLOWING_RE = /^\/kraken\/users\/([^/]+)\/follows\/channels/,
FOLLOWER_RE = /^\/kraken\/channels\/([^/]+)\/follows/;
// --------------------
// Settings
// --------------------
FFZ.settings_info.enhance_profile_following = {
type: "boolean",
value: true,
category: "Directory",
no_mobile: true,
name: "Enhanced Following Control",
help: "Display additional controls on your own profile's Following tab to make management easier, as well as telling you how long everyone has been following everyone else in the profile."
}
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_profile_following = function() {
if ( ! window.App )
return;
var f = this;
// Build our is-following cache.
this._following_cache = {};
this._follower_cache = {};
// We want to hook the API to gather this information. It's easier than
// modifying the deserialization path.
var process_follows = function(channel_id, data, cache) {
f.log("Loading Follow Information for: " + channel_id, data);
var user_cache = cache[channel_id] = cache[channel_id] || {},
now = Date.now();
for(var i=0; i < data.length; i++) {
var follow = data[i],
user = follow && (follow.user || follow.channel);
if ( ! user || ! user.name )
continue;
if ( user.display_name && user.display_name !== 'jtv' )
FFZ.capitalization[user.name] = [user.display_name, now];
user_cache[user.name] = [
follow.created_at ? utils.parse_date(follow.created_at) : null,
follow.notifications || false];
}
};
var ServiceAPI = utils.ember_lookup('service:api');
if ( ServiceAPI )
ServiceAPI.reopen({
request: function(method, url, data, options) {
if ( method !== 'get' || url.indexOf('/kraken/') !== 0 )
return this._super(method, url, data, options);
var t = this;
return new Promise(function(success, fail) {
t._super(method, url, data, options).then(function(result) {
if ( result.follows ) {
var match = FOLLOWING_RE.exec(url);
if ( match )
// Following Information
process_follows(match[1], result.follows, f._following_cache);
match = FOLLOWER_RE.exec(url);
if ( match )
// Follower Information
process_follows(match[1], result.follows, f._follower_cache);
}
success(result);
}).catch(function(err) {
fail(err);
})
});
}
});
else
this.error("Unable to locate the Ember service:api");
// Modify followed items.
//this.update_views('component:display-followed-item', this.modify_display_followed_item);
this.update_views('component:twitch-profile-card', this.modify_twitch_profile_card);
}
FFZ.prototype.modify_twitch_profile_card = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffzParentModel: function() {
var x = this.get('parentView');
while(x) {
var model = x.get('model');
if ( model )
return model;
x = x.get('parentView');
}
}.property('parentView'),
ffz_init: function() {
var el = this.get('element');
el.classList.add('ffz-processed');
jQuery('.aspect', el).zipsy();
if ( ! f.settings.enhance_profile_following )
return;
this.ffzUpdate();
},
ffzUpdate: function() {
var el = this.get('element'),
t_el = el.querySelector('.ffz-followed-since'),
//notif_el = el.querySelector('.ffz-followed-notifications'),
channel_id = this.get('ffzParentModel.model.id'),
is_following = this.get('ffzParentModel.relationshipName') === 'following',
user = f.get_user(),
mine = user && user.login && user.login === channel_id,
big_cache = is_following ? f._following_cache : f._follower_cache,
user_cache = big_cache[channel_id] = big_cache[channel_id] || {},
user_id = this.get('channelInfo.id'),
data = user_cache[user_id];
//f.log("Profile Card [" + channel_id + "] " + user_id + " <" + JSON.stringify(data) + ">", this);
if ( ! data || ! el ) {
if ( t_el )
jQuery(t_el).remove();
/*if ( notif_el )
jQuery(notif_el).remove();*/
return false;
}
var now = Date.now() - (f._ws_server_offset || 0),
age = data[0] ? Math.floor((now - data[0].getTime()) / 1000) : 0,
t_el = el.querySelector('.ffz-followed-since')
update_time = function() {
var data = user_cache[user_id],
now = Date.now() - (f._ws_server_offset || 0),
age = data && data[0] ? Math.floor((now - data[0].getTime()) / 1000) : undefined;
if ( age !== undefined ) {
t_el.innerHTML = constants.CLOCK + ' ' + (age < 60 ? 'now' : utils.human_time(age, 10));
t_el.title = 'Follow' + (is_following ? 'ed by ' : 'er of ') + channel_id + ' since: <nobr>' + data[0].toLocaleString() + '</nobr>';
t_el.style.display = '';
} else
t_el.style.display = 'none';
};
if ( ! t_el ) {
t_el = createElement('div', 'overlay_info length html-tooltip ffz-followed-since');
el.appendChild(t_el);
}
update_time();
/*if ( ! mine || ! is_following ) {
if ( notif_el )
jQuery(notif_el).remove();
return;
}
if ( ! notif_el ) {
notif_el = createElement('button', 'ffz-followed-notifications button html-tooltip');
var cont = el.querySelector('.profile-card__actions');
if ( ! cont )
return;
cont.appendChild(notif_el);
}
var update_notif = function() {
var data = user_cache[user_id];
notif_el.classList.toggle('notifications-on', data && data[1]);
notif_el.textContent = 'Notifications';
notif_el.setAttribute('original-title', 'Email Notifications: ' + (data && data[1] ? 'En' : 'Dis') + 'abled');
jQuery(notif_el).trigger('mouseout');
};
update_notif();*/
}.observes('channelInfo')
});
}
/*FFZ.prototype.modify_display_followed_item = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffzParentModel: function() {
var x = this.get('parentView');
while(x) {
var model = x.get('model');
if ( model )
return model;
x = x.get('parentView');
}
}.property('parentView'),
ffz_init: function() {
var el = this.get('element'),
channel_id = this.get('ffzParentModel.id'), //.get('parentView.parentView.parentView.model.id'),
is_following = document.body.getAttribute('data-current-path').indexOf('.following') !== -1,
user = f.get_user(),
mine = user && user.login && user.login === channel_id,
big_cache = is_following ? f._following_cache : f._follower_cache,
user_cache = big_cache[channel_id] = big_cache[channel_id] || {},
user_id = this.get('followed.id'),
data = user_cache[user_id];
if ( ! f.settings.enhance_profile_following )
return;
el.classList.add('ffz-processed');
jQuery('.aspect', el).zipsy();
if ( ! data )
return false;
var now = Date.now() - (f._ws_server_offset || 0),
age = data[0] ? Math.floor((now - data[0].getTime()) / 1000) : 0,
t_el = createElement('div', 'overlay_info length html-tooltip'),
update_time = function() {
var now = Date.now() - (f._ws_server_offset || 0),
age = data && data[0] ? Math.floor((now - data[0].getTime()) / 1000) : undefined;
if ( age !== undefined ) {
t_el.innerHTML = constants.CLOCK + ' ' + (age < 60 ? 'now' : utils.human_time(age, 10));
t_el.title = 'Follow' + (is_following ? 'ing' : 'er') + ' Since: <nobr>' + data[0].toLocaleString() + '</nobr>';
t_el.style.display = '';
} else
t_el.style.display = 'none';
};
update_time();
el.appendChild(t_el);
if ( ! mine || ! is_following )
return;
var actions = createElement('div', 'actions'),
follow = createElement('button', 'button ffz-no-bg follow'),
notif = createElement('button', 'button ffz-no-bg notifications html-tooltip'),
update_follow = function() {
data = user_cache[user_id];
el.classList.toggle('followed', data);
follow.innerHTML = constants.HEART + constants.UNHEART + '<span> Follow</span>';
},
update_notif = function() {
data = user_cache[user_id];
notif.classList.toggle('notifications-on', data && data[1]);
notif.textContent = 'Notifications'; // ' + (data && data[1] ? 'On' : 'Off');
notif.setAttribute('original-title', 'Email Notifications: ' + (data && data[1] ? 'En' : 'Dis') + 'abled');
jQuery(notif).trigger('mouseout');
};
update_follow();
update_notif();
follow.addEventListener('click', function() {
var was_following = !!data;
follow.disabled = true;
notif.disabled = true;
follow.textContent = 'Updating';
(was_following ?
utils.api.del("users/:login/follows/channels/" + user_id) :
utils.api.put("users/:login/follows/channels/" + user_id, {notifications: false}))
.done(function() {
data = user_cache[user_id] = was_following ? null : [new Date(Date.now() - (f._ws_server_offset||0)), false];
})
.always(function() {
update_follow();
update_notif();
update_time();
follow.disabled = false;
notif.disabled = false;
})
});
notif.addEventListener('click', function() {
var was_following = data[1];
follow.disabled = true;
notif.disabled = true;
notif.textContent = 'Updating';
utils.api.put("users/:login/follows/channels/" + user_id, {notifications: !was_following})
.done(function() {
data[1] = ! was_following;
})
.always(function() {
update_notif();
follow.disabled = false;
notif.disabled = false;
});
});
actions.appendChild(follow);
actions.appendChild(notif);
el.appendChild(actions);
}
});
}*/

View file

@ -1,461 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
route_helper;
// --------------------
// Settings
// --------------------
FFZ.settings_info.portrait_mode = {
type: "select",
options: {
0: "Disabled",
1: "Automatic (Use Window Aspect Ratio)",
2: "Always On",
3: "Automatic (Video Below)",
4: "Always On (Video Below)"
},
value: 0,
process_value: utils.process_int(0, 0, 1),
category: "Appearance",
no_mobile: true,
no_bttv: true,
name: "Portrait Mode (Chat Below Video)",
help: "Display the right sidebar beneath (or above) the video player for viewing in portrait orientations.",
on_update: function(val) {
if ( this.has_bttv )
return;
var Layout = utils.ember_lookup('service:layout');
if ( ! Layout )
return;
Layout.set('rawPortraitMode', val);
this._fix_menu_position();
}
}
FFZ.settings_info.portrait_warning = {
value: false,
visible: false
}
FFZ.settings_info.swap_sidebars = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
no_bttv: true,
name: "Swap Sidebar Positions",
help: "Swap the positions of the left and right sidebars, placing chat on the left.",
on_update: function(val) {
if ( this.has_bttv )
return;
document.body.classList.toggle("ffz-sidebar-swap", val);
this._fix_menu_position();
}
};
FFZ.settings_info.flip_dashboard = {
type: "boolean",
value: false,
category: "Dashboard",
no_mobile: true,
no_bttv: true,
name: "Swap Column Positions",
help: "Swap the positions of the left and right columns of the dashboard.",
on_update: function(val) {
if ( this.has_bttv )
return;
document.body.classList.toggle("ffz-flip-dashboard", val);
}
};
FFZ.settings_info.right_column_width = {
type: "button",
value: 340,
category: "Appearance",
no_mobile: true,
no_bttv: true,
name: "Right Sidebar Width",
help: "Set the width of the right sidebar for chat.",
method: function() {
var f = this,
old_val = this.settings.right_column_width || 340;
utils.prompt("Right Sidebar Width", "Please enter a new width for the right sidebar, in pixels.</p><p><b>Minimum:</b> 250<br><b>Default:</b> 340", old_val, function(new_val) {
if ( new_val === null || new_val === undefined )
return;
var width = parseInt(new_val);
if ( ! width || Number.isNaN(width) || ! Number.isFinite(width) )
width = 340;
f.settings.set('right_column_width', Math.max(250, width));
});
},
on_update: function(val) {
if ( this.has_bttv )
return;
var Layout = utils.ember_lookup('service:layout');
if ( ! Layout )
return;
Layout.set('rightColumnWidth', val);
Ember.propertyDidChange(Layout, 'contentWidth');
}
};
FFZ.settings_info.minimize_navigation = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
no_bttv: true,
name: "Minimize Navigation",
help: "Slide the navigation bar mostly out of view when it's not being used.",
on_update: function(val) {
if ( this.has_bttv )
return;
var Layout = utils.ember_lookup('service:layout');
if ( ! Layout )
return;
utils.toggle_cls('ffz-sidebar-minimize')(val);
Layout.set('ffzMinimizeNavigation', val);
//Ember.propertyDidChange(Layout, 'contentWidth');
}
}
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_layout = function() {
if ( this.has_bttv )
return;
utils.toggle_cls("ffz-sidebar-swap")(this.settings.swap_sidebars);
utils.toggle_cls('ffz-sidebar-minimize')(this.settings.minimize_navigation);
this.log("Creating layout style element.");
var s = this._layout_style = document.createElement('style');
s.id = 'ffz-layout-css';
document.head.appendChild(s);
try {
route_helper = window.require("web-client/utilities/route-matcher");
} catch(err) {
this.error("Unable to require the route-matcher utility.", err);
}
var Layout = utils.ember_lookup('service:layout'),
LS = function(x) { return x },
f = this;
if ( ! Layout )
return this.log("Unable to locate the Ember service:layout");
try {
LS = window.require("web-client/utilities/layout-scaling").scalePixelValue;
} catch(err) { }
this.log("Hooking the Ember service:layout");
Layout.reopen({
rightColumnWidth: 340,
rawPortraitMode: 0,
portraitVideoBelow: false,
channelCoverHeight: function() {
var setting = f.settings.hide_channel_banner,
banner_hidden = setting === 1 ? f.settings.channel_bar_bottom : setting > 0;
return ( banner_hidden || ! route_helper || route_helper.routeMatches && !route_helper.routeMatches(this.get('globals.currentPath'), route_helper.ROUTES.CHANNEL_ANY) ) ?
0 : 380;
}.property("globals.currentPath"),
portraitMode: function() {
var raw = this.get("rawPortraitMode");
this.set('portraitVideoBelow', raw === 3 || raw === 4);
if ( raw === 0 )
return false;
if ( raw === 2 || raw === 4 )
return true;
// Not sure if I should be adding some other value to offset the ratio. What feels best?
var ratio = this.get("windowWidth") / (this.get("windowHeight") + 120 + 60);
return ratio < 1;
}.property("rawPortraitMode", "windowHeight", "windowWidth"),
isTooSmallForRightColumn: function() {
if ( ! f.has_bttv && this.get('portraitMode') ) {
var size = this.get('fullSizePlayerDimensions'),
extra = this.get('ffzExtraHeight'),
height = size.height + extra;
// Make sure we have at least a bit of room for the chat.
return this.get("windowHeight") < height;
} else
return this.get("windowWidth") < (1090 - this.get('rightColumnWidth'))
}.property("ffzExtraHeight", "windowWidth", "rightColumnWidth", "fullSizePlayerDimensions", "windowHeight"),
contentWidth: function() {
var left_width = LS(f.settings.socialbar_hide ? 0 : this.get('isSocialColumnCollapsed') ? 50 : 240),
right_width = ! f.has_bttv && this.get('portraitMode') ? 0 : this.get("isRightColumnClosed") ? 0 : this.get("rightColumnWidth");
return this.get("windowWidth") - left_width - right_width - LS(60);
}.property("windowWidth", 'ffzMinimizeNavigation', "portraitMode", "isRightColumnClosed", "rightColumnWidth", "isSocialColumnCollapsed"),
ffzExtraHeight: function() {
return (this.get('ffzMinimizeNavigation') ? 10 : 50) +
(f.settings.channel_bar_collapse ? 10 : 60) + 15 +
(f.settings.channel_title_top === 2 ? 20 : f.settings.channel_title_top > 0 ? 55 : 0) +
(f.settings.channel_title_top ? 70 : 80);
}.property("ffzMinimizeNavigation"),
fullSizePlayerDimensions: function() {
var h = this.get('windowHeight'),
c = this.get('PLAYER_CONTROLS_HEIGHT'),
r = this.get('contentWidth'),
extra_height = this.get('ffzExtraHeight'),
extra_theater_height = 120 + (f.settings.channel_bar_collapse ? 10 : 60) + 40,
i = Math.round(9 * r / 16) + c,
d = h - extra_height,
e = h - extra_theater_height,
l = Math.floor(r),
o = Math.floor(Math.min(i, d)),
s = Math.floor(Math.min(i, e));
return {
width: l,
height: o,
targetHeight: s
}
}.property("ffzExtraHeight", "contentWidth", "windowHeight", "portraitMode", "PLAYER_CONTROLS_HEIGHT"),
playerStyle: function() {
var size = this.get('fullSizePlayerDimensions');
return '<style>' +
'.dynamic-player, .dynamic-player object, .dynamic-player video {' +
'width:' + size.width + 'px !important;' +
'height:' + size.height + 'px !important}' +
'.dynamic-target-player, .dynamic-target-player object, .dynamic-target-player video {' +
'width:' + size.width + 'px !important;' +
'height:' + size.targetHeight + 'px !important}' +
'.dynamic-player .player object,' +
'.dynamic-player .player video {' +
'width: 100% !important;' +
'height: 100% !important}';
}.property("fullSizePlayerDimensions"),
ffzPortraitWarning: function() {
var t = this;
// Delay this, in case we're just resizing the window.
setTimeout(function() {
if ( ! f.settings.portrait_mode || f._portrait_warning || f.settings.portrait_warning || document.body.getAttribute('data-current-path').indexOf('user.') !== 0 || ! t.get('isTooSmallForRightColumn') )
return;
f._portrait_warning = true;
f.show_message('Twitch\'s Chat Sidebar has been hidden as a result of FrankerFaceZ\'s Portrait Mode because the window is too wide.<br><br>Please <a href="#" onclick="ffz.settings.set(\'portrait_mode\',0);jQuery(this).parents(\'.ffz-noty\').remove();ffz._portrait_warning = false;return false">disable Portrait Mode</a> or make your window narrower.<br><br><a href="#" onclick="ffz.settings.set(\'portrait_warning\',true);jQuery(this).parents(\'.ffz-noty\').remove();return false">Do not show this message again</a>');
}, 50);
}.observes("isTooSmallForRightColumn"),
ffzUpdateCss: function() {
var window_height = this.get('windowHeight'),
window_width = this.get('windowWidth'),
width = this.get('rightColumnWidth'),
out = '';
if ( ! f.has_bttv ) {
if ( ! this.get('isRightColumnClosed') ) {
if ( this.get('portraitMode') ) {
var size = this.get('fullSizePlayerDimensions'),
video_below = this.get('portraitVideoBelow'),
top_height = this.get('ffzMinimizeNavigation') ? 10 : 50,
video_height = size.height + this.get('ffzExtraHeight'),
chat_height = window_height - video_height,
video_top = video_below ? chat_height : 0,
video_bottom = window_height - (video_top + video_height),
chat_top = video_below ? 0 : video_height,
theatre_video_height = Math.floor(Math.max(window_height * 0.1, Math.min(window_height - 300, 9 * window_width / 16))),
theatre_chat_height = window_height - theatre_video_height,
theatre_video_top = video_below ? theatre_chat_height : 0,
theatre_video_bottom = window_height - (theatre_video_top + theatre_video_height),
theatre_chat_top = video_below ? 0 : theatre_video_height;
out += '.player-mini {' +
'bottom: ' + (10 + video_bottom) + 'px}' +
'.ffz-channel-bar-bottom .player-mini {' +
'bottom: ' + (60 + video_bottom) + 'px}' +
'.ffz-channel-bar-bottom.ffz-minimal-channel-bar .player-mini {' +
'bottom: ' + (20 + video_bottom) + 'px}' +
'.ffz-sidebar-swap .player-mini {' +
'left: 10px !important}' +
'body[data-current-path^="user."] #left_col .warp { min-height: inherit }' +
'body[data-current-path^="user."] #left_col { overflow: hidden }' +
'body[data-current-path^="user."] .social-column {' +
'top:' + video_top + 'px;' +
'height:' + (video_height - top_height) + 'px}' +
'body[data-current-path^="user."] #left_col .warp,' +
'body[data-current-path^="user."] #left_col,' +
'body[data-current-path^="user."] .searchPanel--slide,' +
'body[data-current-path^="user."]:not(.ffz-sidebar-swap) #main_col{' +
'margin-right:0 !important;' +
'top:' + video_top + 'px;' +
'height:' + (video_height - top_height) + 'px}' +
'body[data-current-path^="user."].ffz-sidebar-swap #main_col{' +
'margin-left:0 !important;' +
'top:' + video_top + 'px;' +
'height:' + (video_height - top_height) + 'px}' +
'body[data-current-path^="user."] #right_col{' +
'width:100%;' +
'top:' + (video_below ? chat_top : chat_top - top_height) + 'px;' +
'height:' + chat_height + 'px}' +
'body[data-current-path^="user."] .app-main.theatre .social-column,' +
'body[data-current-path^="user."] .app-main.theatre #left_col .warp,' +
'body[data-current-path^="user."] .app-main.theatre #left_col,' +
'body[data-current-path^="user."] .app-main.theatre #player,' +
'body[data-current-path^="user."] .app-main.theatre #main_col{' +
'top:' + theatre_video_top + 'px;' +
'height:' + theatre_video_height + 'px !important}' +
'body[data-current-path^="user."] .app-main.theatre #right_col{' +
'top:' + theatre_chat_top + 'px;' +
'height:' + theatre_chat_height + 'px}' +
'.app-main.theatre #player {' +
'left: 0 !important;right: 0 !important}' +
'body.ffz-minimal-channel-bar:not(.ffz-channel-bar-bottom) .cn-bar-fixed {' +
'top: ' + (video_top - 40) + 'px}' +
'body.ffz-minimal-channel-bar:not(.ffz-channel-bar-bottom) .cn-bar-fixed:hover,' +
'body:not(.ffz-channel-bar-bottom) .cn-bar-fixed {' +
'top: ' + video_top + 'px}' +
'.ffz-minimal-channel-bar.ffz-channel-bar-bottom .cn-bar {' +
'bottom: ' + (video_bottom - 40) + 'px}' +
'.ffz-minimal-channel-bar.ffz-channel-bar-bottom .cn-bar:hover,' +
'.ffz-channel-bar-bottom .cn-bar {' +
'bottom: ' + video_bottom + 'px}' +
'body:not(.ffz-sidebar-swap) .cn-bar-fixed { right: 0 !important }' +
'body.ffz-sidebar-swap .cn-bar-fixed { left: 0 !important }' +
'.ffz-theater-stats .app-main.theatre .cn-hosting--bottom,' +
'.ffz-theater-stats .app-main.theatre .cn-metabar__more {' +
'max-width:calc(100% - 350px);' +
'bottom:' + (theatre_video_bottom + 85) + 'px !important}' +
'.ffz-theater-stats:not(.ffz-theatre-conversations):not(.ffz-top-conversations) .app-main.theatre .cn-metabar__more {' +
'bottom:' + (theatre_video_bottom + 130) + 'px !important}' +
(video_below ? '.js-player-persistent {' +
'margin-top:-' + video_top + 'px}' +
'.ffz-sidebar-minimize .has-sc .js-player-persistent {' +
'margin-top:-' + (video_top - 40) + 'px}' : '');
} else {
out += '.ffz-sidebar-swap .player-mini{left:' + (width + 10) + 'px !important}' +
'#main_col.expandRight #right_close{left: none !important}' +
'#right_col{width:' + width + 'px}' +
'body:not(.ffz-sidebar-swap) #main_col:not(.expandRight){' +
'margin-right:' + width + 'px !important}' +
'body.ffz-sidebar-swap .theatre #main_col:not(.expandRight),' +
'body.ffz-sidebar-swap #main_col:not(.expandRight){' +
'margin-left:' + width + 'px !important}' +
'body:not(.ffz-sidebar-swap) .app-main.theatre #main_col:not(.expandRight) #player {' +
'right: ' + width + 'px !important}' +
'body.ffz-sidebar-swap .app-main.theatre #main_col:not(.expandRight) #player {' +
'right: 0 !important;' +
'left:' + width + 'px !important}' +
'body:not(.ffz-sidebar-swap) #main_col:not(.expandRight) .cn-bar-fixed {' +
'right: ' + width + 'px}' +
'body.ffz-sidebar-swap .theatre .cn-hosting--bottom,' +
'body.ffz-sidebar-swap .theatre .cn-metabar__more {' +
'left: ' + (width + 10) + 'px !important}' +
'body.ffz-sidebar-swap #main_col:not(.expandRight) .cn-bar-fixed {' +
'left: ' + width + 'px !important}' +
'.ffz-theater-stats .app-main.theatre .cn-hosting--bottom,' +
'.ffz-theater-stats .app-main.theatre .cn-metabar__more {' +
'max-width: calc(100% - ' + (width + 350) + 'px)}';
}
}
f._layout_style.innerHTML = out;
}
}.observes("ffzExtraHeight", "isRightColumnClosed", "fullSizePlayerDimensions", "rightColumnWidth", "portraitMode", "windowHeight", "windowWidth"),
ffzUpdatePlayerStyle: function() {
Ember.propertyDidChange(Layout, 'playerStyle');
}.observes('windowHeight', 'windowWidth'),
ffzUpdatePortraitCSS: function() {
var portrait = this.get("portraitMode");
document.body.classList.toggle("ffz-portrait", ! f.has_bttv && portrait);
}.observes("portraitMode"),
ffzFixTabs: function() {
if ( f.settings.group_tabs && f._chatv && f._chatv._ffz_tabs ) {
setTimeout(function() {
var cr = f._chatv && f._chatv.$('.chat-room');
cr && cr.css && cr.css('top', f._chatv._ffz_tabs.offsetHeight + "px");
},0);
}
}.observes("isRightColumnClosed", "rightColumnWidth", "portraitMode", "fullSizePlayerDimensions")
});
// Force the layout to update.
Layout.set('rightColumnWidth', this.settings.right_column_width);
Layout.set('rawPortraitMode', this.settings.portrait_mode);
Layout.set('ffzMinimizeNavigation', this.settings.minimize_navigation);
// Force re-calculation of everything.
Ember.propertyDidChange(Layout, 'windowWidth');
Ember.propertyDidChange(Layout, 'windowHeight');
Ember.propertyDidChange(Layout, 'ffzExtraHeight');
Ember.propertyDidChange(Layout, 'isTooSmallForRightColumn');
Ember.propertyDidChange(Layout, 'fullSizePlayerDimensions');
Layout.ffzUpdatePortraitCSS();
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,307 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// ---------------
// Settings
// ---------------
FFZ.settings_info.player_stats = {
type: 'select',
options: {
0: ['Disabled', -2],
'-1': ['Monochrome', -1],
10: 'Warning Colors (10s+)',
15: 'Warning Colors (15s+)',
20: 'Warning Colors (20s+)',
25: 'Warning Colors (25s+)',
30: 'Warning Colors (30s+)',
},
value: 0,
process_value: utils.process_int(0, 0, -1),
no_mobile: true,
category: "Channel Metadata",
name: "Stream Latency",
help: "Display your current stream latency (how far behind the broadcast you are) under the player, with a few useful statistics in a tooltip.",
on_update: function(val) {
if ( this._cindex )
this._cindex.ffzUpdateMetadata('player_stats');
}
};
FFZ.settings_info.classic_player = {
type: "boolean",
value: false,
no_mobile: true,
category: "Player",
name: "Classic Player",
help: "Alter the appearance of the player to resemble the older Twitch player with always visible controls.",
on_update: function(val) {
utils.toggle_cls('ffz-classic-player')(val);
var Layout = utils.ember_lookup('service:layout');
if ( Layout )
Layout.set('PLAYER_CONTROLS_HEIGHT', val ? 32 : 0);
}
};
FFZ.settings_info.player_volume_bar = {
type: "boolean",
value: false,
no_mobile: true,
category: "Player",
name: "Volume Always Expanded",
help: "Keep the volume slider expanded even when not hovering over it with the mouse.",
on_update: utils.toggle_cls('ffz-player-volume')
};
FFZ.settings_info.player_volume_scroll = {
type: "boolean",
value: false,
no_mobile: true,
category: "Player",
name: "Adjust Volume by Scrolling",
help: "Adjust the player's volume by scrolling up and down with your mouse wheel."
};
/*FFZ.settings_info.player_pause_hosts = {
type: "select",
options: {
0: "Disabled",
1: "When Hosting Channel was Paused",
2: "Always"
},
value: 1,
process_value: utils.process_int(1),
category: "Player",
name: "Auto-Pause Hosted Channels",
help: "Automatically pause hosted channels if you paused the channel doing the hosting, or just pause all hosts."
}*/
// ---------------
// Initialization
// ---------------
FFZ.prototype.setup_player = function() {
utils.toggle_cls('ffz-player-volume')(this.settings.player_volume_bar);
utils.toggle_cls('ffz-classic-player')(this.settings.classic_player);
var Layout = utils.ember_lookup('service:layout');
if ( Layout )
Layout.set('PLAYER_CONTROLS_HEIGHT', this.settings.classic_player ? 32 : 0);
this.update_views('component:twitch-player2', this.modify_twitch_player);
this.update_views('component:persistent-player', this.modify_persistent_player);
}
// ---------------
// Component
// ---------------
FFZ.prototype.modify_persistent_player = function(player) {
var f = this;
utils.ember_reopen_view(player, {
ffz_init: function() {
var t = this;
this.$().off('mousewheel').on('mousewheel', function(event) {
if ( ! f.settings.player_volume_scroll )
return;
// I ain't about that life, jQuery.
event = event.originalEvent || event;
var delta = event.wheelDelta || -event.detail,
player = t.childViews && t.childViews[0] && t.childViews[0].get('player');
if ( player )
player.volume = Math.max(0, Math.min(1, player.volume + (delta > 0 ? .1 : -.1)));
event.preventDefault();
return false;
});
}
})
}
FFZ.prototype.modify_twitch_player = function(player) {
var f = this;
utils.ember_reopen_view(player, {
ffz_init: function() {
// We can have multiple players in page now, thanks to persistent players.
// Usually the second one will be something we don't want though. Like
// the creative showcase.
if ( ! f._player || f._player.isDestroying || f._player.isDestroyed )
f._player = this;
var player = this.get('player');
if ( player && !this.get('ffz_post_player') )
this.ffzPostPlayer();
},
ffz_destroy: function() {
if ( f._player === this )
f._player = undefined;
},
/*insertPlayer: function(ffz_reset) {
// We want to see if this is a hosted video on a play
var should_start_paused = this.get('shouldStartPaused'),
channel_id = this.get('hostChannel.name'),
hosted_id = this.get('channel.name'),
is_hosting = channel_id !== hosted_id;
// Always start unpaused if the person used the FFZ setting to Reset Player.
if ( ffz_reset )
this.set('shouldStartPaused', false);
// Alternatively, depending on the setting...
else if ( f.settings.player_pause_hosts === 2 && is_hosting )
this.set('shouldStartPaused', true);
this._super();
// Restore the previous value so it doesn't mess anything up.
this.set('shouldStartPaused', should_start_paused);
}.on('didInsertElement'),*/
postPlayerSetup: function() {
this._super();
try {
if ( ! this.get('ffz_post_player') )
this.ffzPostPlayer();
} catch(err) {
f.error("Player2 postPlayerSetup: " + err);
}
},
ffzRecreatePlayer: function() {
var t = this,
player = this.get('player'),
theatre, fullscreen, had_player = false;
// Tell the player to destroy itself.
if ( player ) {
had_player = true;
fullscreen = player.fullscreen;
theatre = player.theatre;
player.fullscreen = false;
player.theatre = false;
player.destroy();
}
// Break down everything left over from that player.
this.$('#player').html('');
Mousetrap.unbind(['alt+x', 'alt+t', 'esc']);
this.set('player', null);
this.set('ffz_post_player', false);
// Now, let Twitch create a new player as usual.
Ember.run.next(function() {
t.didInsertElement();
had_player && setTimeout(function() {
var player = t.get('player');
if ( player ) {
//player.fullscreen = fullscreen;
player.theatre = theatre;
}
})
});
},
/*ffzUpdatePlayerPaused: function() {
var channel_id = this.get('hostChannel.name'),
hosted_id = this.get('channel.name'),
is_hosting = channel_id !== hosted_id,
player = this.get('player'),
is_paused = player.paused;
f.log("Player Pause State for " + channel_id + ": " + is_paused);
if ( ! is_hosting ) {
this.set('ffz_host_paused', false);
this.set('ffz_original_paused', is_paused);
return;
}
if ( ! f.settings.player_pause_hosts || is_paused || this.get('ffz_host_paused') )
return;
this.set('ffz_host_paused', true);
if ( this.get('ffz_original_paused') || f.settings.player_pause_hosts === 2 )
player.pause();
},
ffzHostChange: function() {
this.set('ffz_host_paused', false);
}.observes('channel'),*/
ffzPostPlayer: function() {
var t = this,
/*channel_id = this.get('hostChannel.name'),
hosted_id = this.get('channel.name'),
is_hosting = channel_id !== hosted_id,*/
player = this.get('player');
if ( ! player )
return;
this.set('ffz_post_player', true);
//if ( ! is_hosting )
// this.set('ffz_original_paused', player.paused);
//player.addEventListener('pause', this.ffzUpdatePlayerPaused.bind(this));
//player.addEventListener('play', this.ffzUpdatePlayerPaused.bind(this));
// Make the stats window draggable and fix the button.
var stats = this.$('.player .js-playback-stats');
stats.draggable({cancel: 'li', containment: 'parent'});
// Add an option to the menu to recreate the player.
var t = this,
el = this.$('.player-buttons-right .pl-flex')[0],
container = el && el.parentElement;
if ( el && ! container.querySelector('.ffz-player-reset') ) {
var btn = utils.createElement('button', 'player-button player-button--reset ffz-player-reset');
btn.type = 'button';
btn.innerHTML = '<span class="player-tip js-control-tip" data-tip="Double-Click to Reset Player"></span>' +
constants.CLOSE;
jQuery(btn).on('dblclick', function(e) {
//btn.addEventListener('click', function(e) {
t.ffzRecreatePlayer();
e.preventDefault();
return false;
});
container.insertBefore(btn, el.nextSibling);
}
}
});
}

File diff suppressed because it is too large Load diff

View file

@ -1,47 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require("../utils");
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_router = function() {
this.log("Hooking the Ember router.");
if ( ! window.App )
return;
var f = this,
Router = utils.ember_lookup('router:main');
if ( Router )
Router.reopen({
ffzTransition: function() {
// TODO: Do this before the transition happens.
if ( f._force_refresh ) {
location.href = this.get('url');
return;
}
// If we're coming from a page without app-main, make sure we install the
// scroll listener.
f.fix_scroll();
f.try_modify_dashboard();
try {
document.body.setAttribute('data-current-path', App.get('currentPath'));
} catch(err) {
f.error("ffzTransition: " + err);
}
}.on('didTransition')
});
document.body.setAttribute('data-current-path', App.get('currentPath'));
}
FFZ.ws_commands.please_refresh = function() {
this.log("Refreshing the page upon the next transition.");
this._force_refresh = true;
}

View file

@ -1,514 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants'),
PRESENCE_SERVICE = 'service:twitch-presence/presence',
was_invisible = false;
// --------------------
// Settings
// --------------------
/*FFZ.settings_info.sidebar_followed_games = {
type: "select",
options: {
0: "Disabled",
5: "Normal (5)",
10: "Large (10)",
999: "No Limit"
},
value: 5,
process_value: utils.process_int(5),
no_mobile: true,
category: "Sidebar",
name: "Followed Games",
help: "Display this number of followed games on the sidebar.",
on_update: function(val) {
var controller = utils.ember_lookup('controller:games-following');
if ( controller )
controller.set('ffz_sidebar_games', val);
}
};*/
FFZ.settings_info.sidebar_hide_recommended_channels = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Hide Recommended Channels",
help: "Hide the Recommended Channels section from the sidebar.",
on_update: utils.toggle_cls('ffz-hide-recommended-channels')
};
FFZ.settings_info.socialbar_hide = {
type: "boolean",
value: false,
no_mobile: true,
category: "Sidebar",
name: "Hide Social Bar",
help: "Hide the social bar to the left of the page.",
on_update: function(val) {
utils.toggle_cls('ffz-hide-socialbar')(val);
var Layout = utils.ember_lookup('service:layout');
Layout && Ember.propertyDidChange(Layout, 'contentWidth');
}
}
FFZ.settings_info.sidebar_hide_prime = {
type: "select",
options: {
0: "Disabled",
1: "When Collapsed",
2: "Always"
},
value: 0,
process_value: utils.process_int(0),
no_mobile: true,
category: "Sidebar",
name: "Hide Twitch Prime Offers",
help: "Hide the Free with Prime section from the sidebar.",
on_update: function(val) {
utils.toggle_cls('ffz-hide-prime')(val === 2);
utils.toggle_cls('ffz-hide-prime-collapsed')(val === 1);
}
};
FFZ.settings_info.sidebar_hide_promoted_games = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Hide Promoted Games",
help: "Hide the Promoted Games section from the sidebar.",
on_update: utils.toggle_cls('ffz-hide-promoted-games')
};
FFZ.settings_info.sidebar_hide_recommended_friends = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Hide Recommended Friends",
help: "Hide the Recommended Friends section from the sidebar.",
on_update: utils.toggle_cls('ffz-hide-recommended-friends')
};
FFZ.settings_info.sidebar_hide_friends_collapsed = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Hide Friends when Collapsed",
help: "Hide your friends from the sidebar when it's collapsed.",
on_update: utils.toggle_cls('ffz-hide-friends-collapsed')
};
FFZ.settings_info.sidebar_hide_more_at_twitch = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Hide More at Twitch",
help: "Hide the More at Twitch section from the sidebar.",
on_update: utils.toggle_cls('ffz-hide-more-at-twitch')
};
FFZ.settings_info.disable_friend_notices = {
type: 'boolean',
value: false,
category: 'Chat Filtering',
no_mobile: true,
name: 'Disable Watching Friends Notices',
help: 'Do not display notices in chat when your friends are watching the same stream.'
};
FFZ.settings_info.sidebar_disable_friends = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Disable Friends",
help: "Hide the Friends UI entirely and set you as Invisible.",
on_update: function(val) {
utils.toggle_cls('ffz-hide-friends')(val);
var presence = utils.ember_lookup(PRESENCE_SERVICE);
if ( presence ) {
if ( val )
presence.setVisibility('none');
else if ( ! was_invisible )
presence.setVisibility('full');
}
}
};
var TWITCH_NAV_COLOR = "#4b367c",
TWITCH_NAV_RGB = FFZ.Color.RGBA.fromCSS(TWITCH_NAV_COLOR),
TWITCH_NAV_Luv = TWITCH_NAV_RGB.toLUVA();
FFZ.settings_info.top_nav_color = {
type: "button",
value: "#4b367c",
category: "Sidebar",
no_mobile: true,
name: "Top Navigation Color",
help: "Set a custom background color for the top navigation bar.",
on_update: function(val) {
var process = true;
val = val.trim();
if ( val.charAt(0) === '!' ) {
process = false;
val = val.substr(1).trimLeft();
}
var color = val && FFZ.Color.RGBA.fromCSS(val),
color_luv = color && color.toLUVA();
if ( ! val || process && ! color )
return utils.update_css(this._theme_style, 'top-nav-color');
else if ( process && color_luv.l > TWITCH_NAV_Luv.l ) {
color = color_luv._l(TWITCH_NAV_Luv.l).toRGBA();
val = color.toCSS();
}
var out = '.theme--dark .top-nav__menu,.top-nav__menu,.top-nav__drawer-anchor,.top-nav__logo{background-color:' + val + '}';
if ( color.luminance() > 0.2 ) {
out += '.top-nav__search .form__icon svg,.top-nav .notification-center__icon svg,.top-nav .prime-logo-crown.prime-logo-crown--white svg,.top-nav__logo svg path, .top-nav__overflow svg path{fill: #000}' +
'.top-nav__user-card:after{border-top-color:#000}' +
'.top-nav__nav-link .pill{background-color:rgba(0,0,0,0.2)}' +
'.top-nav__user-status,.ffz-dark .top-nav__nav-link,.top-nav__nav-link{color: #111!important}' +
'.top-nav__nav-link .ffz-follow-count,.top-nav #user_display_name,.ffz-dark .top-nav__nav-link:hover,.top-nav__nav-link:hover{color: #000!important}' +
'.ffz-dark .top-nav__search .form__input[type=text],.top-nav__search .form__input[type=text]{color:#000;background-color:rgba(0,0,0,0.05);box-shadow:rgba(0,0,0,0.2) 0 0 0 1px inset}' +
'.ffz-dark .top-nav__search .form__input[type=text]:focus,.top-nav__search .form__input[type=text]:focus{box-shadow:rgba(0,0,0,0.4) 0 0 0 1px inset}';
} else
out += '.top-nav__nav-link{color: #d7d7d7}';
utils.update_css(this._theme_style, 'top-nav-color', out);
},
method: function() {
var f = this;
utils.prompt(
"Top Navigation Color",
"Please enter a custom color for the top navigation bar. This supports any valid CSS color or color name.</p><p><b>Examples:</b> <code>red</code>, <code>orange</code>, <code>#333</code>, <code>rgb(255,127,127)</code></p><p><b>Note:</b> Colors will be darkened by default. To prevent a color being darkened, please start your input with an exclamation mark. Example: <code>!orange</code>",
this.settings.top_nav_color,
function(new_val) {
if ( new_val === null || new_val === undefined )
return;
new_val = new_val.trim();
f.settings.set("top_nav_color", new_val);
}
)
}
}
/*FFZ.settings_info.sidebar_start_open = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Automatically Open Drawer",
help: "Open the drawer at the bottom of the sidebar by default when the page is loaded."
};*/
FFZ.settings_info.sidebar_directly_to_followed_channels = {
type: "boolean",
value: false,
category: "Sidebar",
no_mobile: true,
name: "Open Following to Channels",
help: "When going to your Following directory, view the Live Channels tab by default."
};
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_sidebar = function() {
var s = this._theme_style = utils.createElement('style');
s.id = 'ffz-theme-style';
s.type = 'text/css';
document.head.appendChild(s);
FFZ.settings_info.top_nav_color.on_update.call(this, this.settings.top_nav_color);
// CSS to Hide Stuff
utils.toggle_cls('ffz-hide-promoted-games')(this.settings.sidebar_hide_promoted_games);
utils.toggle_cls('ffz-hide-recommended-channels')(this.settings.sidebar_hide_recommended_channels);
utils.toggle_cls('ffz-hide-recommended-friends')(this.settings.sidebar_hide_recommended_friends);
utils.toggle_cls('ffz-hide-friends-collapsed')(this.settings.sidebar_hide_friends_collapsed);
utils.toggle_cls('ffz-hide-more-at-twitch')(this.settings.sidebar_hide_more_at_twitch);
utils.toggle_cls('ffz-hide-friends')(this.settings.sidebar_disable_friends);
utils.toggle_cls('ffz-hide-prime')(this.settings.sidebar_hide_prime === 2);
utils.toggle_cls('ffz-hide-prime-collapsed')(this.settings.sidebar_hide_prime === 1);
utils.toggle_cls('ffz-hide-socialbar')(this.settings.socialbar_hide);
if ( this.settings.sidebar_disable_friends ) {
try {
var presence = utils.ember_lookup(PRESENCE_SERVICE);
if ( presence ) {
was_invisible = presence.get('isInvisible') || false;
presence.setVisibility('none');
}
} catch(err) {
this.error("Setting Friends Visibility", err);
}
}
// Sidebar Followed Games
/*var f = this,
GamesFollowing = utils.ember_lookup('controller:games-following');
if ( GamesFollowing ) {
this.log("Hooking the Ember games-following controller.");
GamesFollowing.reopen({
ffz_sidebar_games: this.settings.sidebar_followed_games,
sidePanelFollowing: function() {
var content = this.get('liveFollowing.sortedContent'),
limit = this.get('ffz_sidebar_games');
return limit === 999 ? content : _.first(content, limit);
}.property("liveFollowing.@each", "ffz_sidebar_games")
});
Ember.propertyDidChange(GamesFollowing, 'sidePanelFollowing');
} else
this.error("Unable to load the Ember games-following controller.", null);*/
// Navigation Component
var f = this;
this.update_views('component:twitch-navigation', function(x) { return f.modify_navigation(x, false) });
this.update_views('component:top-nav', function(x) { return f.modify_navigation(x, true) });
this.update_views('component:recommended-channels', this.modify_recommended_channels);
this.update_views('component:social-column/followed-channel', this.modify_social_followed_channel)
// Navigation Service
/*var NavService = utils.ember_lookup('service:navigation');
if ( NavService ) {
// Open Drawer by Default
var Layout = utils.ember_lookup('service:layout');
if ( this.settings.sidebar_start_open && Layout && ! Layout.get('isSocialColumnEnabled') )
NavService.set('isDrawerOpen', true);
} else
this.error("Unable to load the Ember Navigation service.")*/
}
FFZ.prototype.setup_following_link = function() {
var f = this,
following_link = document.body.querySelector('#header_following');
if ( following_link ) {
following_link.href = '/directory/following' + (f.settings.sidebar_directly_to_followed_channels ? '/live' : '');
following_link.addEventListener('click', function(e) {
following_link.href = '/directory/following' + (f.settings.sidebar_directly_to_followed_channels ? '/live' : '');
});
}
}
FFZ.prototype.modify_recommended_channels = function(component) {
utils.ember_reopen_view(component, {
ffz_init: function() {
var el = this.get('element');
el.classList.add('js-recommended-channels');
}
});
}
FFZ._sc_followed_tooltip_id = 0;
FFZ.prototype.modify_social_followed_channel = function(component) {
var f = this;
utils.ember_reopen_view(component, {
ffzUpdateVisibility: function() {
var el = this.get('element'),
game = this.get('stream.game'),
is_blocked = game ? f.settings.banned_games.indexOf(game.toLowerCase()) !== -1 : false;
el && el.classList.toggle('hidden', is_blocked);
}.observes('stream.game'),
ffz_init: function() {
var t = this,
el = this.get('element'),
card = jQuery('.js-sc-card', el),
data = card && card.data('tipsy');
this.ffzUpdateVisibility();
if ( ! data || ! data.options )
return;
data.options.html = true;
data.options.gravity = utils.tooltip_placement(constants.TOOLTIP_DISTANCE, 'w');
data.options.title = function(el) {
var old_text = t.get('tooltipText');
if ( ! f.settings.following_count )
return old_text; //utils.sanitize(old_text);
var tt_id = FFZ._sc_followed_tooltip_id++;
utils.api.get("streams/" + t.get('stream.id'), null, {version: 5}).then(function(data) {
var el = document.querySelector('#ffz-sc-tooltip-' + tt_id);
if ( ! el || ! data || ! data.stream )
return;
var channel = data.stream.channel,
is_spoilered = f.settings.spoiler_games.indexOf(channel.game && channel.game.toLowerCase()) !== -1,
up_since = f.settings.stream_uptime && data.stream.created_at && utils.parse_date(data.stream.created_at),
now = Date.now() - (f._ws_server_offset || 0),
uptime = up_since && (Math.floor((now - up_since.getTime()) / 60000) * 60) || 0;
var cl = el.parentElement.parentElement.classList;
cl.add('ffz-wide-tip');
cl.add('ffz-follow-tip');
el.innerHTML = '<img class="ffz-image-hover" src="' + utils.quote_san(is_spoilered ? 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg' : data.stream.preview.large) + '">' +
'<span class="ffz-tt-channel-title">' + utils.sanitize(channel.status) + '</span><hr>' +
(uptime > 0 ? '<span class="stat">' + constants.CLOCK + ' ' + utils.duration_string(uptime) + '</span>' : '') +
'<span class="stat">' + constants.LIVE + ' ' + utils.number_commas(data.stream.viewers) + '</span>' +
'<b>' + utils.sanitize(channel.display_name || channel.name) + '</b><br>' +
'<span class="playing">' +
(data.stream.stream_type === 'watch_party' ? '<span class="pill is-watch-party">Vodcast</span> ' : '') +
(channel.game === 'Creative' ?
'Being Creative' :
(channel.game ?
'Playing ' + utils.sanitize(channel.game) :
'Not Playing')) +
'</span>';
});
return '<div id="ffz-sc-tooltip-' + tt_id + '">' + old_text + '</div>';
};
}
})
}
FFZ.prototype.modify_navigation = function(component, is_top_nav) {
var f = this;
utils.ember_reopen_view(component, {
ffz_init: function() {
f._nav = this;
// Fix tooltips now that we've overrode the function.
! is_top_nav && this._initTooltips();
// Override behavior for the Following link.
var el = this.get('element'),
following_link = el && el.querySelector(is_top_nav ? 'a[data-tt_content="directory_following"]' : 'a[data-href="following"]');
if ( following_link ) {
following_link.href = '/directory/following' + (f.settings.sidebar_directly_to_followed_channels ? '/live' : '');
following_link.addEventListener('click', function(e) {
following_link.href = '/directory/following' + (f.settings.sidebar_directly_to_followed_channels ? '/live' : '');
if ( e && (e.button !== 0 || e.ctrlKey || e.metaKey) )
return;
e.stopImmediatePropagation();
e.preventDefault();
utils.transition('directory.following.' + (f.settings.sidebar_directly_to_followed_channels ? 'channels' : 'index'));
return false;
});
}
// Find the Settings warp item.
/*var settings_svg = el && el.querySelector('.svg-nav_settings');
if ( settings_svg ) {
var warp = settings_svg.parentElement.parentElement.parentElement,
container = warp.parentElement;
var figure = utils.createElement('figure', 'warp__avatar', constants.ZREKNARF),
span = utils.createElement('span', 'is-drawer-closed--hide', 'FrankerFaceZ Settings'),
ffz_setting = utils.createElement('a', 'ffz-settings-link html-tooltip', figure),
ffz_warp = utils.createElement('li', 'warp__item', ffz_setting);
ffz_setting.title = 'FrankerFaceZ Settings';
ffz_setting.appendChild(span);
container.insertBefore(ffz_warp, warp.nextElementSibling);
}*/
},
_initTooltips: function() {
this._tipsySelector = this.$("#js-warp a, #small_search button, #small_more button");
this._tipsySelector.off("mouseenter").off("mouseleave").teardownTipsy();
this._tipsySelector.zipsy({
gravity: utils.newtip_placement(constants.TOOLTIP_DISTANCE, 'w')});
this.$('a[data-href="following"]').zipsy({
html: true,
className: function() { return f.settings.following_count ? 'ffz-wide-tip' : '' },
title: function() { return f._build_following_tooltip(this) },
gravity: utils.newtip_placement(constants.TOOLTIP_DISTANCE * 2, 'w')
});
},
ffz_destroy: function() {
if ( f._nav === this )
f._nav = null;
}
});
}

View file

@ -1,105 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
VIEWER_CATEGORIES = [
['staff', 'Staff'],
['admins', 'Admins'],
['global_mods', 'Global Moderators'],
['moderators', 'Moderators'],
['viewers', 'Viewers']
];
// --------------------
// Settings
// --------------------
FFZ.settings_info.sort_viewers = {
type: "boolean",
value: true,
category: "Chat Appearance",
name: "Sort Viewer List",
help: "Make sure the viewer list is alphabetically sorted and place the Broadcaster in their own category."
};
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_viewers = function() {
this.update_views('component:chat/twitch-chat-viewers', this.modify_viewer_list);
}
FFZ.prototype.modify_viewer_list = function(component) {
var f = this;
utils.ember_reopen_view(component, {
lines: function() {
var viewers = this._super();
if ( ! f.settings.sort_viewers )
return this._super();
try {
var viewers = [],
has_broadcaster = false,
raw_viewers = this.get('model.chatters') || {};
// Get the broadcaster name.
var Channel = utils.ember_lookup('controller:channel'),
broadcaster = room_id = this.get('model.id');
// We can get capitalization for the broadcaster from the channel.
if ( Channel && Channel.get('channelModel.id') === room_id ) {
var display_name = Channel.get('channelModel.displayName');
if ( display_name && display_name !== 'jtv' )
FFZ.capitalization[broadcaster] = [display_name, Date.now()];
}
// Iterate over everything~!
for(var i=0; i < VIEWER_CATEGORIES.length; i++) {
var data = raw_viewers[VIEWER_CATEGORIES[i][0]],
label = VIEWER_CATEGORIES[i][1],
first_user = true;
if ( ! data || ! data.length )
continue;
for(var x=0; x < data.length; x++) {
if ( data[x] === broadcaster ) {
has_broadcaster = true;
continue;
}
if ( first_user ) {
viewers.push({category: i18n(label)});
viewers.push({chatter: ""});
first_user = false;
}
var display_name = FFZ.get_capitalization(data[x]);
if ( display_name.trim().toLowerCase() !== data[x] )
display_name = data[x];
viewers.push({chatter: display_name});
}
}
if ( has_broadcaster )
viewers.splice(0, 0,
{category: i18n("Broadcaster")},
{chatter: ""},
{chatter: FFZ.get_capitalization(broadcaster)});
return viewers;
} catch(err) {
f.error("ViewersController lines: " + err);
return this._super();
}
}.property("model.chatters")
});
}

View file

@ -1,164 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require("../utils"),
constants = require("../constants");
// ---------------------
// Settings
// ---------------------
// ---------------------
// Initialization
// ---------------------
FFZ.prototype.setup_vod_chat = function() {
// Get the VOD Chat Service
var f = this,
VODService = utils.ember_lookup('service:vod-chat-service');
if ( VODService )
VODService.reopen({
messageBufferSize: f.settings.scrollback_length,
pushMessage: function(msg) {
if ( msg.get("color") === null ) {
var colors = this.get("colorSettings"),
from = msg.get("from");
if ( from ) {
var clr = colors.get(from);
if ( ! clr ) {
clr = constants.CHAT_COLORS[Math.floor(Math.random() * constants.CHAT_COLORS.length)];
colors.set(from, clr);
}
if ( typeof clr === 'string' )
msg.set('color', clr);
}
}
this.get("messages").pushObject(msg);
var messages = this.get("messages"),
len = this.get("messages.length"),
limit = this.get("messageBufferSize");
if ( len > limit )
messages.removeAt(0, len - limit);
}
});
else
f.error("Unable to locate VOD Chat Service.");
this.update_views('component:video/rechat/display-container', this.modify_vod_chat_display);
}
FFZ.prototype.modify_vod_chat_display = function(component) {
var f = this,
VODService = utils.ember_lookup('service:vod-chat-service');
utils.ember_reopen_view(component, _.extend({
ffz_init: function() {
f._vodc = this;
if ( f.settings.dark_twitch )
this.$().parents('.chat-container').addClass('dark').addClass('theme--dark');
this.parentView.addObserver('layout.isTheatreMode', function() {
if ( f._vodc && f.settings.dark_twitch )
setTimeout(function(){
f._vodc.$().parents('.chat-container').addClass('dark').addClass('theme--dark');
});
});
this.ffzUpdateBadges();
// Load the room, if necessary.
var room_id = this.get('video.channel.id');
if ( room_id && ! f.rooms[room_id] ) {
// Load the room model.
f.log("Loading Room for VOD: " + room_id);
var Room = utils.ember_resolve('model:room'),
room = Room && Room.findOne(room_id);
if ( room && App.currentPath === 'user.chat' )
room.set('isEmbedChat', true);
}
if ( ! f.has_bttv_6 ) {
this.ffzFixStickyBottom();
this.ffzAddKeyHook();
if ( f.settings.chat_hover_pause )
this.ffzEnableFreeze();
}
},
ffz_destroy: function() {
if ( f._vodc === this )
f._vodc = undefined;
var room_id = this.get('video.channel.id'),
room = f.rooms && f.rooms[room_id];
// We don't need the chat room anymore, in theory. This will
// check if the room is still important after a short delay.
if ( room && room.room )
room.room.ffzScheduleDestroy();
this.ffzDisableFreeze();
this.ffzRemoveKeyHook();
},
ffzUpdateBadges: function() {
var t = this,
channel_name = this.get('video.channel.id'),
owner_name = this.get('video.owner.name'),
owner_id = this.get('video.owner._id');
if ( channel_name !== owner_name ) {
t.set('ffzBadgeSet', null);
return Ember.propertyDidChange(t, 'badgeStyle');
}
fetch("https://badges.twitch.tv/v1/badges/channels/" + owner_id + "/display?language=" + (Twitch.receivedLanguage || "en"), {
headers: {
'Client-ID': constants.CLIENT_ID
}
}).then(utils.json).then(function(data) {
t.set('ffzBadgeSet', data.badge_sets);
Ember.propertyDidChange(t, 'badgeStyle');
});
},
badgeStyle: function() {
var badges = this.get('ffzBadgeSet');
if ( ! badges )
return this._super();
var room_id = this.get('video.channel.id'),
output = [];
for(var badge_id in badges) {
var versions = badges[badge_id] && badges[badge_id].versions || {};
for(var version in versions)
output.push(utils.room_badge_css(room_id, badge_id, version, versions[version]));
}
return Ember.String.htmlSafe('<style>' + output.join('') + '</style>');
}.property('badgeSet', 'ffzBadgeSet'),
ffzFreezeUpdateBuffer: function(val) {
if ( val === undefined )
val = this.get('stuckToBottom');
VODService && VODService.set("messageBufferSize", f.settings.scrollback_length + (val ? 0 : 150));
},
_scheduleScrollToBottom: function() {
this._scrollToBottom();
}
}, FFZ.HoverPause));
}

View file

@ -1,99 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require("../utils"),
constants = require("../constants");
// --------------------
// Initialization
// --------------------
FFZ.prototype.setup_ember_wrapper = function() {
this._views_to_update = [];
this._ember_finalized = false;
}
FFZ.prototype.update_views = function(klass, modifier, if_not_exists, immediate, no_modify_existing) {
var original_klass;
if ( typeof klass === 'string' ) {
original_klass = klass;
klass = utils.ember_resolve(klass);
if ( ! klass && if_not_exists ) {
if ( typeof if_not_exists === "function" )
if_not_exists.call(this, klass, modifier);
else {
klass = Ember.Component.extend({});
App.__registry__.register(original_klass, klass);
}
}
if ( ! klass ) {
this.error("Unable to locate the Ember " + original_klass);
return false;
}
} else
original_klass = klass.toString();
if ( this._ember_finalized || immediate || ! this._views_to_update )
this._update_views([[original_klass, klass, modifier, no_modify_existing || false]]);
else
this._views_to_update.push([original_klass, klass, modifier, no_modify_existing || false]);
return true;
}
FFZ.prototype.finalize_ember_wrapper = function() {
this._ember_finalized = true;
var views = this._views_to_update;
this._views_to_update = null;
this._update_views(views);
}
FFZ.prototype._update_views = function(klasses) {
this.log("Updating Ember classes and instances.", klasses);
var updated_instances = 0,
updated_klasses = 0;
// Modify all pending classes and clear them from cache.
for(var i=0; i < klasses.length; i++) {
klasses[i][2].call(this, klasses[i][1]);
updated_klasses++;
try {
klasses[i][1].create().destroy()
} catch(err) {
if ( constants.DEBUG )
this.log("There was an error creating and destroying an instance of the Ember class \"" + klasses[i][0] + "\" to clear its cache.", err);
}
}
// Iterate over all existing views and update them as necessary.
var views = utils.ember_views();
for(var view_id in views) {
var view = views[view_id];
if ( ! view )
continue;
for(var i=0; i < klasses.length; i++)
if ( view instanceof klasses[i][1] ) {
updated_instances++;
try {
if ( ! view.ffz_modified && ! klasses[i][3] )
klasses[i][2].call(this, view);
var func = view.ffz_update || view.ffz_init;
if ( func )
func.call(view);
} catch(err) {
this.error("An error occured when updating an existing Ember instance of: " + klasses[i][0], err);
}
break;
}
}
this.log("Updated " + utils.number_commas(updated_instances) + " existing instances across " + updated_klasses + " classes.");
}

View file

@ -1,572 +0,0 @@
var FFZ = window.FrankerFaceZ,
CSS = /\.([\w\-_]+)\s*?\{content:\s*?"([^"]+)";\s*?background-image:\s*?url\("([^"]+)"\);\s*?height:\s*?(\d+)px;\s*?width:\s*?(\d+)px;\s*?margin:([^;}]+);?([^}]*)\}/mg,
MOD_CSS = /[^\n}]*\.badges\s+\.moderator\s*{\s*background-image:\s*url\(\s*['"]([^'"]+)['"][^}]+(?:}|$)/,
constants = require('./constants'),
utils = require('./utils'),
IS_OSX = constants.IS_OSX,
MODIFIERS = {
59847: {
modifier_offset: '0 15px 15px 0',
modifier: true
},
70852: {
modifier: true,
modifier_offset: '0 5px 20px 0',
extra_width: 5,
shrink_to_fit: true
},
70854: {
modifier: true,
modifier_offset: '30px 0 0'
},
147049: {
modifier: true,
modifier_offset: '4px 1px 0 3px'
},
147011: {
modifier: true,
modifier_offset: '0'
},
70864: {
modifier: true,
modifier_offset: '0'
},
147038: {
modifier: true,
modifier_offset: '0'
}
};
// ---------------------
// Initialization
// ---------------------
FFZ.prototype.setup_emoticons = function() {
this.log("Preparing emoticon system.");
// Usage Data
this.emote_usage = {};
this.log("Creating emoticon style element.");
var s = this._emote_style = document.createElement('style');
s.id = "ffz-emoticon-css";
document.head.appendChild(s);
this.log("Generating CSS for existing API emoticon sets.");
for(var set_id in this.emote_sets) {
var es = this.emote_sets[set_id];
if ( es && es.pending_css ) {
utils.update_css(s, set_id, es.pending_css + (es.css || ''));
es.pending_css = null;
}
}
this.log("Loading global emote sets.");
this.load_global_sets();
this.log("Loading emoji data.");
this.load_emoji_data();
//this.log("Watching Twitch emoticon parser to ensure it loads.");
//this._twitch_emote_check = setTimeout(this.check_twitch_emotes.bind(this), 10000);
this._twitch_inventory_sets = [];
this.refresh_twitch_inventory();
}
FFZ.prototype.refresh_twitch_inventory = function() {
var f = this;
return new Promise(function(succeed) {
var o = [],
user = f.get_user();
if ( ! user ) {
f._twitch_inventory_sets = o;
return succeed(o);
}
utils.api.get("/v5/inventory/emoticons", null, {version: 5}, user.chat_oauth_token)
.done(function(data) {
o = Object.keys(data.emoticon_sets || {});
var ets = f._twitch_emote_to_set = f._twitch_emote_to_set || {};
for(var i=0; i < o.length; i++) {
var set_id = o[i],
emotes = data.emoticon_sets[set_id];
for(var j=0; j < emotes.length; j++)
ets[emotes[j].id] = set_id;
}
f._twitch_inventory_sets = o;
return succeed(o);
}).fail(function() {
f._twitch_inventory_sets = o;
return succeed(o);
});
});
}
// ------------------------
// Emote Usage
// ------------------------
FFZ.prototype.add_usage = function(room_id, emote, count) {
// Only report usage from FFZ emotes. Not extensions to FFZ.
var emote_set = this.emote_sets[emote.set_id];
if ( ! emote_set || emote_set.source_ext )
return;
var emote_id = emote.id,
rooms = this.emote_usage[emote_id] = this.emote_usage[emote_id] || {};
rooms[room_id] = (rooms[room_id] || 0) + (count || 1);
if ( this._emote_report_scheduled )
return;
this._emote_report_scheduled = setTimeout(this._report_emotes.bind(this), 30000);
}
FFZ.prototype._report_emotes = function() {
if ( this._emote_report_scheduled )
delete this._emote_report_scheduled;
var usage = this.emote_usage;
this.emote_usage = {};
this.ws_send("emoticon_uses", [usage], function(){}, true);
}
// ------------------------
// Emote Click Handler
// ------------------------
FFZ.prototype._click_emote = function(target, event) {
if ( ! target || ! target.classList.contains('emoticon') )
return;
if ( event && ! event.shiftKey && ! event.shiftLeft && ((!IS_OSX && event.ctrlKey) || (IS_OSX && event.metaKey)) ) {
var eid, favorite_key;
if ( target.classList.contains('emoji') ) {
var emoji = target.getAttribute('data-ffz-emoji'),
emoji_data = this.emoji_data[emoji];
if ( emoji_data ) {
favorite_key = 'emoji';
eid = emoji_data.raw;
}
} else {
eid = target.getAttribute('data-emote');
if ( eid ) {
eid = parseInt(eid);
var twitch_set = this._twitch_emote_to_set[eid];
if ( twitch_set )
if ( this._twitch_inventory_sets.indexOf(twitch_set) !== -1 )
favorite_key = 'twitch-inventory';
else
favorite_key = 'twitch-' + twitch_set;
} else {
eid = target.getAttribute('data-ffz-emote');
var set_id = target.getAttribute('data-ffz-set'),
emote_set = set_id && this.emote_sets[set_id];
if ( emote_set && emote_set.emoticons && emote_set.emoticons[eid] ) {
favorite_key = 'ffz-' + (emote_set.hasOwnProperty('source_ext') ? 'ext-' + emote_set.source_ext + '-' + emote_set.source_id : set_id);
eid = emote_set.emoticons[eid].id || eid;
}
}
}
if ( favorite_key ) {
var favs = this.settings.favorite_emotes[favorite_key] = this.settings.favorite_emotes[favorite_key] || [],
is_favorited = favs.indexOf(eid) !== -1;
if ( is_favorited )
favs.removeObject(eid);
else
favs.push(eid);
this.settings.set("favorite_emotes", this.settings.favorite_emotes, true);
jQuery(target).trigger('mouseout').trigger('mouseover');
this._inputv && this._inputv.propertyDidChange('ffz_emoticons');
}
return true;
}
if ( ! this.settings.clickable_emoticons || (event && !(event.shiftKey || event.shiftLeft)) )
return;
var eid = target.getAttribute('data-emote');
if ( eid )
window.open("https://twitchemotes.com/emote/" + eid);
else {
eid = target.getAttribute("data-ffz-emote");
var es = target.getAttribute("data-ffz-set"),
emote_set = es && this.emote_sets[es],
url;
if ( ! emote_set )
return;
if ( emote_set.hasOwnProperty('source_ext') ) {
var api = this._apis[emote_set.source_ext];
if ( api && api.emote_url_generator )
url = api.emote_url_generator(emote_set.source_id, eid);
} else
url = "https://www.frankerfacez.com/emoticons/" + eid;
if ( url ) {
window.open(url);
return true;
}
}
}
// ------------------------
// Twitch Emoticon Checker
// ------------------------
/*FFZ.prototype.check_twitch_emotes = function() {
if ( this._twitch_emote_check ) {
clearTimeout(this._twitch_emote_check);
delete this._twitch_emote_check;
}
var room;
if ( this.rooms ) {
for(var key in this.rooms) {
if ( this.rooms.hasOwnProperty(key) ) {
room = this.rooms[key];
break;
}
}
}
if ( ! room || ! room.room || ! room.room.tmiSession ) {
this._twitch_emote_check = setTimeout(this.check_twitch_emotes.bind(this), 10000);
return;
}
var parser = room.room.tmiSession._emotesParser,
emotes = Object.keys(parser.emoticonRegexToIds).length;
// If we have emotes, we're done!
if ( emotes > 0 )
return;
// No emotes. Try loading them.
var sets = parser.emoticonSetIds;
parser.emoticonSetIds = "";
parser.updateEmoticons(sets);
// Check again in a bit to see if we've got them.
this._twitch_emote_check = setTimeout(this.check_twitch_emotes.bind(this), 10000);
}*/
// ---------------------
// Set Management
// ---------------------
FFZ.prototype.getEmotes = function(user_id, room_id) {
var user = this.users && this.users[user_id],
room = this.rooms && this.rooms[room_id],
room_user = room && room.users && room.users[user_id];
return _.union(user && user.sets || [], room_user && room_user.sets || [], room && room.set && [room.set] || [], room && room.extra_sets || [], room && room.ext_sets || [], this.default_sets);
}
// ---------------------
// Commands
// ---------------------
FFZ.ws_commands.reload_set = function(set_id) {
if ( this.emote_sets.hasOwnProperty(set_id) )
this.load_set(set_id);
}
FFZ.ws_commands.load_set = function(set_id) {
this.load_set(set_id);
}
// ---------------------
// Emoji Loading
// ---------------------
FFZ.prototype.load_emoji_data = function(callback, tries) {
var f = this,
puny = window.punycode && punycode.ucs2;
jQuery.getJSON(constants.SERVER + "emoji/emoji-data.json")
.done(function(data) {
var new_data = {},
by_name = {};
for(var eid in data) {
var emoji = data[eid];
eid = eid.toLowerCase();
emoji.code = eid;
new_data[eid] = emoji;
if ( emoji.short_name )
by_name[emoji.short_name] = eid;
if ( emoji.names && emoji.names.length )
for(var x=0,y=emoji.names.length; x < y; x++)
by_name[emoji.names[x]] = eid;
emoji.raw = _.map(emoji.code.split("-"), utils.codepoint_to_emoji).join("");
emoji.tw_src = constants.SERVER + 'emoji/tw/' + eid + '.svg';
emoji.noto_src = constants.SERVER + 'emoji/noto-' + eid + '.svg';
emoji.one_src = constants.SERVER + 'emoji/one/' + eid + '.svg';
emoji.token = {
type: "emoticon",
imgSrc: true,
length: puny ? puny.decode(emoji.raw).length : emoji.raw.length,
tw_src: emoji.tw_src,
noto_src: emoji.noto_src,
one_src: emoji.one_src,
tw: emoji.tw,
noto: emoji.noto,
one: emoji.one,
ffzEmoji: eid,
altText: emoji.raw
};
}
f.emoji_data = new_data;
f.emoji_names = by_name;
f.log("Loaded data on " + Object.keys(new_data).length + " emoji.");
if ( typeof callback === "function" )
callback(true, data);
}).fail(function(data) {
if ( data.status === 404 )
return typeof callback === "function" && callback(false);
tries = (tries || 0) + 1;
if ( tries < 50 )
return f.load_emoji(callback, tries);
return typeof callback === "function" && callback(false);
});
}
// ---------------------
// Set Loading
// ---------------------
FFZ.prototype.load_global_sets = function(callback, tries) {
var f = this;
jQuery.getJSON(constants.API_SERVER + "v1/set/global")
.done(function(data) {
// Apply default sets.
var ds = f.default_sets,
gs = f.global_sets,
sets = data.sets || {};
// Remove non-API sets from default and global sets.
for(var i=ds.length; i--; ) {
var set_id = ds[i];
if ( data.default_sets.indexOf(set_id) === -1 && (!f.feature_friday || f.feature_friday.set !== set_id) && (! f.emote_sets[set_id] || ! f.emote_sets[set_id].source_ext) )
ds.splice(i, 1);
}
for(var i=0; i < data.default_sets.length; i++) {
var set_id = data.default_sets[i];
if ( ds.indexOf(set_id) === -1 )
ds.push(set_id);
}
for(var i=gs.length; i--; ) {
var set_id = gs[i];
if ( ! sets[set_id] && (!f.feature_friday || f.feature_friday.set !== set_id) && (! f.emote_sets[set_id] || ! f.emote_sets[set_id].source_ext) )
gs.splice(i, 1);
}
for(var set_id in sets) {
var set = sets[set_id];
if ( gs.indexOf(set_id) === -1 )
gs.push(set_id);
f._load_set_json(set_id, undefined, set);
}
f._load_set_users(data.users);
}).fail(function(data) {
if ( data.status == 404 )
return typeof callback == "function" && callback(false);
tries = tries || 0;
tries++;
if ( tries < 50 )
return f.load_global_sets(callback, tries);
return typeof callback == "function" && callback(false);
});
}
FFZ.prototype._load_set_users = function(data) {
if ( data )
for(var set_id in data)
if ( data.hasOwnProperty(set_id) ) {
var emote_set = this.emote_sets[set_id],
users = data[set_id];
for(var i=0; i < users.length; i++) {
var user = users[i],
ud = this.users[user] = this.users[user] || {},
sets = ud.sets = ud.sets || [];
if ( sets.indexOf(set_id) === -1 )
sets.push(set_id);
}
this.log('Added "' + (emote_set ? emote_set.title : set_id) + '" emote set to ' + utils.number_commas(users.length) + ' users.');
}
}
FFZ.prototype.load_set = function(set_id, callback, tries) {
var f = this;
jQuery.getJSON(constants.API_SERVER + "v1/set/" + set_id)
.done(function(data) {
f._load_set_json(set_id, callback, data && data.set);
f._load_set_users(data.users);
}).fail(function(data) {
if ( data.status == 404 )
return typeof callback == "function" && callback(false);
tries = tries || 0;
tries++;
if ( tries < 10 )
return f.load_set(set_id, callback, tries);
return typeof callback == "function" && callback(false);
});
}
FFZ.prototype.unload_set = function(set_id) {
var set = this.emote_sets[set_id];
if ( ! set )
return;
this.log("Unloading emoticons for set: " + set_id);
utils.update_css(this._emote_style, set_id, null);
delete this.emote_sets[set_id];
if ( set.hasOwnProperty('source_ext') ) {
var api = this._apis[set.source_ext];
if ( api && api.emote_sets && api.emote_sets[set_id] )
api.emote_sets[set_id] = undefined;
}
if ( this._inputv )
Ember.propertyDidChange(this._inputv, 'ffz_emoticons');
}
FFZ.prototype._load_set_json = function(set_id, callback, data) {
if ( ! data )
return typeof callback == "function" && callback(false);
// Do we have existing users?
var users = this.emote_sets[set_id] && this.emote_sets[set_id].users || [];
// Store our set.
this.emote_sets[set_id] = data;
data.users = users;
data.count = 0;
// Iterate through all the emoticons, building CSS and regex objects as appropriate.
var output_css = "",
ems = data.emoticons;
data.emoticons = {};
for(var i=0; i < ems.length; i++) {
var emote = ems[i];
//emote.klass = "ffz-emote-" + emote.id;
emote.set_id = set_id;
emote.srcSet = emote.urls[1] + " 1x";
if ( emote.urls[2] )
emote.srcSet += ", " + emote.urls[2] + " 2x";
if ( emote.urls[4] )
emote.srcSet += ", " + emote.urls[4] + " 4x";
if ( emote.name[emote.name.length-1] === "!" )
emote.regex = new RegExp("(^|\\W|\\b)(" + utils.escape_regex(emote.name) + ")(?=\\W|$)", "g");
else
emote.regex = new RegExp("(^|\\W|\\b)(" + utils.escape_regex(emote.name) + ")\\b", "g");
emote.token = {
type: "emoticon",
srcSet: emote.srcSet,
imgSrc: emote.urls[1],
ffzEmote: emote.id,
ffzEmoteSet: set_id,
altText: emote.hidden ? '???' : emote.name
};
if ( MODIFIERS.hasOwnProperty(emote.id) )
emote = _.extend(emote, MODIFIERS[emote.id]);
output_css += utils.emote_css(emote);
data.count++;
data.emoticons[emote.id] = emote;
}
utils.update_css(this._emote_style, set_id, output_css + (data.css || ""));
this.log("Updated emoticons for set #" + set_id + ": " + data.title, data);
if ( this._cindex )
this._cindex.ffzFixTitle();
this.update_ui_link();
if ( this._inputv )
Ember.propertyDidChange(this._inputv, 'ffz_emoticons');
this.rerender_feed_cards(set_id);
if ( callback )
callback(true, data);
}

16
src/entry.js Normal file
View file

@ -0,0 +1,16 @@
/* eslint strict: off */
'use strict';
(() => {
if ( location.hostname === 'player.twitch.tv' )
return;
const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev'),
SERVER = DEBUG ? '//localhost:8000' : '//cdn.frankerfacez.com',
FLAVOR = window.Ember ? 'umbral' : 'avalon',
script = document.createElement('script');
script.id = 'ffz-script';
script.src = `${SERVER}/script/${FLAVOR}.js?_=${Date.now()}`;
document.head.appendChild(script);
})();

View file

@ -1,682 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils'),
constants = require('../constants');
// ---------------------
// Badware Check
// ---------------------
FFZ.prototype.check_badware = function() {
if ( this.embed_in_dash || ! window.jQuery || ! window.jQuery.noty )
return;
// Check for the stolen version of BTTV4FFZ.
if ( FFZ.settings_info.bttv_global_emotes && FFZ.settings_info.bttv_global_emotes.category === "BetterTTV" ) {
var shown = localStorage.ffz_warning_bttv4ffz_clone;
if ( shown !== "true" ) {
localStorage.ffz_warning_bttv4ffz_clone = "true";
this.show_message("You appear to be using an unofficial version of BTTV4FFZ that was copied without the developer's permission. Please use the official version available at <a href=\"https://lordmau5.com/bttv4ffz/\">https://lordmau5.com/bttv4ffz/</a>");
}
}
}
// ---------------------
// API Constructor
// ---------------------
var API = FFZ.API = function(instance, name, icon, version, name_key) {
this.ffz = instance || FFZ.get();
// Check for a known API!
if ( name ) {
for(var id in this.ffz._known_apis) {
if ( this.ffz._known_apis[id] === name ) {
this.id = id;
break;
}
}
}
if ( ! this.id ) {
var i = 0;
while( ! this.id ) {
if ( ! this.ffz._known_apis.hasOwnProperty(i) ) {
this.id = i;
break;
}
i++;
}
if ( name ) {
this.ffz._known_apis[this.id] = name;
localStorage.ffz_known_apis = JSON.stringify(this.ffz._known_apis);
}
}
this._events = {};
this.ffz._apis[this.id] = this;
this.emote_sets = {};
this.global_sets = [];
this.default_sets = [];
this.badges = {};
this.users = {};
this.name = name || ("Extension#" + this.id);
this.name_key = name_key || this.name.replace(/[^A-Z0-9_\-]/g, '').toLowerCase();
// We can't start name key with a number.
if ( /^[0-9]/.test(this.name_key) )
this.name_key = '_' + this.name_key;
this.icon = icon || null;
this.version = version || null;
this.ffz.log('Registered New Extension #' + this.id + ' (' + this.name_key + '): ' + this.name);
};
FFZ.prototype.api = function(name, icon, version, name_key) {
// Load the known APIs list.
if ( ! this._known_apis ) {
this._known_apis = {};
var stored_val = localStorage.getItem("ffz_known_apis");
if ( stored_val !== null )
try {
this._known_apis = JSON.parse(stored_val);
} catch(err) {
this.error("Error loading Known APIs", err);
}
}
return new API(this, name, icon, version, name_key);
}
API.prototype.log = function(msg, data, to_json, log_json) {
this.ffz.log('Ext #' + this.id + ' (' + this.name_key + '): ' + msg, data, to_json, log_json);
}
API.prototype.error = function(msg, error, to_json, log_json) {
this.ffz.error('Ext #' + this.id + ' (' + this.name_key + '): ' + msg, error, to_json, log_json);
}
// ---------------------
// Events
// ---------------------
API.prototype.on = function(event, func) {
var e = this._events[event] = this._events[event] || [];
if ( e.indexOf(func) === -1 )
e.push(func);
}
API.prototype.off = function(event, func) {
if ( func === undefined )
this._events[event] = [];
else {
var e = this._events[event] = this._events[event] || [],
ind = e.indexOf(func);
if ( ind !== -1 )
e.splice(ind, 1);
}
}
var slice = Array.prototype.slice;
API.prototype.trigger = function(event /*, args... */) {
var e = this._events[event];
if ( e && e.length )
for(var i=0; i < e.length; i++)
e[i].apply(this, slice.call(arguments, 1));
}
FFZ.prototype.api_trigger = function(/*event, args...*/) {
for(var api_id in this._apis) {
var api = this._apis[api_id];
api.trigger.apply(api, arguments);
}
}
// ---------------------
// Metadata~!
// ---------------------
API.prototype.register_metadata = function(key, data) {
data.source_ext = this.id;
data.source_id = key;
FFZ.channel_metadata[this.id + '-' + key] = data;
this.update_metadata(key);
}
API.prototype.unregister_metadata = function(key) {
delete FFZ.channel_metadata[this.id + '-' + key];
this.update_metadata(key, true);
}
API.prototype.update_metadata = function(key, full_update) {
var real_key = this.id + '-' + key,
channel = this.ffz._cindex;
if ( channel ) {
if ( full_update )
channel.$('.cn-metabar__ffz[data-key="' + real_key + '"]').remove();
channel.ffzUpdateMetadata(real_key);
}
}
// ---------------------
// Set Loading
// ---------------------
API.prototype._load_set = function(real_id, set_id, data) {
if ( ! data )
return null;
// Check for an existing set to copy the users.
var users = [];
if ( this.emote_sets[set_id] && this.emote_sets[set_id].users )
users = this.emote_sets[set_id].users;
var emote_set = _.extend({
source: this.name,
icon: this.icon || null,
title: "Global Emoticons",
_type: 0
}, data, {
source_ext: this.id,
source_id: set_id,
id: real_id,
users: users,
count: 0,
emoticons: {},
});
this.emote_sets[set_id] = emote_set;
// Use the real ID for FFZ's own tracking.
if ( this.ffz.emote_sets )
this.ffz.emote_sets[real_id] = emote_set;
var output_css = "",
ems = data.emoticons,
emoticons = emote_set.emoticons;
for(var i=0; i < ems.length; i++) {
var emote = ems[i],
id = emote.id || (this.name + '-' + set_id + '-' + i);
if ( ! emote.name )
continue;
/*var new_emote = _.extend({}, emote, {
id: id,
set_id: real_id,
srcSet: emote.urls[1] + ' 1x'
});*/
emote.id = id;
emote.set_id = real_id;
emote.srcSet = emote.urls[1] + ' 1x';
if ( emote.urls[2] )
/*new_*/emote.srcSet += ', ' + emote.urls[2] + ' 2x';
if ( emote.urls[3] )
/*new_*/emote.srcSet += ', ' + emote.urls[3] + ' 3x';
if ( emote.urls[4] )
/*new_*/emote.srcSet += ', ' + emote.urls[4] + ' 4x';
/*new_*/emote.token = {
type: "emoticon",
srcSet: /*new_*/emote.srcSet,
imgSrc: /*new_*/emote.urls[1],
ffzEmote: id,
ffzEmoteSet: real_id,
altText: /*new_*/emote.hidden ? '???' : /*new_*/emote.name
};
output_css += utils.emote_css(/*new_*/emote);
emote_set.count++;
emoticons[id] = /*new_*/emote;
}
// Use the real ID for building CSS.
if ( this.ffz._emote_style )
utils.update_css(this.ffz._emote_style, real_id, output_css + (emote_set.css || ""));
else
emote_set.pending_css = output_css;
if ( this.ffz._cindex )
this.ffz._cindex.ffzFixTitle();
try {
this.ffz.update_ui_link();
} catch(err) { }
return emote_set;
}
// -------------------------
// Loading / Unloading Sets
// -------------------------
API.prototype.load_set = function(set_id, emote_set) {
var exact_id = this.id + '-' + set_id,
already_loaded = this.emote_sets[set_id];
emote_set.title = emote_set.title || "Global Emoticons";
emote_set._type = emote_set._type || 0;
emote_set = this._load_set(exact_id, set_id, emote_set);
// Avoid spamming the console if and when other extensions
// spend time constantly updating emote sets.
if ( this.log_sets )
this.log("Loaded Emoticon Set #" + set_id + ": " + emote_set.title + " (" + emote_set.count + " emotes)", emote_set);
return emote_set;
}
API.prototype.unload_set = function(set_id) {
var exact_id = this.id + '-' + set_id,
emote_set = this.emote_sets[set_id];
if ( ! emote_set )
return;
// First, let's unregister it as a global.
this.unregister_global_set(set_id);
// Now, remove the set data.
if ( this.ffz._emote_style )
utils.update_css(this.ffz._emote_style, exact_id, null);
this.emote_sets[set_id] = undefined;
if ( this.ffz.emote_sets )
this.ffz.emote_sets[exact_id] = undefined;
// Remove from all its Rooms
if ( emote_set.users ) {
for(var i=0; i < emote_set.users.length; i++) {
var room_id = emote_set.users[i],
room = this.ffz.rooms && this.ffz.rooms[room_id];
if ( ! room )
continue;
var ind = room.ext_sets ? room.ext_sets.indexOf(exact_id) : -1;
if ( ind !== -1 )
room.ext_sets.splice(ind,1);
}
emote_set.users = [];
}
return emote_set;
}
API.prototype.get_set = function(set_id) {
return this.emote_sets[set_id];
}
// ---------------------
// Global Emote Sets
// ---------------------
API.prototype.register_global_set = function(set_id, emote_set) {
var exact_id = this.id + '-' + set_id;
if ( emote_set ) {
// If a set was provided, load it.
emote_set = this.load_set(set_id, emote_set);
} else
emote_set = this.emote_sets[set_id];
if ( ! emote_set )
throw new Error("Invalid set ID");
// Make sure the set is still available with FFZ.
if ( this.ffz.emote_sets && ! this.ffz.emote_sets[exact_id] )
this.ffz.emote_sets[exact_id] = emote_set;
// It's a valid set if we get here, so make it global.
if ( this.global_sets.indexOf(set_id) === -1 )
this.global_sets.push(set_id);
if ( this.default_sets.indexOf(set_id) === -1 )
this.default_sets.push(set_id);
if ( this.ffz.global_sets && this.ffz.global_sets.indexOf(exact_id) === -1 )
this.ffz.global_sets.push(exact_id);
if ( this.ffz.default_sets && this.ffz.default_sets.indexOf(exact_id) === -1 )
this.ffz.default_sets.push(exact_id);
// Update tab completion.
if ( this.ffz._inputv )
Ember.propertyDidChange(this.ffz._inputv, 'ffz_emoticons');
};
API.prototype.unregister_global_set = function(set_id) {
var exact_id = this.id + '-' + set_id,
emote_set = this.emote_sets[set_id];
if ( ! emote_set )
return;
// Remove the set from global sets.
var ind = this.global_sets.indexOf(set_id);
if ( ind !== -1 )
this.global_sets.splice(ind,1);
ind = this.default_sets.indexOf(set_id);
if ( ind !== -1 )
this.default_sets.splice(ind,1);
ind = this.ffz.global_sets ? this.ffz.global_sets.indexOf(exact_id) : -1;
if ( ind !== -1 )
this.ffz.global_sets.splice(ind,1);
ind = this.ffz.default_sets ? this.ffz.default_sets.indexOf(exact_id) : -1;
if ( ind !== -1 )
this.ffz.default_sets.splice(ind,1);
// Update tab completion.
if ( this.ffz._inputv )
Ember.propertyDidChange(this.ffz._inputv, 'ffz_emoticons');
};
// -----------------------
// Per-Channel Emote Sets
// -----------------------
API.prototype.register_room_set = function(room_id, set_id, emote_set) {
var exact_id = this.id + '-' + set_id,
room = this.ffz.rooms && this.ffz.rooms[room_id];
if ( ! room )
throw new Error("Room not loaded");
if ( emote_set ) {
// If a set was provided, load it.
emote_set.title = emote_set.title || "Channel: " + (room.display_name || room_id);
emote_set._type = emote_set._type || 1;
emote_set = this.load_set(set_id, emote_set);
} else
emote_set = this.emote_sets[set_id];
if ( ! emote_set )
throw new Error("Invalid set ID");
// Make sure the set is still available with FFZ.
if ( ! this.ffz.emote_sets[exact_id] )
this.ffz.emote_sets[exact_id] = emote_set;
// Register it on the room.
if ( room.ext_sets && room.ext_sets.indexOf(exact_id) === -1 )
room.ext_sets.push(exact_id);
if ( emote_set.users.indexOf(room_id) === -1 )
emote_set.users.push(room_id);
// Update tab completion.
if ( this.ffz._inputv )
Ember.propertyDidChange(this.ffz._inputv, 'ffz_emoticons');
}
API.prototype.unregister_room_set = function(room_id, set_id) {
var exact_id = this.id + '-' + set_id,
emote_set = this.emote_sets[set_id],
room = this.ffz.rooms && this.ffz.rooms[room_id];
if ( ! emote_set || ! room )
return;
var ind = room.ext_sets ? room.ext_sets.indexOf(exact_id) : -1;
if ( ind !== -1 )
room.ext_sets.splice(ind,1);
ind = emote_set.users.indexOf(room_id);
if ( ind !== -1 )
emote_set.users.splice(ind,1);
// Update tab completion.
if ( this.ffz._inputv )
Ember.propertyDidChange(this.ffz._inputv, 'ffz_emoticons');
}
// -----------------------
// Badge APIs
// -----------------------
API.prototype.add_badge = function(badge_id, badge) {
var exact_id = this.name_key + '-' + badge_id;
badge.id = badge_id;
badge.source_ext = this.id,
badge.real_id = exact_id;
if ( badge.replaces && badge.replaces !== true && ! badge.replaces_type ) {
badge.replaces_type = badge.replaces;
badge.replaces = true;
}
if ( ! badge.color )
badge.color = "transparent";
this.badges[badge_id] = badge;
if ( this.ffz.badges )
this.ffz.badges[exact_id] = badge;
if ( this.ffz._badge_style )
utils.update_css(this.ffz._badge_style, exact_id, utils.badge_css(badge));
}
API.prototype.remove_badge = function(badge_id) {
var exact_id = this.name_key + '-' + badge_id;
this.badges[badge_id] = undefined;
if ( this.ffz.badges )
this.ffz.badges[exact_id] = undefined;
if ( this.ffz._badge_style )
utils.update_css(this.ffz._badge_style, exact_id);
}
// -----------------------
// User Modifications
// -----------------------
API.prototype.user_add_badge = function(username, slot, badge_id) {
var user = this.users[username] = this.users[username] || {},
ffz_user = this.ffz.users[username] = this.ffz.users[username] || {},
badges = user.badges = user.badges || {},
ffz_badges = ffz_user.badges = ffz_user.badges || {},
badge = typeof badge_id !== "object" ? {id: badge_id} : badge_id;
badge.real_id = this.name_key + '-' + badge.id;
badges[slot] = ffz_badges[slot] = badge;
}
API.prototype.user_remove_badge = function(username, slot) {
var user = this.users[username] = this.users[username] || {},
ffz_user = this.ffz.users[username] = this.ffz.users[username] || {},
badges = user.badges = user.badges || {},
ffz_badges = ffz_user.badges = ffz_user.badges || {};
badges[slot] = ffz_badges[slot] = null;
}
API.prototype.room_add_user_badge = function(room_name, username, slot, badge_id) {
var ffz_room_users = this.ffz.rooms[room_name] && this.ffz.rooms[room_name].users;
if ( ! ffz_room_users )
return;
var ffz_user = ffz_room_users[username] = ffz_room_users[username] || {badges: {}, sets: []},
ffz_badges = ffz_user && ffz_user.badges,
badge = typeof badge_id !== "object" ? {id: badge_id} : badge_id;
badge.real_id = this.name_key + '-' + badge.id;
ffz_badges[slot] = badge;
}
API.prototype.room_remove_user_badge = function(room_name, username, slot) {
var ffz_room_users = this.ffz.rooms[room_name] && this.ffz.rooms[room_name].users,
ffz_user = ffz_room_users && ffz_room_users[username],
ffz_badges = ffz_user && ffz_user.badges;
if ( ffz_badges )
ffz_badges[slot] = null;
}
API.prototype.user_add_set = function(username, set_id) {
var user = this.users[username] = this.users[username] || {},
ffz_user = this.ffz.users[username] = this.ffz.users[username] || {},
emote_sets = user.sets = user.sets || [],
ffz_sets = ffz_user.sets = ffz_user.sets || [],
exact_id = this.id + '-' + set_id;
if ( emote_sets.indexOf(set_id) === -1 )
emote_sets.push(set_id);
if ( ffz_sets.indexOf(exact_id) === -1 )
ffz_sets.push(exact_id);
// Update tab completion.
var user = this.ffz.get_user();
if ( this.ffz._inputv && user && user.login === username )
Ember.propertyDidChange(this.ffz._inputv, 'ffz_emoticons');
}
API.prototype.user_remove_set = function(username, set_id) {
var user = this.users[username],
ffz_user = this.ffz.users[username],
emote_sets = user && user.sets,
ffz_sets = ffz_user && ffz_user.sets,
exact_id = this.id + '-' + set_id;
var ind = emote_sets ? emote_sets.indexOf(set_id) : -1;
if ( ind !== -1 )
emote_sets.splice(ind, 1);
ind = ffz_sets ? ffz_sets.indexOf(exact_id) : -1;
if ( ind !== -1 )
ffz_sets.splice(ind, 1);
// Update tab completion.
var user = this.ffz.get_user();
if ( this.ffz._inputv && user && user.login === username )
Ember.propertyDidChange(this.ffz._inputv, 'ffz_emoticons');
}
API.prototype.retokenize_messages = function(room, user, max_age, update_badges) {
var rooms = room ? [room] : Object.keys(this.ffz.rooms),
ffz_rooms = this.ffz && this.ffz.rooms || {};
for(var i=0; i < rooms.length; i++) {
var room_id = rooms[i],
room = ffz_rooms[room_id];
room && room.room && room.room.ffzRetokenizeUser(user, max_age, update_badges);
}
}
// -----------------------
// Chat Callback
// -----------------------
API.prototype.register_chat_filter = function(filter) {
this.on('room-message', filter);
}
API.prototype.unregister_chat_filter = function(filter) {
this.off('room-message', filter);
}
// -----------------------
// Channel Callbacks
// -----------------------
API.prototype.iterate_chat_views = function(func) {
if ( func === undefined )
func = this.trigger.bind(this, 'chat-view-init');
if ( this.ffz._chatv ) {
var view = this.ffz._chatv;
func(view.get('element'), view);
}
}
API.prototype.iterate_rooms = function(func) {
if ( func === undefined )
func = this.trigger.bind(this, 'room-add');
for(var room_id in this.ffz.rooms)
func(room_id);
}
API.prototype.register_on_room_callback = function(callback, dont_iterate) {
var cb = this.register_room_set.bind(this, room_id),
thing = function(room_id) {
return callback(room_id, cb);
};
thing.original_func = callback;
this.on('room-add', thing);
if ( ! dont_iterate )
this.iterate_rooms(thing);
}
API.prototype.unregister_on_room_callback = function(callback) {
var e = this._events['room-add'] || [];
for(var i=e.length; i--;) {
var cb = e[i];
if ( cb && cb.original_func === callback )
e.splice(i, 1);
}
}

View file

@ -1,539 +0,0 @@
var FFZ = window.FrankerFaceZ,
constants = require('../constants'),
utils = require('../utils'),
SENDER_REGEX = /(\sdata-sender="[^"]*"(?=>))/,
HOP = Object.prototype.hasOwnProperty;
// --------------------
// Initialization
// --------------------
FFZ.prototype.find_bttv = function(increment, delay) {
this.has_bttv = this.has_bttv_6 = this.has_bttv_7 = false;
if ( window.BetterTTV && BetterTTV.version && BetterTTV.version.indexOf('7.') === 0 )
return this.setup_bttv_7(delay||0);
if ( window.BTTVLOADED )
return this.setup_bttv(delay||0);
if ( delay >= 60000 )
this.log("BetterTTV was not detected after 60 seconds.");
else
setTimeout(this.find_bttv.bind(this, increment, (delay||0) + increment),
increment);
}
FFZ.prototype.setup_bttv_7 = function(delay) {
this.log("BetterTTV v7 was detected after " + delay + "ms. Hooking.");
this.has_bttv = 7;
this.has_bttv_6 = false;
this.has_bttv_7 = true;
var settings = BetterTTV.settings,
cl = document.body.classList;
// Disable FFZ Dark if it's enabled.
cl.remove("ffz-dark");
if ( this._dark_style ) {
this._dark_style.parentElement.removeChild(this._dark_style);
this._dark_style = undefined;
}
// Disable other styling.
if ( this._layout_style ) {
this._layout_style.parentElement.removeChild(this._layout_style);
this._layout_style = undefined;
}
if ( this._chat_style ) {
utils.update_css(this._chat_style, 'chat_font_size', '');
utils.update_css(this._chat_style, 'chat_ts_font_size', '');
}
this.toggle_style('chat-padding');
this.toggle_style('chat-background');
this.toggle_style('chat-separator');
this.toggle_style('chat-separator-3d');
this.toggle_style('chat-separator-3d-inset');
this.toggle_style('chat-separator-wide');
this.toggle_style('chat-colors-gray');
/*this.toggle_style('badges-rounded');
this.toggle_style('badges-circular');
this.toggle_style('badges-blank');
this.toggle_style('badges-circular-small');
this.toggle_style('badges-transparent');*/
this.toggle_style('badges-sub-notice');
this.toggle_style('badges-sub-notice-on');
//cl.remove('ffz-transparent-badges');
cl.remove("ffz-sidebar-swap");
cl.remove("ffz-portrait");
cl.remove("ffz-minimal-channel-title");
cl.remove("ffz-flip-dashboard");
cl.remove('ffz-minimal-channel-bar');
cl.remove('ffz-channel-bar-bottom');
cl.remove('ffz-channel-title-top');
cl.remove('ffz-sidebar-minimize');
cl.remove('ffz-alias-italics');
// Update the layout service.
var Layout = utils.ember_lookup('service:layout');
if ( Layout ) {
Layout.set('ffzMinimizeNavigation', false);
Layout.set('rawPortraitMode', 0);
}
// Remove Following Count
if ( this.settings.following_count ) {
this._schedule_following_count();
this._draw_following_count();
this._draw_following_channels();
}
/* Update the chat input to not use FFZ's input handling.
if ( this._inputv ) {
var t = this._inputv.$("textarea");
t.off("keyup");
t.off("keydown");
t.off("keypress");
t.on("keyup", this._inputv._onKeyUp.bind(this._inputv));
t.on("keydown", this._inputv._onKeyDown.bind(this._inputv));
}//*/
// Hook into BTTV's dark mode.
cl.add('ffz-bttv');
cl.toggle('ffz-bttv-dark', settings.get('darkenedMode'));
settings.on('changed.darkenedMode', function(val) {
cl.toggle('ffz-bttv-dark', val);
});
this.update_ui_link();
this._roomv && this._roomv.ffzUpdateRecent();
this.api_trigger('bttv-initialized', 7);
}
FFZ.prototype.setup_bttv = function(delay) {
this.log("BetterTTV was detected after " + delay + "ms. Hooking.");
this.has_bttv = true;
this.has_bttv_6 = true;
this.has_bttv_7 = false;
// Disable Dark if it's enabled.
document.body.classList.remove("ffz-dark");
if ( this._dark_style ) {
this._dark_style.parentElement.removeChild(this._dark_style);
this._dark_style = undefined;
}
if ( this._layout_style ) {
this._layout_style.parentElement.removeChild(this._layout_style);
this._layout_style = undefined;
}
if ( this._chat_style ) {
utils.update_css(this._chat_style, 'chat_font_size', '');
utils.update_css(this._chat_style, 'chat_ts_font_size', '');
}
// Remove Sub Count and the Chart
if ( this.is_dashboard ) {
this._update_subscribers();
this._remove_dash_chart();
}
document.body.classList.add('ffz-bttv');
var last_dark = BetterTTV.settings.get('darkenedMode');
document.body.classList.toggle('ffz-bttv-dark', last_dark);
setInterval(function() {
var new_dark = BetterTTV.settings.get('darkenedMode');
if ( new_dark !== last_dark ) {
document.body.classList.toggle('ffz-bttv-dark', new_dark);
last_dark = new_dark;
}
}, 500);
// Disable Chat Tabs
if ( this._chatv ) {
if ( this.settings.group_tabs )
this._chatv.ffzDisableTabs();
this._chatv.ffzTeardownMenu();
this._chatv.ffzUnloadHost();
}
if ( this._roomv ) {
// Disable Chat Pause
this._roomv.ffzDisableFreeze();
this._roomv.ffzRemoveKeyHook();
// And hide the status
if ( this.settings.room_status )
this._roomv.ffzUpdateStatus();
}
this.disconnect_extra_chat();
// Disable style blocks.
this.toggle_style('chat-padding');
this.toggle_style('chat-background');
this.toggle_style('chat-separator');
this.toggle_style('chat-separator-3d');
this.toggle_style('chat-separator-3d-inset');
this.toggle_style('chat-separator-wide');
this.toggle_style('chat-colors-gray');
this.toggle_style('badges-rounded');
this.toggle_style('badges-circular');
this.toggle_style('badges-blank');
this.toggle_style('badges-circular-small');
this.toggle_style('badges-transparent');
this.toggle_style('badges-sub-notice');
this.toggle_style('badges-sub-notice-on');
// Disable other features too.
var cl = document.body.classList;
cl.remove('ffz-transparent-badges');
cl.remove("ffz-sidebar-swap");
cl.remove("ffz-portrait");
cl.remove("ffz-minimal-channel-title");
cl.remove("ffz-flip-dashboard");
cl.remove('ffz-minimal-channel-bar');
cl.remove('ffz-channel-bar-bottom');
cl.remove('ffz-channel-title-top');
cl.remove('ffz-sidebar-minimize');
cl.remove('ffz-alias-italics');
// Update the layout service.
var Layout = utils.ember_lookup('service:layout');
if ( Layout ) {
Layout.set('ffzMinimizeNavigation', false);
Layout.set('rawPortraitMode', 0);
}
// Remove Following Count
if ( this.settings.following_count ) {
this._schedule_following_count();
this._draw_following_count();
this._draw_following_channels();
}
// Send Message Behavior
var f = this,
BC = BetterTTV.chat,
original_send = BC.helpers.sendMessage;
BC.helpers.sendMessage = function(message) {
var cmd = message.split(' ', 1)[0].toLowerCase();
if ( cmd === '/ffz' )
f.run_ffz_command(message.substr(5), BC.store.currentRoom);
else
return original_send(message);
}
// Ugly Hack for Current Room, as this is stripped out before we get to
// the actual privmsg renderer.
var original_handler = BC.handlers.onPrivmsg,
received_room;
BC.handlers.onPrivmsg = function(room, data) {
received_room = room;
var output = original_handler(room, data);
received_room = null;
return output;
}
// Message Display Behavior
var original_privmsg = BC.templates.privmsg;
BC.templates.privmsg = function(data, opts) {
try {
opts = opts || {};
// Handle badges.
data.room = data.room || received_room;
f.bttv_badges(data);
// API Support
f.api_trigger('bttv-room-message', data, opts);
// Now, do everything else manually because things are hard-coded.
return '<div class="chat-line'+(opts.highlight?' highlight':'')+(opts.action?' action':'')+(opts.server?' admin':'')+(opts.notice?' notice':'')+'" data-id="' + data.id + '" data-sender="'+(data.sender||"").toLowerCase()+'" data-room="'+received_room+'">'+
BC.templates.timestamp(data.time)+' '+
(opts.isMod ? BC.templates.modicons():'')+' '+
BC.templates.badges(data.badges)+
BC.templates.from(data.nickname, data.color)+
BC.templates.message(data.sender, data.message, {
emotes: data.emotes,
colored: (opts.action && !opts.highlight) ? data.color : false,
bits: data.bits
}) +
'</div>';
} catch(err) {
f.log("Error: ", err);
return original_privmsg(data, opts);
}
}
// Whispers too!
var original_whisper = BC.templates.whisper;
BC.templates.whisper = function(data) {
try {
// Handle badges.
f.bttv_badges(data);
// Now, do everything else manually because things are hard-coded.
return '<div class="chat-line whisper" data-sender="' + data.sender + '">' +
BC.templates.timestamp(data.time) + ' ' +
(data.badges && data.badges.length ? BC.templates.badges(data.badges) : '') +
BC.templates.whisperName(data.sender, data.receiver, data.from, data.to, data.fromColor, data.toColor) +
BC.templates.message(data.sender, data.message, {
emotes: data.emotes,
colored: false
}) +
'</div>';
} catch(err) {
f.log("Error: ", err);
return original_whisper(data);
}
}
// Message Renderer. I had to completely rewrite this method to get it to
// use my replacement emoticonizer.
var original_message = BC.templates.message,
received_sender;
BC.templates.message = function(sender, message, data) {
try {
data = data || {};
var colored = data.colored || false,
force = data.force || false,
emotes = data.emotes,
bits = data.bits,
rawMessage = encodeURIComponent(message);
if(sender !== 'jtv') {
// Hackilly send our state across.
received_sender = sender;
var tokenizedMessage = BC.templates.emoticonize(message, emotes);
received_sender = null;
for(var i=0; i<tokenizedMessage.length; i++) {
if(typeof tokenizedMessage[i] === 'string') {
tokenizedMessage[i] = BC.templates.bttvMessageTokenize(sender, tokenizedMessage[i], bits);
} else {
tokenizedMessage[i] = tokenizedMessage[i][0];
}
}
message = tokenizedMessage.join(' ');
}
return '<span class="message" ' + (colored ? 'style="color: ' + colored + '" ' : '') + 'data-raw="' + rawMessage + '" data-bits="' + (bits ? encodeURIComponent(JSON.stringify(bits)) : 'false') + '" data-emotes="' + (emotes ? encodeURIComponent(JSON.stringify(emotes)) : 'false') + '">' + message + '</span>';
} catch(err) {
f.log("Error: ", err);
return original_message(sender, message, emotes, colored);
}
};
// Tab Completion
var original_emotes = BC.emotes;
BC.emotes = function() {
var output = original_emotes(),
user = f.get_user(),
room_id = BetterTTV.getChannel(),
ffz_sets = f.getEmotes(user && user.login, room_id);
for(var i=0; i < ffz_sets.length; i++) {
var emote_set = f.emote_sets[ffz_sets[i]];
if ( ! emote_set )
continue;
var set_name = (emote_set.source || "FFZ") + " " + (emote_set.title || "Global") + " Emotes",
set_icon = emote_set.icon || (emote_set.hasOwnProperty('source_ext') && f._apis[emote_set.source_ext] && f._apis[emote_set.source_ext].icon) || '//cdn.frankerfacez.com/script/devicon.png';
for(var emote_id in emote_set.emoticons) {
var emote = emote_set.emoticons[emote_id];
if ( ! emote.hidden && emote.name ) {
output.push({
text: emote.name,
channel: set_name,
badge: set_icon,
url: emote.urls[1]
});
}
}
}
return output;
}
// Emoji!
var parse_emoji = function(token) {
var setting = f.settings.parse_emoji,
output = [],
segments = token.split(constants.EMOJI_REGEX),
text = null;
while(segments.length) {
text = (text || '') + segments.shift();
if ( segments.length ) {
var match = segments.shift(),
eid = utils.emoji_to_codepoint(match),
data = f.emoji_data[eid],
src = data && (setting === 3 ? data.one_src : (setting === 2 ? data.noto_src : data.tw_src));
if ( src ) {
if ( text && text.length )
output.push(text);
// We still want to use a special token even if emoji display is disabled
// as, otherwise, BTTV will render the emoji itself, which the user has no
// way of disabling if not for this.
if ( setting === 0 )
output.push([data.raw]);
else {
var code = utils.quote_attr(data.raw),
html = '<img class="emoticon emoji ffz-tooltip" height="18px" data-ffz-emoji="' + eid + '" src="' + utils.quote_attr(src) + '" alt="' + code + '">';
output.push([html, html, []]);
}
text = null;
} else
text = (text || '') + match;
}
}
if ( text && text.length )
output.push(text);
return output;
}
// Emoticonize
var emote_token = function(emote) {
return '<img class="emoticon ffz-tooltip" data-ffz-set="' + emote.set_id + '" data-ffz-emote="' + emote.id + '" srcset="' + utils.quote_attr(emote.srcSet || "") + '" src="' + utils.quote_attr(emote.urls[1]) + '" alt="' + utils.quote_attr(emote.name) + '">';
};
var original_emoticonize = BC.templates.emoticonize;
BC.templates.emoticonize = function(message, emotes) {
var tokens = original_emoticonize(message, emotes),
room = (received_room || BetterTTV.getChannel()),
l_room = room && room.toLowerCase(),
l_sender = received_sender && received_sender.toLowerCase(),
sets = f.getEmotes(l_sender, l_room),
emotes = {}, emote,
user = f.get_user(),
new_tokens = [],
mine = user && user.login === l_sender;
// Build an object with all of our emotes.
for(var i=0; i < sets.length; i++) {
var emote_set = f.emote_sets[sets[i]];
if ( emote_set && emote_set.emoticons )
for(var emote_id in emote_set.emoticons) {
emote = emote_set.emoticons[emote_id];
if ( ! HOP.call(emotes, emote.name) )
emotes[emote.name] = emote;
}
}
//var last_token = null;
for(var i=0, l=tokens.length; i < l; i++) {
var token = tokens[i];
if ( typeof token !== "string" ) {
// Detect emoticons!
/*if ( /class="emoticon/.test(token[0]) ) {
if ( token.length === 1 ) {
token = [token[0], token[0], []];
}
last_token = token;
} else
last_token = null;*/
new_tokens.push(token);
continue;
}
// Split the token!
var segments = token.split(' '),
text = [], segment;
for(var x=0,y=segments.length; x < y; x++) {
segment = segments[x];
if ( HOP.call(emotes, segment) ) {
emote = emotes[segment];
/*if ( false && emote.modifier && last_token && last_token.length > 1 ) {
if ( last_token[2].indexOf(emote) === -1 ) {
last_token[2].push(emote);
last_token[0] = '<span class="emoticon modified-emoticon">' + last_token[1] + _.map(last_token[2], function(em) {
return ' <span>' + emote_token(em) + '</span>';
}).join('') + '</span>'
}
if ( mine && l_room )
f.add_usage(l_room, emote);
continue;
}*/
if ( text.length ) {
var toks = parse_emoji(text.join(' ') + ' ');
for(var q=0; q < toks.length; q++) {
var tok = toks[q];
/*if ( tok.length > 1 )
last_token = tok;
else
last_token = null;*/
new_tokens.push(tok);
}
text = [];
}
var html = emote_token(emote);
new_tokens.push([html, html, []]);
if ( mine && l_room )
f.add_usage(l_room, emote);
text.push('');
} else
text.push(segment);
}
if ( text.length > 1 || (text.length === 1 && text[0] !== '') ) {
var toks = parse_emoji(text.join(' ') + ' ');
for(var q=0; q < toks.length; q++)
new_tokens.push(toks[q]);
}
}
return new_tokens;
}
this.update_ui_link();
this.api_trigger('bttv-initialized');
}

View file

@ -1,65 +0,0 @@
var FFZ = window.FrankerFaceZ,
utils = require('../utils');
// --------------------
// Initialization
// --------------------
FFZ.prototype.find_emote_menu = function(increment, delay) {
this.has_emote_menu = false;
if ( window.emoteMenu && emoteMenu.registerEmoteGetter )
return this.setup_emote_menu(delay||0);
if ( delay >= 60000 )
this.log("Emote Menu for Twitch was not detected after 60 seconds.");
else
setTimeout(this.find_emote_menu.bind(this, increment, (delay||0) + increment),
increment);
}
FFZ.prototype.setup_emote_menu = function(delay) {
this.log("Emote Menu for Twitch was detected after " + delay + "ms. Registering emote enumerator.");
emoteMenu.registerEmoteGetter("FrankerFaceZ", this._emote_menu_enumerator.bind(this));
}
// --------------------
// Emote Enumerator
// --------------------
FFZ.prototype._emote_menu_enumerator = function() {
if ( this.has_bttv_6 )
return [];
var twitch_user = this.get_user(),
user_id = twitch_user ? twitch_user.login : null,
controller = utils.ember_lookup('controller:chat'),
room_id = controller ? controller.get('currentRoom.id') : null,
sets = this.getEmotes(user_id, room_id),
emotes = [];
for(var x = 0; x < sets.length; x++) {
var set = this.emote_sets[sets[x]];
if ( ! set || ! set.emoticons )
continue;
for(var emote_id in set.emoticons) {
if ( ! set.emoticons.hasOwnProperty(emote_id) )
continue;
var emote = set.emoticons[emote_id];
if ( emote.hidden )
continue;
var title = (set.source || "FrankerFaceZ") + " " + set.title,
badge = set.icon || '//cdn.frankerfacez.com/script/devicon.png';
emotes.push({text: emote.name, url: emote.urls[1],
hidden: false, channel: title, badge: badge});
}
}
return emotes;
}

View file

@ -1,31 +0,0 @@
var FFZ = window.FrankerFaceZ;
// --------------------
// Initialization
// --------------------
FFZ.settings_info.warp_world = {
type: "boolean",
value: true,
category: "Channel Metadata",
no_mobile: true,
name: "Warp World <small>(Requires Refresh)</small>",
help: 'Automatically load <a href="https://warp.world" target="_blank">Warp World</a> when viewing a channel that uses Warp World.'
}
FFZ.ws_commands.warp_world = function(data) {
if ( ! data || ! this.settings.warp_world )
return;
// Make sure that Warp World isn't already loaded or loading.
var ww_script = document.querySelector('script#ww_script');
if ( ww_script || window.WarpWorld )
return;
ww_script = document.createElement('script');
ww_script.id = 'ww_script';
ww_script.src = '//cdn.warp.world/twitch_script/main.min.js?_=' + Date.now();
document.head.appendChild(ww_script);
}

View file

@ -1,149 +0,0 @@
var FFZ = window.FrankerFaceZ,
constants = require('./constants'),
utils = require('./utils');
// --------------------
// Initialization
// --------------------
FFZ.prototype.feature_friday = null;
// --------------------
// Check FF
// --------------------
FFZ.prototype.check_ff = function(tries) {
if ( ! tries )
this.log("Checking for Feature Friday data...");
jQuery.ajax(constants.SERVER + "script/event.json?_=" + (constants.DEBUG ? Date.now() : FFZ.version_info), {dataType: "json", context: this})
.done(function(data) {
return this._load_ff(data);
}).fail(function(data) {
if ( data.status == 404 )
return this._load_ff(null);
tries = tries || 0;
tries++;
if ( tries < 10 )
return setTimeout(this.check_ff.bind(this, tries), 250);
return this._load_ff(null);
});
}
FFZ.ws_commands.reload_ff = function() {
this.check_ff();
}
// --------------------
// Rendering UI
// --------------------
FFZ.prototype._feature_friday_ui = function(room_id, parent, view) {
if ( ! this.feature_friday || this.feature_friday.channel === room_id )
return;
this._emotes_for_set(parent, view, this.feature_friday.set, this.feature_friday.title, this.feature_friday.icon, "FrankerFaceZ");
// Before we add the button, make sure the channel isn't the
// current channel.
var Channel = utils.ember_lookup('controller:channel');
if ( ! this.feature_friday.channel || (Channel && Channel.get('id') === this.feature_friday.channel) )
return;
var ff = this.feature_friday, f = this,
btnc = document.createElement('div'),
btn = document.createElement('a');
btnc.className = 'chat-menu-content';
btnc.style.textAlign = 'center';
var message = ff.display_name + (ff.live ? " is live now!" : "");
btn.className = 'button primary';
btn.classList.toggle('live', ff.live);
btn.classList.toggle('blue', this.has_bttv_6 && BetterTTV.settings.get('showBlueButtons'));
btn.href = "//www.twitch.tv/" + ff.channel;
btn.title = message;
btn.target = "_new";
btn.innerHTML = "<span>" + message + "</span>";
// Track the number of users to click this button.
// btn.addEventListener('click', function() { f.track('trackLink', this.href, 'link'); });
btnc.appendChild(btn);
parent.appendChild(btnc);
}
// --------------------
// Loading Data
// --------------------
FFZ.prototype._load_ff = function(data) {
// Check for previous Feature Friday data and remove it.
if ( this.feature_friday ) {
// Remove the global set, delete the data, and reset the UI link.
var ind = this.global_sets.indexOf(this.feature_friday.set);
if ( ind !== -1 )
this.global_sets.splice(ind, 1);
ind = this.default_sets.indexOf(this.feature_friday.set);
if ( ind !== -1 )
this.default_sets.splice(ind, 1);
this.feature_friday = null;
this.update_ui_link();
}
// If there's no data, just leave.
if ( ! data || ! data.set )
return;
// We have our data! Set it up.
this.feature_friday = {
set: data.set,
channel: data.channel,
live: false,
title: data.title || "Feature Friday",
icon: data.icon,
display_name: data.channel ? FFZ.get_capitalization(data.channel, this._update_ff_name.bind(this)) : data.title || "Feature Friday"
};
// Add the set.
this.global_sets.push(data.set);
this.default_sets.push(data.set);
this.load_set(data.set);
// Check to see if the channel is live.
this._update_ff_live();
}
FFZ.prototype._update_ff_live = function() {
if ( ! this.feature_friday || ! this.feature_friday.channel )
return;
var f = this;
utils.api.get("streams/" + this.feature_friday.channel)
.done(function(data) {
f.feature_friday.live = data.stream != null;
f.update_ui_link();
})
.always(function() {
f.feature_friday.timer = setTimeout(f._update_ff_live.bind(f), 120000);
});
}
FFZ.prototype._update_ff_name = function(name) {
if ( this.feature_friday )
this.feature_friday.display_name = name;
}

489
src/i18n.js Normal file
View file

@ -0,0 +1,489 @@
'use strict';
// ============================================================================
// Localization
// This is based on Polyglot, but with some changes to avoid dependencies on
// additional libraries and with support for Vue.
// ============================================================================
import {SERVER} from 'utilities/constants';
import {has} from 'utilities/object';
import Module from 'utilities/module';
// ============================================================================
// TranslationManager
// ============================================================================
export class TranslationManager extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.availableLocales = ['en']; //, 'de', 'ja'];
this.localeData = {
en: { name: 'English' },
//de: { name: 'Deutsch' },
//ja: { name: '日本語' }
}
this.settings.add('i18n.locale', {
default: -1,
process: (ctx, val) => {
if ( val === -1 )
val = ctx.get('context.session.languageCode');
return this.availableLocales.includes(val) ? val : 'en'
},
_ui: {
path: 'Appearance > Localization >> General',
title: 'Language',
// description: '',
component: 'setting-select-box',
data: (profile, val) => [{
selected: val === -1,
value: -1,
i18n_key: 'setting.appearance.localization.general.language.twitch',
title: "Use Twitch's Language"
}].concat(this.availableLocales.map(l => ({
selected: val === l,
value: l,
title: this.localeData[l].name
})))
},
changed: val => this.locale = val
});
}
onEnable() {
this._ = new TranslationCore; /*({
awarn: (...args) => this.log.info(...args)
});*/
this.locale = this.settings.get('i18n.locale');
}
get locale() {
return this._.locale;
}
set locale(new_locale) {
this.setLocale(new_locale);
}
toLocaleString(thing) {
if ( thing && thing.toLocaleString )
return thing.toLocaleString(this._.locale);
return thing;
}
async loadLocale(locale) {
/*if ( locale === 'en' )
return {};
if ( locale === 'de' )
return {
site: {
menu_button: 'FrankerFaceZ Leitstelle'
},
player: {
reset_button: 'Doppelklicken, um den Player zurückzusetzen'
},
setting: {
reset: 'Zurücksetzen',
appearance: {
_: 'Aussehen',
description: 'Personalisieren Sie das Aussehen von Twitch. Ändern Sie das Farbschema und die Schriften und stimmen Sie das Layout so ab, dass Sie ein optimales Erlebnis erleben.<br><br>(Yes, this is Google Translate still.)',
localization: {
_: 'Lokalisierung',
general: {
language: {
_: 'Sprache',
twitch: "Verwenden Sie Twitch's Sprache"
}
},
dates_and_times: {
_: 'Termine und Zeiten',
allow_relative_times: {
_: 'Relative Zeiten zulassen',
description: 'Wenn dies aktiviert ist, zeigt FrankerFaceZ einige Male in einem relativen Format an. <br>Beispiel: vor 3 Stunden'
}
}
},
layout: 'Layout',
theme: 'Thema'
},
profiles: {
_: 'Profile',
active: 'Dieses Profil ist aktiv.',
inactive: {
_: 'Dieses Profil ist nicht aktiv.',
description: 'Dieses Profil stimmt nicht mit dem aktuellen Kontext überein und ist momentan nicht aktiv, so dass Sie keine Änderungen sehen, die Sie hier bei Twitch vorgenommen haben.'
},
configure: 'Konfigurieren',
default: {
_: 'Standard Profil',
description: 'Einstellungen, die überall auf Twitch angewendet werden.'
},
moderation: {
_: 'Mäßigung',
description: 'Einstellungen, die gelten, wenn Sie ein Moderator des aktuellen Kanals sind.'
}
},
add_ons: {
_: 'Erweiterung'
},
'inherited-from': 'Vererbt von: %{title}',
'overridden-by': 'Überschrieben von: %{title}'
},
'main-menu': {
search: 'Sucheinstellungen',
about: {
_: 'Über',
news: 'Nachrichten',
support: 'Unterstützung'
}
}
}
if ( locale === 'ja' )
return {
greeting: 'こんにちは',
site: {
menu_button: 'FrankerFaceZコントロールセンター'
},
setting: {
appearance: {
_: '外観',
localization: '局地化',
layout: '設計',
theme: '題材'
}
},
'main-menu': {
search: '検索設定',
version: 'バージョン%{version}',
about: {
_: '約',
news: '便り',
support: '対応'
}
}
}*/
const resp = await fetch(`${SERVER}/script/i18n/${locale}.json`);
if ( ! resp.ok ) {
if ( resp.status === 404 ) {
this.log.info(`Cannot Load Locale: ${locale}`);
return {};
}
this.log.warn(`Cannot Load Locale: ${locale} -- Status: ${resp.status}`);
throw new Error(`http error ${resp.status} loading phrases`);
}
return resp.json();
}
async setLocale(new_locale) {
const old_locale = this._.locale;
if ( new_locale === old_locale )
return [];
this._.locale = new_locale;
this._.clear();
this.log.info(`Changed Locale: ${new_locale} -- Old: ${old_locale}`);
this.emit(':changed', new_locale, old_locale);
this.emit(':update');
if ( new_locale === 'en' ) {
// All the built-in messages are English. We don't need special
// logic to load the translations.
this.emit(':loaded', []);
return [];
}
const phrases = await this.loadLocale(new_locale);
if ( this._.locale !== new_locale )
throw new Error('locale has changed since we started loading');
const added = this._.extend(phrases);
if ( added.length ) {
this.log.info(`Loaded Locale: ${new_locale} -- Phrases: ${added.length}`);
this.emit(':loaded', added);
this.emit(':update');
}
return added;
}
has(key) {
return this._.has(key);
}
formatNumber(...args) {
return this._.formatNumber(...args);
}
t(...args) {
return this._.t(...args);
}
}
// ============================================================================
// TranslationCore
// ============================================================================
const REPLACE = String.prototype.replace,
SPLIT = String.prototype.split;
const DEFAULT_FORMATTERS = {
en_plural: n => n !== 1 ? 's' : '',
number: (n, locale) => n.toLocaleString(locale)
}
export default class TranslationCore {
constructor(options) {
options = options || {};
this.warn = options.warn;
this.phrases = new Map;
this.extend(options.phrases);
this.locale = options.locale || 'en';
this.defaultLocale = options.defaultLocale || this.locale;
const allowMissing = options.allowMissing ? transformPhrase : null;
this.onMissingKey = typeof options.onMissingKey === 'function' ? options.onMissingKey : allowMissing;
this.transformPhrase = typeof options.transformPhrase === 'function' ? options.transformPhrase : transformPhrase;
this.delimiter = options.delimiter || /\s*\|\|\|\|\s*/;
this.tokenRegex = options.tokenRegex || /%\{(.*?)(?:\|(.*?))?\}/g;
this.formatters = Object.assign({}, DEFAULT_FORMATTERS, options.formatters || {});
}
formatNumber(value) {
return value.toLocaleString(this.locale);
}
extend(phrases, prefix) {
const added = [];
for(const key in phrases)
if ( has(phrases, key) ) {
let phrase = phrases[key];
const pref_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key;
if ( typeof phrase === 'object' )
added.push(...this.extend(phrase, pref_key));
else {
if ( typeof phrase === 'string' && phrase.indexOf(this.delimiter) !== -1 )
phrase = SPLIT.call(phrase, this.delimiter);
this.phrases.set(pref_key, phrase);
added.push(pref_key);
}
}
return added;
}
unset(phrases, prefix) {
if ( typeof phrases === 'string' )
phrases = [phrases];
const keys = Array.isArray(phrases) ? phrases : Object.keys(phrases);
for(const key of keys) {
const pref_key = prefix ? `${prefix}.${key}` : key;
const phrase = phrases[key];
if ( typeof phrase === 'object' )
this.unset(phrase, pref_key);
else
this.phrases.delete(pref_key);
}
}
has(key) {
return this.phrases.has(key);
}
set(key, phrase) {
if ( typeof phrase === 'string' && phrase.indexOf(this.delimiter) !== -1 )
phrase = SPLIT.call(phrase, this.delimiter);
this.phrases.set(key, phrase);
}
clear() {
this.phrases.clear();
}
replace(phrases) {
this.clear();
this.extend(phrases);
}
t(key, phrase, options, use_default) {
const opts = options == null ? {} : options;
let p, locale;
if ( use_default ) {
p = phrase;
locale = this.defaultLocale;
} else if ( key === undefined && phrase ) {
p = phrase;
locale = this.defaultLocale;
if ( this.warn )
this.warn(`Translation key not generated with phrase "${phrase}"`);
} else if ( this.phrases.has(key) ) {
p = this.phrases.get(key);
locale = this.locale;
} else if ( phrase ) {
if ( this.warn && this.locale !== this.defaultLocale )
this.warn(`Missing translation for key "${key}" in locale "${this.locale}"`);
p = phrase;
locale = this.defaultLocale;
} else if ( this.onMissingKey )
return this.onMissingKey(key, opts, this.locale, this.tokenRegex, this.formatters);
else {
if ( this.warn )
this.warn(`Missing translation for key "${key}" in locale "${this.locale}"`);
return key;
}
return this.transformPhrase(p, opts, locale, this.tokenRegex, this.formatters);
}
}
// ============================================================================
// Transformations
// ============================================================================
const DOLLAR_REGEX = /\$/g;
export function transformPhrase(phrase, substitutions, locale, token_regex, formatters) {
const is_array = Array.isArray(phrase);
if ( substitutions == null )
return is_array ? phrase[0] : phrase;
let result = phrase;
const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions;
if ( is_array )
result = result[pluralTypeIndex(
locale || 'en',
has(options, 'count') ? options.count : 1
)] || result[0];
if ( typeof result === 'string' )
result = REPLACE.call(result, token_regex, (expr, arg, fmt) => {
if ( ! has(options, arg) )
return '';
let val = options[arg];
const formatter = formatters[fmt];
if ( typeof formatter === 'function' )
val = formatter(val, locale, options);
else if ( typeof val === 'string' )
val = REPLACE.call(val, DOLLAR_REGEX, '$$');
return val;
});
return result;
}
// ============================================================================
// Plural Nonsense
// ============================================================================
const PLURAL_TYPE_TO_LANG = {
arabic: ['ar'],
chinese: ['fa', 'id', 'ja', 'ko', 'lo', 'ms', 'th', 'tr', 'zh'],
german: ['da', 'de', 'en', 'es', 'es', 'fi', 'el', 'he', 'hu', 'it', 'nl', 'no', 'pt', 'sv'],
french: ['fr', 'tl', 'pt-br'],
russian: ['hr', 'ru', 'lt'],
czech: ['cs', 'sk'],
polish: ['pl'],
icelandic: ['is']
};
const PLURAL_LANG_TO_TYPE = {};
for(const type in PLURAL_TYPE_TO_LANG) // eslint-disable-line guard-for-in
for(const lang of PLURAL_TYPE_TO_LANG[type])
PLURAL_LANG_TO_TYPE[lang] = type;
const PLURAL_TYPES = {
arabic: n => {
if ( n < 3 ) return n;
const n1 = n % 100;
if ( n1 >= 3 && n1 <= 10 ) return 3;
return n1 >= 11 ? 4 : 5;
},
chinese: () => 0,
german: n => n !== 1 ? 1 : 0,
french: n => n > 1 ? 1 : 0,
russian: n => {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 1 && n2 !== 11 ) return 0;
return n1 >= 2 && n1 <= 4 && (n2 < 10 || n2 >= 20) ? 1 : 2;
},
czech: n => {
if ( n === 1 ) return 0;
return n >= 2 && n <= 4 ? 1 : 2;
},
polish: n => {
if ( n === 1 ) return 0;
const n1 = n % 10, n2 = n % 100;
return n1 >= 2 && n1 <= 4 && (n2 < 10 || n2 >= 20) ? 1 : 2;
},
icelandic: n => n % 10 !== 1 || n % 100 === 11 ? 1 : 0
};
export function pluralTypeIndex(locale, n) {
let type = PLURAL_LANG_TO_TYPE[locale];
if ( ! type ) {
const idx = locale.indexOf('-');
if ( idx !== -1 )
type = PLURAL_LANG_TO_TYPE[locale.slice(0, idx)]
}
return PLURAL_TYPES[type || 'german'](n);
}

8
src/jsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "es6",
"paths": {
"utilities": ["./utilities"]
}
}
}

View file

@ -1,417 +0,0 @@
/* Colors */
@fg-color: #a49ab5;
@bg-color: #101010;
@link-color: #a68ed2;
@remove-link-color: #fc3636;
@hollow-button-color: darken(@link-color, 5%); //#9d8dba;
@bright-bg: lighten(@bg-color, 15%);
@bright-fg: lighten(@fg-color, 15%);
@nav-bg-color: #191919;
.ffz-dark {
html&, body {
background-color: @bg-color;
color: @fg-color;
}
a {
color: @link-color;
&:hover, &:focus {
color: lighten(@link-color, 10%);
}
}
hr {
border-color: fade(white, 10%);
}
.sub-text { color: darken(@fg-color, 10%) }
// Navigation
.nav {
background-color: @nav-bg-color;
border-bottom-color: lighten(@nav-bg-color, 10%);
}
.svg-logo_twitch, .clips-nav__logo { fill: white }
.nav-banner {
background-color: lighten(@nav-bg-color, 10%);
}
// Chat
.clip-chat-message {
color: #ccc;
}
.view-clip__main--darker {
background-color: darken(@nav-bg-color, 2%);
}
.view-clip__smallTitle {
color: @fg-color;
& > div {
background-color: @nav-bg-color;
border-bottom: lighten(@nav-bg-color, 10%);
}
}
// Content Meta
.nv-clip-content {
background-color: @nav-bg-color;
.nv-clip-content__meta {
background-color: @nav-bg-color;
}
}
.view-clip__meta-wrapper {
background-color: @nav-bg-color;
}
.view-clip__actions,
.view-clip__bc {
border-color: lighten(@nav-bg-color, 10%);
}
.view-clip__title {
color: @fg-color;
}
.emote-picker {
background-color: @nav-bg-color;
}
.emote-picker__emotes .reaction__emote:hover {
background-color: lighten(@nav-bg-color, 10%);
}
.yin-alert {
background-color: lighten(@nav-bg-color, 10%);
.yin-link {
color: #fff;
&:hover {
background-color: fade(white, 10%);
}
}
}
.ct-editable-text {
&:hover {
background-color: transparent;
&:after {
background-color: fade(white, 10%);
}
}
}
.ct-text-input {
background-color: fade(white, 10%);
color: white;
border-color: fade(white, 20%);
&:focus {
border-color: fade(white, 50%);
}
}
// Clip Center?
.ce-clip-center:not(.ce-clip-center--editing) .ce-clip-center__pre-control {
background-color: @nav-bg-color;
border-color: fade(white, 10%);
}
.ce-filmstrip-fade--left {
background-image: linear-gradient(90deg, @bg-color 13%, fade(@bg-color, 0%) 70%)
}
.ce-filmstrip-fade--right {
background-image: linear-gradient(-90deg, @bg-color 13%, fade(@bg-color, 0%) 70%)
}
// Buttons
.emote-picker__trigger {
fill: @link-color;
}
.button {
&, &:hover, &:focus {
color: #fff;
}
figure img, figure svg {
fill: @link-color;
}
}
.button--hollow {
box-shadow: inset 0 0 0 1px fade(@link-color, 50%);
color: @hollow-button-color;
&:hover, &:focus {
background-color: fade(@link-color, 10%);
color: @hollow-button-color;
}
}
.button--following {
background-color: darken(@nav-bg-color, 2%);
color: @fg-color;
box-shadow: inset 0 0 0 1px lighten(@bg-color, 10%);
}
.button--disabled {
background-color: fade(black, 20%);
color: fade(@fg-color, 25%);
border-color: fade(@fg-color, 35%);
&:hover, &:focus {
background-color: fade(black, 20%);
color: fade(@fg-color, 25%);
border-color: fade(@fg-color, 35%);
}
}
.button--hollow.button--dropmenu {
&:after {
}
}
// Balloons
.balloon {
background-color: lighten(@bg-color, 10%);
color: #ccc;
box-shadow: 0 0 0 1px fade(white, 20%), 0 1px 1px fade(white, 5%);
&:after {
background-color: lighten(@bg-color, 10%);
}
&--fancy {
color: lighten(@link-color, 5%);
box-shadow: 0 0 0 1px fade(white, 10%);
}
&--left:after { box-shadow: 1px -1px 0 fade(white, 20%) }
&--right:after { box-shadow: -1px 1px 0 fade(white, 20%) }
&--up:after { box-shadow: 1px 1px 0 fade(white, 20%) }
&--down:after { box-shadow: -1px -1px 0 fade(white, 20%) }
&--cols .balloon__list ~ .balloon__list { box-shadow: -1px 0 0 #202021 }
&__stroke { border-bottom-color: fade(white, 10%) }
.balloon__link {
color: @link-color !important;
&:hover {
background-color: @link-color !important;
color: @bg-color !important;
}
&.request-removal-link,
&.clip-remove-link {
color: @remove-link-color !important;
&:hover {
background-color: @remove-link-color !important;
color: white !important;
}
}
}
}
// Card~
.card__title {
color: @fg-color;
}
.more-clips {
border-color: fade(white, 10%);
.card__layout:hover {
background-color: lighten(@nav-bg-color, 10%);
}
}
// Popup Card
.popup-card {
background-color: lighten(@bg-color, 10%);
box-shadow:
0 0 0 1px fade(white, 10%),
0 1rem 2rem -.8rem fade(#6441A4, 25%);
.popup-card__header {
color: lighten(@fg-color, 10%);
}
.cf-footer {
background-color: lighten(@bg-color, 5%);
border-top: fade(white, 10%);
}
}
.form__input {
background-color: fade(white, 5%);
color: lighten(@fg-color, 10%);
border-color: fade(white, 10%);
}
.cf-textarea-input {
background-color: fade(white, 5%);
color: lighten(@fg-color, 10%);
textarea {
background: transparent;
}
}
// Modal Content
.modal {
&__content {
background-color: @bg-color;
box-shadow: 0 1px 1px fade(white, 10%);
}
&-title {
color: @bright-fg;
}
&-body-text {
color: @fg-color;
}
}
// Table
.table {
background-color: @bg-color;
box-shadow: 0 2px 4px 0 fade(white, 20%);
&__row {
background-color: @bg-color;
color: @fg-color;
&:nth-child(even) {
background-color: lighten(@bg-color, 5%);
}
&:hover {
background-color: lighten(@bg-color, 10%);
}
&--no-hover:hover { background-color: @bg-color }
}
&__header {
background-color: lighten(@bg-color, 5%)
}
&__cell {
border-top-color: @bright-bg;
border-bottom-color: @bright-bg;
color: @fg-color;
}
&__cell--header {
border-bottom-color: @bright-bg;
color: @bright-fg;
}
&__zero-title {
color: @bright-fg;
}
&__zero-body {
color: @fg-color;
}
}
// Tabs
.tabs {
box-shadow: inset 0 -1px 0 #202021;
&__item {
box-shadow: inset 0 -1px 0 #202021;
& > a { color: @link-color }
&:hover, &--active {
box-shadow: inset 0 -1px 0 @link-color;
& > a {
color: @bright-fg;
}
}
}
}
.loading-spinner {
border-color: fade(white, 18%);
border-left-color: @bright-fg;
}
.expand-button__border,
.ldboard-head {
border-bottom-color: #3e3d40;
}
// Previewer
.ld-previewer__iframe, .previewer__pane {
background-color: @bg-color;
}
.previewer__close {
fill: @link-color;
&:hover, &:focus {
fill: lighten(@link-color, 10%);
}
}
// Labels
.label {
color: @bright-fg;
}
// Input
select,
.input--text {
background-color: @bg-color;
color: @bright-fg;
border-color: @bright-bg;
&:focus {
border-color: lighten(@bright-bg, 15%);
}
}
}

View file

@ -1,5 +0,0 @@
.ffz-darken-clips {
position: fixed;
bottom: 10px;
left: 10px;
}

View file

@ -1,6 +0,0 @@
var FFZ = window.FrankerFaceZ;
FFZ.prototype.tr = function(s) {
return s;
}

View file

@ -1,659 +1,114 @@
// ----------------
// The Constructor
// ----------------
'use strict';
var FFZ = window.FrankerFaceZ = function() {
FFZ.instance = this;
import Logger from 'utilities/logging';
import Module from 'utilities/module';
// Logging
this._log_data = [];
this._apis = {};
import {DEBUG} from 'utilities/constants';
// Data structures
this.badges = {};
this.users = {};
this.rooms = {};
import SettingsManager from './settings/index';
import {TranslationManager} from './i18n';
import SocketClient from './socket';
import Site from 'site';
import Vue from 'utilities/vue';
this.emoji_data = {};
this.emoji_names = {};
class FrankerFaceZ extends Module {
constructor() {
super();
const start_time = performance.now(),
VER = FrankerFaceZ.version_info;
this.emote_sets = {};
this.global_sets = [];
this.default_sets = [];
this._last_emote_id = 0;
FrankerFaceZ.instance = this;
this.name = 'frankerfacez';
this.__state = 0;
this.__modules.core = this;
this.log = new Logger(this);
this.core_log = this.log.get('core');
this.log.info(`FrankerFaceZ v${VER} (build ${VER.build})`);
// Error Logging
var t = this;
window.addEventListener('error', function(event) {
if ( ! event.error )
return;
// ========================================================================
// Core Systems
// ========================================================================
//var has_stack = event.error && event.error.stack;
t.error("Uncaught JavaScript Error", event.error);
//t.log("JavaScript Error: " + event.message + " [" + event.filename + ":" + event.lineno + ":" + event.colno + "]", has_stack ? event.error.stack : undefined, false, has_stack);
});
this.inject('settings', SettingsManager);
this.inject('i18n', TranslationManager);
this.inject('socket', SocketClient);
this.inject('site', Site);
// Initialize settings as early as possible.
this.load_settings();
// Detect if we need to polyfill
if ( ! window.fetch ) {
this.log("Fetch is not detected. Requesting polyfill.");
var script = document.createElement('script');
script.id = 'ffz-polyfill';
script.type = 'text/javascript';
script.src = FrankerFaceZ.constants.SERVER + 'script/fetch.polyfill.' + (FrankerFaceZ.constants.DEBUG ? '' : 'min.') + 'js?_=' + Date.now();
document.head.appendChild(script);
} else
// Get things started.
this.initialize();
}
this.register('vue', Vue);
FFZ.get = function() { return FFZ.instance; }
// ========================================================================
// Startup
// ========================================================================
// TODO: This should be in a module.
FFZ.msg_commands = {};
FFZ.channel_metadata = {};
this.discoverModules();
this.enable().then(() => this.enableInitialModules()).then(() => {
const duration = performance.now() - start_time;
this.core_log.info(`Initialization complete in ${duration.toFixed(5)}ms.`);
// Version
var VER = FFZ.version_info = {
major: 3, minor: 5, revision: 536,
toString: function() {
return [VER.major, VER.minor, VER.revision].join(".") + (VER.extra || "");
}
}
// Logging
var ua = navigator.userAgent,
IS_WEBKIT = ua.indexOf('AppleWebKit/') !== -1 && ua.indexOf('Edge/') === -1,
IS_FIREFOX = ua.indexOf('Firefox/') !== -1,
RED_COLOR = "color: red",
FFZ_COLOR = "color:#755000; font-weight: bold",
TXT_COLOR = "color:auto; font-weight: normal";
FFZ.prototype.log = function(msg, data, to_json, log_json) {
if ( to_json )
msg = msg + ' -- ' + JSON.stringify(data);
this._log_data.push(msg + ((!to_json && log_json) ? " -- " + JSON.stringify(data) : ""));
if ( data !== undefined && console.groupCollapsed && console.dir ) {
if ( IS_WEBKIT )
console.groupCollapsed("%cFFZ:%c " + msg, FFZ_COLOR, TXT_COLOR);
else
console.groupCollapsed("FFZ: " + msg);
if ( typeof data === "string" || IS_FIREFOX )
console.log(data);
else
console.dir(data);
if ( IS_WEBKIT )
console.groupEnd("%cFFZ:%c " + msg, FFZ_COLOR, TXT_COLOR);
else
console.groupEnd("FFZ: " + msg);
} else
console.log("%cFFZ:%c " + msg, FFZ_COLOR, TXT_COLOR);
}
FFZ.prototype.error = function(msg, error, to_json, log_json) {
var data = error && error.stack || error;
msg = "Error: " + msg + " [" + error + "]" + (to_json ? " -- " + JSON.stringify(data) : "");
this._log_data.push(msg + ((!to_json && log_json) ? " -- " + JSON.stringify(data) : ""));
if ( data === undefined ) {
var err = new Error();
data = err.stack;
}
if ( data !== undefined && console.groupCollapsed && console.dir ) {
if ( IS_WEBKIT )
console.groupCollapsed("%cFFZ " + msg, RED_COLOR);
else
console.groupCollapsed("FFZ " + msg);
if ( typeof data === "string" || IS_FIREFOX )
console.log(data);
else
console.dir(data);
if ( IS_WEBKIT )
console.groupEnd("%cFFZ " + msg, RED_COLOR);
else
console.groupEnd("FFZ " + msg);
} else
console.log("%cFFZ " + msg, RED_COLOR);
}
FFZ.prototype.paste_logs = function() {
var f = this,
output = function(result) {
f._pastebin(result).then(function(url) {
f.log("Your FrankerFaceZ logs have been uploaded to: " + url);
}).catch(function() {
f.error("An error occured uploading the logs to a pastebin.");
});
}
this.get_debugging_info().then(function(data) {
output(data);
}).catch(function(err) {
f.error("Error building debugging information.", err);
output(f._log_data.join("\n"));
});
}
FFZ.prototype._pastebin = function(data) {
return new Promise(function(succeed, fail) {
jQuery.ajax({url: "https://putco.de/", type: "PUT", data: data})
.success(function(e) { succeed(e.trim() + ".log"); })
.fail(function(e) { fail(null); });
});
}
// -------------------
// User Data
// -------------------
FFZ.prototype.get_location = function(force_reload) {
var f = this;
return new Promise(function(succeed, fail) {
if ( ! force_reload && f.__location )
succeed(f.__location);
if ( window.Twitch && Twitch.geo )
Twitch.geo.then(function(data) {
f.__location = {
region: data.region,
country: data.geo,
lang: data.preferred_language
};
succeed(f.__location);
});
else
// TODO: Implement my own lookup.
fail('no provider available');
})
}
FFZ.prototype.get_user = function(force_reload) {
if ( ! force_reload && this.__user && this.__user.chat_oauth_token )
return this.__user;
var LC = FFZ.utils.ember_lookup('service:login'),
user = LC ? LC.get('userData') : undefined;
if ( ! user && window.PP && PP.login )
user = PP;
if ( user )
this.__user = user;
return user;
}
FFZ.prototype._editor_of = null;
FFZ.prototype.get_user_editor_of = function() {
var f = this;
return new Promise(function(succeed,fail) {
var user = f.get_user();
if ( ! user || ! user.login )
return fail();
jQuery.get("/" + user.login + "/dashboard/permissions").done(function(data) {
try {
var dom = new DOMParser().parseFromString(data, 'text/html'),
links = dom.querySelectorAll('#editable .label');
f._editor_of = _.map(links, function(e) {
var href = e.getAttribute('href');
return href && href.substr(href.lastIndexOf('/') + 1);
});
succeed(f._editor_of);
} catch(err) {
f.error("Failed to parse User Editor State", err);
fail();
}
}).fail(function(e) {
f.error("Failed to load User Editor State", e);
fail();
}).catch(err => {
this.core_log.error('An error occurred during initialization.', err);
});
});
}
// -------------------
// Import Everything!
// -------------------
// Import these first to set up data structures
require('./localization');
require('./ui/menu');
require('./settings');
require('./socket');
require('./colors');
require('./emoticons');
require('./badges');
require('./tokenize');
//require('./filtering');
require('./ember/wrapper');
require('./ember/router');
require('./ember/bits');
require('./ember/channel');
require('./ember/player');
require('./ember/room');
require('./ember/vod-chat');
require('./ember/layout');
require('./ember/line');
require('./ember/chatview');
require('./ember/conversations');
require('./ember/viewers');
require('./ember/moderation-card');
require('./ember/chat-input');
//require('./ember/teams');
require('./ember/directory');
require('./ember/following');
require('./ember/feed-card');
require('./ember/sidebar');
require('./ember/dashboard');
require('./ember/commerce');
require('./debug');
require('./ext/betterttv');
require('./ext/emote_menu');
require('./featurefriday');
require('./ui/channel_stats');
require('./ui/logviewer');
//require('./ui/chatpane');
require('./ui/popups');
require('./ui/styles');
require('./ui/dark');
require('./ui/tooltips');
require('./ui/notifications');
//require('./ui/viewer_count');
require('./ui/sub_count');
//require('./ui/dash_stats');
require('./ui/dash_feed');
require('./ui/menu_button');
require('./ui/following');
require('./ui/following-count');
require('./ui/races');
require('./ui/my_emotes');
require('./ui/about_page');
require('./ui/schedule');
require('./commands');
require('./ext/api');
require('./ext/warpworld');
// ---------------
// Initialization
// ---------------
FFZ.prototype.initialized = false;
FFZ.prototype.initialize = function(increment, delay) {
// Make sure that FrankerFaceZ doesn't start setting itself up until the
// Twitch ember application is ready.
// Pages we don't want to interact with at all.
if ( ['passport.twitch.tv', 'im.twitch.tv', 'api.twitch.tv', 'chatdepot.twitch.tv', 'spade.twitch.tv'].indexOf(location.hostname) !== -1 || /^\/pr\//.test(location.pathname) || /^\/user\/two_factor/.test(location.pathname) ) {
this.log("Found banned sub-domain. Not initializing.");
window.FrankerFaceZ = null;
return;
}
// Check for the player
if ( location.hostname === 'player.twitch.tv' )
return this.init_player(delay);
// Clips~
if ( location.hostname === 'clips.twitch.tv' )
return this.init_clips(delay);
// Check for special non-ember pages.
if ( /^\/(?:team\/|user\/|p\/|m\/|settings\/(?:prime|turbo|channel|security|connections)|messages?\/)/.test(location.pathname) )
return this.init_normal(delay);
// Check for the dashboard.
/*if ( window.PP && /\/[^\/]+\/dashboard/.test(location.pathname) && !/bookmarks$/.test(location.pathname) )
return this.init_dashboard(delay);*/
var loaded = FFZ.utils.ember_resolve('model:room');
if ( !loaded ) {
increment = increment || 10;
if ( delay >= 60000 )
this.log("Twitch application not detected in \"" + location.toString() + "\". Aborting.");
else
setTimeout(this.initialize.bind(this, increment, (delay||0) + increment),
increment);
return;
static get() {
return FrankerFaceZ.instance;
}
this.init_ember(delay);
}
// ========================================================================
// Modules
// ========================================================================
FFZ.prototype.init_clips = function(delay) {
var start = (window.performance && performance.now) ? performance.now() : Date.now();
this.log("Found Twitch Clips after " + (delay||0) + " ms at: " + location);
this.log("Initializing FrankerFaceZ version " + FFZ.version_info);
discoverModules() {
const ctx = require.context('src/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.js$/),
modules = this.populate(ctx, this.core_log);
this.is_dashboard = false;
this.embed_in_dash = false;
this.is_clips = true;
try {
this.embed_in_clips = window.top !== window && window.top.location.hostname === 'clips.twitch.tv';
} catch(err) { this.embed_in_clips = false; }
this.setup_dark();
this.setup_css();
this.add_clips_darken_button();
this.initialized = true;
this.api_trigger('initialized');
var end = (window.performance && performance.now) ? performance.now() : Date.now(),
duration = end - start;
this.log("Initialization complete in " + duration + "ms");
}
FFZ.prototype.init_player = function(delay) {
var start = (window.performance && performance.now) ? performance.now() : Date.now();
this.log("Found Twitch Player after " + (delay||0) + " ms at: " + location);
this.log("Initializing FrankerFaceZ version " + FFZ.version_info);
this.is_dashboard = false;
try {
this.embed_in_dash = window.top !== window && /\/[^\/]+\/dashboard/.test(window.top.location.pathname) && !/bookmarks$/.test(window.top.location.pathname);
} catch(err) { this.embed_in_dash = false; }
// Literally only make it dark.
this.setup_dark();
this.setup_css();
this.setup_player();
this.initialized = true;
this.api_trigger('initialized');
var end = (window.performance && performance.now) ? performance.now() : Date.now(),
duration = end - start;
this.log("Initialization complete in " + duration + "ms");
}
FFZ.prototype.init_normal = function(delay, no_socket) {
var start = (window.performance && performance.now) ? performance.now() : Date.now();
this.log("Found non-Ember Twitch after " + (delay||0) + " ms at: " + location);
this.log("Initializing FrankerFaceZ version " + FFZ.version_info);
this.is_dashboard = false;
try {
this.embed_in_dash = window.top !== window && /\/[^\/]+\/dashboard/.test(window.top.location.pathname) && !/bookmarks$/.test(window.top.location.pathname);
} catch(err) { this.embed_in_dash = false; }
// Initialize all the modules.
this.setup_ember_wrapper();
// Start this early, for quick loading.
this.setup_dark();
this.setup_css();
this.setup_popups();
this.setup_following_link();
if ( ! no_socket ) {
this.setup_time();
this.ws_create();
this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
}
this.setup_colors();
this.setup_emoticons();
this.setup_badges();
this.setup_sidebar();
this.setup_notifications();
this.setup_following_count(false);
this.setup_menu();
this.finalize_ember_wrapper();
this.setup_message_event();
this.fix_tooltips();
this.find_bttv(10);
this.initialized = true;
this.api_trigger('initialized');
var end = (window.performance && performance.now) ? performance.now() : Date.now(),
duration = end - start;
this.log("Initialization complete in " + duration + "ms");
}
FFZ.prototype.is_dashboard = false;
FFZ.prototype.init_dashboard = function(delay) {
var start = (window.performance && performance.now) ? performance.now() : Date.now();
this.log("Found Twitch Dashboard after " + (delay||0) + " ms at: " + location);
this.log("Initializing FrankerFaceZ version " + FFZ.version_info);
var match = location.pathname.match(/\/([^\/]+)/);
this.dashboard_channel = match && match[1] || undefined;
this.is_dashboard = true;
this.embed_in_dash = false;
// Initialize all the modules.
this.setup_ember_wrapper();
// Start this early, for quick loading.
this.setup_dark();
this.setup_css();
this.setup_popups();
this.setup_following_link();
this.setup_time();
this.ws_create();
this.setup_colors();
this.setup_emoticons();
this.setup_badges();
this.setup_tokenization();
this.setup_notifications();
this.setup_following_count(false);
this.setup_menu();
//this.setup_dash_stats();
this.setup_dash_feed();
this.finalize_ember_wrapper();
this._update_subscribers();
// Set up the FFZ message passer.
this.setup_message_event();
this.cache_command_aliases();
this.fix_tooltips();
this.find_bttv(10);
this.initialized = true;
this.api_trigger('initialized');
var end = (window.performance && performance.now) ? performance.now() : Date.now(),
duration = end - start;
this.log("Initialization complete in " + duration + "ms");
}
FFZ.prototype.init_ember = function(delay) {
var start = (window.performance && performance.now) ? performance.now() : Date.now();
this.log("Found Twitch application after " + (delay||0) + " ms at: " + location);
this.log("Initializing FrankerFaceZ version " + FFZ.version_info);
this.is_dashboard = false;
try {
this.embed_in_dash = window.top !== window && /\/[^\/]+\/dashboard/.test(window.top.location.pathname) && !/bookmarks$/.test(window.top.location.pathname);
} catch(err) { this.embed_in_dash = false; }
// Is debug mode enabled? Scratch that, everyone gets error handlers!
if ( true ) { //this.settings.developer_mode ) {
// Set up an error listener for RSVP.
var f = this;
if ( Ember.RSVP && Ember.RSVP.on )
Ember.RSVP.on('error', function(error) {
// We want to ignore errors that are just 4xx HTTP responses.
if ( error && error.responseJSON && typeof error.responseJSON.status === "number" && error.responseJSON.status >= 400 )
return;
f.error("There was an error within an Ember RSVP.", error);
});
Ember.onerror = function(error) {
// We want to ignore errors that are just 4xx HTTP responses.
if ( error && error.responseJSON && typeof error.responseJSON.status === "number" && error.responseJSON.status >= 400 )
return;
f.error("There was an unknown error within Ember.", error);
}
}
// Set up all the everything.
this.setup_ember_wrapper();
// Start this early, for quick loading.
this.setup_dark();
this.setup_css();
this.setup_popups();
this.setup_time();
this.ws_create();
this.setup_emoticons();
this.setup_badges();
this.setup_router();
this.setup_colors();
this.setup_tokenization();
//this.setup_filtering();
this.setup_player();
this.setup_channel();
this.setup_room();
this.setup_vod_chat();
this.setup_line();
this.setup_bits();
this.setup_layout();
this.setup_chatview();
this.setup_conversations();
this.setup_viewers();
this.setup_mod_card();
this.setup_chat_input();
this.setup_directory();
this.setup_profile_following();
this.setup_feed_cards();
this.setup_sidebar();
this.setup_dashboard();
this.setup_commerce();
//this.setup_teams();
this.setup_notifications();
this.setup_menu();
this.setup_my_emotes();
this.setup_following();
this.setup_following_count(true);
this.setup_races();
// Do all Ember modification before this point.
this.finalize_ember_wrapper();
this.cache_command_aliases();
this.fix_tooltips();
this.fix_scroll();
setTimeout(this.connect_extra_chat.bind(this), 250);
this.setup_message_event();
this.find_bttv(10);
this.find_emote_menu(10);
setTimeout(this.check_badware.bind(this), 10000);
//this.check_news();
this.check_ff();
this.refresh_chat();
this.initialized = true;
this.api_trigger('initialized');
var end = (window.performance && performance.now) ? performance.now() : Date.now(),
duration = end - start;
this.log("Initialization complete in " + duration + "ms");
}
// ------------------------
// Dashboard Message Event
// ------------------------
FFZ.prototype.setup_message_event = function() {
this.log("Listening for Window Messages.");
window.addEventListener("message", this._on_window_message.bind(this), false);
}
FFZ.prototype._on_window_message = function(e) {
var msg = e.data;
if ( typeof msg === "string" )
try {
msg = JSON.parse(msg);
} catch(err) {
// Not JSON? We don't care.
return;
async enableInitialModules() {
const promises = [];
/* eslint guard-for-in: off */
for(const key in this.__modules) {
const module = this.__modules[key];
if ( module instanceof Module && module.should_enable )
promises.push(module.enable());
}
if ( ! msg || ! msg.from_ffz )
return;
await Promise.all(promises);
}
var handler = FFZ.msg_commands[msg.command];
if ( handler )
handler.call(this, msg.data);
else
this.log("Invalid Message: " + msg.command, msg.data, false, true);
/* eslint class-methods-use-this: off */
api() {
throw new Error('Not Implemented');
}
}
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = {
major: 4, minor: 0, revision: 0, extra: '-beta1',
build: __webpack_hash__,
toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}`
}
FrankerFaceZ.utilities = {
dom: require('utilities/dom'),
color: require('utilities/color'),
events: require('utilities/events')
}
window.FrankerFaceZ = FrankerFaceZ;
window.ffz = new FrankerFaceZ();

View file

@ -0,0 +1,27 @@
'use strict';
// ============================================================================
// Badge Handling
// ============================================================================
import Module from 'utilities/module';
export const CSS_BADGES = {
staff: { 1: { color: '#200f33', use_svg: true } },
admin: { 1: { color: '#faaf19', use_svg: true } },
global_mod: { 1: { color: '#0c6f20', use_svg: true } },
broadcaster: { 1: { color: '#e71818', use_svg: true } },
moderator: { 1: { color: '#34ae0a', use_svg: true } },
twitchbot: { 1: { color: '#34ae0a' } },
partner: { 1: { color: 'transparent', has_trans: true, trans_color: '#6441a5' } },
turbo: { 1: { color: '#6441a5', use_svg: true } },
premium: { 1: { color: '#009cdc' } },
subscriber: { 0: { color: '#6441a4' }, 1: { color: '#6441a4' }},
}
export default class Badges extends Module {
}

325
src/modules/chat/emotes.js Normal file
View file

@ -0,0 +1,325 @@
'use strict';
// ============================================================================
// Emote Handling and Default Provider
// ============================================================================
import Module from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import {has, timeout} from 'utilities/object';
import {API_SERVER} from 'utilities/constants';
const MODIFIERS = {
59847: {
modifier_offset: '0 15px 15px 0',
modifier: true
},
70852: {
modifier: true,
modifier_offset: '0 5px 20px 0',
extra_width: 5,
shrink_to_fit: true
},
70854: {
modifier: true,
modifier_offset: '30px 0 0'
},
147049: {
modifier: true,
modifier_offset: '4px 1px 0 3px'
},
147011: {
modifier: true,
modifier_offset: '0'
},
70864: {
modifier: true,
modifier_offset: '0'
},
147038: {
modifier: true,
modifier_offset: '0'
}
};
export default class Emotes extends Module {
constructor(...args) {
super(...args);
this.inject('socket');
this.twitch_inventory_sets = [];
this.__twitch_emote_to_set = new Map;
this.__twitch_set_to_channel = new Map;
this.default_sets = new Set;
this.global_sets = new Set;
this.emote_sets = {};
}
onEnable() {
this.style = new ManagedStyle('emotes');
if ( Object.keys(this.emote_sets).length ) {
this.log.info('Generating CSS for existing emote sets.');
for(const set_id in this.emote_sets)
if ( has(this.emote_sets, set_id) ) {
const emote_set = this.emote_sets[set_id];
if ( emote_set && emote_set.pending_css ) {
this.style.set(`es--${set_id}`, emote_set.pending_css + (emote_set.css || ''));
emote_set.pending_css = null;
}
}
}
this.load_global_sets();
this.load_emoji_data();
this.refresh_twitch_inventory();
}
// ========================================================================
// Access
// ========================================================================
getSetIDs(user_id, user_login, room_id, room_login) {
const room = this.parent.getRoom(room_id, room_login, true),
user = this.parent.getUser(user_id, user_login, true);
return (user ? user.emote_sets : []).concat(
room ? room.emote_sets : [],
Array.from(this.default_sets)
);
}
getSets(user_id, user_login, room_id, room_login) {
return this.getSetIDs(user_id, user_login, room_id, room_login)
.map(set_id => this.emote_sets[set_id]);
}
// ========================================================================
// FFZ Emote Sets
// ========================================================================
async load_global_sets(tries = 0) {
let response, data;
try {
response = await fetch(`${API_SERVER}/v1/set/global`)
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.load_global_sets(tries), 500 * tries);
this.log.error('Error loading global emote sets.', err);
return false;
}
if ( ! response.ok )
return false;
try {
data = await response.json();
} catch(err) {
this.log.error('Error parsing global emote data.', err);
return false;
}
const sets = data.sets || {};
for(const set_id of data.default_sets)
this.default_sets.add(set_id);
for(const set_id in sets)
if ( has(sets, set_id) ) {
this.global_sets.add(set_id);
this.load_set_data(set_id, sets[set_id]);
}
if ( data.users )
this.load_set_users(data.users);
}
load_set_users(data) {
for(const set_id in data)
if ( has(data, set_id) ) {
const emote_set = this.emote_sets[set_id],
users = data[set_id];
for(const login of users) {
const user = this.parent.getUser(undefined, login),
sets = user.emote_sets;
if ( sets.indexOf(set_id) === -1 )
sets.push(set_id);
}
this.log.info(`Added "${emote_set ? emote_set.title : set_id}" emote set to ${users.length} users.`);
}
}
load_set_data(set_id, data) {
const old_set = this.emote_sets[set_id];
if ( ! data ) {
if ( old_set )
this.emote_sets[set_id] = null;
return;
}
this.emote_sets[set_id] = data;
data.users = old_set ? old_set.users : 0;
let count = 0;
const ems = data.emotes || data.emoticons,
new_ems = data.emotes = {},
css = [];
data.emoticons = undefined;
for(const emote of ems) {
emote.set_id = set_id;
emote.srcSet = `${emote.urls[1]} 1x`;
if ( emote.urls[2] )
emote.srcSet += `, ${emote.urls[2]} 2x`;
if ( emote.urls[4] )
emote.srcSet += `, ${emote.urls[4]} 4x`;
emote.token = {
type: 'emote',
id: emote.id,
set: set_id,
provider: 'ffz',
src: emote.urls[1],
srcSet: emote.srcSet,
text: emote.hidden ? '???' : emote.name,
length: emote.name.length
};
if ( has(MODIFIERS, emote.id) )
Object.assign(emote, MODIFIERS[emote.id]);
const emote_css = this.generateEmoteCSS(emote);
if ( emote_css )
css.push(emote_css);
count++;
new_ems[emote.id] = emote;
}
data.count = count;
if ( this.style && (css.length || data.css) )
this.style.set(`es--${set_id}`, css.join('') + (data.css || ''));
else if ( css.length )
data.pending_css = css.join('');
this.log.info(`Updated emotes for set #${set_id}: ${data.title}`);
this.emit(':loaded', set_id, data);
}
// ========================================================================
// Emote CSS
// ========================================================================
generateEmoteCSS(emote) { // eslint-disable-line class-methods-use-this
if ( ! emote.margins && ( ! emote.modifier || ( ! emote.modifier_offset && ! emote.extra_width && ! emote.shrink_to_fit ) ) && ! emote.css )
return '';
let output = '';
if ( emote.modifier && (emote.modifier_offset || emote.margins || emote.extra_width || emote.shrink_to_fit) ) {
let margins = emote.modifier_offset || emote.margins || '0';
margins = margins.split(/\s+/).map(x => parseInt(x, 10));
if ( margins.length === 3 )
margins.push(margins[1]);
const l = margins.length,
m_top = margins[0 % l],
m_right = margins[1 % l],
m_bottom = margins[2 % l],
m_left = margins[3 % l];
output = `.modified-emote span .ffz-emote[data-id="${emote.id}"] {
padding: ${m_top}px ${m_right}px ${m_bottom}px ${m_left}px;
${emote.shrink_to_fit ? `max-width: calc(100% - ${40 - m_left - m_right - (emote.extra_width||0)}px);` : ''}
margin: 0 !important;
}`;
}
return `${output}.ffz-emote[data-id="${emote.id}"] {
${(emote.margins && ! emote.modifier) ? `margin: ${emote.margins} !important;` : ''}
${emote.css||''}
}`;
}
// ========================================================================
// Emoji
// ========================================================================
load_emoji_data() {
this.log.debug('Unimplemented: load_emoji_data');
}
// ========================================================================
// Twitch Data Lookup
// ========================================================================
refresh_twitch_inventory() {
this.log.debug('Unimplemented: refresh_twitch_inventory');
}
twitch_emote_to_set(emote_id, callback) {
const tes = this.__twitch_emote_to_set;
if ( isNaN(emote_id) || ! isFinite(emote_id) )
return null;
if ( tes.has(emote_id) )
return tes.get(emote_id);
tes.set(emote_id, null);
timeout(this.socket.call('get_emote', emote_id), 1000).then(data => {
const set_id = data['s_id'];
tes.set(emote_id, set_id);
this.__twitch_set_to_channel.set(set_id, data);
if ( callback )
callback(data['s_id']);
}).catch(() => tes.delete(emote_id));
}
twitch_set_to_channel(set_id, callback) {
const tes = this.__twitch_set_to_channel;
if ( isNaN(set_id) || ! isFinite(set_id) )
return null;
if ( tes.has(set_id) )
return tes.get(set_id);
tes.set(set_id, null);
timeout(this.socket.call('get_emote_set', set_id), 1000).then(data => {
tes.set(set_id, data);
if ( callback )
callback(data);
}).catch(() => tes.delete(set_id));
}
}

513
src/modules/chat/index.js Normal file
View file

@ -0,0 +1,513 @@
'use strict';
// ============================================================================
// Chat
// ============================================================================
import {IS_WEBKIT} from 'utilities/constants';
const WEBKIT = IS_WEBKIT ? '-webkit-' : '';
import Module from 'utilities/module';
import {createElement, ManagedStyle} from 'utilities/dom';
import {timeout, has} from 'utilities/object';
import Badges from './badges';
import Emotes from './emotes';
import Room from './room';
import * as TOKENIZERS from './tokenizers';
export default class Chat extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('i18n');
this.inject('tooltips');
this.inject('socket');
this.inject(Badges);
this.inject(Emotes);
this._link_info = {};
this.style = new ManagedStyle;
this.context = this.settings.context({});
this.rooms = {};
this.users = {};
this.room_ids = {};
this.user_ids = {};
this.tokenizers = {};
this.__tokenizers = [];
// ========================================================================
// Settings
// ========================================================================
this.settings.add('tooltip.images', {
default: true,
ui: {
path: 'Chat > Tooltips >> General @{"sort": -1}',
title: 'Display images in tooltips.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.badge-images', {
default: true,
requires: ['tooltip.images'],
process(ctx, val) {
return ctx.get('tooltip.images') ? val : false
},
ui: {
path: 'Chat > Tooltips >> Badges',
title: 'Display large images of badges.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.emote-sources', {
default: true,
ui: {
path: 'Chat > Tooltips >> Emotes',
title: 'Display known sources.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.emote-images', {
default: true,
requires: ['tooltip.images'],
process(ctx, val) {
return ctx.get('tooltip.images') ? val : false
},
ui: {
path: 'Chat > Tooltips >> Emotes',
title: 'Display large images of emotes.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.rich-links', {
default: true,
ui: {
sort: -1,
path: 'Chat > Tooltips >> Links',
title: 'Display rich tooltips for links.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.link-images', {
default: true,
requires: ['tooltip.images'],
process(ctx, val) {
return ctx.get('tooltip.images') ? val : false
},
ui: {
path: 'Chat > Tooltips >> Links',
title: 'Display images for links.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.link-nsfw-images', {
default: false,
ui: {
path: 'Chat > Tooltips >> Links',
title: 'Display potentially NSFW images.',
description: 'When enabled, FrankerFaceZ will include images that are tagged as unsafe or that are not rated.',
component: 'setting-check-box'
}
});
this.settings.add('chat.adjustment-mode', {
default: 1,
ui: {
path: 'Chat > Appearance >> Colors',
title: 'Adjustment',
description: 'Alter user colors to ensure that they remain readable.',
component: 'setting-select-box',
data: [
{value: -1, title: 'No Color'},
{value: 0, title: 'Unchanged'},
{value: 1, title: 'HSL Luma'},
{value: 2, title: 'Luv Luma'},
{value: 3, title: 'HSL Loop (BTTV-Like)'}
]
}
});
this.settings.add('chat.adjustment-contrast', {
default: 4.5,
ui: {
path: 'Chat > Appearance >> Colors',
title: 'Minimum Contrast',
description: 'Set the minimum contrast ratio used by Luma adjustments when determining readability.',
component: 'setting-text-box',
process(val) {
return parseFloat(val)
}
}
});
this.settings.add('chat.bits.stack', {
default: 0,
ui: {
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Cheer Stacking',
description: 'Collect all the cheers in a message into a single cheer at the start of the message.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Grouped by Type'},
{value: 2, title: 'All in One'}
]
}
});
this.settings.add('chat.bits.animated', {
default: true,
ui: {
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Display animated cheers.',
component: 'setting-check-box'
}
});
this.context.on('changed:theme.is-dark', () => {
for(const key in this.rooms)
if ( this.rooms[key] )
this.rooms[key].updateBitsCSS();
});
this.context.on('changed:chat.bits.animated', () => {
for(const key in this.rooms)
if ( this.rooms[key] )
this.rooms[key].updateBitsCSS();
});
}
onEnable() {
for(const key in TOKENIZERS)
if ( has(TOKENIZERS, key) )
this.addTokenizer(TOKENIZERS[key]);
}
getBadge(badge, version, room) {
let b;
if ( this.room_ids[room] ) {
const versions = this.room_ids[room].badges.get(badge);
b = versions && versions.get(version);
}
if ( ! b ) {
const versions = this.badges.get(badge);
b = versions && versions.get(version);
}
return b;
}
updateBadges(badges) {
this.badges = badges;
this.updateBadgeCSS();
}
updateBadgeCSS() {
if ( ! this.badges )
this.style.delete('badges');
const out = [];
for(const [key, versions] of this.badges)
for(const [version, data] of versions) {
out.push(`.ffz-badge.badge--${key}.version--${version} {
background-color: transparent;
filter: none;
${WEBKIT}mask-image: none;
background-image: url("${data.image1x}");
background-image: ${WEBKIT}image-set(
url("${data.image1x}") 1x,
url("${data.image2x}") 2x,
url("${data.image4x}") 4x
);
}`)
}
this.style.set('badges', out.join('\n'));
}
getUser(id, login, no_create, no_login) {
let user;
if ( this.user_ids[id] )
user = this.user_ids[id];
else if ( this.users[login] && ! no_login )
user = this.users[login];
else if ( no_create )
return null;
else
user = {id, login, badges: [], emote_sets: []};
if ( id && id !== user.id ) {
// If the ID isn't what we expected, something is very wrong here.
// Blame name changes.
if ( user.id )
throw new Error('id mismatch');
// Otherwise, we're just here to set the ID.
user.id = id;
this.user_ids[id] = user;
}
if ( login ) {
const other = this.users[login];
if ( other ) {
if ( other !== this && ! no_login ) {
// If the other has an ID, something weird happened. Screw it
// and just take over.
if ( other.id )
this.users[login] = user;
else {
// TODO: Merge Logic~~
}
}
} else
this.users[login] = user;
}
return user;
}
getRoom(id, login, no_create, no_login) {
let room;
if ( this.room_ids[id] )
room = this.room_ids[id];
else if ( this.rooms[login] && ! no_login )
room = this.rooms[login];
else if ( no_create )
return null;
else
room = new Room(this, id, login);
if ( id && id !== room.id ) {
// If the ID isn't what we expected, something is very wrong here.
// Blame name changes. Or React not being atomic.
if ( room.id )
throw new Error('id mismatch');
// Otherwise, we're just here to set the ID.
room.id = id;
this.room_ids[id] = room;
}
if ( login ) {
const other = this.rooms[login];
if ( other ) {
if ( other !== this && ! no_login ) {
// If the other has an ID, something weird happened. Screw it
// and just take over.
if ( other.id )
this.rooms[login] = room;
else {
// TODO: Merge Logic~~
}
}
} else
this.rooms[login] = room;
}
return room;
}
formatTime(time) {
if (!( time instanceof Date ))
time = new Date(time);
let hours = time.getHours();
const minutes = time.getMinutes(),
seconds = time.getSeconds(),
fmt = this.settings.get('chat.timestamp-format');
if ( hours > 12 )
hours -= 12;
else if ( hours === 0 )
hours = 12;
return `${hours}:${minutes < 10 ? '0' : ''}${minutes}`; //:${seconds < 10 ? '0' : ''}${seconds}`;
}
addTokenizer(tokenizer) {
const type = tokenizer.type;
this.tokenizers[type] = tokenizer;
if ( tokenizer.priority == null )
tokenizer.priority = 0;
if ( tokenizer.tooltip )
this.tooltips.types[type] = tokenizer.tooltip.bind(this);
this.__tokenizers.push(tokenizer);
this.__tokenizers.sort((a, b) => {
if ( a.priority > b.priority ) return -1;
if ( a.priority < b.priority ) return 1;
return a.type < b.sort;
});
}
tokenizeString(message, msg) {
let tokens = [{type: 'text', text: message}];
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg);
return tokens;
}
tokenizeMessage(msg) {
let tokens = [{type: 'text', text: msg.message}];
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg);
return tokens;
}
renderBadges(msg, e) { // eslint-disable-line class-methods-use-this
const out = [],
badges = msg.badges || {};
for(const key in badges)
if ( has(badges, key) ) {
const version = badges[key];
out.push(e('span', {
className: `ffz-tooltip ffz-badge badge--${key} version--${version}`,
'data-tooltip-type': 'badge',
'data-badge': key,
'data-version': version
}))
}
return out;
}
renderTokens(tokens, e) {
if ( ! e )
e = createElement;
const out = [],
tokenizers = this.tokenizers,
l = tokens.length;
for(let i=0; i < l; i++) {
const token = tokens[i],
type = token.type,
tk = tokenizers[type];
if ( token.hidden )
continue;
let res;
if ( type === 'text' )
res = token.text;
else if ( tk )
res = tk.render.call(this, token, e);
else
res = e('em', {
className: 'ffz-unknown-token ffz-tooltip',
'data-tooltip-type': 'json',
'data-data': JSON.stringify(token, null, 2)
}, `[unknown token: ${type}]`)
if ( res )
out.push(res);
}
return out;
}
// ====
// Twitch Crap
// ====
get_link_info(url, no_promises) {
let info = this._link_info[url],
expires = info && info[1];
if ( expires && Date.now() > expires )
info = this._link_info[url] = null;
if ( info && info[0] )
return no_promises ? info[2] : Promise.resolve(info[2]);
if ( no_promises )
return null;
else if ( info )
return new Promise((resolve, reject) => info[2].push([resolve, reject]))
return new Promise((resolve, reject) => {
info = this._link_info[url] = [false, null, [[resolve, reject]]];
const handle = (success, data) => {
const callbacks = ! info[0] && info[2];
info[0] = true;
info[1] = Date.now() + 120000;
info[2] = success ? data : null;
if ( callbacks )
for(const cbs of callbacks)
cbs[success ? 0 : 1](data);
}
timeout(this.socket.call('get_link', url), 15000)
.then(data => handle(true, data))
.catch(err => handle(false, err));
});
}
}

216
src/modules/chat/room.js Normal file
View file

@ -0,0 +1,216 @@
'use strict';
// ============================================================================
// Room
// ============================================================================
import {API_SERVER, IS_WEBKIT} from 'utilities/constants';
import {EventEmitter} from 'utilities/events';
import {createElement as e, ManagedStyle} from 'utilities/dom';
import {has} from 'utilities/object';
const WEBKIT = IS_WEBKIT ? '-webkit-' : '';
export default class Room extends EventEmitter {
constructor(manager, id, login) {
super();
this._destroy_timer = null;
this.manager = manager;
this.id = id;
this.login = login;
if ( login )
this.manager.rooms[login] = this;
if ( id )
this.manager.room_ids[id] = this;
this.refs = new Set;
this.style = new ManagedStyle(`room--${login}`);
this.emote_sets = [];
this.users = [];
this.user_ids = [];
this.load_data();
}
destroy() {
clearTimeout(this._destroy_timer);
this._destroy_timer = null;
this.destroyed = true;
this.style.destroy();
if ( this.manager.room_ids[this.id] === this )
this.manager.room_ids[this.id] = null;
if ( this.manager.rooms[this.login] === this )
this.manager.rooms[this.login] = null;
}
// ========================================================================
// FFZ Data
// ========================================================================
async load_data(tries = 0) {
if ( this.destroyed )
return;
let response, data;
try {
response = await fetch(`${API_SERVER}/v1/room/${this.login}`);
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.load_data(tries), 500 * tries);
this.manager.log.error(`Error loading room data for ${this.id}:${this.login}`, err);
return false;
}
if ( ! response.ok )
return false;
try {
data = await response.json();
} catch(err) {
this.manager.log.error(`Error parsing room data for ${this.id}:${this.login}`, err);
return false;
}
const d = this.data = data.room;
if ( d.set && this.emote_sets.indexOf(d) === -1 )
this.emote_sets.push(d.set);
if ( data.sets )
for(const set_id in data.sets)
if ( has(data.sets, set_id) )
this.manager.emotes.load_set_data(set_id, data.sets[set_id]);
// TODO: User data.
// TODO: Generate CSS.
return true;
}
// ========================================================================
// Life Cycle
// ========================================================================
ref(referrer) {
clearTimeout(this._destroy_timer);
this._destroy_timer = null;
this.refs.add(referrer);
}
unref(referrer) {
this.refs.delete(referrer);
if ( ! this.users.size && ! this._destroy_timer )
this._destroy_timer = setTimeout(() => this.destroy(), 5000);
}
// ========================================================================
// Badge Data
// ========================================================================
updateBadges(badges) {
this.badges = badges;
this.updateBadgeCSS();
}
updateBadgeCSS() {
if ( ! this.badges )
return this.style.delete('badges');
const out = [],
id = this.id;
for(const [key, versions] of this.badges) {
for(const [version, data] of versions) {
const rule = `.ffz-badge.badge--${key}.version--${version}`;
out.push(`[data-room-id="${id}"] ${rule} {
background-color: transparent;
filter: none;
${WEBKIT}mask-image: none;
background-image: url("${data.image1x}");
background-image: ${WEBKIT}image-set(
url("${data.image1x}") 1x,
url("${data.image2x}") 2x,
url("${data.image4x}") 4x
);
}`);
}
}
this.style.set('badges', out.join('\n'));
}
// ========================================================================
// Bits Data
// ========================================================================
updateBitsConfig(config) {
this.bitsConfig = config;
this.updateBitsCSS();
}
updateBitsCSS() {
if ( ! this.bitsConfig )
return this.style.delete('bits');
const animated = this.manager.context.get('chat.bits.animated') ? 'animated' : 'static',
is_dark = this.manager.context.get('theme.is-dark'),
theme = is_dark ? 'DARK' : 'LIGHT',
antitheme = is_dark ? 'LIGHT' : 'DARK',
out = [];
for(const key in this.bitsConfig)
if ( has(this.bitsConfig, key) ) {
const action = this.bitsConfig[key],
prefix = action.prefix.toLowerCase(),
tiers = action.tiers,
l = tiers.length;
for(let i=0; i < l; i++) {
const images = tiers[i].images[theme][animated],
anti_images = tiers[i].images[antitheme][animated];
out.push(`.ffz-cheer[data-prefix="${prefix}"][data-tier="${i}"] {
color: ${tiers[i].color};
background-image: url("${images[1]}");
background-image: ${WEBKIT}image-set(
url("${images[1]}") 1x,
url("${images[2]}") 2x,
url("${images[4]}") 4x
);
}
.ffz__tooltip .ffz-cheer[data-prefix="${prefix}"][data-tier="${i}"] {
background-image: url("${anti_images[1]}");
background-image: ${WEBKIT}image-set(
url("${anti_images[1]}") 1x,
url("${anti_images[2]}") 2x,
url("${anti_images[4]}") 4x
);
}
.ffz-cheer-preview[data-prefix="${prefix}"][data-tier="${i}"] {
background-image: url("${anti_images[4]}");
}`)
}
}
this.style.set('bits', out.join('\n'));
}
}

View file

@ -0,0 +1,702 @@
'use strict';
// ============================================================================
// Default Tokenizers
// ============================================================================
import {sanitize, createElement as e} from 'utilities/dom';
import {has} from 'utilities/object';
const EMOTE_CLASS = 'chat-line__message--emote',
LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w.\/@#%&()\-+=:?~]*)?))([^\w.\/@#%&()\-+=:?~]|\s|$)/g,
MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w.\/@#%&()\-+=:?~]|\s|$)/g,
SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g,
TWITCH_BASE = '//static-cdn.jtvnw.net/emoticons/v1/';
function split_chars(str) {
return str.match(SPLIT_REGEX);
}
// ============================================================================
// Links
// ============================================================================
const TOOLTIP_VERSION = 4;
export const Links = {
type: 'link',
priority: 50,
render(token, e) {
return e('a', {
className: 'ffz-tooltip',
'data-tooltip-type': 'link',
'data-url': token.url,
'data-is-mail': token.is_mail,
rel: 'noopener',
target: '_blank',
href: token.url
}, token.text);
},
tooltip(target, tip) {
if ( ! this.context.get('tooltip.rich-links') )
return '';
if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail %{address}', {address: target.textContent})];
return this.get_link_info(target.dataset.url).then(data => {
if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
return '';
let content = data.content || data.html || '';
// TODO: Replace timestamps.
if ( data.urls && data.urls.length > 1 )
content += (content.length ? '<hr>' : '') +
sanitize(this.i18n.t(
'tooltip.link-destination',
'Destination: %{url}',
{url: data.urls[data.urls.length-1]}
));
if ( data.unsafe ) {
const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', ');
content = this.i18n.t(
'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: %{reasons}",
{reasons: sanitize(reasons.toLowerCase())}
) + (content.length ? `<hr>${content}` : '');
}
const show_image = this.context.get('tooltip.link-images') && (data.image_safe || this.context.get('tooltip.link-nsfw-images'));
if ( show_image ) {
if ( data.image && ! data.image_iframe )
content = `<img class="preview-image" src="${sanitize(data.image)}">${content}`
setTimeout(() => {
if ( tip.element )
for(const el of tip.element.querySelectorAll('video,img'))
el.addEventListener('load', tip.update)
});
} else if ( content.length )
content = content.replace(/<!--MS-->.*<!--ME-->/g, '');
if ( data.tooltip_class )
tip.element.classList.add(data.tooltip_class);
return content;
}).catch(error =>
sanitize(this.i18n.t('tooltip.error', 'An error occurred. (%{error})', {error}))
);
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
const out = [];
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
LINK_REGEX.lastIndex = 0;
const text = token.text;
let idx = 0, match;
while((match = LINK_REGEX.exec(text))) {
const nix = match.index + (match[1] ? match[1].length : 0);
if ( idx !== nix )
out.push({type: 'text', text: text.slice(idx, nix)});
const is_mail = ! match[3] && match[2].indexOf('/') === -1 && match[2].indexOf('@') !== -1;
out.push({
type: 'link',
url: (match[3] ? '' : is_mail ? 'mailto:' : 'https://') + match[2],
is_mail,
text: match[2]
});
idx = nix + match[2].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
return out;
}
}
// ============================================================================
// Rich Content
// ============================================================================
/*export const RichContent = {
type: 'rich-content',
render(token, e) {
return e('div', {
className: 'ffz--rich-content elevation-1 mg-y-05',
}, e('a', {
className: 'clips-chat-card flex flex-nowrap pd-05',
target: '_blank',
href: token.url
}, [
e('div', {
className: 'clips-chat-card__thumb align-items-center flex justify-content-center'
})
]));
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
for(const token of tokens) {
if ( token.type !== 'link' )
continue;
}
}
}*/
// ============================================================================
// Mentions
// ============================================================================
export const Mentions = {
type: 'mention',
priority: 40,
render(token, e) {
return e('strong', {
className: 'chat-line__message-mention'
}, `@${token.recipient}`);
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
const out = [];
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
MENTION_REGEX.lastIndex = 0;
const text = token.text;
let idx = 0, match;
while((match = MENTION_REGEX.exec(text))) {
const nix = match.index + (match[1] ? match[1].length : 0);
if ( idx !== nix )
out.push({type: 'text', text: text.slice(idx, nix)});
out.push({
type: 'mention',
recipient: match[3],
length: match[3].length + 1
});
idx = nix + match[2].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
return out;
}
}
// ============================================================================
// Cheers
// ============================================================================
export const CheerEmotes = {
type: 'cheer',
render(token, e) {
return e('span', {
className: `ffz-cheer ffz-tooltip`,
'data-tooltip-type': 'cheer',
'data-prefix': token.prefix,
'data-amount': this.i18n.formatNumber(token.amount),
'data-tier': token.tier,
'data-individuals': token.individuals ? JSON.stringify(token.individuals) : 'null',
alt: token.text
});
},
tooltip(target) {
const ds = target.dataset,
amount = parseInt(ds.amount.replace(/,/g, ''), 10),
prefix = ds.prefix,
tier = ds.tier,
individuals = ds.individuals && JSON.parse(ds.individuals),
length = individuals && individuals.length;
const out = [
this.context.get('tooltip.emote-images') && e('div', {
className: 'preview-image ffz-cheer-preview',
'data-prefix': prefix,
'data-tier': tier
}),
this.i18n.t('tooltip.bits', '%{count|number} Bits', amount),
];
if ( length > 1 ) {
out.push(e('br'));
individuals.sort(i => -i[0]);
for(let i=0; i < length && i < 12; i++) {
const [amount, tier, prefix] = individuals[i];
out.push(this.tokenizers.cheer.render.call(this, {
amount,
prefix,
tier
}, e));
}
if ( length > 12 ) {
out.push(e('br'));
out.push(this.i18n.t('tooltip.bits.more', '(and %{count} more)', length-12));
}
}
return out;
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length || ! msg.bits )
return tokens;
const SiteChat = this.resolve('site.chat'),
chat = SiteChat && SiteChat.currentChat,
bitsConfig = chat && chat.props.bitsConfig;
if ( ! bitsConfig )
return tokens;
const actions = bitsConfig.indexedActions,
matcher = new RegExp('\\b(' + Object.keys(actions).join('|') + ')(\\d+)\\b', 'ig');
const out = [],
collected = {},
collect = this.context.get('chat.bits.stack');
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
matcher.lastIndex = 0;
const text = token.text;
let idx = 0, match;
while((match = matcher.exec(text))) {
const prefix = match[1].toLowerCase(),
cheer = actions[prefix];
if ( ! cheer )
continue;
if ( idx !== match.index )
out.push({type: 'text', text: text.slice(idx, match.index)});
const amount = parseInt(match[2], 10),
tiers = cheer.orderedTiers;
let tier, token;
for(let i=0, l = tiers.length; i < l; i++)
if ( amount >= tiers[i].bits ) {
tier = i;
break;
}
out.push(token = {
type: 'cheer',
prefix,
tier,
amount: parseInt(match[2], 10),
text: match[0]
});
if ( collect ) {
const pref = collect === 2 ? 'cheer' : prefix,
group = collected[pref] = collected[pref] || {total: 0, individuals: []};
group.total += amount;
group.individuals.push([amount, tier, prefix]);
token.hidden = true;
}
idx = match.index + match[0].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
if ( collect ) {
for(const prefix in collected)
if ( has(collected, prefix) ) {
const cheers = collected[prefix],
cheer = actions[prefix],
tiers = cheer.orderedTiers;
let tier = 0;
for(let l = tiers.length; tier < l; tier++)
if ( cheers.total >= tiers[tier].bits )
break;
out.unshift({
type: 'cheer',
prefix,
tier,
amount: cheers.total,
individuals: cheers.individuals,
length: 0
});
}
}
return out;
}
}
// ============================================================================
// Addon Emotes
// ============================================================================
export const AddonEmotes = {
type: 'emote',
render(token, e) {
const mods = token.modifiers || [], ml = mods.length,
emote = e('img', {
className: `${EMOTE_CLASS} ffz-tooltip${token.provider === 'ffz' ? ' ffz-emote' : ''}`,
src: token.src,
srcSet: token.srcSet,
alt: token.text,
'data-tooltip-type': 'emote',
'data-provider': token.provider,
'data-id': token.id,
'data-set': token.set,
'data-modifiers': ml ? mods.map(x => x.id).join(' ') : null,
'data-modifier-info': ml ? JSON.stringify(mods.map(x => [x.set, x.id])) : null
});
if ( ! ml )
return emote;
return e('span', {
className: `${EMOTE_CLASS} modified-emote`,
'data-provider': token.provider,
'data-id': token.id,
'data-set': token.set
}, [
emote,
mods.map(t => e('span', null, this.tokenizers.emote.render(t, e)))
]);
},
tooltip(target, tip) {
const provider = target.dataset.provider,
modifiers = target.dataset.modifierInfo;
let preview, source, owner, mods;
if ( modifiers && modifiers !== 'null' ) {
mods = JSON.parse(modifiers).map(([set_id, emote_id]) => {
const emote_set = this.emotes.emote_sets[set_id],
emote = emote_set && emote_set.emotes[emote_id];
if ( emote )
return e('span', null, [
this.tokenizers.emote.render(emote.token, e),
` - ${emote.hidden ? '???' : emote.name}`
]);
})
}
if ( provider === 'twitch' ) {
const emote_id = parseInt(target.dataset.id, 10),
set_id = this.emotes.twitch_emote_to_set(emote_id, tip.rerender),
emote_set = set_id != null && this.emotes.twitch_set_to_channel(set_id, tip.rerender);
preview = `//static-cdn.jtvnw.net/emoticons/v1/${emote_id}/4.0?_=preview`;
if ( emote_set ) {
source = emote_set.c_name;
if ( source === '--global--' || emote_id === 80393 )
source = this.i18n.t('emote.global', 'Twitch Global');
else if ( source === '--twitch-turbo--' || source === 'turbo' || source === '--turbo-faces--' )
source = this.i18n.t('emote.turbo', 'Twitch Turbo');
else if ( source === '--prime--' || source === '--prime-faces--' )
source = this.i18n.t('emote.prime', 'Twitch Prime');
else
source = this.i18n.t('tooltip.channel', 'Channel: %{source}', {source});
}
} else if ( provider === 'ffz' ) {
const emote_set = this.emotes.emote_sets[target.dataset.set],
emote = emote_set && emote_set.emotes[target.dataset.id];
if ( emote_set )
source = emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global'}`);
if ( emote ) {
if ( emote.owner )
owner = this.i18n.t(
'emote.owner', 'By: %{owner}',
{owner: emote.owner.display_name});
if ( emote.urls[4] )
preview = emote.urls[4];
else if ( emote.urls[2] )
preview = emote.urls[2];
}
}
return [
preview && this.context.get('tooltip.emote-images') && e('img', {
className: 'preview-image',
src: preview,
onLoad: tip.update
}),
this.i18n.t('tooltip.emote', 'Emote: %{code}', {code: target.alt}),
source && this.context.get('tooltip.emote-sources') && e('div', {
className: 'pd-t-05',
}, source),
owner && this.context.get('tooltip.emote-sources') && e('div', {
className: 'pd-t-05'
}, owner),
mods && e('div', {
className: 'pd-t-1'
}, mods)
];
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
const applicable_sets = this.emotes.getSets(
msg.user.userID,
msg.user.userLogin,
msg.roomID,
msg.roomLogin
),
emotes = {},
out = [];
if ( ! applicable_sets || ! applicable_sets.length )
return tokens;
for(const emote_set of applicable_sets)
if ( emote_set && emote_set.emotes )
for(const emote_id in emote_set.emotes ) { // eslint-disable-line guard-for-in
const emote = emote_set.emotes[emote_id];
if ( ! has(emotes, emote.name) )
emotes[emote.name] = emote;
}
let last_token, emote;
for(const token of tokens) {
if ( ! token )
continue;
if ( token.type !== 'text' ) {
if ( token.type === 'emote' && ! token.modifiers )
token.modifiers = [];
out.push(token);
last_token = token;
continue;
}
let text = [];
for(const segment of token.text.split(/ +/)) {
if ( has(emotes, segment) ) {
emote = emotes[segment];
// Is this emote a modifier?
if ( emote.modifier && last_token && last_token.modifiers && (!text.length || (text.length === 1 && text[0] === '')) ) {
if ( last_token.modifiers.indexOf(emote.token) === -1 )
last_token.modifiers.push(emote.token);
continue;
}
if ( text.length ) {
// We have pending text. Join it together, with an extra space.
const t = {type: 'text', text: text.join(' ') + ' '};
out.push(t);
if ( t.text.trim().length )
last_token = t;
text = [];
}
const t = Object.assign({modifiers: []}, emote.token);
out.push(t);
last_token = t;
text.push('');
} else
text.push(segment);
}
if ( text.length > 1 || (text.length === 1 && text[0] !== '') )
out.push({type: 'text', text: text.join(' ')});
}
return out;
}
}
// ============================================================================
// Twitch Emotes
// ============================================================================
export const TwitchEmotes = {
type: 'twitch-emote',
priority: 10,
process(tokens, msg) {
if ( ! msg.emotes )
return tokens;
const data = msg.emotes,
emotes = [];
for(const emote_id in data)
if ( has(data, emote_id) ) {
for(const match of data[emote_id])
emotes.push([emote_id, match.startIndex, match.endIndex + 1]);
}
const out = [],
e_length = emotes.length;
if ( ! e_length )
return tokens;
emotes.sort((a,b) => a[1] - b[1]);
let idx = 0,
eix = 0;
for(const token of tokens) {
const length = token.length || (token.text && split_chars(token.text).length) || 0,
t_start = idx,
t_end = idx + length;
if ( token.type !== 'text' ) {
out.push(token);
idx = t_end;
continue;
}
const text = split_chars(token.text);
while( eix < e_length ) {
const [e_id, e_start, e_end] = emotes[eix];
// Does this emote go outside the bounds of this token?
if ( e_start > t_end || e_end > t_end ) {
// Output the remainder of this token.
if ( t_start === idx )
out.push(token);
else
out.push({
type: 'text',
text: text.slice(idx - t_start).join('')
});
// If this emote goes across token boundaries,
// skip it.
if ( e_start < t_end && e_end > t_end )
eix++;
idx = t_end;
break;
}
// If there's text at the beginning of the token that
// isn't part of this emote, output it.
if ( e_start > idx )
out.push({
type: 'text',
text: text.slice(idx - t_start, e_start - t_start).join('')
});
out.push({
type: 'emote',
id: e_id,
provider: 'twitch',
src: `${TWITCH_BASE}${e_id}/1.0`,
srcSet: `${TWITCH_BASE}${e_id}/1.0 1x, ${TWITCH_BASE}${e_id}/2.0 2x`,
text: text.slice(e_start - t_start, e_end - t_start).join(''),
modifiers: []
});
idx = e_end;
eix++;
}
// We've finished processing emotes. If there is any
// remaining text in the token, push it out.
if ( idx < t_end ) {
if ( t_start === idx )
out.push(token);
else
out.push({
type: 'text',
text: text.slice(idx - t_start).join('')
});
idx = t_end;
}
}
return out;
}
}

View file

@ -0,0 +1,3 @@
'use strict';
export default require.context('./components', false, /\.vue$/);

View file

@ -0,0 +1,50 @@
<template lang="html">
<div class="ffz--changelog border-t pd-t-1">
<div class="align-center">
<h2>{{ t('home.changelog', 'Changelog') }}</h2>
</div>
<div ref="changes" />
</div>
</template>
<script>
import {SERVER} from 'utilities/constants';
export default {
props: ['item', 'context'],
methods: {
fetch(url, container) {
const done = data => {
if ( ! data )
data = 'There was an error loading this page from the server.';
container.innerHTML = data;
const btn = container.querySelector('#ffz-old-news-button');
if ( btn )
btn.addEventListener('click', () => {
btn.parentElement.removeChild(btn);
const old_news = container.querySelector('#ffz-old-news');
if ( old_news )
this.fetch(`${SERVER}/script/old_changes.html`, old_news);
});
}
fetch(url)
.then(resp => resp.ok ? resp.text() : null)
.then(done)
.catch(err => done(null));
}
},
mounted() {
this.fetch(`${SERVER}/script/changelog.html`, this.$refs.changes);
}
}
</script>

View file

@ -0,0 +1,35 @@
<template lang="html">
<div class="ffz--home border-t pd-t-1">
<h2>Feedback</h2>
<div class="mg-y-1 c-background-accent c-text-overlay pd-1">
<h3 class="ffz-i-attention">
Please keep in mind that FrankerFaceZ v4 is under heavy development.
</h3>
</div>
<p>
Okay, still here? Great! You can provide feedback and bug reports by
<a href="https://github.com/FrankerFaceZ/FrankerFaceZ/issues" target="_blank" rel="noopener">
opening an issue at our GitHub repository</a>.
You can also <a href="https://twitter.com/FrankerFaceZ" target="_blank" rel="noopener">
tweet at us</a>.
</p>
<p>
When creating a GitHub issue, please check that someone else hasn't
already created one for what you'd like to discuss or report.
</p>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
}
</script>

View file

@ -0,0 +1,67 @@
<template lang="html">
<div class="ffz--widget ffz--filter-editor">
<div ref="list" class="ffz--rule-list">
<section v-for="(rule, idx) in rules">
<div
class="ffz--rule elevation-1 c-background border mg-b-05 pd-y-05 pd-r-1 flex flex--nowrap align-items-start"
tabindex="0"
>
<div class="flex flex-shrink-0 align-items-center handle pd-x-05 pd-y-1">
<span class="ffz-i-ellipsis-vert" />
</div>
<div class="flex-shrink-0 pd-y-05">
Channel
</div>
<div class="mg-x-1 flex flex-grow-1">
<div class="flex-shrink-0 mg-r-1">
<select class="tw-select">
<option>is one of</option>
<option>is not one of</option>
</select>
</div>
<div class="flex-grow-1">
<input
type="text"
class="tw-input"
value="SirStendec"
/>
</div>
</div>
<div class="flex flex-shrink-0 align-items-center">
<button class="tw-button tw-button--text" @click="del(idx)">
<span class="tw-button__text ffz-i-trash">
{{ t('setting.filters.delete', 'Delete') }}
</span>
</button>
</div>
</div>
</section>
</div>
<button
class="tw-button tw-button--hollow mg-y-1 full-width"
@click="newRule"
>
<span class="tw-button__text ffz-i-plus">
{{ t('', 'Add New Rule') }}
</span>
</button>
</div>
</template>
<script>
export default {
props: ['filters', 'rules', 'context'],
methods: {
newRule() {
}
}
}
</script>

View file

@ -0,0 +1,100 @@
<template lang="html">
<div class="ffz--home flex flex--nowrap">
<div class="flex-grow-1">
<div class="align-center">
<h1 class="ffz-i-zreknarf ffz-i-pd-1">FrankerFaceZ</h1>
<span class="c-text-alt">
{{ t('home.tag-line', 'The Twitch Enhancement Suite') }}
</span>
</div>
<section class="pd-t-1 border-t mg-t-1">
<h2>Welcome to the v4.0 Beta</h2>
<p>
This is the initial, beta release of FrankerFaceZ v4.0 with support
for the Twitch website rewrite.
As you'll notice, this release is <strong>not</strong> complete.
There are missing features. There are bugs. If you are a moderator,
you will want to just keep opening a Legacy Chat Popout for now.
</p>
<p>
FrankerFaceZ v4.0 is still under heavy development and there will
be significant changes and improvements in the coming weeks. For
now, here are some of the bigger issues:
</p>
<ul class="mg-b-2">
<li>Settings from the old version are not being imported.</li>
<li>Settings cannot be searched.</li>
<li>FFZ badges do not display.</li>
<li>Oh god everything is missing.</li>
<li>FFZ:AP is broken.</li>
<li>Uptime breaks occasionally.</li>
</ul>
<p>And the biggest features still under development:</p>
<ul class="mg-b-2">
<li>Dark Theme (Pls No Purple)</li>
<li>Chat Pause on Hover</li>
<li>Badge Customization</li>
<li>Emoji Rendering</li>
<li>Emotes Menu</li>
<li>Chat Filtering (Highlighted Words, etc.)</li>
<li>Room Status Indicators</li>
<li>Custom Mod Cards</li>
<li>Custom Mod Actions</li>
<li>Chat Room Tabs</li>
<li>Recent Highlights</li>
<li>More Channel Metadata</li>
<li>Disable Hosting</li>
<li>Portrait Mode</li>
<li>Hiding stuff in the directory</li>
<li>Directory Host Stacking</li>
<li>Basically anything to do with the directory</li>
<li>Importing and exporting settings</li>
<li>User Aliases</li>
<li>Rich Content in Chat (aka Clip Embeds)</li>
</ul>
<p>
For a possibly more up-to-date list of what I'm working on,
please consult <a href="https://trello.com/b/LGcYPFwi/frankerfacez-v4" target="_blank">this Trello board</a>.
</p>
</section>
</div>
<div class="mg-l-1 flex-shrink-0 tweet-column">
<a class="twitter-timeline" data-width="300" data-theme="dark" href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw">
Tweets by FrankerFaceZ
</a>
</div>
</div>
</template>
<script>
import {createElement as e} from 'utilities/dom';
export default {
props: ['item', 'context'],
mounted() {
let el;
document.head.appendChild(el = e('script', {
id: 'ffz--twitter-widget-script',
async: true,
charset: 'utf-8',
src: 'https://platform.twitter.com/widgets.js',
onLoad: () => el.parentElement.removeChild(el)
}));
}
}
</script>

View file

@ -0,0 +1,180 @@
<template lang="html">
<div class="ffz-main-menu elevation-3 c-background-alt border flex flex--nowrap flex-column" :class="{ maximized }">
<header class="c-background pd-1 pd-l-2 full-width align-items-center flex flex-nowrap" @dblclick="resize">
<h3 class="ffz-i-zreknarf ffz-i-pd-1">FrankerFaceZ</h3>
<div class="flex-grow-1 pd-x-2">
<!--div class="tw-search-input">
<label for="ffz-main-menu.search" class="hide-accessible">{{ t('main-menu.search', 'Search Settings') }}</label>
<div class="relative">
<div class="tw-input__icon-group">
<div class="tw-input__icon">
<figure class="ffz-i-search" />
</div>
</div>
<input type="search" class="tw-input tw-input--icon-left" :placeholder="t('main-menu.search', 'Search Settings')" autocapitalize="off" autocorrect="off" autocomplete="off" id="ffz-main-menu.search">
</div>
</div-->
</div>
<button class="tw-button-icon mg-x-05" @click="resize">
<span class="tw-button-icon__icon">
<figure :class="{'ffz-i-window-maximize': !maximized, 'ffz-i-window-restore': maximized}" />
</span>
</button>
<button class="tw-button-icon mg-x-05" @click="close">
<span class="tw-button-icon__icon">
<figure class="ffz-i-window-close" />
</span>
</button>
</header>
<section class="border-t full-height full-width flex flex--nowrap">
<nav class="ffz-vertical-nav c-background-alt-2 border-r full-height flex flex-column flex-shrink-0 flex-nowrap">
<header class="border-b pd-1">
<profile-selector
:context="context"
@navigate="navigate"
/>
</header>
<div class="full-width full-height overflow-hidden flex flex-nowrap relative">
<div class="ffz-vertical-nav__items full-width flex-grow-1 scrollable-area" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<menu-tree
:currentItem="currentItem"
:modal="nav"
@change-item="changeItem"
@navigate="navigate"
/>
</div>
</div>
</div>
</div>
<footer class="c-text-alt border-t pd-1">
<div>
{{ t('main-menu.version', 'Version %{version}', {version: version.toString()}) }}
</div>
<div class="c-text-alt-2">
{{version.build}}
</div>
</footer>
</nav>
<main class="flex-grow-1 scrollable-area" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<menu-page
ref="page"
:context="context"
:item="currentItem"
@change-item="changeItem"
@navigate="navigate"
v-if="currentItem"
/>
</div>
</div>
</main>
</section>
</div>
</template>
<script>
import displace from 'displacejs';
export default {
data() {
return this.$vnode.data;
},
created() {
this.context.context._add_user();
},
destroyed() {
this.context.context._remove_user();
},
methods: {
changeProfile() {
const new_id = this.$refs.profiles.value,
new_profile = this.context.profiles[new_id];
if ( new_profile )
this.context.currentProfile = new_profile;
},
changeItem(item) {
if ( this.$refs.page && this.$refs.page.onBeforeChange ) {
if ( this.$refs.page.onBeforeChange(this.currentItem, item) === false )
return;
}
this.currentItem = item;
let current = item;
while(current = current.parent)
current.expanded = true;
},
updateDrag() {
if ( this.maximized )
this.destroyDrag();
else
this.createDrag();
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
createDrag() {
this.$nextTick(() => {
if ( ! this.maximized )
this.displace = displace(this.$el, {
handle: this.$el.querySelector('header'),
highlightInputs: true,
constrain: true
});
})
},
handleResize() {
if ( this.displace )
this.displace.reinit();
},
navigate(key) {
let item = this.nav_keys[key];
while(item && item.page)
item = item.parent;
if ( ! item )
return;
this.changeItem(item);
}
},
watch: {
maximized() {
this.updateDrag();
}
},
mounted() {
this.updateDrag();
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
},
beforeDestroy() {
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
}
}
</script>

View file

@ -0,0 +1,34 @@
<template lang="html">
<div v-bind:class="classes" v-if="item.contents">
<header v-if="! item.no_header">
{{ t(item.i18n_key, item.title, item) }}
</header>
<section
v-if="item.description"
v-html="t(item.desc_i18n_key, item.description, item)"
class="pd-b-1"
/>
<component
v-for="i in item.contents"
v-bind:is="i.component"
:context="context"
:item="i"
:key="i.full_key"
/>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
computed: {
classes() {
return [
'ffz--menu-container',
this.item.full_box ? 'border' : 'border-t'
]
}
}
}
</script>

View file

@ -0,0 +1,89 @@
<template lang="html">
<div class="ffz--menu-page">
<header class="mg-b-1">
<template v-for="i in breadcrumbs">
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title, i) }}</a>
<strong v-if="i === item">{{ t(i.i18n_key, i.title, i) }}</strong>
<template v-if="i !== item">&raquo; </template>
</template>
</header>
<section v-if="! context.currentProfile.live && item.profile_warning !== false" class="border-t pd-t-1 pd-b-2">
<div class="c-background-accent c-text-overlay pd-1">
<h3 class="ffz-i-attention">
{{ t('setting.profiles.inactive', "This profile isn't active.") }}
</h3>
{{ t(
'setting.profiles.inactive.description',
"This profile's rules don't match the current context and it therefore isn't currently active, so you " +
"won't see changes you make here reflected on Twitch."
) }}
</div>
</section>
<section
v-if="item.description"
class="border-t pd-y-1"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</section>
<template v-if="! item.contents">
<ul class="border-t pd-y-1">
<li class="pd-x-1" v-for="i in item.items">
<a href="#" @click="$emit('change-item', i, false)">
{{ t(i.i18n_key, i.title, i) }}
</a>
</li>
</ul>
</template>
<component
v-for="i in item.contents"
v-bind:is="i.component"
ref="children"
:context="context"
:item="i"
:key="i.full_key"
@change-item="changeItem"
@navigate="navigate"
/>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
computed: {
breadcrumbs() {
const out = [];
let current = this.item;
while(current) {
out.unshift(current);
current = current.parent;
}
return out;
}
},
methods: {
changeItem(item) {
this.$emit('change-item', item);
},
navigate(...args) {
this.$emit('navigate', ...args);
},
onBeforeChange(current, new_item) {
for(const child of this.$refs.children)
if ( child && child.onBeforeChange ) {
const res = child.onBeforeChange(current, new_item);
if ( res !== undefined )
return res;
}
}
}
}
</script>

View file

@ -0,0 +1,165 @@
<template lang="html">
<ul
v-if="modal"
class="ffz--menu-tree"
:role="[root ? 'group' : 'tree']"
:tabindex="tabIndex"
@keyup.up="prevItem"
@keyup.down="nextItem"
@keyup.left="prevLevel"
@keyup.right="nextLevel"
@keyup.*="expandAll"
>
<li
v-for="item in modal"
:key="item.full_key"
:class="[currentItem === item ? 'active' : '']"
role="presentation"
>
<div
class="flex__item flex flex--nowrap align-items-center pd-y-05 pd-r-05"
role="treeitem"
:aria-expanded="item.expanded"
:aria-selected="currentItem === item"
@click="clickItem(item)"
>
<span
role="presentation"
class="arrow"
:class="[
item.items ? '' : 'ffz--invisible',
item.expanded ? 'ffz-i-down-dir' : 'ffz-i-right-dir'
]"
/>
<span class="flex-grow-1">
{{ t(item.i18n_key, item.title, item) }}
</span>
<span v-if="item.pill" class="pill">
{{ item.pill_i18n_key ? t(item.pill_i18n_key, item.pill, item) : item.pill }}
</span>
</div>
<menu-tree
:root="item"
:currentItem="currentItem"
:modal="item.items"
v-if="item.items && item.expanded"
@change-item="i => $emit('change-item', i)"
/>
</li>
</ul>
</template>
<script>
function findLastVisible(node) {
if ( node.expanded && node.items )
return findLastVisible(node.items[node.items.length - 1]);
return node;
}
function findNextVisible(node, modal) {
const items = node.parent ? node.parent.items : modal,
idx = items.indexOf(node);
if ( items[idx + 1] )
return items[idx+1];
if ( node.parent )
return findNextVisible(node.parent, modal);
return null;
}
function recursiveExpand(node) {
node.expanded = true;
if ( node.items )
for(const item of node.items)
recursiveExpand(item);
}
export default {
props: ['root', 'modal', 'currentItem'],
computed: {
tabIndex() {
return this.root ? undefined : 0;
}
},
methods: {
clickItem(item) {
if ( ! item.expanded )
item.expanded = true;
else if ( this.currentItem === item )
item.expanded = false;
this.$emit('change-item', item);
},
expandAll() {
for(const item of this.modal)
recursiveExpand(item);
},
prevItem() {
if ( this.root ) return;
const i = this.currentItem,
items = i.parent ? i.parent.items : this.modal,
idx = items.indexOf(i);
if ( idx > 0 )
this.$emit('change-item', findLastVisible(items[idx-1]));
else if ( i.parent )
this.$emit('change-item', i.parent);
},
nextItem(e) {
if ( this.root ) return;
const i = this.currentItem;
let target;
if ( i.expanded && i.items )
target = i.items[0];
else
target = findNextVisible(i, this.modal);
if ( target )
this.$emit('change-item', target);
},
prevLevel() {
if ( this.root ) return;
const i = this.currentItem;
if ( i.expanded && i.items )
i.expanded = false;
else if ( i.parent )
this.$emit('change-item', i.parent);
},
nextLevel() {
if ( this.root ) return;
const i = this.currentItem;
if ( i.expanded && i.items )
this.$emit('change-item', i.items[0]);
else
i.expanded = true;
if ( event.ctrlKey )
recursiveExpand(this.currentItem);
}
}
}
</script>

View file

@ -0,0 +1,205 @@
<template lang="html">
<div class="ffz--profile-editor">
<div class="flex align-items-center border-t pd-1">
<div class="flex-grow-1"></div>
<button
class="tw-button tw-button--text"
@click="save"
>
<span class="tw-button__text ffz-i-floppy">
{{ t('settings.profiles.save', 'Save') }}
</span>
</button>
<button
class="mg-l-1 tw-button tw-button--text"
:disabled="item.profile && context.profiles.length < 2"
@click="del"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.profiles.delete', 'Delete') }}
</span>
</button>
<!--button class="mg-l-1 tw-button tw-button--text">
<span class="tw-button__text ffz-i-download">
{{ t('setting.profiles.export', 'Export') }}
</span>
</button-->
</div>
<div class="ffz--menu-container border-t">
<header>
{{ t('settings.data_management.profiles.edit.general', 'General') }}
</header>
<div class="ffz--widget flex flex--nowrap">
<label for="ffz:editor:name">
{{ t('settings.data_management.profiles.edit.name', 'Name') }}
</label>
<input
class="tw-input"
ref="name"
id="ffz:editor:name"
v-model="name"
/>
</div>
<div class="ffz--widget flex flex--nowrap">
<label for="ffz:editor:description">
{{ t('settings.data_management.profiles.edit.desc', 'Description') }}
</label>
<textarea
class="tw-input"
ref="desc"
id="ffz:editor:description"
v-model="desc"
/>
</div>
</div>
<div class="ffz--menu-container border-t">
<header>
{{ t('settings.data_management.profiles.edit.rules', 'Rules') }}
</header>
<section class="pd-b-1">
{{ t(
'settings.data_management.profiles.edit.rules.description',
'Rules allows you to define a series of conditions under which this profile will be active.'
) }}
</section>
<filter-editor
:filters="filters"
:rules="rules"
:context="test_context"
@change="unsaved = true"
/>
</div>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
data() {
return {
old_name: null,
old_desc: null,
name: null,
desc: null,
unsaved: false,
filters: null,
rules: null,
test_context: null
}
},
created() {
this.context.context.on('context_changed', this.updateContext, this);
this.updateContext();
this.revert();
},
beforeDestroy() {
this.context.context.off('context_changed', this.updateContext, this);
},
watch: {
name() {
if ( this.name !== this.old_name )
this.unsaved = true;
},
desc() {
if ( this.desc !== this.old_desc )
this.unsaved = true;
}
},
methods: {
revert() {
const profile = this.item.profile;
this.old_name = this.name = profile ?
profile.i18n_key ?
this.t(profile.i18n_key, profile.title, profile) :
profile.title :
'Unnamed Profile',
this.old_desc = this.desc = profile ?
profile.desc_i18n_key ?
this.t(profile.desc_i18n_key, profile.description, profile) :
profile.description :
'';
this.rules = profile ? profile.context : {};
this.unsaved = ! profile;
},
del() {
if ( this.item.profile || this.unsaved ) {
if ( ! confirm(this.t(
'settings.profiles.warn-delete',
'Are you sure you wish to delete this profile? It cannot be undone.'
)) )
return
if ( this.item.profile )
this.context.deleteProfile(this.item.profile);
}
this.unsaved = false;
this.$emit('navigate', 'data_management.profiles');
},
save() {
if ( ! this.item.profile ) {
this.item.profile = this.context.createProfile({
name: this.name,
description: this.desc
});
} else if ( this.unsaved ) {
const changes = {
name: this.name,
description: this.desc
};
// Disable i18n if required.
if ( this.name !== this.old_name )
changes.i18n_key = undefined;
if ( this.desc !== this.old_desc )
changes.desc_i18n_key = undefined;
this.item.profile.update(changes);
}
this.unsaved = false;
this.$emit('navigate', 'data_management.profiles');
},
updateContext() {
this.test_context = this.context.context.context;
},
onBeforeChange() {
if ( this.unsaved )
return confirm(
this.t(
'settings.warn-unsaved',
'You have unsaved changes. Are you sure you want to leave the editor?'
));
}
}
}
</script>

View file

@ -0,0 +1,129 @@
<template lang="html">
<div class="ffz--widget ffz--profile-manager border-t pd-y-1">
<div class="c-background-accent c-text-overlay pd-1 mg-b-1">
<h3 class="ffz-i-attention">
This feature is not yet finished.
</h3>
Creating and editing profiles is disabled until the rule editor is finished.
</div>
<div class="flex align-items-center pd-b-05">
<div class="flex-grow-1">
{{ t('setting.profiles.drag', 'Drag profiles to change their priority.') }}
</div>
<button class="mg-l-1 tw-button tw-button--text" disabled @notclick="edit()">
<span class="tw-button__text ffz-i-plus">
{{ t('setting.profiles.new', 'New Profile') }}
</span>
</button>
<!--button class="mg-l-1 tw-button tw-button--text">
<span class="tw-button__text ffz-i-upload">
{{ t('setting.profiles.import', 'Import…') }}
</span>
</button-->
</div>
<div ref="list" class="ffz--profile-list">
<section
v-for="p in context.profiles"
:key="p.id"
:data-profile="p.id"
>
<div
class="ffz--profile elevation-1 c-background border pd-y-05 pd-r-1 mg-y-05 flex flex--nowrap"
:class="{live: p.live}"
tabindex="0"
>
<div class="flex flex-shrink-0 align-items-center handle pd-x-05 pd-t-1 pd-b-05">
<span class="ffz-i-ellipsis-vert" />
</div>
<div class="flex-grow-1">
<h4>{{ t(p.i18n_key, p.title, p) }}</h4>
<div v-if="p.description" class="description">
{{ t(p.desc_i18n_key, p.description, p) }}
</div>
</div>
<div class="flex flex-shrink-0 align-items-center">
<button class="tw-button tw-button--text" disabled @notclick="edit(p)">
<span class="tw-button__text ffz-i-cog">
{{ t('setting.profiles.configure', 'Configure') }}
</span>
</button>
</div>
<div class="flex flex-shrink-0 align-items-center border-l mg-l-1 pd-l-1">
<div v-if="p.live" class="ffz--profile__icon ffz-i-ok tw-tooltip-wrapper">
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.profiles.active', 'This profile is active.') }}
</div>
</div>
<div v-if="! p.live" class="ffz--profile__icon ffz-i-cancel tw-tooltip-wrapper">
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.profiles.inactive', 'This profile is not active.') }}
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
export default {
props: ['item', 'context'],
methods: {
edit(profile) {
const item = {
full_key: 'data_management.profiles.edit_profile',
key: 'edit_profile',
profile_warning: false,
title: `Edit Profile`,
i18n_key: 'setting.data_management.profiles.edit_profile',
parent: this.item.parent,
contents: [{
page: true,
profile,
component: 'profile-editor'
}]
};
item.contents[0].parent = item;
this.$emit('change-item', item);
}
},
mounted() {
this._sortable = Sortable.create(this.$refs.list, {
draggable: 'section',
filter: 'button',
onUpdate: (event) => {
const id = event.item.dataset.profile,
profile = this.context.profile_keys[id];
if ( profile )
profile.move(event.newIndex);
}
});
},
beforeDestroy() {
if ( this._sortable )
this._sortable.destroy();
this._sortable = null;
}
}
</script>

View file

@ -0,0 +1,218 @@
<template lang="html">
<div class="ffz--widget ffz--profile-selector">
<div
tabindex="0"
class="tw-select"
:class="{active: opened}"
ref="button"
@keyup.up.stop.prevent="focusShow"
@keyup.left.stop.prevent="focusShow"
@keyup.down.stop.prevent="focusShow"
@keyup.right.stop.prevent="focusShow"
@keyup.enter="focusShow"
@keyup.space="focusShow"
@click="togglePopup"
>
{{ t(context.currentProfile.i18n_key, context.currentProfile.title, context.currentProfile) }}
</div>
<div v-if="opened" v-on-clickaway="hide" class="tw-balloon block tw-balloon--lg tw-balloon--down tw-balloon--left">
<div
class="ffz--profile-list elevation-2 c-background-alt"
@keyup.escape="focusHide"
@focusin="focus"
@focusout="blur"
>
<div class="scrollable-area border-b" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content" ref="popup">
<div
v-for="(p, idx) in context.profiles"
tabindex="0"
class="ffz--profile-row relative border-b pd-y-05 pd-r-3 pd-l-1"
:class="{
live: p.live,
current: p === context.currentProfile
}"
@keydown.up.stop.prevent=""
@keydown.down.stop.prevent=""
@keydown.page-up.stop.prevent=""
@keydown.page-down.stop.prevent=""
@keyup.up.stop="prevItem"
@keyup.down.stop="nextItem"
@keyup.home="firstItem"
@keyup.end="lastItem"
@keyup.page-up.stop="prevPage"
@keyup.page-down.stop="nextPage"
@keyup.enter="changeProfile(p)"
@click="changeProfile(p)"
>
<div
v-if="p.live"
class="tw-tooltip-wrapper ffz--profile-row__icon ffz-i-ok absolute"
>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.profiles.active', 'This profile is active.') }}
</div>
</div>
<h4>{{ t(p.i18n_key, p.title, p) }}</h4>
<div v-if="p.description" class="description">
{{ t(p.desc_i18n_key, p.description, p) }}
</div>
</div>
</div>
</div>
</div>
<div class="pd-y-05 pd-x-05 align-right">
<button class="tw-button tw-button--text" @click="openConfigure">
<span class="tw-button__text ffz-i-cog">
{{ t('setting.profiles.configure', 'Configure') }}
</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mixin as clickaway} from 'vue-clickaway';
const indexOf = Array.prototype.indexOf;
export default {
mixins: [clickaway],
props: ['context'],
data() {
return {
opened: false
}
},
methods: {
openConfigure() {
this.hide();
this.$emit('navigate', 'data_management.profiles');
},
focus() {
this._focused = true;
},
blur() {
this._focused = false;
if ( ! this._blur_timer )
this._blur_timer = setTimeout(() => {
this._blur_timer = null;
if ( ! this._focused && document.hasFocus() )
this.hide();
}, 10);
},
hide() {
this.opened = false;
},
show() {
if ( ! this.opened )
this.opened = true;
},
togglePopup() {
if ( this.opened )
this.hide();
else
this.show();
},
focusHide() {
this.hide();
this.$refs.button.focus();
},
focusShow() {
this.show();
this.$nextTick(() => this.$refs.popup.querySelector('.current').focus());
},
prevItem(e) {
const el = e.target.previousSibling;
if ( el ) {
this.scroll(el);
el.focus();
}
},
nextItem(e) {
const el = e.target.nextSibling;
if ( el ) {
this.scroll(el);
el.focus();
}
},
firstItem() {
const el = this.$refs.popup.firstElementChild;
if ( el ) {
this.scroll(el);
el.focus();
}
},
prevPage(e) {
this.select(indexOf.call(this.$refs.popup.children, e.target) - 5);
},
nextPage(e) {
this.select(indexOf.call(this.$refs.popup.children, e.target) + 5);
},
select(idx) {
const kids = this.$refs.popup.children,
el = kids[idx <= 0 ? 0 : Math.min(idx, kids.length - 1)];
if ( el ) {
this.scroll(el);
el.focus();
}
},
lastItem() {
const el = this.$refs.popup.lastElementChild;
if ( el ) {
this.scroll(el);
el.focus();
}
},
scroll(el) {
const scroller = this.$refs.popup.parentElement,
top = el.offsetTop,
bottom = el.offsetHeight + top,
// We need to use the margin-bottom because of the scrollbar library.
// In fact, the scrollbar library is why any of this function exists.
scroll_top = scroller.scrollTop,
scroll_bottom = scroller.offsetHeight + parseInt(scroller.style.marginBottom || 0, 10) + scroll_top;
if ( top < scroll_top )
scroller.scrollBy(0, top - scroll_top);
else if ( bottom > scroll_bottom )
scroller.scrollBy(0, bottom - scroll_bottom);
},
changeProfile(profile) {
this.context.currentProfile = profile;
this.focusHide();
}
}
}
</script>

View file

@ -0,0 +1,58 @@
<template lang="html">
<div class="ffz--widget ffz--checkbox" :class="{inherits: isInherited, default: isDefault}">
<div class="flex align-items-center">
<input
type="checkbox"
class="tw-checkbox__input"
ref="control"
:id="item.full_key"
:checked="value"
@change="onChange"
>
<label class="tw-checkbox__label" :for="item.full_key">
{{ t(item.i18n_key, item.title, item) }}
</label>
<button
v-if="source && source !== profile"
class="mg-l-05 tw-button tw-button--text"
@click="context.currentProfile = source"
>
<span class="tw-button__text ffz-i-right-dir">
{{ sourceDisplay }}
</span>
</button>
<button v-if="has_value" class="mg-l-05 tw-button tw-button--text tw-tooltip-wrapper" @click="clear">
<span class="tw-button__text ffz-i-cancel"></span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</div>
</button>
</div>
<section
v-if="item.description"
class="c-text-alt-2"
style="padding-left:2.2rem"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
methods: {
onChange() {
this.set(this.$refs.control.checked);
}
}
}
</script>

View file

@ -0,0 +1,44 @@
<template lang="html">
<div class="ffz--widget ffz--hotkey-input">
<label
:for="item.full_key"
v-html="t(item.i18n_key, item.title, item)"
/>
<div class="relative">
<div class="tw-input__icon-group tw-input__icon-group--right">
<div class="tw-input__icon">
<figure class="ffz-i-keyboard" />
</div>
</div>
<div
type="text"
class="mg-05 tw-input tw-input--icon-right"
ref="display"
:id="item.full_key"
tabindex="0"
@keyup="onKey"
>
&nbsp;
</div>
</div>
<section
v-if="item.description"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
methods: {
onKey(e) {
const name = `${e.ctrlKey ? 'Ctrl-' : ''}${e.shiftKey ? 'Shift-' : ''}${e.altKey ? 'Alt-' : ''}${e.code}`;
this.$refs.display.innerText = name;
}
}
}
</script>

View file

@ -0,0 +1,27 @@
<template lang="html">
<div class="atw-input">
<header>
{{ t(item.i18n_key, item.title, item) }}
</header>
<section
v-if="item.description"
class="c-text-alt-2"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
<div v-for="(i, idx) in data" class="mg-l-1">
<input type="radio" :name="item.full_key" :id="item.full_key + idx" :value="i.value" class="tw-radio__input">
<label :for="item.full_key + idx" class="pd-y-05 tw-radio__label">{{ t(i.i18n_key, i.title, i) }}</label>
</div>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context']
}
</script>

View file

@ -0,0 +1,64 @@
<template lang="html">
<div class="ffz--widget ffz--select-box" :class="{inherits: isInherited, default: isDefault}">
<div class="flex align-items-center">
<label :for="item.full_key">
{{ t(item.i18n_key, item.title, item) }}
</label>
<select
class="mg-05 tw-select display-inline width-auto"
ref="control"
:id="item.full_key"
@change="onChange"
>
<option v-for="i in data" :selected="i.value === value">
{{ i.i18n_key ? t(i.i18n_key, i.title, i) : i.title }}
</option>
</select>
<button
v-if="source && source !== profile"
class="mg-l-05 tw-button tw-button--text"
@click="context.currentProfile = source"
>
<span class="tw-button__text ffz-i-right-dir">
{{ sourceDisplay }}
</span>
</button>
<button v-if="has_value" class="mg-l-05 tw-button tw-button--text tw-tooltip-wrapper" @click="clear">
<span class="tw-button__text ffz-i-cancel"></span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</div>
</button>
</div>
<section
v-if="item.description"
class="c-text-alt-2"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
methods: {
onChange() {
const idx = this.$refs.control.selectedIndex,
raw_value = this.data[idx];
if ( raw_value )
this.set(raw_value.value);
}
}
}
</script>

View file

@ -0,0 +1,59 @@
<template lang="html">
<div class="ffz--widget ffz--text-box" :class="{inherits: isInherited, default: isDefault}">
<div class="flex align-items-center">
<label :for="item.full_key">
{{ t(item.i18n_key, item.title, item) }}
</label>
<input
class="mg-05 tw-input display-inline width-auto"
ref="control"
:id="item.full_key"
@change="onChange"
:value="value"
/>
<button
v-if="source && source !== profile"
class="mg-l-05 tw-button tw-button--text"
@click="context.currentProfile = source"
>
<span class="tw-button__text ffz-i-right-dir">
{{ sourceDisplay }}
</span>
</button>
<button v-if="has_value" class="mg-l-05 tw-button tw-button--text tw-tooltip-wrapper" @click="clear">
<span class="tw-button__text ffz-i-cancel"></span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</div>
</button>
</div>
<section
v-if="item.description"
class="c-text-alt-2"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
methods: {
onChange() {
const value = this.$refs.control.value;
if ( value != null )
this.set(value);
}
}
}
</script>

View file

@ -0,0 +1,551 @@
'use strict';
// ============================================================================
// Menu Module
// ============================================================================
import Module from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {has, deep_copy} from 'utilities/object';
function format_term(term) {
return term.replace(/<[^>]*>/g, '').toLocaleLowerCase();
}
// TODO: Rewrite literally everything about the menu to use vue-router and further
// separate the concept of navigation from visible pages.
export default class MainMenu extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('i18n');
this.inject('site');
this.inject('vue');
//this.should_enable = true;
this._settings_tree = null;
this._settings_count = 0;
this._menu = null;
this._visible = true;
this._maximized = false;
this.settings.addUI('profiles', {
path: 'Data Management @{"sort": 1000, "profile_warning": false} > Profiles @{"profile_warning": false}',
component: 'profile-manager'
});
this.settings.addUI('home', {
path: 'Home @{"sort": -1000, "profile_warning": false}',
component: 'home-page'
});
this.settings.addUI('feedback', {
path: 'Home > Feedback',
component: 'feedback-page'
});
this.settings.addUI('changelog', {
path: 'Home > Changelog',
component: 'changelog'
});
}
async onLoad() {
this.vue.component(
(await import(/* webpackChunkName: "main-menu" */ './components.js')).default
);
}
get maximized() {
return this._maximized;
}
set maximized(val) {
val = Boolean(val);
if ( val === this._maximized )
return;
if ( this.enabled )
this.toggleSize();
}
get visible() {
return this._visible;
}
set visible(val) {
val = Boolean(val);
if ( val === this._visible )
return;
if ( this.enabled )
this.toggleVisible();
}
async onEnable(event) {
await this.site.awaitElement('.twilight-root');
this.on('site.menu_button:clicked', this.toggleVisible);
if ( this._visible ) {
this._visible = false;
this.toggleVisible(event);
}
}
onDisable() {
if ( this._visible ) {
this.toggleVisible();
this._visible = true;
}
this.off('site.menu_button:clicked', this.toggleVisible);
}
toggleVisible(event) {
if ( event && event.button !== 0 )
return;
const maximized = this._maximized,
visible = this._visible = !this._visible,
main = document.querySelector(maximized ? '.twilight-main' : '.twilight-root > .full-height');
if ( ! visible ) {
if ( maximized )
main.classList.remove('ffz-has-menu');
if ( this._menu ) {
main.removeChild(this._menu);
this._vue.$destroy();
this._menu = this._vue = null;
}
return;
}
if ( ! this._menu )
this.createMenu();
if ( maximized )
main.classList.add('ffz-has-menu');
main.appendChild(this._menu);
}
toggleSize(event) {
if ( ! this._visible || event && event.button !== 0 )
return;
const maximized = this._maximized = !this._maximized,
main = document.querySelector(maximized ? '.twilight-main' : '.twilight-root > .full-height'),
old_main = this._menu.parentElement;
if ( maximized )
main.classList.add('ffz-has-menu');
else
old_main.classList.remove('ffz-has-menu');
old_main.removeChild(this._menu);
main.appendChild(this._menu);
this._vue.$children[0].maximized = maximized;
}
rebuildSettingsTree() {
this._settings_tree = {};
this._settings_count = 0;
for(const [key, def] of this.settings.definitions)
this._addDefinitionToTree(key, def);
for(const [key, def] of this.settings.ui_structures)
this._addDefinitionToTree(key, def);
}
_addDefinitionToTree(key, def) {
if ( ! def.ui || ! this._settings_tree )
return;
if ( ! def.ui.path_tokens ) {
if ( def.ui.path )
def.ui.path_tokens = parse_path(def.ui.path);
else
return;
}
if ( ! def.ui || ! def.ui.path_tokens || ! this._settings_tree )
return;
const tree = this._settings_tree,
tokens = def.ui.path_tokens,
len = tokens.length;
let prefix = null,
token;
// Create and/or update all the necessary structure elements for
// this node in the settings tree.
for(let i=0; i < len; i++) {
const raw_token = tokens[i],
key = prefix ? `${prefix}.${raw_token.key}` : raw_token.key;
token = tree[key];
if ( ! token )
token = tree[key] = {
full_key: key,
sort: 0,
parent: prefix,
expanded: prefix === null,
i18n_key: `setting.${key}`,
desc_i18n_key: `setting.${key}.description`
};
Object.assign(token, raw_token);
prefix = key;
}
// Add this setting to the tree.
token.settings = token.settings || [];
token.settings.push([key, def]);
this._settings_count++;
}
getSettingsTree() {
const started = performance.now();
if ( ! this._settings_tree )
this.rebuildSettingsTree();
const tree = this._settings_tree,
root = {},
copies = {},
needs_sort = new Set,
needs_component = new Set,
have_locale = this.i18n.locale !== 'en';
for(const key in tree) {
if ( ! has(tree, key) )
continue;
const token = copies[key] = copies[key] || Object.assign({}, tree[key]),
p_key = token.parent,
parent = p_key ?
(copies[p_key] = copies[p_key] || Object.assign({}, tree[p_key])) :
root;
token.parent = p_key ? parent : null;
token.page = token.page || parent.page;
if ( token.page && ! token.component )
needs_component.add(token);
if ( token.settings ) {
const list = token.contents = token.contents || [];
for(const [setting_key, def] of token.settings)
if ( def.ui ) { //} && def.ui.title ) {
const i18n_key = `${token.i18n_key}.${def.ui.key}`
const tok = Object.assign({
i18n_key,
desc_i18n_key: `${i18n_key}.description`,
sort: 0,
title: setting_key
}, def.ui, {
full_key: `setting:${setting_key}`,
setting: setting_key,
path_tokens: undefined,
parent: token
});
if ( def.default && ! tok.default ) {
const def_type = typeof def.default;
if ( def_type === 'object' ) {
// TODO: Better way to deep copy this object.
tok.default = JSON.parse(JSON.stringify(def.default));
} else
tok.default = def.default;
}
const terms = [
setting_key,
this.i18n.t(tok.i18n_key, tok.title, tok, true)
];
if ( have_locale && this.i18n.has(tok.i18n_key) )
terms.push(this.i18n.t(tok.i18n_key, tok.title, tok));
if ( tok.description ) {
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok, true));
if ( have_locale && this.i18n.has(tok.desc_i18n_key) )
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok));
}
tok.search_terms = terms.map(format_term).join('\n');
list.push(tok);
}
token.settings = undefined;
if ( list.length > 1 )
needs_sort.add(list);
}
if ( ! token.search_terms ) {
const formatted = this.i18n.t(token.i18n_key, token.title, token, true);
let terms = [token.key];
if ( formatted && formatted.localeCompare(token.key, undefined, {sensitivity: 'base'}) )
terms.push(formatted);
if ( have_locale && this.i18n.has(token.i18n_key) )
terms.push(this.i18n.t(token.i18n_key, token.title, token));
if ( token.description ) {
terms.push(this.i18n.t(token.desc_i18n_key, token.description, token, true));
if ( have_locale && this.i18n.has(token.desc_i18n_key) )
terms.push(this.i18n.t(token.desc_i18n_key, token.description, token));
}
terms = terms.map(format_term);
for(const lk of ['tabs', 'contents', 'items'])
if ( token[lk] )
for(const tok of token[lk] )
if ( tok.search_terms )
terms.push(tok.search_terms);
terms = token.search_terms = terms.join('\n');
let p = parent;
while(p && p.search_terms) {
p.search_terms += '\n' + terms;
p = p.parent;
}
}
const lk = token.tab ? 'tabs' : token.page ? 'contents' : 'items',
list = parent[lk] = parent[lk] || [];
list.push(token);
if ( list.length > 1 )
needs_sort.add(list);
}
for(const token of needs_component) {
token.component = token.tabs ? 'tab-container' :
token.contents ? 'menu-container' :
'setting-check-box';
}
for(const list of needs_sort)
list.sort((a, b) => {
if ( a.sort < b.sort ) return -1;
if ( a.sort > b.sort ) return 1;
return a.key.localeCompare(b.key);
});
this.log.info(`Built Tree in ${(performance.now() - started).toFixed(5)}ms with ${Object.keys(tree).length} structure nodes and ${this._settings_count} settings nodes.`);
const items = root.items || [];
items.keys = copies;
return items;
}
getProfiles(context) {
const profiles = [],
keys = {};
context = context || this.settings.main_context;
for(const profile of this.settings.__profiles)
profiles.push(keys[profile.id] = this.getProfileProxy(profile, context));
return [profiles, keys];
}
getProfileProxy(profile, context) {
return {
id: profile.id,
order: context.manager.__profiles.indexOf(profile),
live: context.__profiles.includes(profile),
title: profile.name,
i18n_key: profile.i18n_key,
description: profile.description,
desc_i18n_key: profile.desc_i18n_key || profile.i18n_key && `${profile.i18n_key}.description`,
move: idx => context.manager.moveProfile(profile.id, idx),
save: () => profile.save(),
update: data => {
profile.data = data
profile.save()
},
context: deep_copy(profile.context),
get: key => profile.get(key),
set: (key, val) => profile.set(key, val),
delete: key => profile.delete(key),
has: key => profile.has(key),
on: (...args) => profile.on(...args),
off: (...args) => profile.off(...args)
}
}
getContext() {
const t = this,
Vue = this.vue.Vue,
settings = this.settings,
context = settings.main_context,
[profiles, profile_keys] = this.getProfiles(),
_c = {
profiles,
profile_keys,
currentProfile: profile_keys[0],
createProfile: data => {
const profile = settings.createProfile(data);
return t.getProfileProxy(profile, context);
},
deleteProfile: profile => settings.deleteProfile(profile),
context: {
_users: 0,
profiles: context.__profiles.map(profile => profile.id),
get: key => context.get(key),
uses: key => context.uses(key),
on: (...args) => context.on(...args),
off: (...args) => context.off(...args),
order: id => context.order.indexOf(id),
context: deep_copy(context.context),
_update_profiles(changed) {
const new_list = [],
profiles = context.manager.__profiles;
for(let i=0; i < profiles.length; i++) {
const profile = profile_keys[profiles[i].id];
profile.order = i;
new_list.push(profile);
}
Vue.set(_c, 'profiles', new_list);
if ( changed && changed.id === _c.currentProfile.id )
_c.currentProfile = profile_keys[changed.id];
},
_profile_created(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles()
},
_profile_changed(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles(profile);
},
_profile_deleted(profile) {
Vue.delete(profile_keys, profile.id);
this._update_profiles();
if ( _c.currentProfile.id === profile.id )
_c.currentProfile = profile_keys[0]
},
_context_changed() {
this.context = deep_copy(context.context);
const ids = this.profiles = context.__profiles.map(profile => profile.id);
for(const id in profiles) {
const profile = profiles[id];
profile.live = this.profiles.includes(profile.id);
}
},
_add_user() {
this._users++;
if ( this._users === 1 ) {
settings.on(':profile-created', this._profile_created, this);
settings.on(':profile-changed', this._profile_changed, this);
settings.on(':profile-deleted', this._profile_deleted, this);
settings.on(':profiles-reordered', this._update_profiles, this);
context.on('context_changed', this._context_changed, this);
context.on('profiles_changed', this._context_changed, this);
this.profiles = context.__profiles.map(profile => profile.id);
}
},
_remove_user() {
this._users--;
if ( this._users === 0 ) {
settings.off(':profile-created', this._profile_created, this);
settings.off(':profile-changed', this._profile_changed, this);
settings.off(':profile-deleted', this._profile_deleted, this);
settings.off(':profiles-reordered', this._update_profiles, this);
context.off('context_changed', this._context_changed, this);
context.off('profiles_changed', this._context_changed, this);
}
}
}
};
return _c;
}
getData() {
const settings = this.getSettingsTree(),
context = this.getContext();
return {
context,
nav: settings,
currentItem: settings.keys['home'], // settings[0],
nav_keys: settings.keys,
maximized: this._maximized,
resize: e => this.toggleSize(e),
close: e => this.toggleVisible(e),
version: window.FrankerFaceZ.version_info
}
}
createMenu() {
if ( this._menu )
return;
this._vue = new this.vue.Vue({
el: e('div'),
render: h => h('main-menu', this.getData())
});
this._menu = this._vue.$el;
}
}
MainMenu.requires = ['site.menu_button'];

View file

@ -0,0 +1,178 @@
'use strict';
export default {
data() {
return {
value: undefined,
has_value: false,
profile: null,
source: null,
source_value: undefined,
}
},
created() {
const ctx = this.context.context,
setting = this.item.setting;
ctx._add_user();
this._update_profile();
this._uses_changed(ctx.uses(setting));
ctx.on(`uses_changed:${setting}`, this._uses_changed, this);
},
destroyed() {
const ctx = this.context.context,
setting = this.item.setting;
if ( this.profile )
this.profile.off('changed', this._setting_changed, this);
if ( this.source )
this.source.off('changed', this._source_setting_changed, this);
ctx.off(`uses_changed:${setting}`, this._uses_changed, this);
this.value = undefined;
this.has_value = false;
this.profile = null;
this.source_value = undefined;
this.source = null;
ctx._remove_user();
},
watch: {
'context.currentProfile'() {
this._update_profile();
}
},
computed: {
data() {
const data = this.item.data;
if ( typeof data === 'function' )
return data(this.profile, this.value);
return data;
},
default_value() {
if ( typeof this.item.default === 'function' )
return this.item.default(this.context.context);
return this.item.default;
},
isInherited() {
return ! this.has_value && this.source && this.sourceOrder > this.profileOrder;
},
isDefault() {
return ! this.has_value && ! this.source
},
isOverridden() {
return this.source && this.sourceOrder < this.profileOrder;
},
sourceOrder() {
return this.source ? this.source.order : Infinity
},
profileOrder() {
return this.profile ? this.profile.order : Infinity
},
sourceTitle() {
if ( this.source )
return this.source.i18n_key ?
this.t(this.source.i18n_key, this.source.title, this.source) :
this.source.title;
},
sourceDisplay() {
const opts = {
title: this.sourceTitle
};
if ( this.isInherited )
return this.t('setting.inherited-from', 'Inherited From: %{title}', opts);
else if ( this.isOverridden )
return this.t('setting.overridden-by', 'Overridden By: %{title}', opts);
}
},
methods: {
_update_profile() {
if ( this.profile )
this.profile.off('changed', this._setting_changed, this);
const profile = this.profile = this.context.currentProfile,
setting = this.item.setting;
profile.on('changed', this._setting_changed, this);
this.has_value = profile.has(setting);
this.value = this.has_value ?
profile.get(setting) :
this.isInherited ?
this.source_value :
this.default_value;
},
_setting_changed(key, value, deleted) {
if ( key !== this.item.setting )
return;
this.has_value = deleted !== true;
this.value = this.has_value ?
value :
this.isInherited ?
this.source_value :
this.default_value;
},
_source_setting_changed(key, value, deleted) {
if ( key !== this.item.setting )
return;
this.source_value = value;
if ( this.isInherited )
this.value = deleted ? this.default_value : value;
},
_uses_changed(uses) {
if ( this.source )
this.source.off('changed', this._source_setting_changed, this);
const source = this.source = this.context.profile_keys[uses],
setting = this.item.setting;
if ( source ) {
source.on('changed', this._source_setting_changed, this);
this.source_value = source.get(setting);
} else
this.source_value = undefined;
if ( ! this.has_value )
this.value = this.isInherited ? this.source_value : this.default_value;
},
set(value) {
if ( this.item.process )
value = this.item.process(value);
this.profile.set(this.item.setting, value);
},
clear() {
this.profile.delete(this.item.setting);
}
}
}

342
src/modules/metadata.js Normal file
View file

@ -0,0 +1,342 @@
'use strict';
// ============================================================================
// Channel Metadata
// ============================================================================
import {createElement as e} from 'utilities/dom';
import {has, get, maybe_call} from 'utilities/object';
import {duration_to_string} from 'utilities/time';
import Tooltip from 'utilities/tooltip';
import Module from 'utilities/module';
export default class Metadata extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('i18n');
this.should_enable = true;
this.definitions = {};
this.settings.add('metadata.player-stats', {
default: false,
ui: {
path: 'Channel > Metadata >> Player',
title: 'Playback Statistics',
description: 'Show the current stream delay, with playback rate and dropped frames in the tooltip.',
component: 'setting-check-box'
},
changed: () => this.updateMetadata('player-stats')
});
this.settings.add('metadata.uptime', {
default: 1,
ui: {
path: 'Channel > Metadata >> Player',
title: 'Stream Uptime',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Enabled'},
{value: 2, title: 'Enabled (with Seconds)'}
]
},
changed: () => this.updateMetadata('uptime')
});
this.definitions.uptime = {
refresh() { return this.settings.get('metadata.uptime') > 0 },
setup() {
const socket = this.resolve('socket'),
query = this.resolve('site.apollo').getQuery('ChannelPage_ChannelInfoBar_User'),
result = query.lastResult,
created_at = get('data.user.stream.createdAt', result);
if ( created_at === undefined && ! query._ffz_refetched ) {
query._ffz_refetched = true;
query.refetch();
return {};
}
if ( ! created_at )
return {};
const created = new Date(created_at),
now = Date.now() - socket._time_drift;
return {
created,
uptime: created ? Math.floor((now - created.getTime()) / 1000) : -1
}
},
order: 2,
icon: 'ffz-i-clock',
label(data) {
const setting = this.settings.get('metadata.uptime');
if ( ! setting || ! data.created )
return null;
return duration_to_string(data.uptime, false, false, false, setting !== 2);
},
tooltip(data) {
if ( ! data.created )
return null;
return `${this.i18n.t(
'metadata.uptime.tooltip',
'Stream Uptime'
)}<div class="pd-t-05">${this.i18n.t(
'metadata.uptime.since',
'(since %{since})',
{since: data.created.toLocaleString()}
)}</div>`;
}
}
this.definitions['player-stats'] = {
refresh() {
return this.settings.get('metadata.player-stats')
},
setup() {
const Player = this.resolve('site.player'),
socket = this.resolve('socket'),
player = Player.current,
stats = player && player.getVideoInfo();
if ( ! stats )
return {stats};
let delay = stats.hls_latency_broadcaster / 1000,
drift = 0;
if ( socket && socket.connected )
drift = socket._time_drift;
return {
stats,
drift,
delay,
old: delay > 180
}
},
order: 3,
icon: 'ffz-i-gauge',
label(data) {
if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
return null;
const delayed = data.drift > 5000 ? '(!) ' : '';
if ( data.old )
return `${delayed}${data.delay.toFixed(2)}s old`;
else
return `${delayed}${data.delay.toFixed(2)}s`;
},
color(data) {
const setting = this.settings.get('some.thing');
if ( setting == null || ! data.delay || data.old )
return;
if ( data.delay > (setting * 2) )
return '#ec1313';
else if ( data.delay > setting )
return '#fc7835';
},
tooltip(data) {
const delayed = data.drift > 5000 ?
`${this.i18n.t(
'metadata.player-stats.delay-warning',
'Your local clock seems to be off by roughly %{count} seconds, which could make this inaccurate.',
Math.round(data.drift / 10) / 100
)}<hr>` :
'';
if ( ! data.stats || ! data.delay )
return delayed + this.i18n.t('metadata.player-stats.latency-tip', 'Stream Latency');
const stats = data.stats,
video_info = this.i18n.t(
'metadata.player-stats.video-info',
'Video: %{vid_width}x%{vid_height}p%{current_fps}\nPlayback Rate: %{current_bitrate|number} Kbps\nDropped Frames:%{dropped_frames|number}',
stats
);
if ( data.old )
return `${delayed}${this.i18n.t(
'metadata.player-stats.video-tip',
'Video Information'
)}<div class="pd-t-05">${this.i18n.t(
'metadata.player-stats.broadcast-ago',
'Broadcast %{count}s Ago',
data.delay
)}</div><div class="pd-t-05">${video_info}</div>`;
return `${delayed}${this.i18n.t(
'metadata.player-stats.latency-tip',
'Stream Latency'
)}<div class="pd-t-05">${video_info}</div>`;
}
}
}
get keys() {
return Object.keys(this.definitions);
}
async getData(key) {
const def = this.definitions[key];
if ( ! def )
return {label: null};
return {
icon: maybe_call(def.icon),
label: maybe_call(def.label),
refresh: maybe_call(def.refresh)
}
}
updateMetadata(keys) {
const bar = this.resolve('site.channel_bar');
if ( bar ) {
for(const inst of bar.ChannelBar.instances)
bar.updateMetadata(inst, keys);
for(const inst of bar.HostBar.instances)
bar.updateMetadata(inst, keys);
}
}
async render(key, data, container, timers, refresh_fn) {
if ( timers[key] )
clearTimeout(timers[key]);
let el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
const def = this.definitions[key],
destroy = () => {
if ( el ) {
if ( el.tooltip )
el.tooltip.destroy();
if ( el.popper )
el.popper.destroy();
el.tooltip = el.popper = null;
el.parentElement.removeChild(el);
}
};
if ( ! def )
return destroy();
try {
// Process the data if a setup method is defined.
if ( def.setup )
data = await def.setup.call(this, data);
// Let's get refresh logic out of the way now.
const refresh = maybe_call(def.refresh, this, data);
if ( refresh )
timers[key] = setTimeout(
() => refresh_fn(key),
typeof refresh === 'number' ? refresh : 1000
);
// Grab the element again in case it changed, somehow.
el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
let stat, old_color;
const label = maybe_call(def.label, this, data);
if ( ! label )
return destroy();
const tooltip = maybe_call(def.tooltip, this, data),
order = maybe_call(def.order, this, data),
color = maybe_call(def.color, this, data);
if ( ! el ) {
let icon = maybe_call(def.icon, this, data);
if ( typeof icon === 'string' )
icon = e('span', 'tw-stat__icon', e('figure', icon));
el = e('div', {
className: 'ffz-stat tw-stat',
'data-key': key,
tip_content: tooltip
}, [
icon,
stat = e('span', 'tw-stat__value')
]);
el._ffz_order = order;
if ( order != null )
el.style.order = order;
container.appendChild(el);
if ( def.tooltip )
el.tooltip = new Tooltip(container, el, {
live: false,
html: true,
content: () => el.tip_content,
onShow: (t, tip) => el.tip = tip,
onHide: () => el.tip = null
});
} else {
stat = el.querySelector('.tw-stat__value');
old_color = el.dataset.color || '';
if ( el._ffz_order !== order )
el.style.order = el._ffz_order = order;
if ( el.tip_content !== tooltip ) {
el.tip_content = tooltip;
if ( el.tip )
el.tip.element.innerHTML = tooltip;
}
}
if ( old_color !== color )
el.dataset.color = el.style.color = color;
stat.innerHTML = label;
if ( def.disabled !== undefined )
el.disabled = maybe_call(def.disabled, this, data);
} catch(err) {
this.log.error(`Error rendering metadata for ${key}`, err);
return destroy();
}
}
}

93
src/modules/tooltips.js Normal file
View file

@ -0,0 +1,93 @@
'use strict';
// ============================================================================
// Tooltip Handling
// ============================================================================
import {createElement as e} from 'utilities/dom';
import Tooltip from 'utilities/tooltip';
import Module from 'utilities/module';
export default class TooltipProvider extends Module {
constructor(...args) {
super(...args);
this.types = {};
this.should_enable = true;
this.inject('i18n');
this.inject('chat');
this.types.json = target => {
const title = target.dataset.title;
return [
title && e('strong', null, title),
e('code', {
className: `block${title ? ' pd-t-05 border-t mg-t-05' : ''}`,
style: {
fontFamily: 'monospace',
textAlign: 'left'
}
}, target.dataset.data)
]
}
this.types.badge = (target, tip) => {
const container = target.parentElement.parentElement,
badge = target.dataset.badge,
version = target.dataset.version,
room = container.dataset.roomId,
data = this.chat.getBadge(badge, version, room);
if ( ! data )
return;
return [
this.chat.context.get('tooltip.badge-images') && e('img', {
className: 'preview-image',
src: data.image4x,
style: {
height: '72px'
},
onLoad: tip.update
}),
data.title
]
};
}
onEnable() {
this.tips = new Tooltip('[data-reactroot]', 'ffz-tooltip', {
html: true,
content: this.process.bind(this),
popper: {
placement: 'top'
}
});
}
process(target, tip) { //eslint-disable-line class-methods-use-this
const type = target.dataset.tooltipType,
handler = this.types[type];
if ( ! handler )
return [
e('strong', null, 'Unhandled Tooltip Type'),
e('code', {
className: 'block pd-t-05 border-t mg-t-05',
style: {
fontFamily: 'monospace',
textAlign: 'left'
}
}, JSON.stringify(target.dataset, null, 4))
];
return handler(target, tip);
}
}

View file

@ -0,0 +1,3 @@
'use strict';
export default require.context('./components', false, /\.vue$/);

View file

@ -0,0 +1,42 @@
'use strict';
// ============================================================================
// Translation UI
// ============================================================================
import Module from 'utilities/module';
import {createElement as e} from 'utilities/dom';
export default class TranslationUI extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('site');
this.inject('vue');
//this.should_enable = true;
this._dialog = null;
this._visible = true;
}
async onLoad() {
this.vue.component(
(await import(/* webpackChunkName: "translation-ui" */ './components.js')).default
);
}
async onEnable(event) {
await this.site.awaitElement('.twilight-root');
this.ps = this.site.web_munch.getModule('ps');
}
onDisable() {
if ( this._visible ) {
this.toggleVisible();
this._visible = true;
}
}
}

View file

@ -1,870 +0,0 @@
var FFZ = window.FrankerFaceZ,
constants = require("./constants"),
utils = require("./utils"),
FileSaver = require("./FileSaver"),
createElement = document.createElement.bind(document),
make_ls = function(key) {
return "ffz_setting_" + key;
},
favorite_setting = function(swit, key, info) {
var ind = this.settings.favorite_settings.indexOf(key);
if ( ind === -1 ) {
this.settings.favorite_settings.push(key);
swit.setAttribute('original-title', 'Unfavorite this Setting');
swit.classList.add('active');
} else {
this.settings.favorite_settings.splice(ind,1);
swit.setAttribute('original-title', 'Favorite this Setting');
swit.classList.remove('active');
}
jQuery(swit).trigger('mouseout').trigger('mouseover');
this.settings.set('favorite_settings', this.settings.favorite_settings);
},
toggle_setting = function(swit, key, info) {
var val = !(info.get ? (typeof info.get === 'function' ? info.get.call(this) : this.settings.get(info.get)) : this.settings.get(key));
if ( typeof info.set === "function" )
info.set.call(this, val);
else
this.settings.set(info.set || key, val);
swit.classList.toggle('active', val);
},
option_setting = function(select, key, info) {
var val = JSON.parse(select.options[select.selectedIndex].value);
if ( typeof info.set === "function" )
info.set.call(this, val);
else
this.settings.set(info.set || key, val);
};
// --------------------
// Initializer
// --------------------
FFZ.settings_info = {
advanced_settings: { value: false, visible: false }
};
FFZ.basic_settings = {};
FFZ.prototype.load_settings = function() {
this.log("Loading settings.");
// Build a settings object.
this.settings = {};
// Helpers
this.settings.get = this._setting_get.bind(this);
this.settings.set = this._setting_set.bind(this);
this.settings.del = this._setting_del.bind(this);
this.settings.load = this._setting_load.bind(this);
this.settings.get_twitch = this._setting_get_twitch.bind(this);
for(var key in FFZ.settings_info) {
if ( ! FFZ.settings_info.hasOwnProperty(key) )
continue;
var info = FFZ.settings_info[key],
ls_key = info && info.storage_key || make_ls(key);
this._setting_load(key);
}
// Listen for Changes
window.addEventListener("storage", this._setting_update.bind(this), false);
}
// --------------------
// Backup and Restore
// --------------------
FFZ.prototype.reset_settings = function() {
if ( ! confirm(this.tr('Are you sure you wish to reset FrankerFaceZ?\n\nThis will force the tab to refresh.')) )
return;
// Clear Settings
for(var key in FFZ.settings_info) {
if ( ! FFZ.settings_info.hasOwnProperty(key) )
continue;
this.settings.del(key);
}
// Clear Aliases
this.aliases = {};
localStorage.ffz_aliases = '{}';
// TODO: Filters
// Refresh
window.location.reload();
}
FFZ.prototype._get_settings_object = function(skip_default) {
var data = {
version: 1,
script_version: FFZ.version_info + '',
aliases: this.aliases,
filters: this.filters,
settings: {}
};
for(var key in FFZ.settings_info) {
if ( ! FFZ.settings_info.hasOwnProperty(key) )
continue;
var info = FFZ.settings_info[key],
ls_key = info.storage_key || make_ls(key),
stored_val = localStorage.getItem(ls_key);
if ( (stored_val !== null) && (!skip_default || this.settings[key] !== info.value) )
data.settings[key] = this.settings[key];
}
return data;
}
FFZ.prototype.save_settings_file = function() {
var data = this._get_settings_object(),
blob = new Blob(
[JSON.stringify(data, null, 4)], {type: "application/json;charset=utf-8"});
FileSaver.saveAs(blob, "ffz-settings.json");
}
FFZ.prototype.load_settings_file = function(file) {
if ( typeof file === "string" )
this._load_settings_file(file);
else {
var reader = new FileReader(),
f = this;
reader.onload = function(e) { f._load_settings_file(e.target.result); }
reader.readAsText(file);
}
}
FFZ.prototype._load_settings_file = function(data, hide_alert) {
if ( typeof data === "string" )
try {
data = JSON.parse(data);
} catch(err) {
this.error("Error Loading Settings: " + err);
if ( ! hide_alert )
alert("There was an error attempting to read the provided settings data.");
return [-1,-1,-1];
}
this.log("Loading Settings Data", data);
var skipped = [], applied = [],
aliases = 0;
if ( data.settings ) {
for(var key in data.settings) {
if ( ! FFZ.settings_info.hasOwnProperty(key) ) {
skipped.push(key);
continue;
}
var info = FFZ.settings_info[key],
val = data.settings[key];
if ( info.process_value )
val = info.process_value.call(this, val);
if ( val !== this.settings.get(key) )
this.settings.set(key, val);
applied.push(key);
}
}
if ( data.aliases ) {
for(var key in data.aliases) {
if ( this.aliases[key] === data.aliases[key] )
continue;
this.aliases[key] = data.aliases[key];
aliases++;
}
if ( aliases )
localStorage.ffz_aliases = JSON.stringify(this.aliases);
}
if ( data.filters ) {
// TODO: Load filters!
}
// Do this in a timeout so that any styles have a moment to update.
if ( ! hide_alert )
setTimeout(function(){
alert('Successfully loaded ' + applied.length + ' settings and skipped ' + skipped.length + ' settings. Added ' + aliases + ' user nicknames.');
});
return [applied.length, skipped.length, aliases];
}
// --------------------
// Menu Page
// --------------------
var is_android = navigator.userAgent.indexOf('Android') !== -1,
settings_renderer = function(settings_data, collapsable, collapsed_key, show_pin) {
return function(view, container) {
var f = this,
settings = {},
categories = [];
// Searching!
if ( show_pin ) {
var search_cont = utils.createElement('div', 'ffz-filter-container'),
search_input = utils.createElement('input', 'emoticon-selector__filter-input form__input js-filter-input text text--full-width'),
filtered_cont = utils.createElement('div', 'ffz-filter-children ffz-ui-sub-menu-page');
search_input.placeholder = 'Search for Settings';
search_input.type = 'text';
filtered_cont.style.maxHeight = (parseInt(container.style.maxHeight) - 53) + 'px';
search_cont.appendChild(search_input);
container.appendChild(filtered_cont);
container.appendChild(search_cont);
container = filtered_cont;
search_input.addEventListener('input', function(e) {
var filter = search_input.value || '',
groups = filtered_cont.querySelectorAll('.chat-menu-content');
filter = filter.toLowerCase();
for(var i=0; i < groups.length; i++) {
var el = groups[i],
settings = el.querySelectorAll('.ffz-setting'),
hidden = true;
for(var j=0; j < settings.length; j++) {
var se = settings[j],
shidden = filter.length && se.getAttribute('data-filter').indexOf(filter) === -1;
se.classList.toggle('hidden', shidden);
hidden = hidden && shidden;
}
var incompat = el.querySelector('.bttv-incompatibility'),
settings = incompat && incompat.querySelectorAll('b'),
incompat_hidden = true;
if ( incompat ) {
for(var j=0; j < settings.length; j++) {
var se = settings[j],
shidden = filter.length && se.getAttribute('data-filter').indexOf(filter) === -1;
se.classList.toggle('hidden', shidden);
incompat_hidden = incompat_hidden && shidden;
}
incompat.classList.toggle('hidden', incompat_hidden);
hidden = hidden && incompat_hidden;
}
el.classList.toggle('collapsable', ! filter.length);
el.classList.toggle('hidden', hidden);
}
});
}
for(var key in settings_data) {
var info = settings_data[key],
cat = info.category || "Miscellaneous",
cat_store = settings[cat];
if ( info.hasOwnProperty('visible') ) {
var visible = info.visible;
if ( typeof visible === "function" )
visible = visible.call(this);
if ( ! visible )
continue;
}
if ( is_android && info.no_mobile )
continue;
if ( ! cat_store ) {
categories.push(cat);
cat_store = settings[cat] = [];
}
cat_store.push([key, info]);
}
categories.sort(function(a,b) {
var a = a.toLowerCase(),
b = b.toLowerCase();
if ( a === "debugging" )
a = "zzz" + a;
if ( b === "debugging" )
b = "zzz" + b;
if ( a < b ) return -1;
else if ( a > b ) return 1;
return 0;
});
var current_category = collapsed_key ? this[collapsed_key] || true : categories[0];
for(var ci=0; ci < categories.length; ci++) {
var category = categories[ci],
cset = settings[category],
bttv_skipped = [],
added = 0,
menu = createElement('div'),
heading = createElement('div');
heading.className = 'heading';
menu.className = 'chat-menu-content';
menu.setAttribute('data-category', category);
if ( collapsable ) {
menu.classList.add('collapsable');
menu.classList.toggle('collapsed', current_category !== category);
menu.addEventListener('click', function(e) {
var t = this;
if ( ! t.classList.contains('collapsable') )
return;
else if ( ! t.classList.contains('collapsed') ) {
if ( e.target.classList.contains('heading') ) {
t.classList.add('collapsed');
if ( collapsed_key )
f[collapsed_key] = true;
} else
return;
} else {
jQuery(".chat-menu-content:not(.collapsed)", container).addClass("collapsed");
t.classList.remove('collapsed');
if ( collapsed_key )
f[collapsed_key] = t.getAttribute('data-category');
}
setTimeout(function(){t.scrollIntoViewIfNeeded()});
});
}
heading.innerHTML = category;
menu.appendChild(heading);
cset.sort(function(a,b) {
var a = a[1],
b = b[1],
at = 2, //a.type === "boolean" ? 1 : 2,
bt = 2, //b.type === "boolean" ? 1 : 2,
an = a.name.toLowerCase(),
bn = b.name.toLowerCase();
if ( at < bt ) return -1;
else if ( at > bt ) return 1;
else if ( an < bn ) return -1;
else if ( an > bn ) return 1;
return 0;
});
for(var i=0; i < cset.length; i++) {
var key = cset[i][0],
info = cset[i][1],
el = createElement('p'),
pin_btn = createElement('a'),
val = info.get ? (typeof info.get === 'function' ? info.get.call(this) : this.settings.get(info.get)) : this.settings.get(key);
el.className = 'ffz-setting clearfix';
if ( (info.no_bttv === 6 && this.has_bttv_6) ||
(info.no_bttv === 7 && this.has_bttv_7) ||
(info.no_bttv === true && this.has_bttv) ) {
bttv_skipped.push([info.name, info.help]);
continue;
} else if ( (info.require_bttv === 6 && ! this.has_bttv_6) ||
(info.require_bttv === 7 && ! this.has_bttv_7) ||
(info.require_bttv === true && ! this.has_bttv) ) {
continue;
} else {
if ( show_pin ) {
var faved = this.settings.favorite_settings.indexOf(key) !== -1;
pin_btn.className = 'pin-switch html-tooltip';
pin_btn.classList.toggle('active', faved);
pin_btn.addEventListener('click', favorite_setting.bind(this, pin_btn, key, info));
pin_btn.title = (faved ? 'Unf' : 'F') + 'avorite this Setting';
pin_btn.innerHTML = constants.STAR;
}
if ( info.type === "boolean" ) {
var swit = createElement('a'),
label = createElement('span');
swit.className = 'switch';
swit.classList.toggle('active', val);
swit.appendChild(createElement('span'))
label.className = 'switch-label';
label.innerHTML = info.name;
el.appendChild(swit);
if ( show_pin )
el.appendChild(pin_btn);
el.appendChild(label);
swit.addEventListener('click', toggle_setting.bind(this, swit, key, info))
} else if ( info.type === "select" ) {
var select = createElement('select'),
label = createElement('span');
label.className = 'option-label';
label.innerHTML = info.name;
var op_list = [];
for(var ok in info.options) {
var op = createElement('option'),
desc = info.options[ok],
sort_key = 0;
op.value = JSON.stringify(ok);
if ( val == ok )
op.setAttribute('selected', true);
if ( typeof desc === "object" ) {
op.innerHTML = desc[0];
sort_key = desc[1];
} else
op.innerHTML = desc;
op_list.push([sort_key, op]);
}
op_list.sort(function(a,b) {return a[0] - b[0]});
for(var op_i=0; op_i < op_list.length; op_i++)
select.appendChild(op_list[op_i][1]);
select.addEventListener('change', option_setting.bind(this, select, key, info));
if ( show_pin )
el.appendChild(pin_btn);
el.appendChild(label);
el.appendChild(select);
} else if ( typeof info.method === "function" ) {
el.classList.add("option");
var link = createElement('a');
link.innerHTML = info.name;
link.href = '#';
if ( show_pin )
el.appendChild(pin_btn);
el.appendChild(link);
link.addEventListener('click', info.method.bind(this));
} else
continue;
if ( info.help || info.experiment_warn || (this.has_bttv && info.warn_bttv) ) {
var help = document.createElement('span');
help.className = 'help';
var parts = [];
if ( info.experiment_warn )
parts.push('<b>Note:</b> This affects an active Twitch experiment. Give feedback at: <a href="mailto:feedback@twitch.tv">feedback@twitch.tv</a>');
if ( this.has_bttv && info.warn_bttv )
parts.push('<i>' + info.warn_bttv + '</i>');
if ( info.help )
parts.push(info.help);
help.innerHTML = parts.join('<br>');
el.appendChild(help);
}
}
// Search by any of the present text.
el.setAttribute('data-filter', el.textContent.toLowerCase());
added++;
menu.appendChild(el);
}
if ( ! added )
continue;
if ( bttv_skipped.length ) {
var el = createElement('p'),
label = createElement('span'),
help = createElement('span');
el.className = 'bttv-incompatibility clearfix disabled';
label.className = 'switch-label';
label.innerHTML = "Features Incompatible with BetterTTV";
help.className = 'help';
for(var i=0; i < bttv_skipped.length; i++) {
var skipped = bttv_skipped[i],
filter_text = skipped[0].toLowerCase() + (skipped[1] ? ' ' + skipped[1].toLowerCase() : '');
help.innerHTML += '<b data-filter="' + utils.quote_attr(filter_text) + '"' + (skipped[1] ? ' class="html-tooltip" title="' + utils.quote_attr(skipped[1]) + '"' : '') + '>' + skipped[0] + '</b>';
}
el.appendChild(label);
el.appendChild(help);
menu.appendChild(el);
}
container.appendChild(menu);
}
}
},
render_basic = settings_renderer(FFZ.basic_settings, false, '_ffz_basic_settings_page'),
render_advanced = settings_renderer(FFZ.settings_info, true, '_ffz_settings_page', true);
FFZ.settings_info.favorite_settings = {
value: [],
hidden: true
}
FFZ.menu_pages.settings = {
name: "Settings",
icon: constants.GEAR,
sort_order: 99999,
wide: true,
default_page: function() { return this.settings.favorite_settings.length ? "favorites" : this.settings.advanced_settings ? 'advanced' : 'basic' },
pages: {
favorites: {
name: "Favorites",
sort_order: 1,
render: function(view, container) {
var favorites = this.settings.favorite_settings,
count = 0;
if ( ! this.has_bttv )
count = favorites.length;
else
for(var i=0; i < favorites.length; i++)
if ( FFZ.settings_info[favorites[i]] && ! FFZ.settings_info[favorites[i]].no_bttv )
count++;
if ( ! count ) {
var el = utils.createElement('div');
el.className = 'emoticon-grid ffz-no-emotes center';
el.innerHTML = "You have no favorite settings.<br>" +
'<img src=\"//cdn.frankerfacez.com/emoticon/26608/2\"><br>' +
'To make a setting a favorite, find it on the <nobr>Advanced</nobr> tab and click the star icon to the right.';
container.appendChild(el);
return;
}
var favorite_settings = {};
for(var i=0, l = favorites.length; i < l; i++) {
var key = favorites[i],
val = FFZ.settings_info[key];
if ( val )
favorite_settings[key] = val;
}
return settings_renderer(favorite_settings, false, '_ffz_favorite_settings_page').call(this, view, container);
}
},
basic: {
name: "Basic",
sort_order: 2,
render: function(view, container) {
this.settings.set("advanced_settings", false);
return render_basic.call(this, view, container);
}
},
advanced: {
name: "Advanced",
sort_order: 3,
render: function(view, container) {
this.settings.set("advanced_settings", true);
return render_advanced.call(this, view, container);
}
},
backup: {
name: "Backup & Restore",
sort_order: 4,
render: function(view, container) {
var backup_head = createElement('div'),
restore_head = createElement('div'),
reset_head = createElement('div'),
backup_cont = createElement('div'),
restore_cont = createElement('div'),
reset_cont = createElement('div'),
backup_para = createElement('p'),
backup_link = createElement('a'),
backup_help = createElement('span'),
restore_para = createElement('p'),
restore_input = createElement('input'),
restore_link = createElement('a'),
restore_help = createElement('span'),
reset_para = createElement('p'),
reset_link = createElement('a'),
reset_help = createElement('span'),
f = this;
backup_cont.className = 'chat-menu-content';
backup_head.className = 'heading';
backup_head.innerHTML = 'Backup Settings';
backup_cont.appendChild(backup_head);
backup_para.className = 'clearfix option';
backup_link.href = '#';
backup_link.innerHTML = 'Save to File';
backup_link.addEventListener('click', this.save_settings_file.bind(this));
backup_help.className = 'help';
backup_help.innerHTML = 'This generates a JSON file containing all of your settings and prompts you to save it.';
backup_para.appendChild(backup_link);
backup_para.appendChild(backup_help);
backup_cont.appendChild(backup_para);
restore_cont.className = 'chat-menu-content';
restore_head.className = 'heading';
restore_head.innerHTML = 'Restore Settings';
restore_cont.appendChild(restore_head);
restore_para.className = 'clearfix option';
restore_input.type = 'file';
restore_input.addEventListener('change', function() { f.load_settings_file(this.files[0]); })
restore_link.href = '#';
restore_link.innerHTML = 'Restore from File';
restore_link.addEventListener('click', function(e) { e.preventDefault(); restore_input.click(); });
restore_help.className = 'help';
restore_help.innerHTML = 'This loads settings from a previously generated JSON file.';
restore_para.appendChild(restore_link);
restore_para.appendChild(restore_help);
restore_cont.appendChild(restore_para);
reset_cont.className = 'chat-menu-content';
reset_head.className = 'heading';
reset_head.innerHTML = this.tr('Reset Settings');
reset_cont.appendChild(reset_head);
reset_para.className = 'clearfix option';
reset_link.href = '#';
reset_link.innerHTML = this.tr('Reset FrankerFaceZ');
reset_link.addEventListener('click', this.reset_settings.bind(this));
reset_help.className = 'help';
reset_help.innerHTML = this.tr('This resets all of your FFZ data. That includes chat filters, nicknames for users, and settings.');
reset_para.appendChild(reset_link);
reset_para.appendChild(reset_help);
reset_cont.appendChild(reset_para);
container.appendChild(backup_cont);
container.appendChild(restore_cont);
container.appendChild(reset_cont);
}
}
}
};
// --------------------
// Tracking Updates
// --------------------
FFZ.prototype._setting_update = function(e) {
if ( ! e )
e = window.event;
if ( ! e.key || e.key.substr(0, 12) !== "ffz_setting_" )
return;
var ls_key = e.key,
key = ls_key.substr(12),
val = undefined,
info = FFZ.settings_info[key];
if ( ! info ) {
// Try iterating to find the key.
for(key in FFZ.settings_info) {
if ( ! FFZ.settings_info.hasOwnProperty(key) )
continue;
info = FFZ.settings_info[key];
if ( info.storage_key == ls_key )
break;
}
// Not us.
if ( info.storage_key != ls_key )
return;
}
this.log("Updated Setting: " + key);
try {
val = JSON.parse(e.newValue);
} catch(err) {
this.log('Error loading new value for "' + key + '": ' + err);
val = info.value || undefined;
}
if ( info.process_value )
try {
val = info.process_value.call(this, val);
} catch(err) {
this.log('Error processing value for setting "' + key + '": ' + err);
return;
}
this.settings[key] = val;
if ( info.on_update )
try {
info.on_update.call(this, val, false);
} catch(err) {
this.log('Error running updater for setting "' + key + '": ' + err);
}
}
// --------------------
// Settings Access
// --------------------
FFZ.prototype._setting_load = function(key, default_value) {
var info = FFZ.settings_info[key],
ls_key = info && info.storage_key || make_ls(key),
val = default_value || (info && info.hasOwnProperty("value") ? info.value : undefined);
var stored_val = localStorage.getItem(ls_key);
if ( stored_val !== null )
try {
val = JSON.parse(stored_val);
} catch(err) {
this.log('Error parsing value for "' + key + '": ' + err);
}
if ( info && info.process_value )
val = info.process_value.call(this, val);
this.settings[key] = val;
return val;
}
FFZ.prototype._setting_get = function(key) {
if ( ! this.settings.hasOwnProperty(key) && FFZ.settings_info[key] )
this._setting_load(key);
return this.settings[key];
}
FFZ.prototype._setting_get_twitch = function(key) {
var Settings = utils.ember_settings();
return Settings && Settings.get(key);
}
FFZ.prototype._setting_set = function(key, val, suppress_log) {
var info = FFZ.settings_info[key],
ls_key = info.storage_key || make_ls(key);
if ( info.process_value )
try {
val = info.process_value.call(this, val)
} catch(err) {
this.log('Error processing value for setting "' + key + '": ' + err);
return false;
}
this.settings[key] = val;
var jval = JSON.stringify(val);
localStorage.setItem(ls_key, jval);
if ( ! suppress_log )
this.log('Changed Setting "' + key + '" to: ' + jval);
if ( info.on_update )
try {
info.on_update.call(this, val, true);
} catch(err) {
this.log('Error running updater for setting "' + key + '": ' + err);
}
}
FFZ.prototype._setting_del = function(key) {
var info = FFZ.settings_info[key],
ls_key = info.storage_key || make_ls(key),
val = undefined;
localStorage.removeItem(ls_key);
if ( info )
val = this.settings[key] = info.hasOwnProperty("value") ? info.value : undefined;
this.settings[key] = val;
if ( info.on_update )
try {
info.on_update.call(this, val, true);
} catch(err) {
this.log('Error running updater for setting "' + key + '": ' + err);
}
}

287
src/settings/context.js Normal file
View file

@ -0,0 +1,287 @@
'use strict';
// ============================================================================
// Settings Contexts
// ============================================================================
import {EventEmitter} from 'utilities/events';
import {has, get as getter, array_equals} from 'utilities/object';
/**
* The SettingsContext class provides a context through which to read
* settings values in addition to emitting events when settings values
* are changed.
* @extends EventEmitter
*/
export default class SettingsContext extends EventEmitter {
constructor(manager, context) {
super();
if ( manager instanceof SettingsContext ) {
this.parent = manager;
this.manager = manager.manager;
this.parent.on('context_changed', this._rebuildContext, this);
} else {
this.parent = null;
this.manager = manager;
}
this.manager.__contexts.push(this);
this._context = context || {};
this.__cache = new Map;
this.__meta = new Map;
this.__profiles = [];
this.order = [];
this._rebuildContext();
}
destroy() {
if ( this.parent )
this.parent.off('context_changed', this._rebuildContext, this);
for(const profile of this.__profiles)
profile.off('changed', this._onChanged, this);
const contexts = this.manager.__contexts,
idx = contexts.indexOf(this);
if ( idx !== -1 )
contexts.splice(idx, 1);
}
// ========================================================================
// State Construction
// ========================================================================
_rebuildContext() {
this.__context = this.parent ?
Object.assign({}, this.parent._context, this._context) :
this._context;
// Make sure we re-build the cache. Dependency hell.
if ( ! this.selectProfiles() )
this.rebuildCache();
this.emit('context_changed');
}
selectProfiles() {
const new_profiles = [],
order = this.order = [];
for(const profile of this.manager.__profiles)
if ( profile.matches(this.__context) ) {
new_profiles.push(profile);
order.push(profile.id);
}
if ( array_equals(this.__profiles, new_profiles) )
return false;
const changed_ids = new Set;
for(const profile of this.__profiles)
if ( ! new_profiles.includes(profile) ) {
profile.off('changed', this._onChanged, this);
changed_ids.add(profile.id);
}
for(const profile of new_profiles)
if ( ! this.__profiles.includes(profile) ) {
profile.on('changed', this._onChanged, this);
changed_ids.add(profile.id);
}
this.__profiles = new_profiles;
this.emit('profiles_changed');
this.rebuildCache(changed_ids);
return true;
}
rebuildCache() {
const old_cache = this.__cache,
old_meta = this.__meta,
meta = this.__meta = new Map;
this.__cache = new Map;
// TODO: Limit the values we recalculate to ones affected by the change
// that happened to the profiles. This is harder because of setting
// dependencies.
for(const [key, old_value] of old_cache) {
const new_value = this.get(key),
new_m = meta.get(key),
old_m = old_meta.get(key),
new_uses = new_m ? new_m.uses : null,
old_uses = old_m ? old_m.uses : null;
if ( new_value !== old_value ) {
this.emit('changed', key, new_value, old_value);
this.emit(`changed:${key}`, new_value, old_value);
}
if ( new_uses !== old_uses ) {
this.emit('uses_changed', key, new_uses, old_uses);
this.emit(`uses_changed:${key}`, new_uses, old_uses);
}
}
}
// ========================================================================
// Context Control
// ========================================================================
context(context) {
return new SettingsContext(this, context);
}
updateContext(context) {
let changed = false;
for(const key in context)
if ( has(context, key) && context[key] !== this._context[key] ) {
this._context[key] = context[key];
changed = true;
}
if ( changed )
this._rebuildContext();
}
setContext(context) {
this._context = context;
this._rebuildContext();
}
// ========================================================================
// Data Access
// ========================================================================
_onChanged(key) {
this._update(key, key, []);
}
_update(key, initial, visited) {
if ( ! this.__cache.has(key) )
return;
else if ( visited.includes(key) )
throw new Error(`cyclic dependent chain when updating setting "${initial}"`);
visited.push(key);
const old_value = this.__cache.get(key),
old_meta = this.__meta.get(key),
new_value = this._get(key, key, []),
new_meta = this.__meta.get(key),
old_uses = old_meta ? old_meta.uses : null,
new_uses = new_meta ? new_meta.uses : null;
if ( old_uses !== new_uses ) {
this.emit('uses_changed', key, new_uses, old_uses);
this.emit(`uses_changed:${key}`, new_uses, old_uses);
}
if ( old_value === new_value )
return;
this.emit('changed', key, new_value, old_value);
this.emit(`changed:${key}`, new_value, old_value);
const definition = this.manager.definitions.get(key);
if ( definition && definition.required_by )
for(const req_key of definition.required_by)
if ( ! req_key.startsWith('context.') )
this._update(req_key, initial, Array.from(visited));
}
_get(key, initial, visited) {
if ( visited.includes(key) )
throw new Error(`cyclic dependency when resolving setting "${initial}"`);
visited.push(key);
const definition = this.manager.definitions.get(key),
raw_value = this._getRaw(key),
meta = {
uses: raw_value ? raw_value[1].id : null
};
let value = raw_value ? raw_value[0] : undefined;
if ( definition ) {
if ( Array.isArray(definition) )
throw new Error(`non-existent setting "${key}" required when resolving setting "${initial}"`);
if ( meta.uses === null ) {
const def_default = definition.default;
if ( typeof def_default === 'function' )
value = def_default(this);
else
value = def_default;
}
if ( definition.requires )
for(const req_key of definition.requires)
if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key) )
this._get(req_key, initial, Array.from(visited));
if ( definition.process )
value = definition.process(this, value, meta);
}
this.__cache.set(key, value);
this.__meta.set(key, meta);
return value;
}
_getRaw(key) {
for(const profile of this.__profiles)
if ( profile.has(key) )
return [profile.get(key), profile]
}
// ========================================================================
// Data Access
// ========================================================================
update(key) {
this._update(key, key, []);
}
get(key) {
if ( key.startsWith('context.') )
return getter(key.slice(8), this.__context);
if ( this.__cache.has(key) )
return this.__cache.get(key);
return this._get(key, key, []);
}
uses(key) {
if ( key.startsWith('context.') )
return null;
if ( ! this.__meta.has(key) )
this._get(key, key, []);
return this.__meta.get(key).uses;
}
}

452
src/settings/index.js Normal file
View file

@ -0,0 +1,452 @@
'use strict';
// ============================================================================
// Settings System
// ============================================================================
import Module from 'utilities/module';
import {has} from 'utilities/object';
import {CloudStorageProvider, LocalStorageProvider} from './providers';
import SettingsProfile from './profile';
import SettingsContext from './context';
import MigrationManager from './migration';
// ============================================================================
// SettingsManager
// ============================================================================
/**
* The SettingsManager module creates all the necessary class instances
* required for the settings system to operate, facilitates communication
* and discovery, and emits events for other modules to react to.
* @extends Module
*/
export default class SettingsManager extends Module {
/**
* Create a SettingsManager module.
*/
constructor(...args) {
super(...args);
// State
this.__contexts = [];
this.__profiles = [];
this.__profile_ids = {};
this.ui_structures = new Map;
this.definitions = new Map;
// Create our provider as early as possible.
const provider = this.provider = this._createProvider();
this.log.info(`Using Provider: ${provider.constructor.name}`);
provider.on('changed', this._onProviderChange, this);
this.migrations = new MigrationManager(this);
// Also create the main context as early as possible.
this.main_context = new SettingsContext(this);
this.main_context.on('changed', (key, new_value, old_value) => {
this.emit(`:changed:${key}`, new_value, old_value);
});
this.main_context.on('uses_changed', (key, new_uses, old_uses) => {
this.emit(`:uses_changed:${key}`, new_uses, old_uses);
});
// Don't wait around to be required.
this._start_time = performance.now();
this.enable();
}
/**
* Called when the SettingsManager instance should be enabled.
*/
async onEnable() {
// Before we do anything else, make sure the provider is ready.
await this.provider.awaitReady();
// Load profiles, but don't run any events because we haven't done
// migrations yet.
this.loadProfiles(true);
// Handle migrations.
await this.migrations.process('core');
// Now we can tell our context(s) about the profiles we have.
for(const context of this.__contexts)
context.selectProfiles();
const duration = performance.now() - this._start_time;
this.log.info(`Initialization complete after ${duration.toFixed(5)}ms -- Values: ${this.provider.size} -- Profiles: ${this.__profiles.length}`)
}
// ========================================================================
// Provider Interaction
// ========================================================================
/**
* Evaluate the environment that FFZ is running in and then decide which
* provider should be used to retrieve and store settings.
*/
_createProvider() {
// If the loader has reported support for cloud settings...
if ( document.body.classList.contains('ffz-cloud-storage') )
return new CloudStorageProvider(this);
// Fallback
return new LocalStorageProvider(this);
}
/**
* React to a setting that has changed elsewhere. Generally, this is
* the result of a setting being changed in another tab or, when cloud
* settings are enabled, on another computer.
*/
_onProviderChange(key, new_value, deleted) {
// If profiles have changed, reload our profiles.
if ( key === 'profiles' )
return this.loadProfiles();
// If we're still here, it means an individual setting was changed.
// Look up the profile it belongs to and emit a changed event from
// that profile, thus notifying any contexts or UI instances.
const idx = key.indexOf(':');
if ( idx === -1 )
return;
const profile = this.__profile_ids[key.slice(0, idx)],
s_key = key.slice(idx + 1);
if ( profile )
profile.emit('changed', s_key, new_value, deleted);
}
// ========================================================================
// Profile Management
// ========================================================================
/**
* Get an existing {@link SettingsProfile} instance.
* @param {number} id - The id of the profile.
*/
profile(id) {
return this.__profile_ids[id] || null;
}
/**
* Build {@link SettingsProfile} instances for all of the profiles
* defined in storage, re-using existing instances when possible.
*/
loadProfiles(suppress_events) {
const old_profile_ids = this.__profile_ids,
old_profiles = this.__profiles,
profile_ids = this.__profile_ids = {},
profiles = this.__profiles = [],
// Create a set of actual IDs with a map from the profiles
// list rather than just getting the keys from the ID map
// because the ID map is an object and coerces its strings
// to keys.
old_ids = new Set(old_profiles.map(x => x.id));
let changed = false,
moved_ids = new Set,
new_ids = new Set,
changed_ids = new Set;
const raw_profiles = this.provider.get('profiles', [
SettingsProfile.Moderation,
SettingsProfile.Default
]);
for(const profile_data of raw_profiles) {
const id = profile_data.id,
old_profile = old_profile_ids[id],
old_slot_id = parseInt(old_profiles[profiles.length] || -1, 10);
old_ids.delete(id);
if ( old_slot_id !== id ) {
moved_ids.add(old_slot_id);
moved_ids.add(id);
}
// TODO: Better method for checking if the profile data has changed.
if ( old_profile && JSON.stringify(old_profile.data) === JSON.stringify(profile_data) ) {
// Did the order change?
if ( old_profiles[profiles.length] !== old_profile )
changed = true;
profiles.push(profile_ids[id] = old_profile);
continue;
}
const new_profile = profiles.push(profile_ids[id] = new SettingsProfile(this, profile_data));
if ( old_profile ) {
// Move all the listeners over.
new_profile.__listeners = old_profile.__listeners;
old_profile.__listeners = {};
changed_ids.add(id);
} else
new_ids.add(id);
changed = true;
}
if ( ! changed && ! old_ids.size || suppress_events )
return;
for(const context of this.__contexts)
context.selectProfiles();
for(const id of new_ids)
this.emit(':profile-created', profile_ids[id]);
for(const id of changed_ids)
this.emit(':profile-changed', profile_ids[id]);
if ( moved_ids.size )
this.emit(':profiles-reordered');
}
/**
* Create a new profile and return the {@link SettingsProfile} instance
* representing it.
* @returns {SettingsProfile}
*/
createProfile(options) {
let i = 0;
while( this.__profile_ids[i] )
i++;
options = options || {};
options.id = i;
if ( ! options.name )
options.name = `Unnamed Profile ${i}`;
const profile = this.__profile_ids[i] = new SettingsProfile(this, options);
this.__profiles.unshift(profile);
this._saveProfiles();
this.emit(':profile-created', profile);
return profile;
}
/**
* Delete a profile.
* @param {number|SettingsProfile} id - The profile to delete
*/
deleteProfile(id) {
if ( typeof id === 'object' && id.id )
id = id.id;
const profile = this.__profile_ids[id];
if ( ! profile )
return;
if ( profile.id === 0 )
throw new Error('cannot delete default profile');
profile.clear();
this.__profile_ids[id] = null;
const idx = this.__profiles.indexOf(profile);
if ( idx !== -1 )
this.__profiles.splice(idx, 1);
this._saveProfiles();
this.emit(':profile-deleted', profile);
}
moveProfile(id, index) {
if ( typeof id === 'object' && id.id )
id = id.id;
const profile = this.__profile_ids[id];
if ( ! profile )
return;
const profiles = this.__profiles,
idx = profiles.indexOf(profile);
if ( idx === index )
return;
profiles.splice(index, 0, ...profiles.splice(idx, 1));
this._saveProfiles();
this.emit(':profiles-reordered');
}
saveProfile(id) {
if ( typeof id === 'object' && id.id )
id = id.id;
const profile = this.__profile_ids[id];
if ( ! profile )
return;
this._saveProfiles();
this.emit(':profile-changed', profile);
}
_saveProfiles() {
this.provider.set('profiles', this.__profiles.map(prof => prof.data));
for(const context of this.__contexts)
context.selectProfiles();
}
// ========================================================================
// Context Helpers
// ========================================================================
context(env) { return this.main_context.context(env) }
get(key) { return this.main_context.get(key) }
uses(key) { return this.main_context.uses(key) }
update(key) { return this.main_context.update(key) }
updateContext(context) { return this.main_context.updateContext(context) }
setContext(context) { return this.main_context.setContext(context) }
// ========================================================================
// Definitions
// ========================================================================
add(key, definition) {
if ( typeof key === 'object' ) {
for(const k in key)
if ( has(key, k) )
this.add(k, key[k]);
return;
}
const old_definition = this.definitions.get(key),
required_by = old_definition ?
(Array.isArray(old_definition) ? old_definition : old_definition.required_by) : [];
definition.required_by = required_by;
definition.requires = definition.requires || [];
for(const req_key of definition.requires) {
const req = this.definitions.get(req_key);
if ( ! req )
this.definitions.set(req_key, [key]);
else if ( Array.isArray(req) )
req.push(key);
else
req.required_by.push(key);
}
if ( definition.ui ) {
const ui = definition.ui;
ui.path_tokens = ui.path_tokens ?
format_path_tokens(ui.path_tokens) :
ui.path ?
parse_path(ui.path) :
undefined;
if ( ! ui.key && ui.title )
ui.key = ui.title.toSnakeCase();
}
if ( definition.changed )
this.on(`:changed:${key}`, definition.changed);
this.definitions.set(key, definition);
this.emit(':added-definition', key, definition);
}
addUI(key, definition) {
if ( typeof key === 'object' ) {
for(const k in key)
if ( has(key, k) )
this.add(k, key[k]);
return;
}
if ( ! definition.ui )
definition = {ui: definition};
const ui = definition.ui;
ui.path_tokens = ui.path_tokens ?
format_path_tokens(ui.path_tokens) :
ui.path ?
parse_path(ui.path) :
undefined;
if ( ! ui.key && ui.title )
ui.key = ui.title.toSnakeCase();
this.ui_structures.set(key, definition);
this.emit(':added-definition', key, definition);
}
}
const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g;
export function parse_path(path) {
const tokens = [];
let match;
while((match = PATH_SPLITTER.exec(path))) {
const page = match[1] === '>>',
tab = match[1] === '~>',
title = match[2].trim(),
key = title.toSnakeCase(),
options = match[3],
opts = { key, title, page, tab };
if ( options )
Object.assign(opts, JSON.parse(options));
tokens.push(opts);
}
return tokens;
}
export function format_path_tokens(tokens) {
for(let i=0, l = tokens.length; i < l; i++) {
const token = tokens[i];
if ( typeof token === 'string' ) {
tokens[i] = {
key: token.toSnakeCase(),
title: token
}
continue;
}
if ( ! token.key )
token.key = token.title.toSnakeCase();
}
return tokens;
}

16
src/settings/migration.js Normal file
View file

@ -0,0 +1,16 @@
'use strict';
// ============================================================================
// Settings Migrations
// ============================================================================
export default class MigrationManager {
constructor(manager) {
this.manager = manager;
this.provider = manager.provider;
}
process(key) {
return false;
}
}

178
src/settings/profile.js Normal file
View file

@ -0,0 +1,178 @@
'use strict';
// ============================================================================
// Settings Profiles
// ============================================================================
import {EventEmitter} from 'utilities/events';
import {has, filter_match} from 'utilities/object';
/**
* Instances of SettingsProfile are used for getting and setting raw settings
* values, enumeration, and emit events when the raw settings are changed.
* @extends EventEmitter
*/
export default class SettingsProfile extends EventEmitter {
constructor(manager, data) {
super();
this.manager = manager;
this.provider = manager.provider;
this.data = data;
this.prefix = `p:${this.id}:`;
}
get data() {
return {
id: this.id,
parent: this.parent,
name: this.name,
i18n_key: this.i18n_key,
description: this.description,
desc_i18n_key: this.desc_i18n_key,
context: this.context
}
}
set data(val) {
if ( typeof val !== 'object' )
throw new TypeError('data must be an object');
for(const key in val)
if ( has(val, key) )
this[key] = val[key];
}
matches(context) {
// If we don't have any specific context, then we work!
if ( ! this.context )
return true;
// If we do have context and didn't get any, then we don't!
else if ( ! context )
return false;
// Got context? Have context? One-sided deep comparison time.
// Let's go for a walk!
return filter_match(this.context, context);
}
save() {
this.manager.saveProfile(this.id);
}
// ========================================================================
// Context
// ========================================================================
updateContext(context) {
if ( this.id === 0 )
throw new Error('cannot set context of default profile');
this.context = Object.assign(this.context || {}, context);
this.manager._saveProfiles();
}
setContext(context) {
if ( this.id === 0 )
throw new Error('cannot set context of default profile');
this.context = context;
this.manager._saveProfiles();
}
// ========================================================================
// Setting Access
// ========================================================================
get(key, default_value) {
return this.provider.get(this.prefix + key, default_value);
}
set(key, value) {
this.provider.set(this.prefix + key, value);
this.emit('changed', key, value);
}
delete(key) {
this.provider.delete(this.prefix + key);
this.emit('changed', key, undefined, true);
}
has(key) {
return this.provider.has(this.prefix + key);
}
keys() {
const out = [],
p = this.prefix,
len = p.length;
for(const key of this.provider.keys())
if ( key.startsWith(p) )
out.push(key.slice(len));
return out;
}
clear() {
const p = this.prefix,
len = p.length;
for(const key of this.provider.keys())
if ( key.startsWith(p) ) {
this.provider.delete(key);
this.emit('changed', key.slice(len), undefined, true);
}
}
*entries() {
const p = this.prefix,
len = p.length;
for(const key of this.provider.keys())
if ( key.startsWith(p) )
yield [key.slice(len), this.provider.get(key)];
}
get size() {
const p = this.prefix;
let count = 0;
for(const key of this.provider.keys())
if ( key.startsWith(p) )
count++;
return count;
}
}
SettingsProfile.Default = {
id: 0,
name: 'Default Profile',
i18n_key: 'setting.profiles.default',
description: 'Settings that apply everywhere on Twitch.'
}
SettingsProfile.Moderation = {
id: 1,
name: 'Moderation',
i18n_key: 'setting.profiles.moderation',
description: 'Settings that apply when you are a moderator of the current channel.',
context: {
moderator: true
}
}

301
src/settings/providers.js Normal file
View file

@ -0,0 +1,301 @@
'use strict';
// ============================================================================
// Settings Providers
// ============================================================================
import {EventEmitter} from 'utilities/events';
import {has} from 'utilities/object';
// ============================================================================
// SettingsProvider
// ============================================================================
/**
* Base class for providers for the settings system. A provider is in charge
* of reading and writing values from storage as well as sending events to
* the {@link SettingsManager} when a value is changed remotely.
*
* @extends EventEmitter
*/
export class SettingsProvider extends EventEmitter {
/**
* Create a new SettingsProvider
* @param {SettingsManager} manager - The manager that owns this provider.
*/
constructor(manager) {
super();
this.manager = manager;
this.disabled = false;
}
awaitReady() {
if ( this.ready )
return Promise.resolve();
return Promise.reject(new Error('Not Implemented'));
}
get(key, default_value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
set(key, value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
delete(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
clear() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
has(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
keys() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
entries() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
get size() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
}
// ============================================================================
// LocalStorage
// ============================================================================
export class LocalStorageProvider extends SettingsProvider {
constructor(manager, prefix) {
super(manager);
this.prefix = prefix = prefix == null ? 'FFZ:setting:' : prefix;
const cache = this._cached = new Map,
len = prefix.length;
for(const key in localStorage)
if ( has(localStorage, key) && key.startsWith(prefix) ) {
const val = localStorage.getItem(key);
try {
cache.set(key.slice(len), JSON.parse(val));
} catch(err) {
this.manager.log.warn(`unable to parse value for ${key}`, val);
}
}
this.ready = true;
this._boundHandleStorage = this.handleStorage.bind(this);
window.addEventListener('storage', this._boundHandleStorage);
}
destroy() {
this.disable();
this._cached.clear();
}
disable() {
this.disabled = true;
if ( this._boundHandleStorage ) {
window.removeEventListener('storage', this._boundHandleStorage);
this._boundHandleStorage = null;
}
}
handleStorage(event) {
if ( this.disabled )
return;
this.manager.log.debug('storage event', event);
if ( event.storageArea !== localStorage )
return;
if ( event.key.startsWith(this.prefix) ) {
// If value is null, the key was deleted.
const key = event.key.slice(this.prefix.length);
let val = event.newValue;
if ( val === null ) {
this._cached.delete(key);
this.emit('changed', key, undefined, true);
} else {
val = JSON.parse(val);
this._cached.set(key, val);
this.emit('changed', key, val, false);
}
}
}
get(key, default_value) {
return this._cached.has(key) ?
this._cached.get(key) :
default_value;
}
set(key, value) {
this._cached.set(key, value);
localStorage.setItem(this.prefix + key, JSON.stringify(value));
}
delete(key) {
this._cached.delete(key);
localStorage.removeItem(this.prefix + key);
}
has(key) {
return this._cached.has(key);
}
keys() {
return this._cached.keys();
}
clear() {
for(const key of this._cached.keys())
localStorage.removeItem(this.prefix + key);
this._cached.clear();
}
entries() {
return this._cached.entries();
}
get size() {
return this._cached.size;
}
}
export class CloudStorageProvider extends SettingsProvider {
constructor(manager) {
super(manager);
this._cached = new Map;
this.ready = false;
this._ready_wait = null;
this._boundHandleStorage = this.handleStorage.bind(this);
window.addEventListener('message', this._boundHandleStorage);
this._send('get_all');
}
destroy() {
this.disable();
this._cached.clear();
}
disable() {
this.disabled = true;
if ( this._boundHandleStorage ) {
window.removeEventListener('message', this._boundHandleStorage)
this._boundHandleStorage = null;
}
}
awaitReady() {
if ( this.ready )
return Promise.resolve();
return new Promise((resolve, reject) => {
const waiters = this._ready_wait = this._ready_wait || [];
waiters.push([resolve, reject]);
})
}
// ========================================================================
// Communication
// ========================================================================
handleStorage(event) {
if ( event.source !== window || ! event.data || ! event.data.ffz )
return;
const cmd = event.data.cmd,
data = event.data.data;
if ( cmd === 'all_values' ) {
const old_keys = new Set(this._cached.keys());
for(const key in data)
if ( has(data, key) ) {
const val = data[key];
old_keys.delete(key);
this._cached.set(key, val);
if ( this.ready )
this.emit('changed', key, val);
}
for(const key of old_keys) {
this._cached.delete(key);
if ( this.ready )
this.emit('changed', key, undefined, true);
}
this.ready = true;
if ( this._ready_wait ) {
for(const resolve of this._ready_wait)
resolve();
this._ready_wait = null;
}
} else if ( cmd === 'changed' ) {
this._cached.set(data.key, data.value);
this.emit('changed', data.key, data.value);
} else if ( cmd === 'deleted' ) {
this._cached.delete(data);
this.emit('changed', data, undefined, true);
} else {
this.manager.log.info('unknown storage event', event);
}
}
_send(cmd, data) { // eslint-disable-line class-methods-use-this
window.postMessage({
ffz: true,
cmd,
data
}, location.origin);
}
// ========================================================================
// Data Access
// ========================================================================
get(key, default_value) {
return this._cached.has(key) ?
this._cached.get(key) :
default_value;
}
set(key, value) {
this._cached.set(key, value);
this._send('set', {key, value});
}
delete(key) {
this._cached.delete(key);
this._send('delete', key);
}
has(key) {
return this._cached.has(key);
}
keys() {
return this._cached.keys();
}
clear() {
this._cached.clear();
this._send('clear');
}
entries() {
return this._cached.entries();
}
get size() {
return this._cached.size;
}
}

View file

@ -1,24 +0,0 @@
Array.prototype.equals = function (array) {
// if the other array is a falsy value, return
if (!array)
return false;
// compare lengths - can save a lot of time
if (this.length != array.length)
return false;
for (var i = 0, l=this.length; i < l; i++) {
// Check if we have nested arrays
if (this[i] instanceof Array && array[i] instanceof Array) {
// recurse into the nested arrays
if (!this[i].equals(array[i]))
return false;
}
else if (this[i] != array[i]) {
// Warning - two different object instances will never be equal: {x:20} != {x:20}
return false;
}
}
return true;
}

106
src/sites/base.js Normal file
View file

@ -0,0 +1,106 @@
'use strict';
import Module from 'utilities/module';
let last_site = 0;
let last_call = 0;
export default class BaseSite extends Module {
constructor(...args) {
super(...args);
this._id = `_ffz$${last_site++}`;
this.inject('settings');
this.log.info(`Using: ${this.constructor.name}`);
}
populateModules() {
const ctx = require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.js$/);
const modules = this.populate(ctx, this.log);
this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
}
// ========================================================================
// DOM Manipulation
// ========================================================================
awaitElement(selector, parent, timeout = 60000) {
if ( ! parent )
parent = document.documentElement;
const el = parent.querySelector(selector);
if ( el )
return Promise.resolve(el);
return new Promise((resolve, reject) => {
const observer_name = `${this._id}$observer`,
data = parent[observer_name],
call_id = last_call++,
timer = timeout && setTimeout(() => {
const data = parent[observer_name];
if ( ! data )
return;
const [observer, selectors] = data;
for(let i=0; i < selectors.length; i++) {
const d = selectors[i];
if ( d[0] === call_id ) {
selectors.splice(i, 1);
d[3]('Timed out');
break;
}
}
if ( ! selectors.length ) {
observer.disconnect();
parent[observer_name] = null;
}
}, timeout);
if ( data ) {
data[1].push([call_id, selector, resolve, reject, timer]);
return;
}
const observer = new MutationObserver(() => {
const data = parent[observer_name];
if ( ! data ) {
observer.disconnect();
return;
}
const selectors = data[1];
for(let i=0; i < selectors.length; i++) {
const d = selectors[i];
const el = parent.querySelector(d[1]);
if ( el ) {
selectors.splice(i, 1);
i--;
if ( d[4] )
clearTimeout(d[4]);
d[2](el);
}
}
if ( ! selectors.length ) {
observer.disconnect();
parent[observer_name] = null;
}
});
parent[observer_name] = [observer, [[call_id, selector, resolve, reject, timer]]];
observer.observe(parent, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
});
}
}

View file

@ -0,0 +1,113 @@
'use strict';
// ============================================================================
// Site Support: Twitch Twilight
// ============================================================================
import BaseSite from '../base';
import WebMunch from 'utilities/compat/webmunch';
import Fine from 'utilities/compat/fine';
import FineRouter from 'utilities/compat/fine-router';
import Apollo from 'utilities/compat/apollo';
import {createElement as e} from 'utilities/dom';
import MAIN_URL from 'site/styles/main.scss';
// ============================================================================
// The Site
// ============================================================================
export default class Twilight extends BaseSite {
constructor(...args) {
super(...args);
this.inject(WebMunch);
this.inject(Fine);
this.inject('router', FineRouter);
this.inject(Apollo);
}
onLoad() {
this.populateModules();
this.web_munch.known(Twilight.KNOWN_MODULES);
this.router.route(Twilight.ROUTES);
}
onEnable() {
const root = this.fine.getParent(this.fine.react),
ctx = this.context = root && root._context,
store = this.store = ctx && ctx.store;
if ( ! store )
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable());
// Share Context
store.subscribe(() => this.updateContext());
this.updateContext();
this.router.on(':route', (route, match) => {
this.log.info('Navigation', route && route.name, match[0]);
});
document.head.appendChild(e('link', {
href: MAIN_URL,
rel: 'stylesheet',
type: 'text/css'
}));
}
updateContext() {
const state = this.store.getState();
this.settings.updateContext({
location: state.router.location,
ui: state.ui,
session: state.session
});
}
getSession() {
const state = this.store && this.store.getState();
return state && state.session;
}
getUser() {
const session = this.getSession();
return session && session.user;
}
}
Twilight.KNOWN_MODULES = {
simplebar: n => n.globalObserver && n.initDOMLoadedElements,
react: n => n.Component && n.createElement,
'extension-service': n => n.extensionService
}
Twilight.ROUTES = {
'front-page': '/',
'collection': '/collections/:collectionID',
'dir-community': '/communities/:communityName',
'dir-community-index': '/directory/communities',
'dir-creative': '/directory/creative',
'dir-following': '/directory/following/:category?',
'dir-game-clips': '/directory/game/:gameName/clips',
'dir-game-details': '/directory/game/:gameName/details',
'dir-game-videos': '/directory/game/:gameName/videos/:filter',
'dir-game-index': '/directory/game/:gameName',
'dir-all': '/directory/all/:filter?',
'dir-category': '/directory/:category?',
'event': '/event/:eventName',
'following': '/following',
'popout': '/popout',
'video': '/videos/:videoID',
'user-videos': '/:userName/videos/:filter?',
'user-clips': '/:userName/manager/clips',
'user': '/:userName'
}

View file

@ -0,0 +1,93 @@
'use strict';
// ============================================================================
// Channel Bar
// ============================================================================
import Module from 'utilities/module';
import {sanitize, createElement as e} from 'utilities/dom';
export default class ChannelBar extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('site.fine');
this.inject('metadata');
this.ChannelBar = this.fine.define(
'channel-bar',
n => n.getTitle && n.getGame && n.renderGame
);
this.HostBar = this.fine.define(
'host-container',
n => n.handleReportHosterClick
)
}
onEnable() {
this.ChannelBar.ready((cls, instances) => {
for(const inst of instances)
this.updateChannelBar(inst);
});
this.ChannelBar.on('unmount', this.unmountChannelBar, this);
this.ChannelBar.on('mount', this.updateChannelBar, this);
this.ChannelBar.on('update', this.updateChannelBar, this);
this.HostBar.on('mount', inst => {
this.log.info('host-mount', inst, this.fine.getHostNode(inst));
});
this.HostBar.ready((cls, instances) => {
for(const inst of instances)
this.log.info('host-found', inst, this.fine.getHostNode(inst));
})
}
unmountChannelBar(inst) { // eslint-disable-line class-methods-use-this
const timers = inst._ffz_meta_timers;
if ( timers )
for(const key in timers)
if ( timers[key] )
clearTimeout(timers[key]);
inst._ffz_meta_timers = null;
}
updateChannelBar(inst) {
this.updateMetadata(inst);
}
updateMetadata(inst, keys) {
const container = this.fine.getHostNode(inst),
metabar = container && container.querySelector && container.querySelector('.channel-info-bar__action-container > .flex');
if ( ! inst._ffz_mounted || ! metabar )
return;
if ( ! keys )
keys = this.metadata.keys;
else if ( ! Array.isArray(keys) )
keys = [keys];
const timers = inst._ffz_meta_timers = inst._ffz_meta_timers || {},
refresh_func = key => this.updateMetadata(inst, key),
data = {
channel: inst.props.userData && inst.props.userData.user,
hosting: false,
_inst: inst
}
for(const key of keys)
this.metadata.render(key, data, metabar, timers, refresh_func);
}
}

View file

@ -0,0 +1,704 @@
'use strict';
// ============================================================================
// Chat Hooks
// ============================================================================
import {ColorAdjuster} from 'utilities/color';
import {setChildren} from 'utilities/dom';
import {has} from 'utilities/object';
import Module from 'utilities/module';
const EVENTS = [
'onJoinedEvent',
'onDisconnectedEvent',
'onReconnectingEvent',
'onHostingEvent',
'onUnhostEvent',
'onChatMessageEvent',
'onChatActionEvent',
'onChatNoticeEvent',
'onTimeoutEvent',
'onBanEvent',
'onModerationEvent',
'onSubscriptionEvent',
'onResubscriptionEvent',
'onRoomStateEvent',
'onSlowModeEvent',
'onFollowerOnlyModeEvent',
'onSubscriberOnlyModeEvent',
'onClearChatEvent',
'onRaidEvent',
'onUnraidEvent',
'onBadgesUpdatedEvent'
];
export default class ChatHook extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.colors = new ColorAdjuster;
this.inject('settings');
this.inject('site');
this.inject('site.router');
this.inject('site.fine');
this.inject('site.web_munch');
this.inject('site.css_tweaks');
this.inject('chat');
this.ChatController = this.fine.define(
'chat-controller',
n => n.chatService
);
this.ChatContainer = this.fine.define(
'chat-container',
n => n.showViewersList && n.onChatInputFocus
);
this.ChatLine = this.fine.define(
'chat-line',
n => n.renderMessageBody
);
this.PinnedCheer = this.fine.define(
'pinned-cheer',
n => n.collapseCheer && n.saveRenderedMessageRef
);
// Settings
this.settings.add('chat.width', {
default: 340,
ui: {
path: 'Chat > Appearance >> General @{"sort": -1}',
title: 'Width',
description: 'How wide chat should be, in pixels.',
component: 'setting-text-box',
process(val) {
val = parseInt(val, 10);
if ( isNaN(val) || ! isFinite(val) || val <= 0 )
return 340;
return val;
}
}
});
this.settings.add('chat.bits.show-pinned', {
default: true,
ui: {
path: 'Chat > Bits and Cheering >> Pinned Cheers',
title: 'Display Pinned Cheer',
component: 'setting-check-box'
}
});
this.settings.add('chat.lines.alternate', {
default: false,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Display lines with alternating background colors.',
component: 'setting-check-box'
}
});
this.settings.add('chat.lines.padding', {
default: false,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Reduce padding around lines.',
component: 'setting-check-box'
}
});
this.settings.add('chat.lines.borders', {
default: 0,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Separators',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Basic Line (1px Solid)'},
{value: 2, title: '3D Line (2px Groove)'},
{value: 3, title: '3D Line (2px Groove Inset)'},
{value: 4, title: 'Wide Line (2px Solid)'}
]
}
});
}
get currentChat() {
for(const inst of this.ChatController.instances)
if ( inst && inst.chatService )
return inst;
}
updateColors() {
const is_dark = this.chat.context.get('theme.is-dark'),
mode = this.chat.context.get('chat.adjustment-mode'),
contrast = this.chat.context.get('chat.adjustment-contrast'),
c = this.colors;
// TODO: Get the background color from the theme system.
c._base = is_dark ? '#0e0c13' : '#faf9fa';
c.mode = mode;
c.contrast = contrast;
this.updateChatLines();
}
updateChatWidth() {
const width = this.chat.context.get('chat.width');
if ( width === 340 )
this.css_tweaks.style.delete('chat-width');
else
this.css_tweaks.style.set('chat-width', `.channel-page__right-column{width:${width}px!important}`);
}
updateLineBorders() {
const mode = this.chat.context.get('chat.lines.borders');
this.css_tweaks.toggle('chat-borders', mode > 0);
this.css_tweaks.toggle('chat-borders-3d', mode === 2);
this.css_tweaks.toggle('chat-borders-3d-inset', mode === 3);
this.css_tweaks.toggle('chat-borders-wide', mode === 4);
}
onEnable() {
this.chat.context.on('changed:chat.width', this.updateChatWidth, this);
this.chat.context.on('changed:chat.bits.stack', this.updateChatLines, this);
this.chat.context.on('changed:chat.adjustment-mode', this.updateColors, this);
this.chat.context.on('changed:chat.adjustment-contrast', this.updateColors, this);
this.chat.context.on('changed:theme.is-dark', this.updateColors, this);
this.chat.context.on('changed:chat.lines.borders', this.updateLineBorders, this);
this.chat.context.on('changed:chat.lines.alternate', val =>
this.css_tweaks.toggle('chat-rows', val));
this.chat.context.on('changed:chat.lines.padding', val =>
this.css_tweaks.toggle('chat-padding', val));
this.chat.context.on('changed:chat.bits.show', val =>
this.css_tweaks.toggle('hide-bits', !val));
this.chat.context.on('changed:chat.bits.show-pinned', val =>
this.css_tweaks.toggleHide('pinned-cheer', !val));
this.css_tweaks.toggleHide('pinned-cheer', !this.chat.context.get('chat.bits.show-pinned'));
this.css_tweaks.toggle('hide-bits', !this.chat.context.get('chat.bits.show'));
this.css_tweaks.toggle('chat-rows', this.chat.context.get('chat.lines.alternate'));
this.css_tweaks.toggle('chat-padding', this.chat.context.get('chat.lines.padding'));
this.updateChatWidth();
this.updateColors();
this.updateLineBorders();
this.ChatController.on('mount', this.chatMounted, this);
this.ChatController.on('unmount', this.removeRoom, this);
this.ChatController.on('receive-props', this.chatUpdated, this);
this.ChatController.ready((cls, instances) => {
for(const inst of instances) {
const service = inst.chatService;
if ( ! service._ffz_was_here )
this.wrapChatService(service.constructor);
service.client.events.removeAll();
service.connectHandlers();
this.chatMounted(inst);
}
});
this.ChatContainer.on('mount', this.containerMounted, this);
this.ChatContainer.on('unmount', this.removeRoom, this);
this.ChatContainer.on('receive-props', this.containerUpdated, this);
this.ChatContainer.ready((cls, instances) => {
for(const inst of instances)
this.containerMounted(inst);
});
this.PinnedCheer.on('mount', this.fixPinnedCheer, this);
this.PinnedCheer.on('update', this.fixPinnedCheer, this);
this.PinnedCheer.ready((cls, instances) => {
for(const inst of instances)
this.fixPinnedCheer(inst);
});
const React = this.web_munch.getModule('react');
if ( React ) {
const t = this,
e = React.createElement;
this.ChatLine.ready((cls, instances) => {
cls.prototype.shouldComponentUpdate = function(props, state) {
const show = state.alwaysShowMessage || ! props.message.deleted,
old_show = this._ffz_show;
// We can't just compare props.message.deleted to this.props.message.deleted
// because the message object is the same object. So, store the old show
// state for later reference.
this._ffz_show = show;
return show !== old_show ||
//state.renderDebug !== this.state.renderDebug ||
props.message !== this.props.message ||
props.isCurrentUserModerator !== this.props.isCurrentUserModerator ||
props.showModerationIcons !== this.props.showModerationIcons ||
props.showTimestamps !== this.props.showTimestamps;
}
//const old_render = cls.prototype.render;
cls.prototype.render = function() {
const msg = this.props.message,
is_action = msg.type === 1,
user = msg.user,
color = t.colors.process(user.color),
room = msg.channel ? msg.channel.slice(1) : undefined,
show = this.state.alwaysShowMessage || ! this.props.message.deleted;
if ( ! msg.message && msg.messageParts )
detokenizeMessage(msg);
const tokens = t.chat.tokenizeMessage(msg),
fragment = t.chat.renderTokens(tokens, e);
return e('div', {
className: 'chat-line__message',
'data-room-id': this.props.channelID,
'data-room': room,
'data-user-id': user.userID,
'data-user': user.userLogin,
//onClick: () => this.setState({renderDebug: ((this.state.renderDebug||0) + 1) % 3})
}, [
this.props.showTimestamps && e('span', {
className: 'chat-line__timestamp'
}, t.chat.formatTime(msg.timestamp)),
this.renderModerationIcons(),
e('span', {
className: 'chat-line__message--badges'
}, t.chat.renderBadges(msg, e)),
e('a', {
className: 'chat-author__display-name',
style: { color },
onClick: this.usernameClickHandler
}, user.userDisplayName),
user.isIntl && e('span', {
className: 'chat-author__intl-login',
style: { color }
}, ` (${user.userLogin})`),
e('span', null, is_action ? ' ' : ': '),
show ?
e('span', {
className:'message',
style: is_action ? { color } : null
}, fragment)
:
e('span', {
className: 'chat-line__message--deleted',
}, e('a', {
href: '',
onClick: this.alwaysShowMessage
}, `<message deleted>`)),
/*this.state.renderDebug === 2 && e('div', {
className: 'border mg-t-05'
}, old_render.call(this)),
this.state.renderDebug === 1 && e('div', {
className: 'message--debug',
style: {
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
lineHeight: '1.1em'
}
}, JSON.stringify([tokens, msg.emotes], null, 2))*/
])
}
for(const inst of instances)
inst.forceUpdate();
});
}
}
wrapChatService(cls) {
const t = this,
old_handler = cls.prototype.connectHandlers;
cls.prototype._ffz_was_here = true;
cls.prototype.connectHandlers = function(...args) {
if ( ! this._ffz_init ) {
const i = this,
pm = this.postMessage;
for(const key of EVENTS) { // eslint-disable-line guard-for-in
const original = this[key];
if ( original )
this[key] = function(e, t) {
i._wrapped = e;
const ret = original.call(i, e, t);
i._wrapped = null;
return ret;
}
}
this.postMessage = function(e) {
const original = this._wrapped;
if ( original ) {
// Check that the message is relevant to this channel.
if ( original.channel && original.channel.slice(1) !== this.channelLogin )
return;
const c = e.channel = original.channel;
if ( c )
e.roomLogin = c.charAt(0) === '#' ? c.slice(1) : c;
if ( original.message ) {
if ( original.action )
e.message = original.action;
else
e.message = original.message.body;
if ( original.message.user )
e.emotes = original.message.user.emotes;
}
//e.original = original;
}
//t.log.info('postMessage', e);
return pm.call(this, e);
}
this._ffz_init = true;
}
return old_handler.apply(this, ...args);
}
}
updateChatLines() {
for(const inst of this.PinnedCheer.instances)
inst.forceUpdate();
for(const inst of this.ChatLine.instances)
inst.forceUpdate();
}
// ========================================================================
// Pinned Cheers
// ========================================================================
fixPinnedCheer(inst) {
const el = this.fine.getHostNode(inst),
container = el && el.querySelector && el.querySelector('.pinned-cheer__headline'),
tc = inst.props.topCheer;
if ( ! container || ! tc )
return;
container.dataset.roomId = inst.props.channelID;
container.dataset.room = inst.props.channelLogin;
container.dataset.userId = tc.user.userID;
container.dataset.user = tc.user.userLogin;
if ( tc.user.color ) {
const user_el = container.querySelector('.chat-author__display-name');
if ( user_el )
user_el.style.color = this.colors.process(tc.user.color);
const login_el = container.querySelector('.chat-author__intl-login');
if ( login_el )
login_el.style.color = this.colors.process(tc.user.color);
}
const bit_el = container.querySelector('.chat-line__message--emote'),
cont = bit_el ? bit_el.parentElement.parentElement : container.querySelector('.ffz--pinned-top-emote'),
prefix = extractCheerPrefix(tc.messageParts);
if ( cont && prefix ) {
const tokens = this.chat.tokenizeString(`${prefix}${tc.bits}`, tc);
cont.classList.add('ffz--pinned-top-emote');
cont.innerHTML = '';
setChildren(cont, this.chat.renderTokens(tokens));
}
}
// ========================================================================
// Room Handling
// ========================================================================
addRoom(thing, props) {
if ( ! props )
props = thing.props;
if ( ! props.channelID )
return null;
const room = thing._ffz_room = this.chat.getRoom(props.channelID, props.channelLogin, false, true);
room.ref(thing);
return room;
}
removeRoom(thing) { // eslint-disable-line class-methods-use-this
if ( ! thing._ffz_room )
return;
thing._ffz_room.unref(thing);
thing._ffz_room = null;
}
// ========================================================================
// Chat Controller
// ========================================================================
chatMounted(chat, props) {
if ( ! props )
props = chat.props;
if ( ! this.addRoom(chat, props) )
return;
this.updateRoomBitsConfig(chat, props.bitsConfig);
}
chatUpdated(chat, props) {
if ( props.channelID !== chat.props.channelID ) {
this.removeRoom(chat);
this.chatMounted(chat, props);
return;
}
if ( props.bitsConfig !== chat.props.bitsConfig )
this.updateRoomBitsConfig(chat, props.bitsConfig);
// TODO: Check if this is the room for the current channel.
this.settings.updateContext({
moderator: props.isCurrentUserModerator,
chatHidden: props.isHidden
});
this.chat.context.updateContext({
moderator: props.isCurrentUserModerator,
channel: props.channelLogin,
channelID: props.channelID,
ui: {
theme: props.theme
}
});
}
updateRoomBitsConfig(chat, config) { // eslint-disable-line class-methods-use-this
const room = chat._ffz_room;
if ( ! room )
return;
room.updateBitsConfig(formatBitsConfig(config));
this.updateChatLines();
}
// ========================================================================
// Chat Containers
// ========================================================================
containerMounted(cont, props) {
if ( ! props )
props = cont.props;
if ( ! this.addRoom(cont, props) )
return;
if ( props.badgeSets ) {
this.chat.updateBadges(props.badgeSets.globalsBySet);
this.updateRoomBadges(cont, props.badgeSets.channelsBySet);
}
}
containerUpdated(cont, props) {
if ( props.channelID !== cont.props.channelID ) {
this.removeRoom(cont);
this.containerMounted(cont, props);
return;
}
// Twitch, React, and Apollo are the trifecta of terror so we
// can't compare the badgeSets property in any reasonable way.
// Instead, just check the lengths to see if they've changed
// and hope that badge versions will never change separately.
const bs = props.badgeSets,
obs = cont.props.badgeSets,
bsgl = bs.globalsBySet && bs.globalsBySet.size || 0,
obsgl = obs.globalsBySet && obs.globalsBySet.size || 0,
bscl = bs.channelsBySet && bs.channelsBySet.size || 0,
obscl = obs.channelsBySet && obs.channelsBySet.size || 0;
if ( bsgl !== obsgl )
this.chat.updateBadges(bs.globalsBySet);
if ( bscl !== obscl )
this.updateRoomBadges(cont, bs.channelsBySet);
}
updateRoomBadges(cont, badges) { // eslint-disable-line class-methods-use-this
const room = cont._ffz_room;
if ( ! room )
return;
room.updateBadges(badges);
this.updateChatLines();
}
}
// ============================================================================
// Processing Functions
// ============================================================================
export function formatBitsConfig(config) {
if ( ! config )
return;
const out = {},
actions = config.indexedActions;
for(const key in actions)
if ( has(actions, key) ) {
const action = actions[key],
new_act = out[key] = {
id: action.id,
prefix: action.prefix,
tiers: []
};
for(const tier of action.orderedTiers) {
const images = {};
for(const im of tier.images) {
const themed = images[im.theme] = images[im.theme] || [],
ak = im.isAnimated ? 'animated' : 'static',
anim = themed[ak] = themed[ak] || {};
anim[im.dpiScale] = im.url;
}
new_act.tiers.push({
amount: tier.bits,
color: tier.color,
id: tier.id,
images
})
}
}
return out;
}
function extractCheerPrefix(parts) {
for(const part of parts) {
if ( part.type !== 3 || ! part.content.cheerAmount )
continue;
return part.content.alt;
}
return null;
}
export function detokenizeMessage(msg) {
const out = [],
parts = msg.messageParts,
l = parts.length,
emotes = {};
let idx = 0, ret, last_type = null;
for(let i=0; i < l; i++) {
const part = parts[i],
type = part.type,
content = part.content;
if ( type === 0 )
ret = content;
else if ( type === 1 )
ret = `@${content.recipient}`;
else if ( type === 2 )
ret = content.displayText;
else if ( type === 3 ) {
if ( content.cheerAmount ) {
ret = `${content.alt}${content.cheerAmount}`;
} else {
const url = (content.images.themed ? content.images.dark : content.images.sources)['1x'],
match = /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url),
id = match && match[1];
ret = content.alt;
if ( id ) {
const em = emotes[id] = emotes[id] || [],
offset = last_type > 0 ? 1 : 0;
em.push({startIndex: idx + offset, endIndex: idx + ret.length - 1});
}
}
if ( last_type > 0 )
ret = ` ${ret}`;
} else if ( type === 4 )
ret = `https://clips.twitch.tv/${content.slug}`;
if ( ret ) {
idx += ret.length;
last_type = type;
out.push(ret);
}
}
msg.message = out.join('');
msg.emotes = emotes;
return msg;
}

View file

@ -0,0 +1,214 @@
'use strict';
// ============================================================================
// CSS Tweaks for Twitch Twilight
// ============================================================================
import Module from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import {has} from 'utilities/object';
const CLASSES = {
'side-nav': '.side-nav',
'side-rec-channels': '.side-nav .recommended-channels',
'side-rec-friends': '.side-nav .recommended-friends',
'side-friends': '.side-nav .online-friends',
'side-closed-friends': '.side-nav--collapsed .online-friends',
'side-closed-rec-channels': '.side-nav--collapsed .recommended-channels',
'pinned-cheer': '.pinned-cheer',
'whispers': '.whispers'
};
export default class CSSTweaks extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('site.chat');
this.inject('site.theme');
this.style = new ManagedStyle;
this.chunks = {};
this.chunks_loaded = false;
// Layout
this.settings.add('layout.side-nav.show', {
default: true,
ui: {
sort: -1,
path: 'Appearance > Layout >> Side Navigation',
title: 'Display Side Navigation',
component: 'setting-check-box'
},
changed: val => this.toggleHide('side-nav', !val)
});
this.settings.add('layout.side-nav.show-rec-channels', {
default: 1,
ui: {
path: 'Appearance > Layout >> Side Navigation',
title: 'Display Recommended Channels',
component: 'setting-select-box',
data: [
{value: 0, title: 'Never'},
{value: 1, title: 'Always'},
{value: 2, title: 'When Side Navigation is Open'}
]
},
changed: val => {
this.toggleHide('side-rec-channels', val === 0);
this.toggleHide('side-closed-rec-channels', val === 2);
}
});
this.settings.add('layout.side-nav.show-friends', {
default: 1,
ui: {
path: 'Appearance > Layout >> Side Navigation',
title: 'Display Online Friends',
component: 'setting-select-box',
data: [
{value: 0, title: 'Never'},
{value: 1, title: 'Always'},
{value: 2, title: 'When Side Navigation is Open'}
]
},
changed: val => {
this.toggleHide('side-friends', val === 0);
this.toggleHide('side-closed-friends', val === 2);
}
});
this.settings.add('layout.side-nav.show-rec-friends', {
default: true,
ui: {
path: 'Appearance > Layout >> Side Navigation',
title: 'Display Recommended Friends',
component: 'setting-check-box'
},
changed: val => this.toggleHide('side-rec-friends', !val)
});
this.settings.add('layout.swap-sidebars', {
default: false,
ui: {
path: 'Appearance > Layout >> General',
title: 'Swap Sidebars',
description: 'Swap navigation and chat to the opposite sides of the window.',
component: 'setting-check-box'
},
changed: val => this.toggle('swap-sidebars', val)
});
this.settings.add('layout.minimal-navigation', {
default: false,
ui: {
path: 'Appearance > Layout >> General',
title: 'Minimize Navigation',
description: "Slide the site navigation bar up out of view when it isn't in use.",
component: 'setting-check-box'
},
changed: val => this.toggle('minimal-navigation', val)
});
// Chat
this.settings.add('whispers.show', {
default: true,
ui: {
path: 'Chat > Whispers >> General',
title: 'Display Whispers',
component: 'setting-check-box'
},
changed: val => this.toggleHide('whispers', !val)
});
this.settings.add('chat.bits.show', {
default: true,
ui: {
order: -1,
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Display Bits',
description: 'Display UI associated with bits. Note: This will not hide cheering in chat messages.',
component: 'setting-check-box'
},
changed: val => this.toggle('hide-bits', !val)
});
}
onEnable() {
this.toggle('swap-sidebars', this.settings.get('layout.swap-sidebars'));
this.toggle('minimal-navigation', this.settings.get('layout.minimal-navigation'));
this.toggleHide('side-nav', !this.settings.get('layout.side-nav.show'));
this.toggleHide('side-rec-friends', !this.settings.get('layout.side-nav.show-rec-friends'));
const recs = this.settings.get('layout.side-nav.show-rec-channels');
this.toggleHide('side-rec-channels', recs === 0);
this.toggleHide('side-closed-rec-channels', recs === 2);
const friends = this.settings.get('layout.side-nav.show-friends');
this.toggleHide('side-friends', friends === 0);
this.toggleHide('side-closed-friends', friends === 2);
this.toggleHide('whispers', !this.settings.get('whispers.show'));
}
toggleHide(key, val) {
const k = `hide--${key}`;
if ( ! val ) {
this.style.delete(k);
return;
}
if ( ! has(CLASSES, key) )
throw new Error(`cannot find class for "${key}"`);
this.style.set(k, `${CLASSES[key]} { display: none !important }`);
}
async toggle(key, val) {
if ( ! val ) {
this.style.delete(key);
return;
}
if ( ! this.chunks_loaded )
await this.populate();
if ( ! has(this.chunks, key) )
throw new Error(`cannot find chunk "${key}"`);
this.style.set(key, this.chunks[key]);
}
populate() {
if ( this.chunks_loaded )
return;
return new Promise(async r => {
const raw = (await import(/* webpackChunkName: "site-css-tweaks" */ './styles.js')).default;
for(const key of raw.keys()) {
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
this.chunks[k] = raw(key);
}
this.chunks_loaded = true;
r();
})
}
}

View file

@ -0,0 +1,3 @@
'use strict';
export default require.context('!raw-loader!sass-loader!./styles', false, /\.s?css$/);

View file

@ -0,0 +1,19 @@
.chat-line__message,
.chat-line__moderation,
.chat-line__status,
.chat-line__raid,
.chat-line__subscribe {
padding-top: calc(.5rem - 1px) !important;
border-top: 1px solid #aaa;
border-bottom-color: rgba(255,255,255,0.5);
.theme--dark & {
border-top-color: #000;
border-bottom-color: rgba(255,255,255,0.1);
}
&:first-child {
border-top-color: transparent !important;
}
}

View file

@ -0,0 +1,19 @@
.chat-line__message,
.chat-line__moderation,
.chat-line__status,
.chat-line__raid,
.chat-line__subscribe {
padding-top: calc(.5rem - 1px) !important;
border-top: 1px solid rgba(255,255,255,0.5);
border-bottom-color: #aaa;
.theme--dark & {
border-top-color: rgba(255,255,255,0.1);
border-bottom-color: #000;
}
&:first-child {
border-top-color: transparent !important;
}
}

View file

@ -0,0 +1,17 @@
.chat-line__message,
.chat-line__moderation,
.chat-line__status,
.chat-line__raid,
.chat-line__subscribe {
padding-top: calc(.5rem - 1px) !important;
border-top: 1px solid #dad8de;
.theme--dark & {
border-top-color: #2c2541;
}
&:first-child {
border-top-color: transparent !important;
}
}

View file

@ -0,0 +1,17 @@
.chat-line__message,
.chat-line__moderation,
.chat-line__status,
.chat-line__raid,
.chat-line__subscribe {
padding-bottom: calc(.5rem - 1px) !important;
border-bottom: 1px solid #dad8de;
.theme--dark & {
border-bottom-color: #2c2541;
}
&:last-child:nth-child(odd) {
border-bottom-color: transparent !important;
}
}

View file

@ -0,0 +1,7 @@
.chat-line__message,
.chat-line__moderation,
.chat-line__status,
.chat-line__raid,
.chat-line__subscribe {
padding: .5rem 1rem !important;
}

View file

@ -0,0 +1,16 @@
.chat-line__moderation,
.chat-line__status,
.chat-line__raid,
.chat-line__subscribe,
.chat-line__message {
background-color: transparent !important;
&:nth-child(2n+0) {
background-color: rgba(0,0,0,0.1) !important;
.theme--dark & {
background-color: rgba(255,255,255,0.05) !important;
}
}
}

View file

@ -0,0 +1,4 @@
.chat-input button[data-a-target="bits-button"],
.channel-header__right > .mg-l-1 > div > div > button:not([data-a-target]) {
display: none;
}

Some files were not shown because too many files have changed in this diff Show more