diff --git a/res/font/ffz-fontello.eot b/res/font/ffz-fontello.eot index 90ba0098..1adccb3b 100644 Binary files a/res/font/ffz-fontello.eot and b/res/font/ffz-fontello.eot differ diff --git a/res/font/ffz-fontello.svg b/res/font/ffz-fontello.svg index 5dad7d65..b519b454 100644 --- a/res/font/ffz-fontello.svg +++ b/res/font/ffz-fontello.svg @@ -106,6 +106,8 @@ + + diff --git a/res/font/ffz-fontello.ttf b/res/font/ffz-fontello.ttf index ee9319bd..52093499 100644 Binary files a/res/font/ffz-fontello.ttf and b/res/font/ffz-fontello.ttf differ diff --git a/res/font/ffz-fontello.woff b/res/font/ffz-fontello.woff index e55ef1d0..9063a7c8 100644 Binary files a/res/font/ffz-fontello.woff and b/res/font/ffz-fontello.woff differ diff --git a/res/font/ffz-fontello.woff2 b/res/font/ffz-fontello.woff2 index 7028f393..2a6eb277 100644 Binary files a/res/font/ffz-fontello.woff2 and b/res/font/ffz-fontello.woff2 differ diff --git a/src/i18n.js b/src/i18n.js index f9a6452c..55013980 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -92,7 +92,8 @@ export class TranslationManager extends Module { changed: val => { this._.transformation = TRANSFORMATIONS[val]; - this.emit(':update') + this.emit(':transform'); + this.emit(':update'); } }); @@ -387,6 +388,10 @@ export class TranslationManager extends Module { return this._.has(key); } + formatNode(...args) { + return this._.formatNode(...args); + } + toLocaleString(...args) { return this._.toLocaleString(...args); } diff --git a/src/main.js b/src/main.js index 57a99673..04ccad1d 100644 --- a/src/main.js +++ b/src/main.js @@ -151,7 +151,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}` FrankerFaceZ.Logger = Logger; const VER = FrankerFaceZ.version_info = { - major: 4, minor: 3, revision: 0, + major: 4, minor: 3, revision: 1, commit: __git_commit__, build: __webpack_hash__, toString: () => diff --git a/src/modules/chat/actions/components/edit-icon.vue b/src/modules/chat/actions/components/edit-icon.vue index ffddbf6a..110fec9b 100644 --- a/src/modules/chat/actions/components/edit-icon.vue +++ b/src/modules/chat/actions/components/edit-icon.vue @@ -4,7 +4,11 @@ {{ t('setting.actions.icon', 'Icon') }} - + diff --git a/src/modules/main_menu/components/chat-actions.vue b/src/modules/main_menu/components/chat-actions.vue index e6da257e..1f0818dc 100644 --- a/src/modules/main_menu/components/chat-actions.vue +++ b/src/modules/main_menu/components/chat-actions.vue @@ -207,13 +207,12 @@ import SettingMixin from '../setting-mixin'; import Sortable from 'sortablejs'; import {deep_copy} from 'utilities/object'; -import {mixin as clickaway} from 'vue-clickaway'; let last_id = 0; export default { - mixins: [clickaway, SettingMixin], + mixins: [SettingMixin], props: ['item', 'context'], data() { diff --git a/src/modules/main_menu/components/color-picker.vue b/src/modules/main_menu/components/color-picker.vue index e95b03fc..c9f3e9b8 100644 --- a/src/modules/main_menu/components/color-picker.vue +++ b/src/modules/main_menu/components/color-picker.vue @@ -55,15 +55,12 @@ import {Color} from 'utilities/color'; import {Sketch} from 'vue-color'; -import {mixin as clickaway} from 'vue-clickaway'; export default { components: { 'chrome-picker': Sketch }, - mixins: [clickaway], - props: { value: String, default: { diff --git a/src/modules/main_menu/components/profile-selector.vue b/src/modules/main_menu/components/profile-selector.vue index b2cbf412..fe02c2a0 100644 --- a/src/modules/main_menu/components/profile-selector.vue +++ b/src/modules/main_menu/components/profile-selector.vue @@ -83,12 +83,9 @@ \ No newline at end of file diff --git a/src/std-components/balloon.vue b/src/std-components/balloon.vue index 727efd3b..1a620f1b 100644 --- a/src/std-components/balloon.vue +++ b/src/std-components/balloon.vue @@ -21,7 +21,7 @@ export default { props: { color: { type: String, - default: 'background' + default: 'background-base' }, size: String, diff --git a/src/std-components/icon-picker.vue b/src/std-components/icon-picker.vue index db152489..5290e632 100644 --- a/src/std-components/icon-picker.vue +++ b/src/std-components/icon-picker.vue @@ -1,51 +1,72 @@ @@ -53,8 +74,9 @@ let id = 0; -import {escape_regex, deep_copy} from 'utilities/object'; +import {escape_regex, deep_copy, debounce} from 'utilities/object'; import {load, ICONS as FA_ICONS, ALIASES as FA_ALIASES} from 'utilities/font-awesome'; +import { maybeLoad } from '../utilities/font-awesome'; const FFZ_ICONS = [ 'cancel', @@ -107,6 +129,7 @@ const FFZ_ICONS = [ 'cw', 'up-dir', 'up-big', + 'play', 'link-ext', 'twitter', 'github', @@ -140,11 +163,30 @@ const ICONS = FFZ_ICONS .concat(FA_ICONS.filter(x => ! FFZ_ICONS.includes(x)).map(x => [`ffz-fa fa-${x}`, FA_ALIASES[x] ? FA_ALIASES[x].join(' ') : x])); export default { - props: ['value'], + props: { + value: String, + alwaysOpen: { + type: Boolean, + required: false, + default: false + }, + clearable: { + type: Boolean, + required: false, + default: false + }, + direction: { + type: String, + required: false, + default: 'down' + } + }, data() { return { id: id++, + open: false, + val: this.value, search: '', icons: deep_copy(ICONS) } @@ -159,25 +201,81 @@ export default { reg = new RegExp('(?:^|-| )' + escape_regex(search), 'i'); return this.icons.filter(x => reg.test(x[1])); + }, + + isOpen() { + return this.alwaysOpen || this.open } }, - mounted() { - load(); + watch: { + value() { + this.val = this.value; + }, - this.$nextTick(() => { - if ( this.value ) { - const el = this.$el.querySelector('.tw-interactable--selected'); - if ( el ) - el.scrollIntoViewIfNeeded(); + isOpen() { + if ( ! this.isOpen ) { + requestAnimationFrame(() => { + const ffz = FrankerFaceZ.get(); + if ( ffz ) + ffz.emit('tooltips:cleanup'); + }); + return; } - }); + + load(); + + this.$nextTick(() => { + if ( this.val ) { + const root = this.$refs.list, + el = root && root.querySelector('.tw-interactable--selected'); + + if ( el ) + el.scrollIntoViewIfNeeded(); + } + }); + } + }, + + created() { + this.maybeClose = debounce(this.maybeClose, 10); + }, + + mounted() { + maybeLoad(this.val); }, methods: { - change(val) { - this.value = val; - this.$emit('input', this.value); + update() { + if ( this.open ) + this.search = this.$refs.input.value; + }, + + close() { + this.open = false; + }, + + change(val, close = true) { + this.val = val; + this.$emit('input', this.val); + if ( close ) + this.open = false; + }, + + onFocus(open = true) { + this.focused = true; + if ( open ) + this.open = true; + }, + + onBlur() { + this.focused = false; + this.maybeClose(); + }, + + maybeClose() { + if ( ! this.focused ) + this.open = false; } } } diff --git a/src/utilities/constants.js b/src/utilities/constants.js index d55cf4e5..949a24ee 100644 --- a/src/utilities/constants.js +++ b/src/utilities/constants.js @@ -73,7 +73,8 @@ export const WS_CLUSTERS = { ['wss://andknuckles.frankerfacez.com/', 0.8], ['wss://tuturu.frankerfacez.com/', 1], ['wss://lilz.frankerfacez.com/', 1], - ['wss://yoohoo.frankerfacez.com/', 1] + ['wss://yoohoo.frankerfacez.com/', 1], + ['wss://pog.frankerfacez.com/', 1] ], Development: [ diff --git a/src/utilities/object.js b/src/utilities/object.js index 7b0f0413..f97b3f68 100644 --- a/src/utilities/object.js +++ b/src/utilities/object.js @@ -73,6 +73,52 @@ export function timeout(promise, delay) { } +/** + * Return a wrapper for a function that will only execute the function + * a period of time after it has stopped being called. + * @param {Function} fn The function to wrap. + * @param {Integer} delay The time to wait, in milliseconds + * @param {Boolean} immediate If immediate is true, trigger the function immediately rather than eventually. + * @returns {Function} wrapped function + */ +export function debounce(fn, delay, immediate) { + let timer; + if ( immediate ) { + const later = () => timer = null; + if ( immediate === 2 ) + // Special Mode! Run immediately OR later. + return function(...args) { + if ( timer ) { + clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + fn.apply(this, args); // eslint-disable-line no-invalid-this + }, delay); + } else { + fn.apply(this, args); // eslint-disable-line no-invalid-this + timer = setTimeout(later, delay); + } + } + + return function(...args) { + if ( ! timer ) + fn.apply(this, args); // eslint-disable-line no-invalid-this + else + clearTimeout(timer); + + timer = setTimeout(later, delay); + } + } + + return function(...args) { + if ( timer ) + clearTimeout(timer); + + timer = setTimeout(fn.bind(this, ...args), delay); // eslint-disable-line no-invalid-this + } +} + + /** * Make sure that a given asynchronous function is only called once * at a time. diff --git a/src/utilities/translation-core.js b/src/utilities/translation-core.js index 08ccc313..56cea45e 100644 --- a/src/utilities/translation-core.js +++ b/src/utilities/translation-core.js @@ -72,7 +72,7 @@ export const DEFAULT_TYPES = { }, humantime(val, node) { - return this.formatHumanTime(val, node.f); + return this.formatHumanTime(val, 1, node.f); }, en_plural: v => v !== 1 ? 's' : '' @@ -226,26 +226,28 @@ export default class TranslationCore { return thing; } - formatHumanTime(value, factor) { + formatHumanTime(value, factor, round = false) { if ( value instanceof Date ) value = (Date.now() - value.getTime()) / 1000; value = Math.floor(value); factor = Number(factor) || 1; - const years = Math.floor((value * factor) / 31536000) / factor; + const fn = round ? Math.round : Math.floor; + + const years = fn((value * factor) / 31536000) / factor; if ( years >= 1 ) return this.t('human-time.years', '{count,number} year{count,en_plural}', years); - const days = Math.floor((value %= 31536000) / 86400); + const days = fn((value %= 31536000) / 86400); if ( days >= 1 ) return this.t('human-time.days', '{count,number} day{count,en_plural}', days); - const hours = Math.floor((value %= 86400) / 3600); + const hours = fn((value %= 86400) / 3600); if ( hours >= 1 ) return this.t('human-time.hours', '{count,number} hour{count,en_plural}', hours); - const minutes = Math.floor((value %= 3600) / 60); + const minutes = fn((value %= 3600) / 60); if ( minutes >= 1 ) return this.t('human-time.minutes', '{count,number} minute{count,en_plural}', minutes); @@ -436,29 +438,33 @@ export default class TranslationCore { return this._processAST(...this._preTransform(key, phrase, options, use_default)); } + formatNode(node, data, locale = null, out = null, ast = null) { + if ( ! node || typeof node !== 'object' ) + return node; + + if ( locale == null ) + locale = this.locale; + + let val = get(node.v, data); + if ( val == null ) + return null; + + if ( node.t ) { + if ( this.types[node.t] ) + return this.types[node.t].call(this, val, node, locale, out, ast, data); + else if ( this.warn ) + this.warn(`Encountered unknown type "${node.t}" when formatting node.`); + } + + return val; + } + _processAST(ast, data, locale) { const out = []; for(const node of ast) { - if ( typeof node === 'string' ) { - out.push(node); - continue; - - } else if ( ! node || typeof node !== 'object' ) - continue; - - let val = get(node.v, data); - if ( val == null ) - continue; - - if ( node.t ) { - if ( this.types[node.t] ) - val = this.types[node.t].call(this, val, node, locale, out, ast, data); - else if ( this.warn ) - this.warn(`Encountered unknown type "${node.t}" when processing AST.`); - } - - if ( val ) + const val = this.formatNode(node, data, locale, out, ast); + if( val != null ) out.push(val); } diff --git a/src/utilities/vue.js b/src/utilities/vue.js index 9fe56cf6..44aa5bb3 100644 --- a/src/utilities/vue.js +++ b/src/utilities/vue.js @@ -19,13 +19,25 @@ export class Vue extends Module { async onLoad() { const Vue = window.ffzVue = this.Vue = (await import(/* webpackChunkName: "vue" */ 'vue')).default, - ObserveVisibility = await import(/* webpackChunkName: "vue" */ 'vue-observe-visibility'), - RavenVue = await import(/* webpackChunkName: "vue" */ 'raven-js/plugins/vue'), components = this._components; - this.component((await import(/* webpackChunkName: "vue" */ 'src/std-components/index.js')).default); + const [ + ObserveVisibility, + Clickaway, + RavenVue, + Components + + ] = await Promise.all([ + import(/* webpackChunkName: "vue" */ 'vue-observe-visibility'), + import(/* webpackChunkName: "vue" */ 'vue-clickaway'), + import(/* webpackChunkName: "vue" */ 'raven-js/plugins/vue'), + import(/* webpackChunkName: "vue" */ 'src/std-components/index.js') + ]); + + this.component(Components.default); Vue.use(ObserveVisibility); + Vue.mixin(Clickaway.mixin); if ( ! DEBUG && this.root.raven ) this.root.raven.addPlugin(RavenVue, Vue); @@ -80,6 +92,11 @@ export class Vue extends Module { return t.i18n.tList(key, phrase, options); }, + tNode_(node, data) { + this.locale; + return t.i18n.formatNode(node, data); + }, + setLocale(locale) { t.i18n.locale = locale; } @@ -158,6 +175,9 @@ export class Vue extends Module { }, tList(key, phrase, options) { return this.$i18n.tList_(key, phrase, options); + }, + tNode(node, data) { + return this.$i18n.tNode_(node, data); } } }); diff --git a/styles/icons.scss b/styles/icons.scss index f79dcde0..88fa772a 100644 --- a/styles/icons.scss +++ b/styles/icons.scss @@ -124,6 +124,7 @@ .ffz-i-cw:before { content: '\e82f'; } /* '' */ .ffz-i-up-dir:before { content: '\e830'; } /* '' */ .ffz-i-up-big:before { content: '\e831'; } /* '' */ +.ffz-i-play:before { content: '\e832'; } /* '' */ .ffz-i-link-ext:before { content: '\f08e'; } /* '' */ .ffz-i-twitter:before { content: '\f099'; } /* '' */ .ffz-i-github:before { content: '\f09b'; } /* '' */ diff --git a/styles/widgets.scss b/styles/widgets.scss index 336dd178..5af40140 100644 --- a/styles/widgets.scss +++ b/styles/widgets.scss @@ -20,6 +20,11 @@ .ffz-monospace { font-family: monospace } .ffz-bottom-100 { bottom: 100% } +.ffz--autocomplete { + .scrollable-area { + max-height: 20rem; + } +} .ffz--widget { input, select { diff --git a/styles/widgets/icon-picker.scss b/styles/widgets/icon-picker.scss index 661f059a..5c87746f 100644 --- a/styles/widgets/icon-picker.scss +++ b/styles/widgets/icon-picker.scss @@ -5,7 +5,7 @@ .ffz-icon { width: auto !important; - > * { + figure { pointer-events: none; } }