0.2.17 - Webpack 4, Bootstrap 4, remove jquery

This commit is contained in:
MayaWolf 2018-03-04 03:32:26 +01:00
parent 690ae19404
commit 04ab2f96da
182 changed files with 6956 additions and 5585 deletions

View File

@ -1,27 +1,32 @@
<template> <template>
<div class="bbcodeEditorContainer"> <div class="bbcode-editor">
<slot></slot> <slot></slot>
<a tabindex="0" class="btn bbcodeEditorButton bbcode-btn" role="button" @click="showToolbar = true" @blur="showToolbar = false"> <a tabindex="0" class="btn btn-secondary bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false"
<span class="fa fa-code"></span></a> style="border-bottom-left-radius:0;border-bottom-right-radius:0">
<div class="bbcode-toolbar" role="toolbar" :style="showToolbar ? 'display:block' : ''" @mousedown.stop.prevent> <i class="fa fa-code"></i>
</a>
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? 'display:flex' : ''" @mousedown.stop.prevent>
<div class="btn-group" style="flex-wrap:wrap">
<div class="btn btn-secondary btn-sm" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<i :class="(button.class ? button.class : 'fa ') + button.icon"></i>
</div>
<div @click="previewBBCode" class="btn btn-secondary btn-sm" :class="preview ? 'active' : ''"
:title="preview ? 'Close Preview' : 'Preview'">
<i class="fa fa-eye"></i>
</div>
</div>
<button type="button" class="close" aria-label="Close" style="margin-left:10px" @click="showToolbar = false">&times;</button> <button type="button" class="close" aria-label="Close" style="margin-left:10px" @click="showToolbar = false">&times;</button>
<div class="bbcodeEditorButton btn" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<span :class="'fa ' + button.icon"></span>
</div>
<div @click="previewBBCode" class="bbcodeEditorButton btn" :class="preview ? 'active' : ''"
:title="preview ? 'Close Preview' : 'Preview'">
<span class="fa fa-eye"></span>
</div>
</div> </div>
<div class="bbcodeEditorTextarea"> <div class="bbcode-editor-text-area">
<textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength" <textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength"
:class="'bbcodeTextAreaTextArea ' + classes" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" :class="finalClasses" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" style="border-top-left-radius:0"
:placeholder="placeholder" @keypress="$emit('keypress', $event)" @keydown="onKeyDown"></textarea> :placeholder="placeholder" @keypress="$emit('keypress', $event)" @keydown="onKeyDown"></textarea>
<div class="bbcodePreviewArea" v-show="preview"> <div ref="sizer"></div>
<div class="bbcodePreviewHeader"> <div class="bbcode-preview" v-show="preview">
<ul class="bbcodePreviewWarnings" v-show="previewWarnings.length"> <div class="bbcode-preview-warnings">
<div class="alert alert-danger" v-show="previewWarnings.length">
<li v-for="warning in previewWarnings">{{warning}}</li> <li v-for="warning in previewWarnings">{{warning}}</li>
</ul> </div>
</div> </div>
<div class="bbcode" ref="preview-element"></div> <div class="bbcode" ref="preview-element"></div>
</div> </div>
@ -35,6 +40,7 @@
import {Prop, Watch} from 'vue-property-decorator'; import {Prop, Watch} from 'vue-property-decorator';
import {BBCodeElement} from '../chat/bbcode'; import {BBCodeElement} from '../chat/bbcode';
import {getKey} from '../chat/common'; import {getKey} from '../chat/common';
import {Keys} from '../keys';
import {CoreBBCodeParser, urlRegex} from './core'; import {CoreBBCodeParser, urlRegex} from './core';
import {defaultButtons, EditorButton, EditorSelection} from './editor'; import {defaultButtons, EditorButton, EditorSelection} from './editor';
import {BBCodeParser} from './parser'; import {BBCodeParser} from './parser';
@ -44,7 +50,7 @@
@Prop() @Prop()
readonly extras?: EditorButton[]; readonly extras?: EditorButton[];
@Prop({default: 1000}) @Prop({default: 1000})
readonly maxlength: number; readonly maxlength!: number;
@Prop() @Prop()
readonly classes?: string; readonly classes?: string;
@Prop() @Prop()
@ -53,15 +59,18 @@
readonly disabled?: boolean; readonly disabled?: boolean;
@Prop() @Prop()
readonly placeholder?: string; readonly placeholder?: string;
@Prop({default: false, type: Boolean})
readonly invalid!: boolean;
preview = false; preview = false;
previewWarnings: ReadonlyArray<string> = []; previewWarnings: ReadonlyArray<string> = [];
previewResult = ''; previewResult = '';
text = this.value !== undefined ? this.value : ''; text = this.value !== undefined ? this.value : '';
element: HTMLTextAreaElement; element!: HTMLTextAreaElement;
maxHeight: number; sizer!: HTMLElement;
minHeight: number; maxHeight!: number;
minHeight!: number;
showToolbar = false; showToolbar = false;
protected parser: BBCodeParser; protected parser!: BBCodeParser;
protected defaultButtons = defaultButtons; protected defaultButtons = defaultButtons;
private isShiftPressed = false; private isShiftPressed = false;
private undoStack: string[] = []; private undoStack: string[] = [];
@ -74,16 +83,31 @@
mounted(): void { mounted(): void {
this.element = <HTMLTextAreaElement>this.$refs['input']; this.element = <HTMLTextAreaElement>this.$refs['input'];
const $element = $(this.element); const styles = getComputedStyle(this.element);
this.maxHeight = parseInt($element.css('max-height'), 10); this.maxHeight = parseInt(styles.maxHeight! , 10);
//tslint:disable-next-line:strict-boolean-expressions //tslint:disable-next-line:strict-boolean-expressions
this.minHeight = parseInt($element.css('min-height'), 10) || $element.outerHeight() || 50; this.minHeight = parseInt(styles.minHeight!, 10) || 50;
setInterval(() => { setInterval(() => {
if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) { if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) {
if(this.undoStack.length >= 30) this.undoStack.pop(); if(this.undoStack.length >= 30) this.undoStack.pop();
this.undoStack.unshift(this.text); this.undoStack.unshift(this.text);
} }
}, 500); }, 500);
this.sizer = <HTMLElement>this.$refs['sizer'];
this.sizer.style.cssText = styles.cssText;
this.sizer.style.height = '0';
this.sizer.style.overflow = 'hidden';
this.sizer.style.position = 'absolute';
this.sizer.style.top = '0';
this.sizer.style.visibility = 'hidden';
this.resize();
}
get finalClasses(): string | undefined {
let classes = this.classes;
if(this.invalid)
classes += ' is-invalid';
return classes;
} }
get buttons(): EditorButton[] { get buttons(): EditorButton[] {
@ -169,15 +193,15 @@
onKeyDown(e: KeyboardEvent): void { onKeyDown(e: KeyboardEvent): void {
const key = getKey(e); const key = getKey(e);
if((e.metaKey || e.ctrlKey) && !e.shiftKey && key !== 'control' && key !== 'meta') { if((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
if(key === 'z') { if(key === Keys.KeyZ) {
e.preventDefault(); e.preventDefault();
if(this.undoIndex === 0 && this.undoStack[0] !== this.text) this.undoStack.unshift(this.text); if(this.undoIndex === 0 && this.undoStack[0] !== this.text) this.undoStack.unshift(this.text);
if(this.undoStack.length > this.undoIndex + 1) { if(this.undoStack.length > this.undoIndex + 1) {
this.text = this.undoStack[++this.undoIndex]; this.text = this.undoStack[++this.undoIndex];
this.lastInput = Date.now(); this.lastInput = Date.now();
} }
} else if(key === 'y') { } else if(key === Keys.KeyY) {
e.preventDefault(); e.preventDefault();
if(this.undoIndex > 0) { if(this.undoIndex > 0) {
this.text = this.undoStack[--this.undoIndex]; this.text = this.undoStack[--this.undoIndex];
@ -191,20 +215,20 @@
this.apply(button); this.apply(button);
break; break;
} }
} else if(key === 'shift') this.isShiftPressed = true; } else if(e.shiftKey) this.isShiftPressed = true;
this.$emit('keydown', e); this.$emit('keydown', e);
} }
onKeyUp(e: KeyboardEvent): void { onKeyUp(e: KeyboardEvent): void {
if(getKey(e) === 'shift') this.isShiftPressed = false; if(!e.shiftKey) this.isShiftPressed = false;
this.$emit('keyup', e); this.$emit('keyup', e);
} }
resize(): void { resize(): void {
if(this.maxHeight > 0) { this.sizer.style.fontSize = this.element.style.fontSize;
this.element.style.height = 'auto'; this.sizer.style.lineHeight = this.element.style.lineHeight;
this.element.style.height = `${Math.max(Math.min(this.element.scrollHeight + 5, this.maxHeight), this.minHeight)}px`; this.sizer.textContent = this.element.value;
} this.element.style.height = `${Math.max(Math.min(this.sizer.scrollHeight, this.maxHeight), this.minHeight)}px`;
} }
onPaste(e: ClipboardEvent): void { onPaste(e: ClipboardEvent): void {

View File

@ -54,7 +54,7 @@ export class CoreBBCodeParser extends BBCodeParser {
} else if(content.length > 0) url = content; } else if(content.length > 0) url = content;
else { else {
parser.warning('url tag contains no url.'); parser.warning('url tag contains no url.');
element.textContent = ''; //Dafuq!? element.textContent = '';
return; return;
} }
@ -78,6 +78,7 @@ export class CoreBBCodeParser extends BBCodeParser {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'link-domain'; span.className = 'link-domain';
span.textContent = ` [${domain(url)}]`; span.textContent = ` [${domain(url)}]`;
(<HTMLElement & {bbcodeHide: true}>span).bbcodeHide = true;
element.appendChild(span); element.appendChild(span);
}, [])); }, []));
} }

View File

@ -1,10 +1,11 @@
import Vue from 'vue'; import Vue from 'vue';
import {Keys} from '../keys';
export interface EditorButton { export interface EditorButton {
title: string; title: string;
tag: string; tag: string;
icon: string; icon: string;
key?: string; key?: Keys;
class?: string; class?: string;
startText?: string; startText?: string;
endText?: string; endText?: string;
@ -23,74 +24,75 @@ export let defaultButtons: ReadonlyArray<EditorButton> = [
title: 'Bold (Ctrl+B)\n\nMakes text appear with a bold weight.', title: 'Bold (Ctrl+B)\n\nMakes text appear with a bold weight.',
tag: 'b', tag: 'b',
icon: 'fa-bold', icon: 'fa-bold',
key: 'b' key: Keys.KeyB
}, },
{ {
title: 'Italic (Ctrl+I)\n\nMakes text appear with an italic style.', title: 'Italic (Ctrl+I)\n\nMakes text appear with an italic style.',
tag: 'i', tag: 'i',
icon: 'fa-italic', icon: 'fa-italic',
key: 'i' key: Keys.KeyI
}, },
{ {
title: 'Underline (Ctrl+U)\n\nMakes text appear with an underline beneath it.', title: 'Underline (Ctrl+U)\n\nMakes text appear with an underline beneath it.',
tag: 'u', tag: 'u',
icon: 'fa-underline', icon: 'fa-underline',
key: 'u' key: Keys.KeyU
}, },
{ {
title: 'Strikethrough (Ctrl+S)\n\nPlaces a horizontal line through the text. Usually used to signify a correction or redaction without omitting the text.', title: 'Strikethrough (Ctrl+S)\n\nPlaces a horizontal line through the text. Usually used to signify a correction or redaction without omitting the text.',
tag: 's', tag: 's',
icon: 'fa-strikethrough', icon: 'fa-strikethrough',
key: 's' key: Keys.KeyS
}, },
{ {
title: 'Color (Ctrl+D)\n\nStyles text with a color. Valid colors are: red, orange, yellow, green, cyan, blue, purple, pink, black, white, gray, primary, secondary, accent, and contrast.', title: 'Color (Ctrl+D)\n\nStyles text with a color. Valid colors are: red, orange, yellow, green, cyan, blue, purple, pink, black, white, gray, primary, secondary, accent, and contrast.',
tag: 'color', tag: 'color',
startText: '[color=]', startText: '[color=]',
icon: 'fa-eyedropper', icon: 'fa-eye-dropper',
key: 'd' key: Keys.KeyD
}, },
{ {
title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.', title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',
tag: 'sup', tag: 'sup',
icon: 'fa-superscript', icon: 'fa-superscript',
key: 'arrowup' key: Keys.ArrowUp
}, },
{ {
title: 'Subscript (Ctrl+↓)\n\nPushes text below the text baseline. Makes text slightly smaller. Cannot be nested.', title: 'Subscript (Ctrl+↓)\n\nPushes text below the text baseline. Makes text slightly smaller. Cannot be nested.',
tag: 'sub', tag: 'sub',
icon: 'fa-subscript', icon: 'fa-subscript',
key: 'arrowdown' key: Keys.ArrowDown
}, },
{ {
title: 'URL (Ctrl+L)\n\nCreates a clickable link to another page of your choosing.', title: 'URL (Ctrl+L)\n\nCreates a clickable link to another page of your choosing.',
tag: 'url', tag: 'url',
startText: '[url=]', startText: '[url=]',
icon: 'fa-link', icon: 'fa-link',
key: 'l' key: Keys.KeyL
}, },
{ {
title: 'User (Ctrl+R)\n\nLinks to a character\'s profile.', title: 'User (Ctrl+R)\n\nLinks to a character\'s profile.',
tag: 'user', tag: 'user',
icon: 'fa-user', icon: 'fa-user',
key: 'r' key: Keys.KeyR
}, },
{ {
title: 'Icon (Ctrl+O)\n\nShows a character\'s profile image, linking to their profile.', title: 'Icon (Ctrl+O)\n\nShows a character\'s profile image, linking to their profile.',
tag: 'icon', tag: 'icon',
icon: 'fa-user-circle', icon: 'fa-user-circle',
key: 'o' key: Keys.KeyO
}, },
{ {
title: 'EIcon (Ctrl+E)\n\nShows a previously uploaded eicon. If the icon is a gif, it will be shown as animated unless a user has turned this off.', title: 'EIcon (Ctrl+E)\n\nShows a previously uploaded eicon. If the icon is a gif, it will be shown as animated unless a user has turned this off.',
tag: 'eicon', tag: 'eicon',
icon: 'fa-smile-o', class: 'far ',
key: 'e' icon: 'fa-smile',
key: Keys.KeyE
}, },
{ {
title: 'Noparse (Ctrl+N)\n\nAll BBCode placed within this tag will be ignored and treated as text. Great for sharing structure without it being rendered.', title: 'Noparse (Ctrl+N)\n\nAll BBCode placed within this tag will be ignored and treated as text. Great for sharing structure without it being rendered.',
tag: 'noparse', tag: 'noparse',
icon: 'fa-ban', icon: 'fa-ban',
key: 'n' key: Keys.KeyN
} }
]; ];

View File

@ -26,7 +26,7 @@ export abstract class BBCodeTag {
export class BBCodeSimpleTag extends BBCodeTag { export class BBCodeSimpleTag extends BBCodeTag {
constructor(tag: string, private elementName: keyof ElementTagNameMap, private classes?: string[], tagList?: string[]) { constructor(tag: string, private elementName: keyof HTMLElementTagNameMap, private classes?: string[], tagList?: string[]) {
super(tag, tagList); super(tag, tagList);
} }
@ -81,9 +81,9 @@ class ParserTag {
export class BBCodeParser { export class BBCodeParser {
private _warnings: string[] = []; private _warnings: string[] = [];
private _tags: {[tag: string]: BBCodeTag | undefined} = {}; private _tags: {[tag: string]: BBCodeTag | undefined} = {};
private _line: number; private _line = -1;
private _column: number; private _column = -1;
private _currentTag: ParserTag; private _currentTag!: ParserTag;
private _storeWarnings = false; private _storeWarnings = false;
parseEverything(input: string): HTMLElement { parseEverything(input: string): HTMLElement {
@ -103,7 +103,7 @@ export class BBCodeParser {
return stack[0].element; return stack[0].element;
} }
createElement<K extends keyof HTMLElementTagNameMap>(tag: K | keyof ElementTagNameMap): HTMLElementTagNameMap[K] { createElement<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K] {
return document.createElement(tag); return document.createElement(tag);
} }
@ -218,6 +218,8 @@ export class BBCodeParser {
quickReset(i); quickReset(i);
continue; continue;
} }
(<HTMLElement & {bbcodeTag: string}>el).bbcodeTag = tagKey;
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>el).bbcodeParam = param;
if(!this._tags[tagKey]!.noClosingTag) if(!this._tags[tagKey]!.noClosingTag)
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column)); stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
} else if(ignoreClosing[tagKey] > 0) { } else if(ignoreClosing[tagKey] > 0) {

View File

@ -1,4 +1,3 @@
import * as $ from 'jquery';
import {CoreBBCodeParser} from './core'; import {CoreBBCodeParser} from './core';
import {InlineDisplayMode} from './interfaces'; import {InlineDisplayMode} from './interfaces';
import {BBCodeCustomTag, BBCodeSimpleTag} from './parser'; import {BBCodeCustomTag, BBCodeSimpleTag} from './parser';
@ -8,6 +7,7 @@ interface InlineImage {
hash: string hash: string
extension: string extension: string
nsfw: boolean nsfw: boolean
name?: string
} }
interface StandardParserSettings { interface StandardParserSettings {
@ -23,6 +23,18 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
allowInlines = true; allowInlines = true;
inlines: {[key: string]: InlineImage | undefined} | undefined; inlines: {[key: string]: InlineImage | undefined} | undefined;
createInline(inline: InlineImage): HTMLElement {
const p1 = inline.hash.substr(0, 2);
const p2 = inline.hash.substr(2, 2);
const outerEl = this.createElement('div');
const el = this.createElement('img');
el.className = 'inline-image';
el.title = el.alt = inline.name!;
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
return outerEl;
}
constructor(public settings: StandardParserSettings) { constructor(public settings: StandardParserSettings) {
super(); super();
const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []); const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
@ -54,16 +66,24 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
//return null; //return null;
} }
const outer = parser.createElement('div'); const outer = parser.createElement('div');
outer.className = 'collapseHeader'; outer.className = 'card bg-light bbcode-collapse';
const headerText = parser.createElement('div'); const headerText = parser.createElement('div');
headerText.className = 'collapseHeaderText'; headerText.className = 'card-header bbcode-collapse-header';
const icon = parser.createElement('i');
icon.className = 'fas fa-chevron-down';
icon.style.marginRight = '10px';
headerText.appendChild(icon);
headerText.appendChild(document.createTextNode(param));
outer.appendChild(headerText); outer.appendChild(headerText);
const innerText = parser.createElement('span');
innerText.appendChild(document.createTextNode(param));
headerText.appendChild(innerText);
const body = parser.createElement('div'); const body = parser.createElement('div');
body.className = 'collapseBlock'; body.className = 'card-body bbcode-collapse-body closed';
body.style.height = '0';
outer.appendChild(body); outer.appendChild(body);
headerText.addEventListener('click', () => {
const isCollapsed = parseInt(body.style.height!, 10) === 0;
body.style.height = isCollapsed ? `${body.scrollHeight}px` : '0';
icon.className = `fas fa-chevron-${isCollapsed ? 'up' : 'down'}`;
});
parent.appendChild(outer); parent.appendChild(outer);
return body; return body;
})); }));
@ -122,7 +142,12 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
img.className = 'character-avatar icon'; img.className = 'character-avatar icon';
parent.replaceChild(img, element); parent.replaceChild(img, element);
}, [])); }, []));
this.addTag('img', new BBCodeCustomTag('img', (p, parent, param) => { this.addTag('img', new BBCodeCustomTag('img', (parser, parent) => {
const el = parser.createElement('span');
parent.appendChild(el);
return el;
}, (p, element, parent, param) => {
const content = element.textContent!;
const parser = <StandardBBCodeParser>p; const parser = <StandardBBCodeParser>p;
if(!this.allowInlines) { if(!this.allowInlines) {
parser.warning('Inline images are not allowed here.'); parser.warning('Inline images are not allowed here.');
@ -132,82 +157,38 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
parser.warning('This page does not support inline images.'); parser.warning('This page does not support inline images.');
return undefined; return undefined;
} }
let p1: string, p2: string, inline;
const displayMode = this.settings.inlineDisplayMode; const displayMode = this.settings.inlineDisplayMode;
if(!/^\d+$/.test(param)) { if(!/^\d+$/.test(param)) {
parser.warning('img tag parameters must be numbers.'); parser.warning('img tag parameters must be numbers.');
return undefined; return undefined;
} }
if(typeof parser.inlines[param] !== 'object') { const inline = parser.inlines[param];
if(typeof inline !== 'object') {
parser.warning(`Could not find an inline image with id ${param} It will not be visible.`); parser.warning(`Could not find an inline image with id ${param} It will not be visible.`);
return undefined; return undefined;
} }
inline = parser.inlines[param]!; inline.name = content;
p1 = inline.hash.substr(0, 2);
p2 = inline.hash.substr(2, 2);
if(displayMode === InlineDisplayMode.DISPLAY_NONE || (displayMode === InlineDisplayMode.DISPLAY_SFW && inline.nsfw)) { if(displayMode === InlineDisplayMode.DISPLAY_NONE || (displayMode === InlineDisplayMode.DISPLAY_SFW && inline.nsfw)) {
const el = parser.createElement('a'); const el = parser.createElement('a');
el.className = 'unloadedInline'; el.className = 'unloadedInline';
el.href = '#'; el.href = '#';
el.dataset.inlineId = param; el.dataset.inlineId = param;
el.onclick = () => { el.onclick = () => {
$('.unloadedInline').each((_, element) => { Array.prototype.forEach.call(document.getElementsByClassName('unloadedInline'), ((e: HTMLElement) => {
const inlineId = $(element).data('inline-id'); const showInline = parser.inlines![e.dataset.inlineId!];
if(typeof parser.inlines![inlineId] !== 'object') if(typeof showInline !== 'object') return;
return; e.parentElement!.replaceChild(parser.createInline(showInline), e);
const showInline = parser.inlines![inlineId]!; }));
const showP1 = showInline.hash.substr(0, 2);
const showP2 = showInline.hash.substr(2, 2);
//tslint:disable-next-line:max-line-length
$(element).replaceWith(`<div><img class="inline-image" src="${this.settings.staticDomain}images/charinline/${showP1}/${showP2}/${showInline.hash}.${showInline.extension}"/></div>`);
});
return false; return false;
}; };
const prefix = inline.nsfw ? '[NSFW Inline] ' : '[Inline] '; const prefix = inline.nsfw ? '[NSFW Inline] ' : '[Inline] ';
el.appendChild(document.createTextNode(prefix)); el.appendChild(document.createTextNode(prefix));
parent.appendChild(el); parent.replaceChild(el, element);
return el; } else parent.replaceChild(parser.createInline(inline), element);
} else {
const outerEl = parser.createElement('div');
const el = parser.createElement('img');
el.className = 'inline-image';
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
parent.appendChild(outerEl);
return el;
}
}, (_, element, __, ___) => {
// Need to remove any appended contents, because this is a total hack job.
if(element.className !== 'inline-image')
return;
while(element.firstChild !== null)
element.removeChild(element.firstChild);
}, [])); }, []));
} }
} }
export function initCollapse(): void {
$('.collapseHeader[data-bound!=true]').each((_, element) => {
const $element = $(element);
const $body = $element.children('.collapseBlock');
$element.children('.collapseHeaderText').on('click', () => {
if($element.hasClass('expandedHeader')) {
$body.css('max-height', '0');
$element.removeClass('expandedHeader');
} else {
$body.css('max-height', 'none');
const height = $body.outerHeight();
$body.css('max-height', '0');
$element.addClass('expandedHeader');
setTimeout(() => $body.css('max-height', height!), 1);
setTimeout(() => $body.css('max-height', 'none'), 250);
}
});
});
$('.collapseHeader').attr('data-bound', 'true');
}
export let standardParser: StandardBBCodeParser; export let standardParser: StandardBBCodeParser;
export function initParser(settings: StandardParserSettings): void { export function initParser(settings: StandardParserSettings): void {

View File

@ -1,22 +1,15 @@
<template> <template>
<modal :buttons="false" :action="l('chat.channels')" @close="closed"> <modal :buttons="false" :action="l('chat.channels')" @close="closed" dialog-class="w-100 channel-list">
<div style="display: flex; flex-direction: column;"> <div style="display:flex;flex-direction:column">
<ul class="nav nav-tabs" style="flex-shrink:0"> <tabs style="flex-shrink:0" :tabs="[l('channelList.public'), l('channelList.private')]" v-model="tab"></tabs>
<li role="presentation" :class="{active: !privateTabShown}">
<a href="#" @click.prevent="privateTabShown = false">{{l('channelList.public')}}</a>
</li>
<li role="presentation" :class="{active: privateTabShown}">
<a href="#" @click.prevent="privateTabShown = true">{{l('channelList.private')}}</a>
</li>
</ul>
<div style="display: flex; flex-direction: column"> <div style="display: flex; flex-direction: column">
<div style="display:flex; padding: 10px 0; flex-shrink: 0;"> <div style="display:flex; padding: 10px 0; flex-shrink: 0;">
<input class="form-control" style="flex:1; margin-right:10px;" v-model="filter" :placeholder="l('filter')"/> <input class="form-control" style="flex:1; margin-right:10px;" v-model="filter" :placeholder="l('filter')"/>
<a href="#" @click.prevent="sortCount = !sortCount"> <a href="#" @click.prevent="sortCount = !sortCount">
<span class="fa fa-2x" :class="{'fa-sort-amount-desc': sortCount, 'fa-sort-alpha-asc': !sortCount}"></span> <span class="fa fa-2x" :class="{'fa-sort-amount-down': sortCount, 'fa-sort-alpha-down': !sortCount}"></span>
</a> </a>
</div> </div>
<div style="overflow: auto;" v-show="!privateTabShown"> <div style="overflow: auto;" v-show="tab == 0">
<div v-for="channel in officialChannels" :key="channel.id"> <div v-for="channel in officialChannels" :key="channel.id">
<label :for="channel.id"> <label :for="channel.id">
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/> <input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
@ -24,7 +17,7 @@
</label> </label>
</div> </div>
</div> </div>
<div style="overflow: auto;" v-show="privateTabShown"> <div style="overflow: auto;" v-show="tab == 1">
<div v-for="channel in openRooms" :key="channel.id"> <div v-for="channel in openRooms" :key="channel.id">
<label :for="channel.id"> <label :for="channel.id">
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/> <input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
@ -46,13 +39,13 @@
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog'; import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';
import {Channel} from '../fchat';
import core from './core'; import core from './core';
import {Channel} from './interfaces';
import l from './localize'; import l from './localize';
import ListItem = Channel.ListItem;
@Component({ @Component({
components: {modal: Modal} components: {modal: Modal, tabs: Tabs}
}) })
export default class ChannelList extends CustomDialog { export default class ChannelList extends CustomDialog {
privateTabShown = false; privateTabShown = false;
@ -60,6 +53,7 @@
sortCount = true; sortCount = true;
filter = ''; filter = '';
createName = ''; createName = '';
tab = '0';
get openRooms(): ReadonlyArray<Channel.ListItem> { get openRooms(): ReadonlyArray<Channel.ListItem> {
return this.applyFilter(core.channels.openRooms); return this.applyFilter(core.channels.openRooms);
@ -92,8 +86,15 @@
this.createName = ''; this.createName = '';
} }
setJoined(channel: ListItem): void { setJoined(channel: Channel.ListItem): void {
channel.isJoined ? core.channels.leave(channel.id) : core.channels.join(channel.id); channel.isJoined ? core.channels.leave(channel.id) : core.channels.join(channel.id);
} }
} }
</script> </script>
<style>
.channel-list .modal-body {
display: flex;
flex-direction: column;
}
</style>

View File

@ -12,9 +12,9 @@
@Component @Component
export default class ChannelView extends Vue { export default class ChannelView extends Vue {
@Prop({required: true}) @Prop({required: true})
readonly id: string; readonly id!: string;
@Prop({required: true}) @Prop({required: true})
readonly text: string; readonly text!: string;
joinChannel(): void { joinChannel(): void {
if(this.channel === undefined || !this.channel.isJoined) if(this.channel === undefined || !this.channel.isJoined)

View File

@ -1,5 +1,5 @@
<template> <template>
<modal :action="l('characterSearch.action')" @submit.prevent="submit" <modal :action="l('characterSearch.action')" @submit.prevent="submit" dialogClass="w-100"
:buttonText="results ? l('characterSearch.again') : undefined" class="character-search"> :buttonText="results ? l('characterSearch.again') : undefined" class="character-search">
<div v-if="options && !results"> <div v-if="options && !results">
<div v-show="error" class="alert alert-danger">{{error}}</div> <div v-show="error" class="alert alert-danger">{{error}}</div>
@ -113,10 +113,9 @@
} }
}); });
core.connection.onMessage('FKS', (data) => { core.connection.onMessage('FKS', (data) => {
this.results = data.characters.filter((x) => core.state.hiddenUsers.indexOf(x) === -1) this.results = data.characters.map((x) => core.characters.get(x))
.map((x) => core.characters.get(x)).sort(sort); .filter((x) => core.state.hiddenUsers.indexOf(x.name) === -1 && !x.isIgnored).sort(sort);
}); });
(<Modal>this.$children[0]).fixDropdowns();
} }
filterKink(filter: RegExp, kink: Kink): boolean { filterKink(filter: RegExp, kink: Kink): boolean {
@ -144,7 +143,7 @@
} }
</script> </script>
<style lang="less"> <style lang="scss">
.character-search { .character-search {
.dropdown { .dropdown {
margin-bottom: 10px; margin-bottom: 10px;

View File

@ -1,14 +1,14 @@
<template> <template>
<div style="display:flex; flex-direction: column; height:100%; justify-content: center"> <div style="display:flex; flex-direction: column; height:100%; justify-content: center">
<div class="well" style="width:400px; max-width:100%; margin:0 auto;" v-if="!connected"> <div class="card bg-light" style="width:400px;max-width:100%;margin:0 auto" v-if="!connected">
<div class="alert alert-danger" v-show="error">{{error}}</div> <div class="alert alert-danger" v-show="error">{{error}}</div>
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3> <h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<div class="card-block"> <div class="card-body">
<h4 class="card-title">{{l('login.selectCharacter')}}</h4> <h4 class="card-title">{{l('login.selectCharacter')}}</h4>
<select v-model="selectedCharacter" class="form-control"> <select v-model="selectedCharacter" class="form-control custom-select">
<option v-for="character in ownCharacters" :value="character">{{character}}</option> <option v-for="character in ownCharacters" :value="character">{{character}}</option>
</select> </select>
<div style="text-align: right; margin-top: 10px;"> <div style="text-align:right;margin-top:10px">
<button class="btn btn-primary" @click="connect" :disabled="connecting"> <button class="btn btn-primary" @click="connect" :disabled="connecting">
{{l(connecting ? 'login.connecting' : 'login.connect')}} {{l(connecting ? 'login.connecting' : 'login.connect')}}
</button> </button>
@ -37,14 +37,44 @@
import core from './core'; import core from './core';
import l from './localize'; import l from './localize';
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string, bbcodeHide?: boolean};
function copyNode(str: string, node: BBCodeNode, range: Range, flags: {endFound?: true, rootFound?: true}): string {
if(node.bbcodeTag !== undefined)
str = `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]${str}[/${node.bbcodeTag}]`;
if(node.nextSibling !== null && !flags.endFound) {
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\n';
str += scanNode(node.nextSibling!, range, flags);
}
if(node.parentElement === null) flags.rootFound = true;
if(flags.rootFound && flags.endFound) return str;
return copyNode(str, node.parentNode!, range, flags);
}
function scanNode(node: BBCodeNode, range: Range, flags: {endFound?: true}): string {
if(node.bbcodeHide) return '';
if(node === range.endContainer) {
flags.endFound = true;
return node.nodeValue!.substr(0, range.endOffset);
}
let str = '';
if(node.bbcodeTag !== undefined) str += `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]`;
if(node instanceof Text) str += node.nodeValue;
if(node.firstChild !== null) str += scanNode(node.firstChild, range, flags);
if(node.bbcodeTag !== undefined) str += `[/${node.bbcodeTag}]`;
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\n';
if(node.nextSibling !== null && !flags.endFound) str += scanNode(node.nextSibling, range, flags);
return str;
}
@Component({ @Component({
components: {chat: ChatView, modal: Modal} components: {chat: ChatView, modal: Modal}
}) })
export default class Chat extends Vue { export default class Chat extends Vue {
@Prop({required: true}) @Prop({required: true})
readonly ownCharacters: string[]; readonly ownCharacters!: string[];
@Prop({required: true}) @Prop({required: true})
readonly defaultCharacter: string | undefined; readonly defaultCharacter!: string | undefined;
selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions
error = ''; error = '';
connecting = false; connecting = false;
@ -52,6 +82,14 @@
l = l; l = l;
mounted(): void { mounted(): void {
document.addEventListener('copy', ((e: ClipboardEvent) => {
const selection = document.getSelection();
if(selection.isCollapsed) return;
const range = selection.getRangeAt(0);
e.clipboardData.setData('text/plain', copyNode(range.startContainer.nodeValue!.substr(range.startOffset),
range.startContainer, range, {}));
e.preventDefault();
}) as EventListener);
core.register('characters', Characters(core.connection)); core.register('characters', Characters(core.connection));
core.register('channels', Channels(core.connection, core.characters)); core.register('channels', Channels(core.connection, core.characters));
core.register('conversations', Conversations()); core.register('conversations', Conversations());

View File

@ -5,7 +5,7 @@
<sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars"> <sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars">
<img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/> <img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/>
{{ownCharacter.name}} {{ownCharacter.name}}
<a href="#" @click.prevent="logOut" class="btn"><span class="fa fa-sign-out"></span>{{l('chat.logout')}}</a><br/> <a href="#" @click.prevent="logOut" class="btn"><i class="fa fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
<div> <div>
{{l('chat.status')}} {{l('chat.status')}}
<a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn"> <a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn">
@ -35,9 +35,10 @@
<div class="name"> <div class="name">
<span>{{conversation.character.name}}</span> <span>{{conversation.character.name}}</span>
<div style="text-align:right;line-height:0"> <div style="text-align:right;line-height:0">
<span class="fa" <span class="fas"
:class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}" :class="{'fa-comment-alt': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}" @mousedown.prevent ></span><span class="fa fa-reply" v-show="needsReply(conversation)"></span>
<span class="pin fa fa-thumbtack" :class="{'active': conversation.isPinned}" @mousedown.prevent
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span> @click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="fa fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span> <span class="fa fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</div> </div>
@ -49,7 +50,7 @@
<div class="list-group conversation-nav" ref="channelConversations"> <div class="list-group conversation-nav" ref="channelConversations">
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()" <a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel" :class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel"
:key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumb-tack" :key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumbtack"
:class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned" :class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave" :aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave"
@click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span> @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
@ -66,7 +67,7 @@
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()" <a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key"> :class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/> <img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<span class="fa fa-user-circle-o conversation-icon" v-else></span> <span class="far fa-user-circle conversation-icon" v-else></span>
<div class="name">{{conversation.character.name}}</div> <div class="name">{{conversation.character.name}}</div>
</a> </a>
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()" <a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
@ -93,6 +94,7 @@
import Sortable = require('sortablejs'); import Sortable = require('sortablejs');
import Vue from 'vue'; import Vue from 'vue';
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import {Keys} from '../keys';
import ChannelList from './ChannelList.vue'; import ChannelList from './ChannelList.vue';
import CharacterSearch from './CharacterSearch.vue'; import CharacterSearch from './CharacterSearch.vue';
import {characterImage, getKey} from './common'; import {characterImage, getKey} from './common';
@ -128,7 +130,7 @@
characterImage = characterImage; characterImage = characterImage;
conversations = core.conversations; conversations = core.conversations;
getStatusIcon = getStatusIcon; getStatusIcon = getStatusIcon;
keydownListener: (e: KeyboardEvent) => void; keydownListener!: (e: KeyboardEvent) => void;
mounted(): void { mounted(): void {
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e); this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
@ -190,12 +192,22 @@
window.removeEventListener('keydown', this.keydownListener); window.removeEventListener('keydown', this.keydownListener);
} }
needsReply(conversation: Conversation): boolean {
if(!core.state.settings.showNeedsReply) return false;
for(let i = conversation.messages.length - 1; i >= 0; --i) {
const sender = conversation.messages[i].sender;
if(sender !== undefined)
return sender !== core.characters.ownCharacter;
}
return false;
}
onKeyDown(e: KeyboardEvent): void { onKeyDown(e: KeyboardEvent): void {
const selected = this.conversations.selectedConversation; const selected = this.conversations.selectedConversation;
const pms = this.conversations.privateConversations; const pms = this.conversations.privateConversations;
const channels = this.conversations.channelConversations; const channels = this.conversations.channelConversations;
const console = this.conversations.consoleTab; const console = this.conversations.consoleTab;
if(getKey(e) === 'arrowup' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) if(getKey(e) === Keys.ArrowUp && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(selected === console) { //tslint:disable-line:curly if(selected === console) { //tslint:disable-line:curly
if(channels.length > 0) channels[channels.length - 1].show(); if(channels.length > 0) channels[channels.length - 1].show();
else if(pms.length > 0) pms[pms.length - 1].show(); else if(pms.length > 0) pms[pms.length - 1].show();
@ -210,7 +222,7 @@
else console.show(); else console.show();
else channels[index - 1].show(); else channels[index - 1].show();
} }
else if(getKey(e) === 'arrowdown' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) else if(getKey(e) === Keys.ArrowDown && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(selected === console) { //tslint:disable-line:curly - false positive if(selected === console) { //tslint:disable-line:curly - false positive
if(pms.length > 0) pms[0].show(); if(pms.length > 0) pms[0].show();
else if(channels.length > 0) channels[0].show(); else if(channels.length > 0) channels[0].show();
@ -263,8 +275,18 @@
} }
</script> </script>
<style lang="less"> <style lang="scss">
@import "../less/flist_variables.less"; @import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
body {
user-select: none;
}
.bbcode, .message, .profile-viewer {
user-select: initial;
}
.list-group.conversation-nav { .list-group.conversation-nav {
margin-bottom: 10px; margin-bottom: 10px;
@ -317,8 +339,10 @@
margin: 0 45px 5px; margin: 0 45px 5px;
overflow: auto; overflow: auto;
display: none; display: none;
align-items: stretch;
flex-direction: row;
@media (max-width: @screen-xs-max) { @media (max-width: breakpoint-max(xs)) {
display: flex; display: flex;
} }
@ -363,7 +387,7 @@
.body a.btn { .body a.btn {
padding: 2px 0; padding: 2px 0;
} }
@media (min-width: @screen-sm-min) { @media (min-width: breakpoint-min(sm)) {
.sidebar { .sidebar {
position: static; position: static;
margin: 0; margin: 0;

View File

@ -78,7 +78,8 @@
name: `/${key} - ${l(`commands.${key}`)}`, name: `/${key} - ${l(`commands.${key}`)}`,
help: l(`commands.${key}.help`), help: l(`commands.${key}.help`),
context, context,
permission: command.permission !== undefined ? l(`commands.help.permission${Permission[command.permission]}`) : undefined, permission: command.permission !== undefined ?
l(`commands.help.permission${Permission[command.permission]}`) : undefined,
params, params,
syntax syntax
}); });
@ -87,7 +88,7 @@
} }
</script> </script>
<style lang="less"> <style lang="scss">
#command-help { #command-help {
h4 { h4 {
margin-bottom: 0; margin-bottom: 0;

View File

@ -50,14 +50,14 @@
}) })
export default class ConversationSettings extends CustomDialog { export default class ConversationSettings extends CustomDialog {
@Prop({required: true}) @Prop({required: true})
readonly conversation: Conversation; readonly conversation!: Conversation;
l = l; l = l;
setting = Conversation.Setting; setting = Conversation.Setting;
notify: Conversation.Setting; notify!: Conversation.Setting;
highlight: Conversation.Setting; highlight!: Conversation.Setting;
highlightWords: string; highlightWords!: string;
joinMessages: Conversation.Setting; joinMessages!: Conversation.Setting;
defaultHighlights: boolean; defaultHighlights!: boolean;
constructor() { constructor() {
super(); super();

View File

@ -22,8 +22,8 @@
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<div style="flex: 1;"> <div style="flex: 1;">
<span v-show="conversation.channel.id.substr(0, 4) !== 'adh-'" class="fa fa-star" :title="l('channel.official')" <span v-show="conversation.channel.id.substr(0, 4) !== 'adh-'" class="fa fa-star" :title="l('channel.official')"
style="margin-right:5px;"></span> style="margin-right:5px;vertical-align:sub"></span>
<h4 style="margin: 0; display:inline; vertical-align: middle;">{{conversation.name}}</h4> <h5 style="margin:0;display:inline;vertical-align:middle">{{conversation.name}}</h5>
<a @click="descriptionExpanded = !descriptionExpanded" class="btn"> <a @click="descriptionExpanded = !descriptionExpanded" class="btn">
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span> <span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
<span class="btn-text">{{l('channel.description')}}</span> <span class="btn-text">{{l('channel.description')}}</span>
@ -37,8 +37,9 @@
<span class="btn-text">{{l('chat.report')}}</span></a> <span class="btn-text">{{l('chat.report')}}</span></a>
</div> </div>
<ul class="nav nav-pills mode-switcher"> <ul class="nav nav-pills mode-switcher">
<li v-for="mode in modes" :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}"> <li v-for="mode in modes" class="nav-item">
<a href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a> <a :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}"
class="nav-link" href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a>
</li> </li>
</ul> </ul>
</div> </div>
@ -51,9 +52,15 @@
<h4>{{l('chat.consoleTab')}}</h4> <h4>{{l('chat.consoleTab')}}</h4>
<logs :conversation="conversation"></logs> <logs :conversation="conversation"></logs>
</div> </div>
<div class="search" v-show="showSearch" style="position:relative">
<input v-model="searchInput" @keydown.esc="showSearch = false; searchInput = ''" @keypress="lastSearchInput = Date.now()"
:placeholder="l('chat.search')" ref="searchField" class="form-control"/>
<a class="btn btn-sm btn-light" style="position:absolute;right:5px;top:50%;transform:translateY(-50%);line-height:0"
@click="showSearch = false"><i class="fas fa-times"></i></a>
</div>
<div class="border-top messages" :class="'messages-' + conversation.mode" style="flex:1;overflow:auto;margin-top:2px" <div class="border-top messages" :class="'messages-' + conversation.mode" style="flex:1;overflow:auto;margin-top:2px"
ref="messages" @scroll="onMessagesScroll"> ref="messages" @scroll="onMessagesScroll">
<template v-for="message in conversation.messages"> <template v-for="message in messages">
<message-view :message="message" :channel="conversation.channel" :key="message.id" <message-view :message="message" :channel="conversation.channel" :key="message.id"
:classes="message == conversation.lastRead ? 'last-read' : ''"> :classes="message == conversation.lastRead ? 'last-read' : ''">
</message-view> </message-view>
@ -80,20 +87,22 @@
<span class="redText" style="flex:1;margin-left:5px">{{conversation.errorText}}</span> <span class="redText" style="flex:1;margin-left:5px">{{conversation.errorText}}</span>
</div> </div>
<div style="position:relative; margin-top:5px;"> <div style="position:relative; margin-top:5px;">
<div class="overlay-disable" v-show="adCountdown">{{adCountdown}}</div> <bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="onInput" :classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')"
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')" :disabled="adCountdown"
ref="textBox" style="position:relative" :maxlength="conversation.maxMessageLength"> ref="textBox" style="position:relative" :maxlength="conversation.maxMessageLength">
<div style="float:right;text-align:right;display:flex;align-items:center"> <div style="float:right;text-align:right;display:flex;align-items:center">
<div v-show="conversation.maxMessageLength" style="margin-right: 5px;"> <div v-show="conversation.maxMessageLength" style="margin-right:5px">
{{getByteLength(conversation.enteredText)}} / {{conversation.maxMessageLength}} {{getByteLength(conversation.enteredText)}} / {{conversation.maxMessageLength}}
</div> </div>
<ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel" style="position:relative;z-index:10"> <ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel" style="position:relative;z-index:10">
<li :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"> <li class="nav-item">
<a href="#" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a> <a href="#" :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
</li> </li>
<li :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"> <li class="nav-item">
<a href="#" @click.prevent="setSendingAds(true)">{{l('channel.mode.ads')}}</a> <a href="#" :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(true)">
{{l('channel.mode.ads' + (adCountdown ? '.countdown' : ''), adCountdown)}}</a>
</li> </li>
</ul> </ul>
</div> </div>
@ -113,6 +122,7 @@
import {Prop, Watch} from 'vue-property-decorator'; import {Prop, Watch} from 'vue-property-decorator';
import {EditorButton, EditorSelection} from '../bbcode/editor'; import {EditorButton, EditorSelection} from '../bbcode/editor';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import {Keys} from '../keys';
import {BBCodeView, Editor} from './bbcode'; import {BBCodeView, Editor} from './bbcode';
import CommandHelp from './CommandHelp.vue'; import CommandHelp from './CommandHelp.vue';
import {characterImage, getByteLength, getKey} from './common'; import {characterImage, getByteLength, getKey} from './common';
@ -135,16 +145,29 @@
}) })
export default class ConversationView extends Vue { export default class ConversationView extends Vue {
@Prop({required: true}) @Prop({required: true})
readonly reportDialog: ReportDialog; readonly reportDialog!: ReportDialog;
modes = channelModes; modes = channelModes;
descriptionExpanded = false; descriptionExpanded = false;
l = l; l = l;
extraButtons: EditorButton[] = []; extraButtons: EditorButton[] = [];
getByteLength = getByteLength; getByteLength = getByteLength;
tabOptions: string[] | undefined; tabOptions: string[] | undefined;
tabOptionsIndex: number; tabOptionsIndex!: number;
tabOptionSelection: EditorSelection; tabOptionSelection!: EditorSelection;
showSearch = false;
searchInput = '';
search = '';
lastSearchInput = 0;
messageCount = 0; messageCount = 0;
searchTimer = 0;
windowHeight = window.innerHeight;
resizeHandler = () => {
const messageView = <HTMLElement>this.$refs['messages'];
if(this.windowHeight - window.innerHeight + messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
messageView.scrollTop = messageView.scrollHeight - messageView.offsetHeight;
this.windowHeight = window.innerHeight;
}
keydownHandler!: EventListener;
created(): void { created(): void {
this.extraButtons = [{ this.extraButtons = [{
@ -153,12 +176,34 @@
icon: 'fa-question', icon: 'fa-question',
handler: () => (<Modal>this.$refs['helpDialog']).show() handler: () => (<Modal>this.$refs['helpDialog']).show()
}]; }];
window.addEventListener('resize', this.resizeHandler);
window.addEventListener('keydown', this.keydownHandler = ((e: KeyboardEvent) => {
if(getKey(e) === Keys.KeyF && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
this.showSearch = true;
this.$nextTick(() => (<HTMLElement>this.$refs['searchField']).focus());
}
}) as EventListener);
this.searchTimer = window.setInterval(() => {
if(Date.now() - this.lastSearchInput > 500 && this.search !== this.searchInput)
this.search = this.searchInput;
}, 500);
}
destroyed(): void {
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('keydown', this.keydownHandler);
clearInterval(this.searchTimer);
} }
get conversation(): Conversation { get conversation(): Conversation {
return core.conversations.selectedConversation; return core.conversations.selectedConversation;
} }
get messages(): ReadonlyArray<Conversation.Message> {
return this.search !== '' ? this.conversation.messages.filter((x) => x.text.indexOf(this.search) !== -1)
: this.conversation.messages;
}
@Watch('conversation') @Watch('conversation')
conversationChanged(): void { conversationChanged(): void {
(<Editor>this.$refs['textBox']).focus(); (<Editor>this.$refs['textBox']).focus();
@ -168,14 +213,14 @@
messageAdded(newValue: Conversation.Message[]): void { messageAdded(newValue: Conversation.Message[]): void {
const messageView = <HTMLElement>this.$refs['messages']; const messageView = <HTMLElement>this.$refs['messages'];
if(!this.keepScroll() && newValue.length === this.messageCount) if(!this.keepScroll() && newValue.length === this.messageCount)
this.$nextTick(() => messageView.scrollTop -= (<HTMLElement>messageView.lastElementChild).clientHeight); messageView.scrollTop -= (<HTMLElement>messageView.firstElementChild).clientHeight;
this.messageCount = newValue.length; this.messageCount = newValue.length;
} }
keepScroll(): boolean { keepScroll(): boolean {
const messageView = <HTMLElement>this.$refs['messages']; const messageView = <HTMLElement>this.$refs['messages'];
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) { if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) {
setTimeout(() => messageView.scrollTop = messageView.scrollHeight - messageView.offsetHeight, 0); setImmediate(() => messageView.scrollTop = messageView.scrollHeight);
return true; return true;
} }
return false; return false;
@ -197,18 +242,9 @@
if(oldValue === 'clear') this.keepScroll(); if(oldValue === 'clear') this.keepScroll();
} }
onInput(): void {
const messageView = <HTMLElement>this.$refs['messages'];
const oldHeight = messageView.offsetHeight;
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
setTimeout(() => {
if(oldHeight > messageView.offsetHeight) messageView.scrollTop += oldHeight - messageView.offsetHeight;
});
}
async onKeyDown(e: KeyboardEvent): Promise<void> { async onKeyDown(e: KeyboardEvent): Promise<void> {
const editor = <Editor>this.$refs['textBox']; const editor = <Editor>this.$refs['textBox'];
if(getKey(e) === 'tab') { if(getKey(e) === Keys.Tab) {
e.preventDefault(); e.preventDefault();
if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return; if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return;
if(this.tabOptions === undefined) { if(this.tabOptions === undefined) {
@ -242,10 +278,10 @@
} }
} else { } else {
if(this.tabOptions !== undefined) this.tabOptions = undefined; if(this.tabOptions !== undefined) this.tabOptions = undefined;
if(getKey(e) === 'arrowup' && this.conversation.enteredText.length === 0 if(getKey(e) === Keys.ArrowUp && this.conversation.enteredText.length === 0
&& !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey)
this.conversation.loadLastSent(); this.conversation.loadLastSent();
else if(getKey(e) === 'enter') { else if(getKey(e) === Keys.Enter) {
if(e.shiftKey) return; if(e.shiftKey) return;
e.preventDefault(); e.preventDefault();
await this.conversation.send(); await this.conversation.send();
@ -270,14 +306,10 @@
} }
} }
get showAdCountdown(): boolean {
return Conversation.isChannel(this.conversation) && this.conversation.adCountdown > 0 && this.conversation.isSendingAds;
}
get adCountdown(): string | undefined { get adCountdown(): string | undefined {
if(!this.showAdCountdown) return; if(!Conversation.isChannel(this.conversation) || this.conversation.adCountdown <= 0) return;
const conv = (<Conversation.ChannelConversation>this.conversation); return l('chat.adCountdown',
return l('chat.adCountdown', Math.floor(conv.adCountdown / 60).toString(), (conv.adCountdown % 60).toString()); Math.floor(this.conversation.adCountdown / 60).toString(), (this.conversation.adCountdown % 60).toString());
} }
get characterImage(): string { get characterImage(): string {
@ -301,11 +333,14 @@
} }
</script> </script>
<style lang="less"> <style lang="scss">
@import "../less/flist_variables.less"; @import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
#conversation { #conversation {
.header { .header {
@media (min-width: @screen-sm-min) { @media (min-width: breakpoint-min(sm)) {
margin-right: 32px; margin-right: 32px;
} }
a.btn { a.btn {
@ -317,7 +352,7 @@
padding: 3px 10px; padding: 3px 10px;
} }
@media (max-width: @screen-xs-max) { @media (max-width: breakpoint-max(xs)) {
.mode-switcher a { .mode-switcher a {
padding: 5px 8px; padding: 5px 8px;
} }

View File

@ -1,33 +1,35 @@
<template> <template>
<span> <span>
<a href="#" @click.prevent="showLogs" class="btn"> <a href="#" @click.prevent="showLogs" class="btn">
<span class="fa" :class="isPersistent ? 'fa-file-text-o' : 'fa-download'"></span> <span :class="isPersistent ? 'fa fa-file-alt' : 'fa fa-download'"></span>
<span class="btn-text">{{l('logs.title')}}</span> <span class="btn-text">{{l('logs.title')}}</span>
</a> </a>
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')" dialogClass="modal-lg" <modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')"
@open="onOpen" class="form-horizontal"> dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen">
<div class="form-group"> <div class="form-group row" style="flex-shrink:0">
<label class="col-sm-2">{{l('logs.conversation')}}</label> <label class="col-2 col-form-label">{{l('logs.conversation')}}</label>
<div class="col-sm-10"> <filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation" :placeholder="l('filter')" @input="loadMessages" class="form-control col-10">
buttonClass="form-control" :placeholder="l('filter')" @input="loadMessages"> <template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template> </filterable-select>
</filterable-select>
</div>
</div> </div>
<div class="form-group"> <div class="form-group row" style="flex-shrink:0">
<label for="date" class="col-sm-2">{{l('logs.date')}}</label> <label for="date" class="col-2 col-form-label">{{l('logs.date')}}</label>
<div class="col-sm-10" style="display:flex"> <div class="col-8">
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages"> <select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
<option>{{l('logs.selectDate')}}</option>
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option> <option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
</select> </select>
<button @click="downloadDay" class="btn btn-default" :disabled="!selectedDate"><span class="fa fa-download"></span></button> </div>
<div class="col-2">
<button @click="downloadDay" class="btn btn-secondary form-control" :disabled="!selectedDate"><span
class="fa fa-download"></span></button>
</div> </div>
</div> </div>
<div class="messages-both" style="overflow: auto"> <div class="messages-both" style="overflow: auto">
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view> <message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
</div> </div>
<input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages"/> <input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages" type="text"/>
</modal> </modal>
</span> </span>
</template> </template>
@ -59,7 +61,7 @@
export default class Logs extends Vue { export default class Logs extends Vue {
//tslint:disable:no-null-keyword //tslint:disable:no-null-keyword
@Prop({required: true}) @Prop({required: true})
readonly conversation: Conversation; readonly conversation!: Conversation;
selectedConversation: {id: string, name: string} | null = null; selectedConversation: {id: string, name: string} | null = null;
selectedDate: string | null = null; selectedDate: string | null = null;
isPersistent = LogInterfaces.isPersistent(core.logs); isPersistent = LogInterfaces.isPersistent(core.logs);
@ -77,7 +79,6 @@
} }
mounted(): void { mounted(): void {
(<Modal>this.$refs['dialog']).fixDropdowns();
this.conversationChanged(); this.conversationChanged();
} }

View File

@ -3,7 +3,8 @@
<a href="#" @click.prevent="openDialog" class="btn"> <a href="#" @click.prevent="openDialog" class="btn">
<span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span> <span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span>
</a> </a>
<modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit"> <modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit"
dialogClass="w-100 modal-lg">
<div class="form-group" v-show="channel.id.substr(0, 4) === 'adh-'"> <div class="form-group" v-show="channel.id.substr(0, 4) === 'adh-'">
<label class="control-label" for="isPublic"> <label class="control-label" for="isPublic">
<input type="checkbox" id="isPublic" v-model="isPublic"/> <input type="checkbox" id="isPublic" v-model="isPublic"/>
@ -27,13 +28,14 @@
<div v-if="isChannelOwner"> <div v-if="isChannelOwner">
<h4>{{l('manageChannel.mods')}}</h4> <h4>{{l('manageChannel.mods')}}</h4>
<div v-for="(mod, index) in opList"> <div v-for="(mod, index) in opList">
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn fa fa-times" <a href="#" @click.prevent="opList.splice(index, 1)" class="btn" style="padding:0;vertical-align:baseline">
style="padding:0;vertical-align:baseline"></a> <i class="fas fa-times"></i>
</a>
{{mod}} {{mod}}
</div> </div>
<div style="display:flex;margin-top:5px"> <div style="display:flex;margin-top:5px">
<input :placeholder="l('manageChannel.modAddName')" v-model="modAddName" class="form-control"/> <input :placeholder="l('manageChannel.modAddName')" v-model="modAddName" class="form-control"/>
<button class="btn btn-default" @click="modAdd" :disabled="!modAddName">{{l('manageChannel.modAdd')}}</button> <button class="btn btn-secondary" @click="modAdd" :disabled="!modAddName">{{l('manageChannel.modAdd')}}</button>
</div> </div>
</div> </div>
</modal> </modal>
@ -56,7 +58,7 @@
}) })
export default class ManageChannel extends Vue { export default class ManageChannel extends Vue {
@Prop({required: true}) @Prop({required: true})
readonly channel: Channel; readonly channel!: Channel;
modes = channelModes; modes = channelModes;
isPublic = this.channelIsPublic; isPublic = this.channelIsPublic;
mode = this.channel.mode; mode = this.channel.mode;

View File

@ -1,5 +1,5 @@
<template> <template>
<modal :buttons="false" :action="l('chat.recentConversations')"> <modal :buttons="false" :action="l('chat.recentConversations')" dialogClass="w-100">
<div style="display:flex; flex-direction:column; max-height:500px; flex-wrap:wrap;"> <div style="display:flex; flex-direction:column; max-height:500px; flex-wrap:wrap;">
<div v-for="recent in recentConversations" style="margin: 3px;"> <div v-for="recent in recentConversations" style="margin: 3px;">
<user-view v-if="recent.character" :character="getCharacter(recent.character)"></user-view> <user-view v-if="recent.character" :character="getCharacter(recent.character)"></user-view>

View File

@ -1,10 +1,6 @@
<template> <template>
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings"> <modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings" dialogClass="w-100">
<ul class="nav nav-tabs" style="flex-shrink:0;margin-bottom:10px"> <tabs style="flex-shrink:0;margin-bottom:10px" :tabs="tabs" v-model="selectedTab"></tabs>
<li role="presentation" v-for="tab in tabs" :class="{active: tab == selectedTab}">
<a href="#" @click.prevent="selectedTab = tab">{{l('settings.tabs.' + tab)}}</a>
</li>
</ul>
<div v-show="selectedTab == 'general'"> <div v-show="selectedTab == 'general'">
<div class="form-group"> <div class="form-group">
<label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label> <label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label>
@ -96,13 +92,19 @@
{{l('settings.alwaysNotify')}} {{l('settings.alwaysNotify')}}
</label> </label>
</div> </div>
<div class="form-group">
<label class="control-label" for="showNeedsReply">
<input type="checkbox" id="showNeedsReply" v-model="showNeedsReply"/>
{{l('settings.showNeedsReply')}}
</label>
</div>
</div> </div>
<div v-show="selectedTab == 'import'" style="display:flex;padding-top:10px"> <div v-show="selectedTab == 'import'" style="display:flex;padding-top:10px">
<select id="import" class="form-control" v-model="importCharacter" style="flex:1;"> <select id="import" class="form-control" v-model="importCharacter" style="flex:1;">
<option value="">{{l('settings.import.selectCharacter')}}</option> <option value="">{{l('settings.import.selectCharacter')}}</option>
<option v-for="character in availableImports" :value="character">{{character}}</option> <option v-for="character in availableImports" :value="character">{{character}}</option>
</select> </select>
<button class="btn btn-default" @click="doImport" :disabled="!importCharacter">{{l('settings.import')}}</button> <button class="btn btn-secondary" @click="doImport" :disabled="!importCharacter">{{l('settings.import')}}</button>
</div> </div>
</modal> </modal>
</template> </template>
@ -111,34 +113,36 @@
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog'; import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';
import core from './core'; import core from './core';
import {Settings as SettingsInterface} from './interfaces'; import {Settings as SettingsInterface} from './interfaces';
import l from './localize'; import l from './localize';
@Component( @Component(
{components: {modal: Modal}} {components: {modal: Modal, tabs: Tabs}}
) )
export default class SettingsView extends CustomDialog { export default class SettingsView extends CustomDialog {
l = l; l = l;
availableImports: ReadonlyArray<string> = []; availableImports: ReadonlyArray<string> = [];
selectedTab = 'general'; selectedTab = 'general';
importCharacter = ''; importCharacter = '';
playSound: boolean; playSound!: boolean;
clickOpensMessage: boolean; clickOpensMessage!: boolean;
disallowedTags: string; disallowedTags!: string;
notifications: boolean; notifications!: boolean;
highlight: boolean; highlight!: boolean;
highlightWords: string; highlightWords!: string;
showAvatars: boolean; showAvatars!: boolean;
animatedEicons: boolean; animatedEicons!: boolean;
idleTimer: string; idleTimer!: string;
messageSeparators: boolean; messageSeparators!: boolean;
eventMessages: boolean; eventMessages!: boolean;
joinMessages: boolean; joinMessages!: boolean;
alwaysNotify: boolean; alwaysNotify!: boolean;
logMessages: boolean; logMessages!: boolean;
logAds: boolean; logAds!: boolean;
fontSize: number; fontSize!: number;
showNeedsReply!: boolean;
constructor() { constructor() {
super(); super();
@ -168,6 +172,7 @@
this.logMessages = settings.logMessages; this.logMessages = settings.logMessages;
this.logAds = settings.logAds; this.logAds = settings.logAds;
this.fontSize = settings.fontSize; this.fontSize = settings.fontSize;
this.showNeedsReply = settings.showNeedsReply;
}; };
async doImport(): Promise<void> { async doImport(): Promise<void> {
@ -178,14 +183,18 @@
}; };
await importKey('settings'); await importKey('settings');
await importKey('pinned'); await importKey('pinned');
await importKey('modes');
await importKey('conversationSettings'); await importKey('conversationSettings');
this.init(); this.init();
core.reloadSettings(); core.reloadSettings();
core.conversations.reloadSettings(); core.conversations.reloadSettings();
} }
get tabs(): ReadonlyArray<string> { get tabs(): {readonly [key: string]: string} {
return this.availableImports.length > 0 ? ['general', 'notifications', 'import'] : ['general', 'notifications']; const tabs: {[key: string]: string} = {};
(this.availableImports.length > 0 ? ['general', 'notifications', 'import'] : ['general', 'notifications'])
.forEach((item) => tabs[item] = l(`settings.tabs.${item}`));
return tabs;
} }
async submit(): Promise<void> { async submit(): Promise<void> {
@ -205,7 +214,8 @@
alwaysNotify: this.alwaysNotify, alwaysNotify: this.alwaysNotify,
logMessages: this.logMessages, logMessages: this.logMessages,
logAds: this.logAds, logAds: this.logAds,
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize,
showNeedsReply: this.showNeedsReply
}; };
if(this.notifications) await core.notifications.requestPermission(); if(this.notifications) await core.notifications.requestPermission();
} }

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="sidebar-wrapper" :class="{open: expanded}"> <div class="sidebar-wrapper" :class="{open: expanded}">
<div :class="'sidebar sidebar-' + (right ? 'right' : 'left')"> <div :class="'sidebar sidebar-' + (right ? 'right' : 'left')">
<button @click="expanded = !expanded" class="btn btn-default btn-xs expander" :aria-label="label"> <button @click="expanded = !expanded" class="btn btn-secondary btn-xs expander" :aria-label="label">
<span :class="'fa fa-rotate-270 ' + icon" style="vertical-align: middle" v-if="right"></span> <span :class="'fa fa-fw fa-rotate-270 ' + icon" v-if="right"></span>
<span class="fa" :class="{'fa-chevron-down': !expanded, 'fa-chevron-up': expanded}"></span> <span class="fa" :class="{'fa-chevron-down': !expanded, 'fa-chevron-up': expanded}"></span>
<span :class="'fa fa-rotate-90 ' + icon" style="vertical-align: middle" v-if="!right"></span> <span :class="'fa fa-fw fa-rotate-90 ' + icon" v-if="!right"></span>
</button> </button>
<div class="body"> <div class="body">
<slot></slot> <slot></slot>
@ -26,9 +26,9 @@
@Prop() @Prop()
readonly label?: string; readonly label?: string;
@Prop({required: true}) @Prop({required: true})
readonly icon: string; readonly icon!: string;
@Prop({default: false}) @Prop({default: false})
readonly open: boolean; readonly open!: boolean;
expanded = this.open; expanded = this.open;
@Watch('open') @Watch('open')

View File

@ -1,19 +1,13 @@
<template> <template>
<modal :action="l('chat.setStatus')" @submit="setStatus" @close="reset"> <modal :action="l('chat.setStatus')" @submit="setStatus" @close="reset" dialogClass="w-100 modal-lg">
<div class="form-group" id="statusSelector"> <div class="form-group" id="statusSelector">
<label class="control-label">{{l('chat.setStatus.status')}}</label> <label class="control-label">{{l('chat.setStatus.status')}}</label>
<div class="dropdown form-control" style="padding: 0;"> <dropdown class="dropdown form-control" style="padding:0">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" <span slot="title"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span>
aria-expanded="false" style="width:100%; text-align:left; display:flex; align-items:center"> <a href="#" class="dropdown-item" v-for="item in statuses" @click.prevent="status = item">
<span style="flex: 1;"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span> <span class="fa fa-fw" :class="getStatusIcon(item)"></span>{{l('status.' + item)}}
<span class="caret"></span> </a>
</button> </dropdown>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<li><a href="#" v-for="item in statuses" @click.prevent="status = item">
<span class="fa fa-fw" :class="getStatusIcon(item)"></span>{{l('status.' + item)}}
</a></li>
</ul>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="control-label">{{l('chat.setStatus.message')}}</label> <label class="control-label">{{l('chat.setStatus.message')}}</label>
@ -29,6 +23,7 @@
<script lang="ts"> <script lang="ts">
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog'; import CustomDialog from '../components/custom_dialog';
import Dropdown from '../components/Dropdown.vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import {Editor} from './bbcode'; import {Editor} from './bbcode';
import {getByteLength} from './common'; import {getByteLength} from './common';
@ -38,7 +33,7 @@
import {getStatusIcon} from './user_view'; import {getStatusIcon} from './user_view';
@Component({ @Component({
components: {modal: Modal, editor: Editor} components: {modal: Modal, editor: Editor, dropdown: Dropdown}
}) })
export default class StatusSwitcher extends CustomDialog { export default class StatusSwitcher extends CustomDialog {
//tslint:disable:no-null-keyword //tslint:disable:no-null-keyword

View File

@ -1,14 +1,7 @@
<template> <template>
<sidebar id="user-list" :label="l('users.title')" icon="fa-users" :right="true" :open="expanded"> <sidebar id="user-list" :label="l('users.title')" icon="fa-users" :right="true" :open="expanded">
<ul class="nav nav-tabs" style="flex-shrink:0"> <tabs style="flex-shrink:0" :tabs="channel ? [l('users.friends'), l('users.members')] : [l('users.friends')]" v-model="tab"></tabs>
<li role="presentation" :class="{active: !channel || !memberTabShown}"> <div class="users" style="padding-left:10px" v-show="tab == 0">
<a href="#" @click.prevent="memberTabShown = false">{{l('users.friends')}}</a>
</li>
<li role="presentation" :class="{active: memberTabShown}" v-show="channel">
<a href="#" @click.prevent="memberTabShown = true">{{l('users.members')}}</a>
</li>
</ul>
<div v-show="!channel || !memberTabShown" class="users" style="padding-left:10px">
<h4>{{l('users.friends')}}</h4> <h4>{{l('users.friends')}}</h4>
<div v-for="character in friends" :key="character.name"> <div v-for="character in friends" :key="character.name">
<user :character="character" :showStatus="true"></user> <user :character="character" :showStatus="true"></user>
@ -18,7 +11,7 @@
<user :character="character" :showStatus="true"></user> <user :character="character" :showStatus="true"></user>
</div> </div>
</div> </div>
<div v-if="channel" v-show="memberTabShown" class="users" style="padding:5px"> <div v-if="channel" class="users" style="padding:5px" v-show="tab == 1">
<h4>{{l('users.memberCount', channel.sortedMembers.length)}}</h4> <h4>{{l('users.memberCount', channel.sortedMembers.length)}}</h4>
<div v-for="member in channel.sortedMembers" :key="member.character.name"> <div v-for="member in channel.sortedMembers" :key="member.character.name">
<user :character="member.character" :channel="channel" :showStatus="true"></user> <user :character="member.character" :channel="channel" :showStatus="true"></user>
@ -30,6 +23,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import Tabs from '../components/tabs';
import core from './core'; import core from './core';
import {Channel, Character, Conversation} from './interfaces'; import {Channel, Character, Conversation} from './interfaces';
import l from './localize'; import l from './localize';
@ -37,11 +31,11 @@
import UserView from './user_view'; import UserView from './user_view';
@Component({ @Component({
components: {user: UserView, sidebar: Sidebar} components: {user: UserView, sidebar: Sidebar, tabs: Tabs}
}) })
export default class UserList extends Vue { export default class UserList extends Vue {
memberTabShown = false; tab = '0';
expanded = window.innerWidth >= 900; expanded = window.innerWidth >= 992;
l = l; l = l;
sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)); sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0));
@ -59,8 +53,11 @@
} }
</script> </script>
<style lang="less"> <style lang="scss">
@import "../less/flist_variables.less"; @import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
#user-list { #user-list {
flex-direction: column; flex-direction: column;
h4 { h4 {
@ -77,7 +74,7 @@
border-top-left-radius: 0; border-top-left-radius: 0;
} }
@media (min-width: @screen-md-min) { @media (min-width: breakpoint-min(md)) {
.sidebar { .sidebar {
position: static; position: static;
margin: 0; margin: 0;

View File

@ -1,46 +1,37 @@
<template> <template>
<div> <div>
<div id="userMenu" class="dropdown-menu" v-show="showContextMenu" :style="position" <div id="userMenu" class="list-group" v-show="showContextMenu" :style="position" v-if="character"
style="position:fixed;padding:10px 10px 5px;display:block;width:200px;z-index:1100" ref="menu"> style="position:fixed;padding:10px 10px 5px;display:block;width:220px;z-index:1100" ref="menu">
<div v-if="character"> <div style="min-height: 65px;padding:5px" class="list-group-item" @click.stop>
<div style="min-height: 65px;" @click.stop> <img :src="characterImage" style="width:60px;height:60px;margin-right:5px;float:left" v-if="showAvatars"/>
<img :src="characterImage" style="width: 60px; height:60px; margin-right: 5px; float: left;" v-if="showAvatars"/> <h5 style="margin:0;line-height:1">{{character.name}}</h5>
<h4 style="margin:0;">{{character.name}}</h4> {{l('status.' + character.status)}}
{{l('status.' + character.status)}}
</div>
<bbcode :text="character.statusText" @click.stop></bbcode>
<ul class="dropdown-menu border-top" role="menu"
style="display:block; position:static; border-width:1px 0 0 0; box-shadow:none; padding:0; width:100%; border-radius:0;">
<li><a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="openConversation(true)">
<span class="fa fa-fw fa-comment"></span>{{l('user.messageJump')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="openConversation(false)">
<span class="fa fa-fw fa-plus"></span>{{l('user.message')}}</a></li>
<li><a tabindex="-1" :href="profileLink" target="_blank" v-if="!showProfileFirst">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="showMemo">
<span class="fa fa-fw fa-sticky-note-o"></span>{{l('user.memo')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="setBookmarked">
<span class="fa fa-fw fa-bookmark-o"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}
</a></li>
<li><a tabindex="-1" href="#" @click.prevent="setIgnored">
<span class="fa fa-fw fa-minus-circle"></span>{{l('user.' + (character.isIgnored ? 'unignore' : 'ignore'))}}
</a></li>
<li><a tabindex="-1" href="#" @click.prevent="setHidden">
<span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}
</a></li>
<li><a tabindex="-1" href="#" @click.prevent="report">
<span class="fa fa-fw fa-exclamation-triangle"></span>{{l('user.report')}}</a></li>
<li v-show="isChannelMod"><a tabindex="-1" href="#" @click.prevent="channelKick">
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a></li>
<li v-show="isChatOp"><a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00">
<span class="fa fa-fw fa-trash-o"></span>{{l('user.chatKick')}}</a>
</li>
</ul>
</div> </div>
<bbcode :text="character.statusText" v-show="character.statusText" class="list-group-item" @click.stop></bbcode>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="openConversation(true)" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-comment"></span>{{l('user.messageJump')}}</a>
<a tabindex="-1" href="#" @click.prevent="openConversation(false)" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-plus"></span>{{l('user.message')}}</a>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="!showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="showMemo" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-sticky-note"></span>{{l('user.memo')}}</a>
<a tabindex="-1" href="#" @click.prevent="setBookmarked" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-bookmark"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}</a>
<a tabindex="-1" href="#" @click.prevent="setIgnored" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-minus-circle"></span>{{l('user.' + (character.isIgnored ? 'unignore' : 'ignore'))}}</a>
<a tabindex="-1" href="#" @click.prevent="setHidden" class="list-group-item list-group-item-action" v-show="!isChatOp">
<span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}</a>
<a tabindex="-1" href="#" @click.prevent="report" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-exclamation-triangle"></span>{{l('user.report')}}</a>
<a tabindex="-1" href="#" @click.prevent="channelKick" class="list-group-item list-group-item-action" v-show="isChannelMod">
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a>
<a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00" class="list-group-item list-group-item-action"
v-show="isChatOp"><span class="far fa-fw fa-trash"></span>{{l('user.chatKick')}}</a>
</div> </div>
<modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo"> <modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo" dialogClass="w-100">
<div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div> <div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div>
<textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea> <textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea>
</modal> </modal>
@ -65,17 +56,17 @@
export default class UserMenu extends Vue { export default class UserMenu extends Vue {
//tslint:disable:no-null-keyword //tslint:disable:no-null-keyword
@Prop({required: true}) @Prop({required: true})
readonly reportDialog: ReportDialog; readonly reportDialog!: ReportDialog;
l = l; l = l;
showContextMenu = false; showContextMenu = false;
getByteLength = getByteLength; getByteLength = getByteLength;
character: Character | null = null; character: Character | null = null;
position = {left: '', top: ''}; position = {left: '', top: ''};
characterImage: string | null = null; characterImage: string | null = null;
touchTimer: number | undefined; touchedElement: HTMLElement | undefined;
channel: Channel | null = null; channel: Channel | null = null;
memo = ''; memo = '';
memoId: number; memoId = 0;
memoLoading = false; memoLoading = false;
openConversation(jump: boolean): void { openConversation(jump: boolean): void {
@ -159,7 +150,7 @@
handleEvent(e: MouseEvent | TouchEvent): void { handleEvent(e: MouseEvent | TouchEvent): void {
const touch = e instanceof TouchEvent ? e.changedTouches[0] : e; const touch = e instanceof TouchEvent ? e.changedTouches[0] : e;
let node = <HTMLElement & {character?: Character, channel?: Channel}>touch.target; let node = <HTMLElement & {character?: Character, channel?: Channel, touched?: boolean}>touch.target;
while(node !== document.body) { while(node !== document.body) {
if(e.type !== 'click' && node === this.$refs['menu']) return; if(e.type !== 'click' && node === this.$refs['menu']) return;
if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break; if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break;
@ -170,25 +161,18 @@
if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!); if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!);
else { else {
this.showContextMenu = false; this.showContextMenu = false;
this.touchedElement = undefined;
return; return;
} }
switch(e.type) { switch(e.type) {
case 'click': case 'click':
if(node.dataset['character'] === undefined) this.onClick(node.character); if(node.dataset['character'] === undefined)
if(node === this.touchedElement) this.openMenu(touch, node.character, node.channel);
else this.onClick(node.character);
e.preventDefault(); e.preventDefault();
break; break;
case 'touchstart': case 'touchstart':
this.touchTimer = window.setTimeout(() => { this.touchedElement = node;
this.openMenu(touch, node.character!, node.channel);
this.touchTimer = undefined;
}, 500);
break;
case 'touchend':
if(this.touchTimer !== undefined) {
clearTimeout(this.touchTimer);
this.touchTimer = undefined;
if(node.dataset['character'] === undefined) this.onClick(node.character);
}
break; break;
case 'contextmenu': case 'contextmenu':
this.openMenu(touch, node.character, node.channel); this.openMenu(touch, node.character, node.channel);
@ -222,8 +206,13 @@
</script> </script>
<style> <style>
#userMenu li a { #userMenu .list-group-item {
padding: 3px 0; padding: 3px;
}
#userMenu .list-group-item-action {
border-top: 0;
z-index: -1;
} }
.user-view { .user-view {

View File

@ -4,7 +4,7 @@ import l from './localize';
export default class Socket implements WebSocketConnection { export default class Socket implements WebSocketConnection {
static host = 'wss://chat.f-list.net:9799'; static host = 'wss://chat.f-list.net:9799';
private socket: WebSocket; private socket: WebSocket;
private errorHandler: (error: Error) => void; private errorHandler: ((error: Error) => void) | undefined;
private lastHandler: Promise<void> = Promise.resolve(); private lastHandler: Promise<void> = Promise.resolve();
constructor() { constructor() {

View File

@ -1,4 +1,5 @@
import {format, isToday} from 'date-fns'; import {format, isToday} from 'date-fns';
import {Keys} from '../keys';
import {Character, Conversation, Settings as ISettings} from './interfaces'; import {Character, Conversation, Settings as ISettings} from './interfaces';
export function profileLink(this: void | never, character: string): string { export function profileLink(this: void | never, character: string): string {
@ -40,6 +41,7 @@ export class Settings implements ISettings {
logMessages = true; logMessages = true;
logAds = false; logAds = false;
fontSize = 14; fontSize = 14;
showNeedsReply = false;
} }
export class ConversationSettings implements Conversation.Settings { export class ConversationSettings implements Conversation.Settings {
@ -63,9 +65,8 @@ export function messageToString(this: void | never, msg: Conversation.Message, t
return `${text} ${msg.text}\r\n`; return `${text} ${msg.text}\r\n`;
} }
export function getKey(e: KeyboardEvent): string { export function getKey(e: KeyboardEvent): Keys {
/*tslint:disable-next-line:strict-boolean-expressions no-any*///because of old browsers. return e.keyCode;
return (e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier).toLowerCase();
} }
/*tslint:disable:no-any no-unsafe-any*///because errors can be any /*tslint:disable:no-any no-unsafe-any*///because errors can be any

View File

@ -29,10 +29,10 @@ abstract class Conversation implements Interfaces.Conversation {
lastRead: Interfaces.Message | undefined = undefined; lastRead: Interfaces.Message | undefined = undefined;
infoText = ''; infoText = '';
abstract readonly maxMessageLength: number | undefined; abstract readonly maxMessageLength: number | undefined;
_settings: Interfaces.Settings; _settings: Interfaces.Settings | undefined;
protected abstract context: CommandContext; protected abstract context: CommandContext;
protected maxMessages = 100; protected maxMessages = 100;
protected allMessages: Interfaces.Message[]; protected allMessages: Interfaces.Message[] = [];
private lastSent = ''; private lastSent = '';
constructor(readonly key: string, public _isPinned: boolean) { constructor(readonly key: string, public _isPinned: boolean) {
@ -199,7 +199,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
private chat: Interfaces.Message[] = []; private chat: Interfaces.Message[] = [];
private ads: Interfaces.Message[] = []; private ads: Interfaces.Message[] = [];
private both: Interfaces.Message[] = []; private both: Interfaces.Message[] = [];
private _mode: Channel.Mode; private _mode!: Channel.Mode;
private adEnteredText = ''; private adEnteredText = '';
private chatEnteredText = ''; private chatEnteredText = '';
private logPromise = core.logs.getBacklog(this).then((messages) => { private logPromise = core.logs.getBacklog(this).then((messages) => {
@ -220,7 +220,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
this.mode = value; this.mode = value;
if(value !== 'both') this.isSendingAds = value === 'ads'; if(value !== 'both') this.isSendingAds = value === 'ads';
}); });
this.mode = this.channel.mode; this.mode = channel.mode === 'both' && channel.id in state.modes ? state.modes[channel.id]! : channel.mode;
} }
get maxMessageLength(): number { get maxMessageLength(): number {
@ -236,6 +236,10 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
this.maxMessages = 100; this.maxMessages = 100;
this.allMessages = this[mode]; this.allMessages = this[mode];
this.messages = this.allMessages.slice(-this.maxMessages); this.messages = this.allMessages.slice(-this.maxMessages);
if(mode === this.channel.mode && this.channel.id in state.modes) delete state.modes[this.channel.id];
else if(mode !== this.channel.mode && mode !== state.modes[this.channel.id]) state.modes[this.channel.id] = mode;
else return;
state.saveModes(); //tslint:disable-line:no-floating-promises
} }
get enteredText(): string { get enteredText(): string {
@ -272,7 +276,8 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
if(message.type !== Interfaces.Message.Type.Event) { if(message.type !== Interfaces.Message.Type.Event) {
if(message.type === Interfaces.Message.Type.Warn) this.addModeMessage('ads', message); if(message.type === Interfaces.Message.Type.Warn) this.addModeMessage('ads', message);
if(core.state.settings.logMessages) await core.logs.logMessage(this, message); if(core.state.settings.logMessages) await core.logs.logMessage(this, message);
if(this !== state.selectedConversation && this.unread === Interfaces.UnreadState.None || !state.windowFocused) if(this.unread === Interfaces.UnreadState.None && (this !== state.selectedConversation || !state.windowFocused)
&& this.mode !== 'ads')
this.unread = Interfaces.UnreadState.Unread; this.unread = Interfaces.UnreadState.Unread;
} else this.addModeMessage('ads', message); } else this.addModeMessage('ads', message);
} }
@ -291,6 +296,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
protected async doSend(): Promise<void> { protected async doSend(): Promise<void> {
const isAd = this.isSendingAds; const isAd = this.isSendingAds;
if(isAd && this.adCountdown > 0) return;
core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText}); core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText});
await this.addMessage( await this.addMessage(
createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date())); createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date()));
@ -335,12 +341,13 @@ class State implements Interfaces.State {
channelConversations: ChannelConversation[] = []; channelConversations: ChannelConversation[] = [];
privateMap: {[key: string]: PrivateConversation | undefined} = {}; privateMap: {[key: string]: PrivateConversation | undefined} = {};
channelMap: {[key: string]: ChannelConversation | undefined} = {}; channelMap: {[key: string]: ChannelConversation | undefined} = {};
consoleTab: ConsoleConversation; consoleTab!: ConsoleConversation;
selectedConversation: Conversation = this.consoleTab; selectedConversation: Conversation = this.consoleTab;
recent: Interfaces.RecentConversation[] = []; recent: Interfaces.RecentConversation[] = [];
pinned: {channels: string[], private: string[]}; pinned!: {channels: string[], private: string[]};
settings: {[key: string]: Interfaces.Settings}; settings!: {[key: string]: Interfaces.Settings};
windowFocused: boolean; modes!: {[key: string]: Channel.Mode | undefined};
windowFocused = document.hasFocus();
get hasNew(): boolean { get hasNew(): boolean {
return this.privateConversations.some((x) => x.unread === Interfaces.UnreadState.Mention) || return this.privateConversations.some((x) => x.unread === Interfaces.UnreadState.Mention) ||
@ -369,6 +376,10 @@ class State implements Interfaces.State {
await core.settingsStore.set('pinned', this.pinned); await core.settingsStore.set('pinned', this.pinned);
} }
async saveModes(): Promise<void> {
await core.settingsStore.set('modes', this.modes);
}
async setSettings(key: string, value: Interfaces.Settings): Promise<void> { async setSettings(key: string, value: Interfaces.Settings): Promise<void> {
this.settings[key] = value; this.settings[key] = value;
await core.settingsStore.set('conversationSettings', this.settings); await core.settingsStore.set('conversationSettings', this.settings);
@ -402,6 +413,7 @@ class State implements Interfaces.State {
async reloadSettings(): Promise<void> { async reloadSettings(): Promise<void> {
//tslint:disable:strict-boolean-expressions //tslint:disable:strict-boolean-expressions
this.pinned = await core.settingsStore.get('pinned') || {private: [], channels: []}; this.pinned = await core.settingsStore.get('pinned') || {private: [], channels: []};
this.modes = await core.settingsStore.get('modes') || {};
for(const conversation of this.channelConversations) for(const conversation of this.channelConversations)
conversation._isPinned = this.pinned.channels.indexOf(conversation.channel.id) !== -1; conversation._isPinned = this.pinned.channels.indexOf(conversation.channel.id) !== -1;
for(const conversation of this.privateConversations) for(const conversation of this.privateConversations)
@ -433,13 +445,22 @@ function isOfInterest(this: void, character: Character): boolean {
return character.isFriend || character.isBookmarked || state.privateMap[character.name.toLowerCase()] !== undefined; return character.isFriend || character.isBookmarked || state.privateMap[character.name.toLowerCase()] !== undefined;
} }
function isOp(conv: ChannelConversation): boolean {
const ownChar = core.characters.ownCharacter;
return ownChar.isChatOp || conv.channel.members[ownChar.name]!.rank > Channel.Rank.Member;
}
export default function(this: void): Interfaces.State { export default function(this: void): Interfaces.State {
state = new State(); state = new State();
window.addEventListener('focus', () => { window.addEventListener('focus', () => {
state.windowFocused = true; state.windowFocused = true;
if(state.selectedConversation !== undefined!) state.selectedConversation.unread = Interfaces.UnreadState.None; if(state.selectedConversation !== undefined!) state.selectedConversation.unread = Interfaces.UnreadState.None;
}); });
window.addEventListener('blur', () => state.windowFocused = false); window.addEventListener('blur', () => {
state.windowFocused = false;
if(state.selectedConversation !== undefined!)
state.selectedConversation.lastRead = state.selectedConversation.messages[state.selectedConversation.messages.length - 1];
});
const connection = core.connection; const connection = core.connection;
connection.onEvent('connecting', async(isReconnect) => { connection.onEvent('connecting', async(isReconnect) => {
state.channelConversations = []; state.channelConversations = [];
@ -465,20 +486,23 @@ export default function(this: void): Interfaces.State {
state.channelConversations.push(conv); state.channelConversations.push(conv);
await state.addRecent(conv); await state.addRecent(conv);
} else { } else {
const conv = state.channelMap[channel.id]!; const conv = state.channelMap[channel.id];
if(conv === undefined) return;
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default && if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
!core.state.settings.joinMessages) return; !core.state.settings.joinMessages) return;
const text = l('events.channelJoin', `[user]${member.character.name}[/user]`); const text = l('events.channelJoin', `[user]${member.character.name}[/user]`);
await conv.addMessage(new EventMessage(text)); await conv.addMessage(new EventMessage(text));
} }
else if(member === undefined) { else if(member === undefined) {
const conv = state.channelMap[channel.id]!; const conv = state.channelMap[channel.id];
if(conv === undefined) return;
state.channelConversations.splice(state.channelConversations.indexOf(conv), 1); state.channelConversations.splice(state.channelConversations.indexOf(conv), 1);
delete state.channelMap[channel.id]; delete state.channelMap[channel.id];
await state.savePinned(); await state.savePinned();
if(state.selectedConversation === conv) state.show(state.consoleTab); if(state.selectedConversation === conv) state.show(state.consoleTab);
} else { } else {
const conv = state.channelMap[channel.id]!; const conv = state.channelMap[channel.id];
if(conv === undefined) return;
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default && if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
!core.state.settings.joinMessages) return; !core.state.settings.joinMessages) return;
const text = l('events.channelLeave', `[user]${member.character.name}[/user]`); const text = l('events.channelLeave', `[user]${member.character.name}[/user]`);
@ -495,9 +519,9 @@ export default function(this: void): Interfaces.State {
}); });
connection.onMessage('MSG', async(data, time) => { connection.onMessage('MSG', async(data, time) => {
const char = core.characters.get(data.character); const char = core.characters.get(data.character);
if(char.isIgnored) return;
const conversation = state.channelMap[data.channel.toLowerCase()]; const conversation = state.channelMap[data.channel.toLowerCase()];
if(conversation === undefined) return core.channels.leave(data.channel); if(conversation === undefined) return core.channels.leave(data.channel);
if(char.isIgnored && !isOp(conversation)) return;
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time); const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
await conversation.addMessage(message); await conversation.addMessage(message);
@ -512,20 +536,21 @@ export default function(this: void): Interfaces.State {
characterImage(data.character), 'attention'); characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention; if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
message.isHighlight = true; message.isHighlight = true;
} else if(conversation.settings.notify === Interfaces.Setting.True) } else if(conversation.settings.notify === Interfaces.Setting.True) {
core.notifications.notify(conversation, conversation.name, messageToString(message), core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention'); characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
}
}); });
connection.onMessage('LRP', async(data, time) => { connection.onMessage('LRP', async(data, time) => {
const char = core.characters.get(data.character); const char = core.characters.get(data.character);
if(char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) return;
const conv = state.channelMap[data.channel.toLowerCase()]; const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel); if(conv === undefined) return core.channels.leave(data.channel);
if((char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) && !isOp(conv)) return;
await conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time)); await conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
}); });
connection.onMessage('RLL', async(data, time) => { connection.onMessage('RLL', async(data, time) => {
const sender = core.characters.get(data.character); const sender = core.characters.get(data.character);
if(sender.isIgnored) return;
let text: string; let text: string;
if(data.type === 'bottle') if(data.type === 'bottle')
text = l('chat.bottle', `[user]${data.target}[/user]`); text = l('chat.bottle', `[user]${data.target}[/user]`);
@ -538,11 +563,17 @@ export default function(this: void): Interfaces.State {
const channel = (<{channel: string}>data).channel.toLowerCase(); const channel = (<{channel: string}>data).channel.toLowerCase();
const conversation = state.channelMap[channel]; const conversation = state.channelMap[channel];
if(conversation === undefined) return core.channels.leave(channel); if(conversation === undefined) return core.channels.leave(channel);
await conversation.addMessage(message); if(sender.isIgnored && !isOp(conversation)) return;
if(data.type === 'bottle' && data.target === core.connection.character) if(data.type === 'bottle' && data.target === core.connection.character) {
core.notifications.notify(conversation, conversation.name, messageToString(message), core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention'); characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused)
conversation.unread = Interfaces.UnreadState.Mention;
message.isHighlight = true;
}
await conversation.addMessage(message);
} else { } else {
if(sender.isIgnored) return;
const char = core.characters.get( const char = core.characters.get(
data.character === connection.character ? (<{recipient: string}>data).recipient : data.character); data.character === connection.character ? (<{recipient: string}>data).recipient : data.character);
if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character}); if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character});
@ -590,7 +621,6 @@ export default function(this: void): Interfaces.State {
conv.infoText = text; conv.infoText = text;
return addEventMessage(new EventMessage(text, time)); return addEventMessage(new EventMessage(text, time));
}); });
connection.onMessage('HLO', async(data, time) => addEventMessage(new EventMessage(data.message, time)));
connection.onMessage('BRO', async(data, time) => { connection.onMessage('BRO', async(data, time) => {
const text = data.character === undefined ? decodeHTML(data.message) : const text = data.character === undefined ? decodeHTML(data.message) :
l('events.broadcast', `[user]${data.character}[/user]`, decodeHTML(data.message.substr(data.character.length + 23))); l('events.broadcast', `[user]${data.character}[/user]`, decodeHTML(data.message.substr(data.character.length + 23)));
@ -703,10 +733,11 @@ export default function(this: void): Interfaces.State {
state.selectedConversation.infoText = data.message; state.selectedConversation.infoText = data.message;
return addEventMessage(new EventMessage(data.message, time)); return addEventMessage(new EventMessage(data.message, time));
}); });
connection.onMessage('UPT', async(data, time) => addEventMessage(new EventMessage(l('events.uptime',
data.startstring, data.channels.toString(), data.users.toString(), data.accepted.toString(), data.maxusers.toString()), time)));
connection.onMessage('ZZZ', async(data, time) => { connection.onMessage('ZZZ', async(data, time) => {
state.selectedConversation.infoText = data.message; state.selectedConversation.infoText = data.message;
return addEventMessage(new EventMessage(data.message, time)); return addEventMessage(new EventMessage(data.message, time));
}); });
//TODO connection.onMessage('UPT', data =>
return state; return state;
} }

View File

@ -18,6 +18,7 @@ export namespace Conversation {
readonly type: Message.Type.Event, readonly type: Message.Type.Event,
readonly text: string, readonly text: string,
readonly time: Date readonly time: Date
readonly sender?: undefined
} }
export interface ChatMessage { export interface ChatMessage {
@ -141,7 +142,8 @@ export namespace Settings {
export type Keys = { export type Keys = {
settings: Settings, settings: Settings,
pinned: {channels: string[], private: string[]}, pinned: {channels: string[], private: string[]},
conversationSettings: {[key: string]: Conversation.Settings} conversationSettings: {[key: string]: Conversation.Settings | undefined}
modes: {[key: string]: Channel.Mode | undefined}
recent: Conversation.RecentConversation[] recent: Conversation.RecentConversation[]
hiddenUsers: string[] hiddenUsers: string[]
}; };
@ -169,6 +171,7 @@ export namespace Settings {
readonly logMessages: boolean; readonly logMessages: boolean;
readonly logAds: boolean; readonly logAds: boolean;
readonly fontSize: number; readonly fontSize: number;
readonly showNeedsReply: boolean;
} }
} }

View File

@ -25,6 +25,9 @@ const strings: {[key: string]: string | undefined} = {
'help.report': 'How to report a user', 'help.report': 'How to report a user',
'help.changelog': 'Changelog', 'help.changelog': 'Changelog',
'fs.error': 'Error writing to disk', 'fs.error': 'Error writing to disk',
'spellchecker.add': 'Add to Dictionary',
'spellchecker.remove': 'Remove from Dictionary',
'spellchecker.noCorrections': 'No corrections available',
'window.newTab': 'New tab', 'window.newTab': 'New tab',
'title': 'F-Chat', 'title': 'F-Chat',
'version': 'Version {0}', 'version': 'Version {0}',
@ -63,7 +66,6 @@ const strings: {[key: string]: string | undefined} = {
'chat.highlight': 'mentioned {0} in {1}:\n{2}', 'chat.highlight': 'mentioned {0} in {1}:\n{2}',
'chat.roll': 'rolls {0}: {1}', 'chat.roll': 'rolls {0}: {1}',
'chat.bottle': 'spins the bottle: {0}', 'chat.bottle': 'spins the bottle: {0}',
'chat.adCountdown': 'You must wait {0}m{1}s to post another ad in this channel.',
'chat.consoleChat': 'You cannot chat here.', 'chat.consoleChat': 'You cannot chat here.',
'chat.typing.typing': '{0} is typing...', 'chat.typing.typing': '{0} is typing...',
'chat.typing.paused': '{0} has entered text.', 'chat.typing.paused': '{0} has entered text.',
@ -72,9 +74,11 @@ const strings: {[key: string]: string | undefined} = {
'chat.disconnected': 'You were disconnected from chat.\nAttempting to reconnect.', 'chat.disconnected': 'You were disconnected from chat.\nAttempting to reconnect.',
'chat.disconnected.title': 'Disconnected', 'chat.disconnected.title': 'Disconnected',
'chat.ignoreList': 'You are currently ignoring: {0}', 'chat.ignoreList': 'You are currently ignoring: {0}',
'chat.search': 'Search in messages...',
'logs.title': 'Logs', 'logs.title': 'Logs',
'logs.conversation': 'Conversation', 'logs.conversation': 'Conversation',
'logs.date': 'Date', 'logs.date': 'Date',
'logs.selectDate': 'Select a date...',
'user.profile': 'Profile', 'user.profile': 'Profile',
'user.message': 'Open conversation', 'user.message': 'Open conversation',
'user.messageJump': 'View conversation', 'user.messageJump': 'View conversation',
@ -150,7 +154,9 @@ Are you sure?`,
'settings.logMessages': 'Log messages', 'settings.logMessages': 'Log messages',
'settings.logAds': 'Log ads', 'settings.logAds': 'Log ads',
'settings.fontSize': 'Font size (experimental)', 'settings.fontSize': 'Font size (experimental)',
'settings.showNeedsReply': 'Show indicator on PM tabs with unanswered messages',
'settings.defaultHighlights': 'Use global highlight words', 'settings.defaultHighlights': 'Use global highlight words',
'settings.beta': 'Opt-in to test unstable prerelease updates',
'conversationSettings.title': 'Tab Settings', 'conversationSettings.title': 'Tab Settings',
'conversationSettings.action': 'Edit settings for {0}', 'conversationSettings.action': 'Edit settings for {0}',
'conversationSettings.default': 'Default', 'conversationSettings.default': 'Default',
@ -160,6 +166,7 @@ Are you sure?`,
'channel.mode.ads': 'Ads', 'channel.mode.ads': 'Ads',
'channel.mode.chat': 'Chat', 'channel.mode.chat': 'Chat',
'channel.mode.both': 'Both', 'channel.mode.both': 'Both',
'channel.mode.ads.countdown': 'Ads ({0}m{1}s)',
'channel.official': 'Official channel', 'channel.official': 'Official channel',
'channel.description': 'Description', 'channel.description': 'Description',
'manageChannel.open': 'Manage', 'manageChannel.open': 'Manage',
@ -219,6 +226,7 @@ Are you sure?`,
'events.channelLeave': '{0} has left the channel.', 'events.channelLeave': '{0} has left the channel.',
'events.ignore_add': 'You are now ignoring {0}\'s messages. Should they go around this by any means, please report it using the Alert Staff button.', 'events.ignore_add': 'You are now ignoring {0}\'s messages. Should they go around this by any means, please report it using the Alert Staff button.',
'events.ignore_delete': '{0} is now allowed to send you messages again.', 'events.ignore_delete': '{0} is now allowed to send you messages again.',
'events.uptime': 'Server has been running since {0}, with currently {1} channels and {2} users, a total of {3} accepted connections, and {4} maximum users.',
'commands.unknown': 'Unknown command. For a list of valid commands, please click the ? button.', 'commands.unknown': 'Unknown command. For a list of valid commands, please click the ? button.',
'commands.badContext': 'This command cannot be used here. Please use the Help (click the ? button) if you need further information.', 'commands.badContext': 'This command cannot be used here. Please use the Help (click the ? button) if you need further information.',
'commands.tooFewParams': 'This command requires more parameters. Please use the Help (click the ? button) if you need further information.', 'commands.tooFewParams': 'This command requires more parameters. Please use the Help (click the ? button) if you need further information.',
@ -267,7 +275,7 @@ Are you sure?`,
'commands.makeroom.param0': 'Room name', 'commands.makeroom.param0': 'Room name',
'commands.makeroom.param0.help': 'A name for your new private room. Must be 1-64 in length.', 'commands.makeroom.param0.help': 'A name for your new private room. Must be 1-64 in length.',
'commands.ignore': 'Ignore a character', 'commands.ignore': 'Ignore a character',
'commands.ignore.help': 'Ignores the given character, and discards all of their messages.', 'commands.ignore.help': 'Ignores the given character, and discards all of their messages, except in channels where you are a moderator.',
'commands.unignore': 'Unignore a character', 'commands.unignore': 'Unignore a character',
'commands.unignore.help': 'Removes the given character from your ignore list, and allows them to send you messages again.', 'commands.unignore.help': 'Removes the given character from your ignore list, and allows them to send you messages again.',
'commands.ignorelist': 'Ignore list', 'commands.ignorelist': 'Ignore list',

View File

@ -8,6 +8,7 @@ export default class Notifications implements Interface {
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void { notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return; if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
if(core.characters.ownCharacter.status === 'dnd') return;
this.playSound(sound); this.playSound(sound);
if(core.state.settings.notifications) { if(core.state.settings.notifications) {
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive /*tslint:disable-next-line:no-object-literal-type-assertion*///false positive

View File

@ -1,11 +1,13 @@
import Axios from 'axios'; import Axios from 'axios';
import Vue from 'vue'; import Vue from 'vue';
import Editor from '../bbcode/Editor.vue';
import {InlineDisplayMode} from '../bbcode/interfaces'; import {InlineDisplayMode} from '../bbcode/interfaces';
import {initParser, standardParser} from '../bbcode/standard'; import {initParser, standardParser} from '../bbcode/standard';
import CharacterLink from '../components/character_link.vue'; import CharacterLink from '../components/character_link.vue';
import CharacterSelect from '../components/character_select.vue'; import CharacterSelect from '../components/character_select.vue';
import {setCharacters} from '../components/character_select/character_list'; import {setCharacters} from '../components/character_select/character_list';
import DateDisplay from '../components/date_display.vue'; import DateDisplay from '../components/date_display.vue';
import SimplePager from '../components/simple_pager.vue';
import {registerMethod, Store} from '../site/character_page/data_store'; import {registerMethod, Store} from '../site/character_page/data_store';
import { import {
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterKink, Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterKink,
@ -115,7 +117,7 @@ async function fieldsGet(): Promise<void> {
validator: oldInfotag.list, validator: oldInfotag.list,
search_field: '', search_field: '',
allow_legacy: true, allow_legacy: true,
infotag_group: parseInt(oldInfotag.group_id, 10) infotag_group: oldInfotag.group_id
}; };
} }
for(const id in fields.listitems) { for(const id in fields.listitems) {
@ -175,6 +177,8 @@ export function init(characters: {[key: string]: number}): void {
Vue.component('character-select', CharacterSelect); Vue.component('character-select', CharacterSelect);
Vue.component('character-link', CharacterLink); Vue.component('character-link', CharacterLink);
Vue.component('date-display', DateDisplay); Vue.component('date-display', DateDisplay);
Vue.component('simple-pager', SimplePager);
Vue.component('bbcode-editor', Editor);
setCharacters(Object.keys(characters).map((name) => ({name, id: characters[name]}))); setCharacters(Object.keys(characters).map((name) => ({name, id: characters[name]})));
core.connection.onEvent('connecting', () => { core.connection.onEvent('connecting', () => {
Utils.Settings.defaultCharacter = characters[core.connection.character]; Utils.Settings.defaultCharacter = characters[core.connection.character];

View File

@ -9,21 +9,21 @@ import {Channel, Character} from './interfaces';
export function getStatusIcon(status: Character.Status): string { export function getStatusIcon(status: Character.Status): string {
switch(status) { switch(status) {
case 'online': case 'online':
return 'fa-user-o'; return 'far fa-user';
case 'looking': case 'looking':
return 'fa-eye'; return 'fa fa-eye';
case 'dnd': case 'dnd':
return 'fa-minus-circle'; return 'fa fa-minus-circle';
case 'offline': case 'offline':
return 'fa-ban'; return 'fa fa-ban';
case 'away': case 'away':
return 'fa-circle-o'; return 'far fa-circle';
case 'busy': case 'busy':
return 'fa-cog'; return 'fa fa-cog';
case 'idle': case 'idle':
return 'fa-clock-o'; return 'far fa-clock';
case 'crown': case 'crown':
return 'fa-birthday-cake'; return 'fa fa-birthday-cake';
} }
} }
@ -35,21 +35,18 @@ const UserView = Vue.extend({
context !== undefined ? context.props : (<Vue>this).$options.propsData); context !== undefined ? context.props : (<Vue>this).$options.propsData);
const character = props.character; const character = props.character;
let rankIcon; let rankIcon;
if(character.isChatOp) rankIcon = 'fa-diamond'; if(character.isChatOp) rankIcon = 'far fa-gem';
else if(props.channel !== undefined) { else if(props.channel !== undefined)
const member = props.channel.members[character.name]; rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ?
if(member !== undefined) (props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : '';
rankIcon = member.rank === Channel.Rank.Owner ? 'fa-asterisk' : else rankIcon = '';
member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-star') : '';
else rankIcon = '';
} else rankIcon = '';
const html = (props.showStatus !== undefined || character.status === 'crown' const html = (props.showStatus !== undefined || character.status === 'crown'
? `<span class="fa fa-fw ${getStatusIcon(character.status)}"></span>` : '') + ? `<span class="fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
(rankIcon !== '' ? `<span class="fa ${rankIcon}"></span>` : '') + character.name; (rankIcon !== '' ? `<span class="${rankIcon}"></span>` : '') + character.name;
return createElement('span', { return createElement('span', {
attrs: {class: `user-view gender-${character.gender !== undefined ? character.gender.toLowerCase() : 'none'}`}, attrs: {class: `user-view gender-${character.gender !== undefined ? character.gender.toLowerCase() : 'none'}`},
domProps: {character, channel: props.channel, innerHTML: html} domProps: {character, channel: props.channel, innerHTML: html, bbcodeTag: 'user'}
}); });
} }
}); });

23
components/Dropdown.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
@blur="isOpen = false" style="width:100%;text-align:left;display:flex;align-items:center">
<div style="flex:1">
<slot name="title" style="flex:1"></slot>
</div>
</button>
<div class="dropdown-menu" :style="isOpen ? 'display:block' : ''" @mousedown.stop.prevent @click="isOpen = false">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
@Component
export default class Dropdown extends Vue {
isOpen = false;
}
</script>

View File

@ -1,59 +1,50 @@
<template> <template>
<div class="dropdown filterable-select"> <dropdown class="dropdown filterable-select">
<button class="btn btn-default dropdown-toggle" :class="buttonClass" data-toggle="dropdown"> <template slot="title" v-if="multiple">{{label}}</template>
<span style="flex:1"> <slot v-else slot="title" :option="selected">{{label}}</slot>
<template v-if="multiple">{{label}}</template>
<slot v-else :option="selected">{{label}}</slot> <div style="padding:10px;">
</span> <input v-model="filter" class="form-control" :placeholder="placeholder"/>
<span class="caret" style="align-self:center;margin-left:5px"></span>
</button>
<div class="dropdown-menu filterable-select" @click.stop>
<div style="padding:10px;">
<input v-model="filter" class="form-control" :placeholder="placeholder"/>
</div>
<ul class="dropdown-menu">
<template v-if="multiple">
<li v-for="option in filtered">
<a href="#" @click.stop="select(option)">
<input type="checkbox" :checked="selected.indexOf(option) !== -1"/>
<slot :option="option">{{option}}</slot>
</a>
</li>
</template>
<template v-else>
<li v-for="option in filtered">
<a href="#" @click="select(option)">
<slot :option="option">{{option}}</slot>
</a>
</li>
</template>
</ul>
</div> </div>
</div> <div class="dropdown-items">
<template v-if="multiple">
<a href="#" @click.stop="select(option)" v-for="option in filtered" class="dropdown-item">
<input type="checkbox" :checked="selected.indexOf(option) !== -1"/>
<slot :option="option">{{option}}</slot>
</a>
</template>
<template v-else>
<a href="#" @click="select(option)" v-for="option in filtered" class="dropdown-item">
<slot :option="option">{{option}}</slot>
</a>
</template>
</div>
</dropdown>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator'; import {Prop, Watch} from 'vue-property-decorator';
import Dropdown from '../components/Dropdown.vue';
@Component @Component({
components: {dropdown: Dropdown}
})
export default class FilterableSelect extends Vue { export default class FilterableSelect extends Vue {
//tslint:disable:no-null-keyword //tslint:disable:no-null-keyword
@Prop() @Prop()
readonly placeholder?: string; readonly placeholder?: string;
@Prop({required: true}) @Prop({required: true})
readonly options: object[]; readonly options!: object[];
@Prop({default: () => ((filter: RegExp, value: string) => filter.test(value))}) @Prop({default: () => ((filter: RegExp, value: string) => filter.test(value))})
readonly filterFunc: (filter: RegExp, value: object) => boolean; readonly filterFunc!: (filter: RegExp, value: object) => boolean;
@Prop() @Prop()
readonly multiple?: true; readonly multiple?: true;
@Prop() @Prop()
readonly value?: object | object[]; readonly value?: object | object[];
@Prop() @Prop()
readonly title?: string; readonly title?: string;
@Prop()
readonly buttonClass?: string;
filter = ''; filter = '';
selected: object | object[] | null = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : null); selected: object | object[] | null = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : null);
@ -68,10 +59,7 @@
const index = selected.indexOf(item); const index = selected.indexOf(item);
if(index === -1) selected.push(item); if(index === -1) selected.push(item);
else selected.splice(index, 1); else selected.splice(index, 1);
} else { } else this.selected = item;
this.selected = item;
$('.dropdown-toggle', this.$el).dropdown('toggle');
}
this.$emit('input', this.selected); this.$emit('input', this.selected);
} }
@ -90,17 +78,11 @@
} }
</script> </script>
<style lang="less"> <style lang="scss">
.filterable-select { .filterable-select {
ul.dropdown-menu { .dropdown-items {
padding: 0;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
position: static;
display: block;
border: 0;
box-shadow: none;
width: 100%;
} }
button { button {
display: flex; display: flex;

View File

@ -1,20 +1,19 @@
<template> <template>
<span v-show="isShown"> <span v-show="isShown">
<div tabindex="-1" class="modal flex-modal" @click.self="hideWithCheck" <div tabindex="-1" class="modal" @click.self="hideWithCheck" style="display:flex">
style="align-items:flex-start;padding:30px;justify-content:center;display:flex"> <div class="modal-dialog" :class="dialogClass" style="display:flex;align-items:center">
<div class="modal-dialog" :class="dialogClass" style="display:flex;flex-direction:column;max-height:100%;margin:0"> <div class="modal-content" style="max-height:100%">
<div class="modal-content" style="display:flex;flex-direction:column;flex-grow:1">
<div class="modal-header" style="flex-shrink:0"> <div class="modal-header" style="flex-shrink:0">
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">&times;</button>
<h4 class="modal-title"> <h4 class="modal-title">
<slot name="title">{{action}}</slot> <slot name="title">{{action}}</slot>
</h4> </h4>
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">&times;</button>
</div> </div>
<div class="modal-body" style="overflow: auto; display: flex; flex-direction: column"> <div class="modal-body" style="overflow:auto">
<slot></slot> <slot></slot>
</div> </div>
<div class="modal-footer" v-if="buttons"> <div class="modal-footer" v-if="buttons">
<button type="button" class="btn btn-default" @click="hideWithCheck" v-if="showCancel">Cancel</button> <button type="button" class="btn btn-secondary" @click="hideWithCheck" v-if="showCancel">Cancel</button>
<button type="button" class="btn" :class="buttonClass" @click="submit" :disabled="disabled"> <button type="button" class="btn" :class="buttonClass" @click="submit" :disabled="disabled">
{{submitText}} {{submitText}}
</button> </button>
@ -31,26 +30,34 @@
import Component from 'vue-class-component'; import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator'; import {Prop} from 'vue-property-decorator';
import {getKey} from '../chat/common'; import {getKey} from '../chat/common';
import {Keys} from '../keys';
const dialogStack: Modal[] = []; const dialogStack: Modal[] = [];
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if(getKey(e) === 'escape' && dialogStack.length > 0) dialogStack.pop()!.isShown = false; if(getKey(e) === Keys.Escape && dialogStack.length > 0) dialogStack[dialogStack.length - 1].hideWithCheck();
}); });
window.addEventListener('backbutton', (e) => {
if(dialogStack.length > 0) {
e.stopPropagation();
e.preventDefault();
dialogStack.pop()!.isShown = false;
}
}, true);
@Component @Component
export default class Modal extends Vue { export default class Modal extends Vue {
@Prop({default: ''}) @Prop({default: ''})
readonly action: string; readonly action!: string;
@Prop() @Prop()
readonly dialogClass?: {string: boolean}; readonly dialogClass?: {string: boolean};
@Prop({default: true}) @Prop({default: true})
readonly buttons: boolean; readonly buttons!: boolean;
@Prop({default: () => ({'btn-primary': true})}) @Prop({default: () => ({'btn-primary': true})})
readonly buttonClass: {string: boolean}; readonly buttonClass!: {string: boolean};
@Prop() @Prop()
readonly disabled?: boolean; readonly disabled?: boolean;
@Prop({default: true}) @Prop({default: true})
readonly showCancel: boolean; readonly showCancel!: boolean;
@Prop() @Prop()
readonly buttonText?: string; readonly buttonText?: string;
isShown = false; isShown = false;
@ -79,37 +86,11 @@
dialogStack.pop(); dialogStack.pop();
} }
private hideWithCheck(): void { hideWithCheck(): void {
if(this.keepOpen) return; if(this.keepOpen) return;
this.hide(); this.hide();
} }
fixDropdowns(): void {
//tslint:disable-next-line:no-this-assignment
const vm = this;
$('.dropdown', this.$el).on('show.bs.dropdown', function(this: HTMLElement & {menu?: HTMLElement}): void {
if(this.menu !== undefined) {
this.menu.style.display = 'block';
return;
}
const $this = $(this).children('.dropdown-menu');
this.menu = $this[0];
vm.$nextTick(() => {
const offset = $this.offset();
if(offset === undefined) return;
$('body').append($this.css({
display: 'block',
left: offset.left,
position: 'absolute',
top: offset.top,
'z-index': 1100
}).detach());
});
}).on('hide.bs.dropdown', function(this: HTMLElement & {menu: HTMLElement}): void {
this.menu.style.display = 'none';
});
}
beforeDestroy(): void { beforeDestroy(): void {
if(this.isShown) this.hide(); if(this.isShown) this.hide();
} }

View File

@ -14,7 +14,7 @@
@Component @Component
export default class CharacterLink extends Vue { export default class CharacterLink extends Vue {
@Prop({required: true}) @Prop({required: true})
readonly character: {name: string, id: number, deleted: boolean} | string; readonly character!: {name: string, id: number, deleted: boolean} | string;
get deleted(): boolean { get deleted(): boolean {
return typeof(this.character) === 'string' ? false : this.character.deleted; return typeof(this.character) === 'string' ? false : this.character.deleted;

View File

@ -19,7 +19,7 @@
@Component @Component
export default class CharacterSelect extends Vue { export default class CharacterSelect extends Vue {
@Prop({required: true, type: Number}) @Prop({required: true, type: Number})
readonly value: number; readonly value!: number;
get characters(): SelectItem[] { get characters(): SelectItem[] {
const characterList = getCharacters(); const characterList = getCharacters();

View File

@ -12,9 +12,9 @@
@Component @Component
export default class DateDisplay extends Vue { export default class DateDisplay extends Vue {
@Prop({required: true}) @Prop({required: true})
readonly time: string | null | number; readonly time!: string | null | number;
primary: string; primary: string | undefined;
secondary: string; secondary: string | undefined;
constructor(options?: ComponentOptions<Vue>) { constructor(options?: ComponentOptions<Vue>) {
super(options); super(options);

View File

@ -1,46 +0,0 @@
<template>
<div class="form-group" :class="allClasses">
<slot></slot>
<div :class="classes" v-if="hasErrors">
<ul>
<li v-for="error in errorList">{{ error }}</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormErrors extends Vue {
@Prop({required: true})
readonly errors: {[key: string]: string[] | undefined};
@Prop({required: true})
readonly field: string;
@Prop({default: 'col-xs-3'})
readonly classes: string;
@Prop()
readonly extraClasses?: {[key: string]: boolean};
get hasErrors(): boolean {
return typeof this.errors[this.field] !== 'undefined';
}
get errorList(): string[] {
return this.errors[this.field] !== undefined ? this.errors[this.field]! : [];
}
get allClasses(): {[key: string]: boolean} {
const classes: {[key: string]: boolean} = {'hash-error': this.hasErrors};
if(this.extraClasses === undefined) return classes;
for(const key in this.extraClasses)
classes[key] = this.extraClasses[key];
return classes;
}
}
</script>

49
components/form_group.vue Normal file
View File

@ -0,0 +1,49 @@
<template>
<div class="form-group">
<label v-if="label && id" :for="id">{{ label }}</label>
<slot :cls="{'is-invalid': hasErrors, 'is-valid': valid}" :invalid="hasErrors" :valid="valid"></slot>
<small v-if="helptext" class="form-text" :id="helpId">{{ helptext }}</small>
<div v-if="hasErrors" class="invalid-feedback">
<ul v-if="errorList.length > 1">
<li v-for="error in errorList">{{ error }}</li>
</ul>
<template v-if="errorList.length === 1"> {{ errorList[0] }}</template>
</div>
<slot v-if="valid" name="valid"></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormGroup extends Vue {
@Prop({required: true})
readonly field!: string;
@Prop({required: true})
readonly errors!: {[key: string]: ReadonlyArray<string> | undefined};
@Prop()
readonly label?: string;
@Prop()
readonly id?: string;
@Prop({default: false})
readonly valid!: boolean;
@Prop()
readonly helptext?: string;
get hasErrors(): boolean {
return typeof this.errors[this.field] !== 'undefined';
}
get errorList(): ReadonlyArray<string> {
return this.errors[this.field] || []; //tslint:disable-line:strict-boolean-expressions
}
get helpId(): string | undefined {
return this.id !== undefined ? `${this.id}Help` : undefined;
}
}
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="form-group">
<label v-if="label && id" :for="id">{{ label }}</label>
<div class="input-group">
<slot :cls="{'is-invalid': hasErrors, 'is-valid': valid}"></slot>
<slot name="button"></slot>
<small v-if="helptext" class="form-text" :id="helpId">{{ helptext }}</small>
<div v-if="hasErrors" class="invalid-feedback">
<ul v-if="errorList.length > 1">
<li v-for="error in errorList">{{ error }}</li>
</ul>
<template v-if="errorList.length === 1"> {{ errorList[0] }}</template>
</div>
<slot v-if="valid" name="valid"></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormGroupInputgroup extends Vue {
@Prop({required: true})
readonly field!: string;
@Prop({required: true})
readonly errors!: {[key: string]: ReadonlyArray<string> | undefined};
@Prop()
readonly label?: string;
@Prop()
readonly id?: string;
@Prop({default: false})
readonly valid!: boolean;
@Prop()
readonly helptext?: string;
get hasErrors(): boolean {
return typeof this.errors[this.field] !== 'undefined';
}
get errorList(): ReadonlyArray<string> {
return this.errors[this.field] || []; //tslint:disable-line:strict-boolean-expressions
}
get helpId(): string | undefined {
return this.id !== undefined ? `${this.id}Help` : undefined;
}
}
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="d-flex w-100 my-2 justify-content-between">
<div>
<slot name="previous" v-if="!routed">
<a class="btn btn-secondary" :class="{'disabled': !prev}" role="button" @click.prevent="previousPage">
<span aria-hidden="true">&larr;</span> {{prevLabel}}
</a>
</slot>
<router-link v-if="routed" :to="prevRoute" class="btn btn-secondary" :class="{'disabled': !prev}" role="button">
<span aria-hidden="true">&larr;</span> {{prevLabel}}
</router-link>
</div>
<div>
<slot name="next" v-if="!routed">
<a class="btn btn-secondary" :class="{'disabled': !next}" role="button" @click.prevent="nextPage">
{{nextLabel}} <span aria-hidden="true">&rarr;</span>
</a>
</slot>
<router-link v-if="routed" :to="nextRoute" class="btn btn-secondary" :class="{'disabled': !next}" role="button">
{{nextLabel}} <span aria-hidden="true">&rarr;</span>
</router-link>
</div>
</div>
</template>
<script lang="ts">
import cloneDeep = require('lodash/cloneDeep'); //tslint:disable-line:no-require-imports
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
type ParamDictionary = {[key: string]: number | undefined};
interface RouteParams {
name?: string
params?: ParamDictionary
}
@Component
export default class SimplePager extends Vue {
@Prop({default: 'Next Page'})
readonly nextLabel!: string;
@Prop({default: 'Previous Page'})
readonly prevLabel!: string;
@Prop({required: true})
readonly next!: boolean;
@Prop({required: true})
readonly prev!: boolean;
@Prop({default: false})
readonly routed!: boolean;
@Prop({default: () => ({})})
readonly route!: RouteParams;
@Prop({default: 'page'})
readonly paramName!: string;
nextPage(): void {
if(!this.next)
return;
this.$emit('next');
}
previousPage(): void {
if(!this.prev)
return;
this.$emit('prev');
}
get prevRoute(): RouteParams {
if(this.route.params !== undefined && this.route.params[this.paramName] !== undefined) {
const newPage = this.route.params[this.paramName]! - 1;
const clone = cloneDeep(this.route);
clone.params![this.paramName] = newPage;
return clone;
}
return {};
}
get nextRoute(): RouteParams {
if(this.route.params !== undefined && this.route.params[this.paramName] !== undefined) {
const newPage = this.route.params[this.paramName]! + 1;
const clone = cloneDeep(this.route);
clone.params![this.paramName] = newPage;
return clone;
}
return {};
}
}
</script>

27
components/tabs.ts Normal file
View File

@ -0,0 +1,27 @@
import Vue, {CreateElement, VNode} from 'vue';
//tslint:disable-next-line:variable-name
const Tabs = Vue.extend({
props: ['value', 'tabs'],
render(this: Vue & {readonly value?: string, tabs: {readonly [key: string]: string}}, createElement: CreateElement): VNode {
let children: {[key: string]: string | VNode | undefined};
if(<VNode[] | undefined>this.$slots['default'] !== undefined) {
children = {};
this.$slots['default'].forEach((child, i) => {
if(child.context !== undefined) children[child.key !== undefined ? child.key : i] = child;
});
} else children = this.tabs;
const keys = Object.keys(children);
if(this.value === undefined || children[this.value] === undefined) this.$emit('input', keys[0]);
return createElement('ul', {staticClass: 'nav nav-tabs'}, keys.map((key) => createElement('li', {staticClass: 'nav-item'},
[createElement('a', {
staticClass: 'nav-link', class: {active: this.value === key}, on: {
click: () => {
this.$emit('input', key);
}
}
}, [children[key]!])])));
}
});
export default Tabs;

View File

@ -1,34 +1,36 @@
<template> <template>
<div @mouseover="onMouseOver" id="page" style="position:relative;padding:5px 10px 10px"> <div @mouseover="onMouseOver" id="page" style="position:relative;padding:5px 10px 10px" @auxclick.prevent>
<div v-html="styling"></div> <div v-html="styling"></div>
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;"> <div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
<div class="well well-lg" style="width: 400px;"> <div class="card bg-light" style="width: 400px;">
<h3 style="margin-top:0">{{l('title')}}</h3> <h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<div class="alert alert-danger" v-show="error"> <div class="card-body">
{{error}} <div class="alert alert-danger" v-show="error">
</div> {{error}}
<div class="form-group"> </div>
<label class="control-label" for="account">{{l('login.account')}}</label> <div class="form-group">
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/> <label class="control-label" for="account">{{l('login.account')}}</label>
</div> <input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
<div class="form-group"> </div>
<label class="control-label" for="password">{{l('login.password')}}</label> <div class="form-group">
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login"/> <label class="control-label" for="password">{{l('login.password')}}</label>
</div> <input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login" :disabled="loggingIn"/>
<div class="form-group" v-show="showAdvanced"> </div>
<label class="control-label" for="host">{{l('login.host')}}</label> <div class="form-group" v-show="showAdvanced">
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/> <label class="control-label" for="host">{{l('login.host')}}</label>
</div> <input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
<div class="form-group"> </div>
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label> <div class="form-group">
</div> <label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
<div class="form-group"> </div>
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label> <div class="form-group">
</div> <label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
<div class="form-group text-right" style="margin:0"> </div>
<button class="btn btn-primary" @click="login" :disabled="loggingIn"> <div class="form-group" style="margin:0;text-align:right">
{{l(loggingIn ? 'login.working' : 'login.submit')}} <button class="btn btn-primary" @click="login" :disabled="loggingIn">
</button> {{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -42,7 +44,8 @@
</modal> </modal>
<modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer"> <modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="true" :oldApi="true" :name="profileName" :image-preview="true"></character-page> <character-page :authenticated="true" :oldApi="true" :name="profileName" :image-preview="true"></character-page>
<template slot="title">{{profileName}} <a class="btn fa fa-external-link" @click="openProfileInBrowser"></a></template> <template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
</template>
</modal> </modal>
</div> </div>
</template> </template>
@ -50,6 +53,7 @@
<script lang="ts"> <script lang="ts">
import Axios from 'axios'; import Axios from 'axios';
import * as electron from 'electron'; import * as electron from 'electron';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as qs from 'querystring'; import * as qs from 'querystring';
@ -71,15 +75,10 @@
import * as SlimcatImporter from './importer'; import * as SlimcatImporter from './importer';
import Notifications from './notifications'; import Notifications from './notifications';
declare module '../chat/interfaces' {
interface State {
generalSettings?: GeneralSettings
}
}
const webContents = electron.remote.getCurrentWebContents(); const webContents = electron.remote.getCurrentWebContents();
const parent = electron.remote.getCurrentWindow().webContents; const parent = electron.remote.getCurrentWindow().webContents;
log.info('About to load keytar');
/*tslint:disable:no-any*///because this is hacky /*tslint:disable:no-any*///because this is hacky
const keyStore = nativeRequire<{ const keyStore = nativeRequire<{
getPassword(account: string): Promise<string> getPassword(account: string): Promise<string>
@ -89,6 +88,7 @@
}>('keytar/build/Release/keytar.node'); }>('keytar/build/Release/keytar.node');
for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat')); for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
//tslint:enable //tslint:enable
log.info('Loaded keytar.');
@Component({ @Component({
components: {chat: Chat, modal: Modal, characterPage: CharacterPage} components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
@ -104,7 +104,7 @@
error = ''; error = '';
defaultCharacter: string | null = null; defaultCharacter: string | null = null;
l = l; l = l;
settings: GeneralSettings; settings!: GeneralSettings;
importProgress = 0; importProgress = 0;
profileName = ''; profileName = '';
@ -115,7 +115,8 @@
Vue.set(core.state, 'generalSettings', this.settings); Vue.set(core.state, 'generalSettings', this.settings);
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => core.state.generalSettings = this.settings = settings); electron.ipcRenderer.on('settings',
(_: Event, settings: GeneralSettings) => core.state.generalSettings = this.settings = settings);
electron.ipcRenderer.on('open-profile', (_: Event, name: string) => { electron.ipcRenderer.on('open-profile', (_: Event, name: string) => {
const profileViewer = <Modal>this.$refs['profileViewer']; const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = name; this.profileName = name;

View File

@ -3,27 +3,31 @@
<div v-html="styling"></div> <div v-html="styling"></div>
<div style="display:flex;align-items:stretch;" class="border-bottom" id="window-tabs"> <div style="display:flex;align-items:stretch;" class="border-bottom" id="window-tabs">
<h4>F-Chat</h4> <h4>F-Chat</h4>
<div :class="'fa fa-cog btn btn-' + (hasUpdate ? 'warning' : 'default')" @click="openMenu"></div> <div class="btn" :class="'btn-' + (hasUpdate ? 'warning' : 'light')" @click="openMenu">
<i class="fa fa-cog"></i>
</div>
<ul class="nav nav-tabs" style="border-bottom:0" ref="tabs"> <ul class="nav nav-tabs" style="border-bottom:0" ref="tabs">
<li role="presentation" :class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}" v-for="tab in tabs" <li v-for="tab in tabs" :key="tab.view.id" class="nav-item">
:key="tab.view.id"> <a href="#" @click.prevent="show(tab)" class="nav-link"
<a href="#" @click.prevent="show(tab)"> :class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}">
<img v-if="tab.user" :src="'https://static.f-list.net/images/avatar/' + tab.user.toLowerCase() + '.png'"/> <img v-if="tab.user" :src="'https://static.f-list.net/images/avatar/' + tab.user.toLowerCase() + '.png'"/>
{{tab.user || l('window.newTab')}} {{tab.user || l('window.newTab')}}
<a href="#" class="fa fa-close btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit" <a href="#" class="btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit"
@click.stop="remove(tab)"> @click.stop="remove(tab)"><i class="fa fa-times"></i>
</a> </a>
</a> </a>
</li> </li>
<li role="presentation" v-show="canOpenTab" class="addTab" id="addTab"> <li v-show="canOpenTab" class="addTab nav-item" id="addTab">
<a href="#" @click.prevent="addTab" class="fa fa-plus"></a> <a href="#" @click.prevent="addTab" class="nav-link"><i class="fa fa-plus"></i></a>
</li> </li>
</ul> </ul>
<div style="flex:1;display:flex;justify-content:flex-end;-webkit-app-region:drag;margin-top:3px" class="btn-group" <div style="flex:1;display:flex;justify-content:flex-end;-webkit-app-region:drag;margin-top:3px" class="btn-group"
id="windowButtons"> id="windowButtons">
<span class="fa fa-window-minimize btn btn-default" @click.stop="minimize"></span> <i class="far fa-window-minimize btn btn-light" @click.stop="minimize"></i>
<span :class="'fa btn btn-default fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></span> <i class="far btn btn-light" :class="'fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></i>
<span class="fa fa-close fa-lg btn btn-default" @click.stop="close"></span> <span class="btn btn-light" @click.stop="close">
<i class="fa fa-times fa-lg"></i>
</span>
</div> </div>
</div> </div>
</div> </div>
@ -61,7 +65,7 @@
@Component @Component
export default class Window extends Vue { export default class Window extends Vue {
//tslint:disable:no-null-keyword //tslint:disable:no-null-keyword
settings: GeneralSettings; settings!: GeneralSettings;
tabs: Tab[] = []; tabs: Tab[] = [];
activeTab: Tab | null = null; activeTab: Tab | null = null;
tabMap: {[key: number]: Tab} = {}; tabMap: {[key: number]: Tab} = {};
@ -77,6 +81,7 @@
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow); electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
electron.ipcRenderer.on('open-tab', () => this.addTab()); electron.ipcRenderer.on('open-tab', () => this.addTab());
electron.ipcRenderer.on('update-available', () => this.hasUpdate = true); electron.ipcRenderer.on('update-available', () => this.hasUpdate = true);
electron.ipcRenderer.on('quit', () => this.tabs.forEach((tab) => this.remove(tab, false)));
electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => { electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => {
const tab = this.tabMap[id]; const tab = this.tabMap[id];
tab.user = name; tab.user = name;
@ -87,6 +92,10 @@
}); });
electron.ipcRenderer.on('disconnect', (_: Event, id: number) => { electron.ipcRenderer.on('disconnect', (_: Event, id: number) => {
const tab = this.tabMap[id]; const tab = this.tabMap[id];
if(tab.hasNew) {
tab.hasNew = false;
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
}
tab.user = undefined; tab.user = undefined;
tab.tray.setToolTip(l('title')); tab.tray.setToolTip(l('title'));
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab))); tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
@ -186,6 +195,7 @@
remove(tab: Tab, shouldConfirm: boolean = true): void { remove(tab: Tab, shouldConfirm: boolean = true): void {
if(shouldConfirm && tab.user !== undefined && !confirm(l('chat.confirmLeave'))) return; if(shouldConfirm && tab.user !== undefined && !confirm(l('chat.confirmLeave'))) return;
this.tabs.splice(this.tabs.indexOf(tab), 1); this.tabs.splice(this.tabs.indexOf(tab), 1);
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
delete this.tabMap[tab.view.webContents.id]; delete this.tabMap[tab.view.webContents.id];
tab.tray.destroy(); tab.tray.destroy();
tab.view.webContents.loadURL('about:blank'); tab.view.webContents.loadURL('about:blank');
@ -210,12 +220,12 @@
} }
openMenu(): void { openMenu(): void {
electron.remote.Menu.getApplicationMenu().popup(); electron.remote.Menu.getApplicationMenu()!.popup();
} }
} }
</script> </script>
<style lang="less"> <style lang="scss">
#window-tabs { #window-tabs {
user-select: none; user-select: none;
.btn { .btn {

View File

@ -1,6 +1,6 @@
{ {
"name": "fchat", "name": "fchat",
"version": "0.2.16", "version": "0.2.17",
"author": "The F-List Team", "author": "The F-List Team",
"description": "F-List.net Chat Client", "description": "F-List.net Chat Client",
"main": "main.js", "main": "main.js",

View File

@ -29,11 +29,8 @@
* @version 3.0 * @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo} * @see {@link https://github.com/f-list/exported|GitHub repo}
*/ */
import 'bootstrap/js/collapse.js';
import 'bootstrap/js/dropdown.js';
import 'bootstrap/js/tab.js';
import 'bootstrap/js/transition.js';
import * as electron from 'electron'; import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as qs from 'querystring'; import * as qs from 'querystring';
import * as Raven from 'raven-js'; import * as Raven from 'raven-js';
@ -41,12 +38,13 @@ import Vue from 'vue';
import {getKey} from '../chat/common'; import {getKey} from '../chat/common';
import l from '../chat/localize'; import l from '../chat/localize';
import VueRaven from '../chat/vue-raven'; import VueRaven from '../chat/vue-raven';
import {Keys} from '../keys';
import {GeneralSettings, nativeRequire} from './common'; import {GeneralSettings, nativeRequire} from './common';
import * as SlimcatImporter from './importer'; import * as SlimcatImporter from './importer';
import Index from './Index.vue'; import Index from './Index.vue';
document.addEventListener('keydown', (e: KeyboardEvent) => { document.addEventListener('keydown', (e: KeyboardEvent) => {
if(e.ctrlKey && e.shiftKey && getKey(e) === 'i') if(e.ctrlKey && e.shiftKey && getKey(e) === Keys.KeyI)
electron.remote.getCurrentWebContents().toggleDevTools(); electron.remote.getCurrentWebContents().toggleDevTools();
}); });
@ -54,8 +52,10 @@ process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
const sc = nativeRequire<{ const sc = nativeRequire<{
Spellchecker: { Spellchecker: {
new(): { new(): {
isMisspelled(x: string): boolean, add(word: string): void
setDictionary(name: string | undefined, dir: string): void, remove(word: string): void
isMisspelled(x: string): boolean
setDictionary(name: string | undefined, dir: string): void
getCorrectionsForMisspelling(word: string): ReadonlyArray<string> getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
} }
} }
@ -122,14 +122,30 @@ webContents.on('context-menu', (_, props) => {
}); });
if(props.misspelledWord !== '') { if(props.misspelledWord !== '') {
const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord); const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
if(corrections.length > 0) { menuTemplate.unshift({
menuTemplate.unshift({type: 'separator'}); label: l('spellchecker.add'),
click: () => {
if(customDictionary.indexOf(props.misspelledWord) !== -1) return;
spellchecker.add(props.misspelledWord);
customDictionary.push(props.misspelledWord);
fs.writeFile(customDictionaryPath, JSON.stringify(customDictionary), () => {/**/});
}
}, {type: 'separator'});
if(corrections.length > 0)
menuTemplate.unshift(...corrections.map((correction: string) => ({ menuTemplate.unshift(...corrections.map((correction: string) => ({
label: correction, label: correction,
click: () => webContents.replaceMisspelling(correction) click: () => webContents.replaceMisspelling(correction)
}))); })));
} else menuTemplate.unshift({enabled: false, label: l('spellchecker.noCorrections')});
} } else if(customDictionary.indexOf(props.selectionText) !== -1)
menuTemplate.unshift({
label: l('spellchecker.remove'),
click: () => {
spellchecker.remove(props.selectionText);
customDictionary.splice(customDictionary.indexOf(props.selectionText), 1);
fs.writeFile(customDictionaryPath, JSON.stringify(customDictionary), () => {/**/});
}
}, {type: 'separator'});
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup(); if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
}); });
@ -151,6 +167,10 @@ if(params['import'] !== undefined)
} }
spellchecker.setDictionary(settings.spellcheckLang, dictDir); spellchecker.setDictionary(settings.spellcheckLang, dictDir);
const customDictionaryPath = path.join(settings.logDirectory, 'words');
const customDictionary = fs.existsSync(customDictionaryPath) ? <string[]>JSON.parse(fs.readFileSync(customDictionaryPath, 'utf8')) : [];
for(const word of customDictionary) spellchecker.add(word);
//tslint:disable-next-line:no-unused-expression //tslint:disable-next-line:no-unused-expression
new Index({ new Index({
el: '#app', el: '#app',

View File

@ -11,6 +11,7 @@ export class GeneralSettings {
spellcheckLang: string | undefined = 'en-GB'; spellcheckLang: string | undefined = 'en-GB';
theme = 'default'; theme = 'default';
version = electron.app.getVersion(); version = electron.app.getVersion();
beta = false;
} }
export function mkdir(dir: string): void { export function mkdir(dir: string): void {

View File

@ -6,7 +6,13 @@ import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core'; import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces'; import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
import l from '../chat/localize'; import l from '../chat/localize';
import {mkdir} from './common'; import {GeneralSettings, mkdir} from './common';
declare module '../chat/interfaces' {
interface State {
generalSettings?: GeneralSettings
}
}
const dayMs = 86400000; const dayMs = 86400000;
@ -204,9 +210,12 @@ function getSettingsDir(character: string = core.connection.character): string {
export class SettingsStore implements Settings.Store { export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> { async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
const file = path.join(getSettingsDir(character), key); try {
if(!fs.existsSync(file)) return undefined; const file = path.join(getSettingsDir(character), key);
return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8')); return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
} catch(e) {
return undefined;
}
} }
async getAvailableCharacters(): Promise<ReadonlyArray<string>> { async getAvailableCharacters(): Promise<ReadonlyArray<string>> {

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>F-Chat</title> <title>F-Chat</title>
<link href="fa.css" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"> <div id="app">

View File

@ -100,6 +100,7 @@ async function setDictionary(lang: string | undefined): Promise<void> {
} }
const settingsDir = path.join(electron.app.getPath('userData'), 'data'); const settingsDir = path.join(electron.app.getPath('userData'), 'data');
mkdir(settingsDir);
const file = path.join(settingsDir, 'settings'); const file = path.join(settingsDir, 'settings');
const settings = new GeneralSettings(); const settings = new GeneralSettings();
let shouldImportSettings = false; let shouldImportSettings = false;
@ -137,7 +138,7 @@ async function addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
function setUpWebContents(webContents: Electron.WebContents): void { function setUpWebContents(webContents: Electron.WebContents): void {
const openLinkExternally = (e: Event, linkUrl: string) => { const openLinkExternally = (e: Event, linkUrl: string) => {
e.preventDefault(); e.preventDefault();
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)\/?#?/); const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/([^/#]+)\/?#?/);
if(profileMatch !== null && settings.profileViewer) webContents.send('open-profile', decodeURIComponent(profileMatch[2])); if(profileMatch !== null && settings.profileViewer) webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
else electron.shell.openExternal(linkUrl); else electron.shell.openExternal(linkUrl);
}; };
@ -179,6 +180,7 @@ function showPatchNotes(): void {
} }
function onReady(): void { function onReady(): void {
app.setAppUserModelId('net.f-list.f-chat');
app.on('open-file', createWindow); app.on('open-file', createWindow);
if(settings.version !== app.getVersion()) { if(settings.version !== app.getVersion()) {
@ -188,6 +190,7 @@ function onReady(): void {
} }
if(process.env.NODE_ENV === 'production') { if(process.env.NODE_ENV === 'production') {
if(settings.beta) autoUpdater.channel = 'beta';
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
const updateTimer = setInterval(async() => autoUpdater.checkForUpdates(), 3600000); const updateTimer = setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
let hasUpdate = false; let hasUpdate = false;
@ -195,12 +198,15 @@ function onReady(): void {
clearInterval(updateTimer); clearInterval(updateTimer);
if(hasUpdate) return; if(hasUpdate) return;
hasUpdate = true; hasUpdate = true;
const menu = electron.Menu.getApplicationMenu(); const menu = electron.Menu.getApplicationMenu()!;
menu.append(new electron.MenuItem({ menu.append(new electron.MenuItem({
label: l('action.updateAvailable'), label: l('action.updateAvailable'),
submenu: electron.Menu.buildFromTemplate([{ submenu: electron.Menu.buildFromTemplate([{
label: l('action.update'), label: l('action.update'),
click: () => autoUpdater.quitAndInstall(false, true) click: () => {
for(const w of windows) w.webContents.send('quit');
autoUpdater.quitAndInstall(false, true);
}
}, { }, {
label: l('help.changelog'), label: l('help.changelog'),
click: showPatchNotes click: showPatchNotes
@ -288,6 +294,13 @@ function onReady(): void {
label: x, label: x,
type: <'radio'>'radio' type: <'radio'>'radio'
})) }))
}, {
label: l('settings.beta'), type: 'checkbox', checked: settings.beta,
click: (item: Electron.MenuItem) => {
settings.beta = item.checked;
setGeneralSettings(settings);
autoUpdater.channel = item.checked ? 'beta' : 'latest';
}
}, },
{type: 'separator'}, {type: 'separator'},
{role: 'minimize'}, {role: 'minimize'},

View File

@ -5,21 +5,10 @@
"description": "F-List.net Chat Client", "description": "F-List.net Chat Client",
"main": "main.js", "main": "main.js",
"license": "MIT", "license": "MIT",
"dependencies": {
"keytar": "^4.0.4",
"spellchecker": "^3.4.3"
},
"devDependencies": {
"electron": "^1.8.1",
"electron-builder": "^19.33.0",
"electron-log": "^2.2.9",
"electron-updater": "^2.8.9",
"extract-text-webpack-plugin": "^3.0.0"
},
"scripts": { "scripts": {
"build": "../node_modules/.bin/webpack", "build": "node ../webpack development",
"build:dist": "../node_modules/.bin/webpack --env production", "build:dist": "node ../webpack production",
"watch": "../node_modules/.bin/webpack --watch", "watch": "node ../webpack watch",
"start": "electron app" "start": "electron app"
}, },
"build": { "build": {
@ -43,8 +32,7 @@
}, },
"publish": { "publish": {
"provider": "generic", "provider": "generic",
"url": "https://client.f-list.net/", "url": "https://client.f-list.net/"
"channel": "latest"
} }
} }
} }

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"sourceMap": true,
"allowJs": true,
"outDir": "build",
"noEmitHelpers": true,
"importHelpers": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["main.ts"]
}

View File

@ -13,10 +13,5 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true
}, },
"include": ["*.ts", "../**/*.d.ts"], "include": ["chat.ts", "window.ts", "../**/*.d.ts"]
"exclude": [
"node_modules",
"dist",
"app"
]
} }

View File

@ -1,11 +1,8 @@
const path = require('path'); const path = require('path');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const webpack = require('webpack');
const UglifyPlugin = require('uglifyjs-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin');
const fs = require('fs'); const fs = require('fs');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const exportLoader = require('../export-loader'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const mainConfig = { const mainConfig = {
entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'application.json')], entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'application.json')],
@ -16,16 +13,16 @@ const mainConfig = {
context: __dirname, context: __dirname,
target: 'electron-main', target: 'electron-main',
module: { module: {
loaders: [ rules: [
{ {
test: /\.ts$/, test: /\.ts$/,
loader: 'ts-loader', loader: 'ts-loader',
options: { options: {
configFile: __dirname + '/tsconfig.json', configFile: __dirname + '/tsconfig-main.json',
transpileOnly: true transpileOnly: true
} }
}, },
{test: /application.json$/, loader: 'file-loader?name=package.json'}, {test: path.join(__dirname, 'application.json'), loader: 'file-loader?name=package.json', type: 'javascript/auto'},
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'} {test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'}
] ]
}, },
@ -34,16 +31,15 @@ const mainConfig = {
__filename: false __filename: false
}, },
plugins: [ plugins: [
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}), new ForkTsCheckerWebpackPlugin({
exportLoader.delayTypecheck workers: 2,
async: false,
tslint: path.join(__dirname, '../tslint.json'),
tsconfig: './tsconfig-main.json'
})
], ],
resolve: { resolve: {
extensions: ['.ts', '.js'] extensions: ['.ts', '.js']
},
resolveLoader: {
modules: [
'node_modules', path.join(__dirname, '../')
]
} }
}, rendererConfig = { }, rendererConfig = {
entry: { entry: {
@ -57,12 +53,11 @@ const mainConfig = {
context: __dirname, context: __dirname,
target: 'electron-renderer', target: 'electron-renderer',
module: { module: {
loaders: [ rules: [
{ {
test: /\.vue$/, test: /\.vue$/,
loader: 'vue-loader', loader: 'vue-loader',
options: { options: {
preLoaders: {ts: 'export-loader'},
preserveWhitespace: false preserveWhitespace: false
} }
}, },
@ -71,7 +66,7 @@ const mainConfig = {
loader: 'ts-loader', loader: 'ts-loader',
options: { options: {
appendTsSuffixTo: [/\.vue$/], appendTsSuffixTo: [/\.vue$/],
configFile: __dirname + '/tsconfig.json', configFile: __dirname + '/tsconfig-renderer.json',
transpileOnly: true transpileOnly: true
} }
}, },
@ -88,48 +83,45 @@ const mainConfig = {
__filename: false __filename: false
}, },
plugins: [ plugins: [
new webpack.ProvidePlugin({ new ForkTsCheckerWebpackPlugin({
'$': 'jquery/dist/jquery.slim.js', workers: 2,
'jQuery': 'jquery/dist/jquery.slim.js', async: false,
'window.jQuery': 'jquery/dist/jquery.slim.js' tslint: path.join(__dirname, '../tslint.json'),
}), tsconfig: './tsconfig-renderer.json',
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}), vue: true
new CommonsChunkPlugin({name: 'common', minChunks: 2}), })
exportLoader.delayTypecheck
], ],
resolve: { resolve: {
extensions: ['.ts', '.js', '.vue', '.css'], extensions: ['.ts', '.js', '.vue', '.css'],
alias: {qs: path.join(__dirname, 'qs.ts')} alias: {qs: path.join(__dirname, 'qs.ts')}
}, },
resolveLoader: { optimization: {
modules: [ splitChunks: {chunks: 'all', minChunks: 2, name: 'common'}
'node_modules', path.join(__dirname, '../')
]
} }
}; };
module.exports = function(env) { module.exports = function(mode) {
const dist = env === 'production'; const themesDir = path.join(__dirname, '../scss/themes/chat');
const themesDir = path.join(__dirname, '../less/themes/chat');
const themes = fs.readdirSync(themesDir); const themes = fs.readdirSync(themesDir);
const cssOptions = {use: [{loader: 'css-loader', options: {minimize: dist}}, 'less-loader']}; const cssOptions = {use: ['css-loader', 'sass-loader']};
for(const theme of themes) { for(const theme of themes) {
if(!theme.endsWith('.less')) continue; if(!theme.endsWith('.scss')) continue;
const absPath = path.join(themesDir, theme); const absPath = path.join(themesDir, theme);
rendererConfig.entry.chat.push(absPath); rendererConfig.entry.chat.push(absPath);
const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css'); const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css');
rendererConfig.plugins.push(plugin); rendererConfig.plugins.push(plugin);
rendererConfig.module.loaders.push({test: absPath, use: plugin.extract(cssOptions)}); rendererConfig.module.rules.push({test: absPath, use: plugin.extract(cssOptions)});
} }
if(dist) { const faPath = path.join(themesDir, '../../fa.scss');
rendererConfig.entry.chat.push(faPath);
const faPlugin = new ExtractTextPlugin('./fa.css');
rendererConfig.plugins.push(faPlugin);
rendererConfig.module.rules.push({test: faPath, use: faPlugin.extract(cssOptions)});
if(mode === 'production') {
mainConfig.devtool = rendererConfig.devtool = 'source-map'; mainConfig.devtool = rendererConfig.devtool = 'source-map';
const plugins = [new UglifyPlugin({sourceMap: true}), rendererConfig.plugins.push(new OptimizeCssAssetsPlugin());
new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify('production')}),
new webpack.LoaderOptionsPlugin({minimize: true})];
mainConfig.plugins.push(...plugins);
rendererConfig.plugins.push(...plugins);
} else { } else {
//config.devtool = 'cheap-module-eval-source-map'; mainConfig.devtool = rendererConfig.devtool = 'none';
} }
return [mainConfig, rendererConfig]; return [mainConfig, rendererConfig];
}; };

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>F-Chat</title> <title>F-Chat</title>
<link href="fa.css" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
const fs = require('fs');
module.exports = function(source) {
fs.writeFileSync(this.resourcePath + '.ts', source);
return source;
};
module.exports.delayTypecheck = function() {
let callback;
this.plugin('fork-ts-checker-service-before-start', (c) => callback = c);
this.plugin('after-compile', (compilation, c) => {
if(compilation.compiler.parentCompilation) return c();
callback();
c();
});
};

View File

@ -33,7 +33,7 @@ function sortMember(this: void | never, array: SortableMember[], member: Sortabl
class Channel implements Interfaces.Channel { class Channel implements Interfaces.Channel {
description = ''; description = '';
opList: string[]; opList: string[] = [];
owner = ''; owner = '';
mode: Interfaces.Mode = 'both'; mode: Interfaces.Mode = 'both';
members: {[key: string]: SortableMember | undefined} = {}; members: {[key: string]: SortableMember | undefined} = {};
@ -163,16 +163,18 @@ export default function(this: void, connection: Connection, characters: Characte
const item = state.getChannelItem(data.channel); const item = state.getChannelItem(data.channel);
if(data.character.identity === connection.character) { if(data.character.identity === connection.character) {
const id = data.channel.toLowerCase(); const id = data.channel.toLowerCase();
if(state.joinedMap[id] !== undefined) return;
const channel = state.joinedMap[id] = new Channel(id, decodeHTML(data.title)); const channel = state.joinedMap[id] = new Channel(id, decodeHTML(data.title));
state.joinedChannels.push(channel); state.joinedChannels.push(channel);
if(item !== undefined) item.isJoined = true; if(item !== undefined) item.isJoined = true;
} else { } else {
const channel = state.getChannel(data.channel); const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel); if(channel === undefined) return state.leave(data.channel);
if(channel.members[data.character.identity] !== undefined) return;
const member = channel.createMember(characters.get(data.character.identity)); const member = channel.createMember(characters.get(data.character.identity));
await channel.addMember(member); await channel.addMember(member);
if(item !== undefined) item.memberCount++;
} }
if(item !== undefined) item.memberCount++;
}); });
connection.onMessage('ICH', async(data) => { connection.onMessage('ICH', async(data) => {
const channel = state.getChannel(data.channel); const channel = state.getChannel(data.channel);
@ -214,7 +216,6 @@ export default function(this: void, connection: Connection, characters: Characte
connection.onMessage('COA', (data) => { connection.onMessage('COA', (data) => {
const channel = state.getChannel(data.channel); const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel); if(channel === undefined) return state.leave(data.channel);
channel.opList.push(data.character);
const member = channel.members[data.character]; const member = channel.members[data.character];
if(member === undefined || member.rank === Interfaces.Rank.Owner) return; if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
member.rank = Interfaces.Rank.Op; member.rank = Interfaces.Rank.Op;
@ -229,7 +230,6 @@ export default function(this: void, connection: Connection, characters: Characte
connection.onMessage('COR', (data) => { connection.onMessage('COR', (data) => {
const channel = state.getChannel(data.channel); const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel); if(channel === undefined) return state.leave(data.channel);
channel.opList.splice(channel.opList.indexOf(data.character), 1);
const member = channel.members[data.character]; const member = channel.members[data.character];
if(member === undefined || member.rank === Interfaces.Rank.Owner) return; if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
member.rank = Interfaces.Rank.Member; member.rank = Interfaces.Rank.Member;

View File

@ -2,7 +2,7 @@ import {decodeHTML} from './common';
import {Character as Interfaces, Connection} from './interfaces'; import {Character as Interfaces, Connection} from './interfaces';
class Character implements Interfaces.Character { class Character implements Interfaces.Character {
gender: Interfaces.Gender; gender: Interfaces.Gender = 'None';
status: Interfaces.Status = 'offline'; status: Interfaces.Status = 'offline';
statusText = ''; statusText = '';
isFriend = false; isFriend = false;

View File

@ -10,15 +10,15 @@ async function queryApi(this: void, endpoint: string, data: object): Promise<Axi
} }
export default class Connection implements Interfaces.Connection { export default class Connection implements Interfaces.Connection {
character: string; character = '';
vars: Interfaces.Vars & {[key: string]: string} = <any>{}; //tslint:disable-line:no-any vars: Interfaces.Vars & {[key: string]: string} = <any>{}; //tslint:disable-line:no-any
protected socket: WebSocketConnection | undefined = undefined; protected socket: WebSocketConnection | undefined = undefined;
private messageHandlers: {[key in keyof Interfaces.ServerCommands]?: Interfaces.CommandHandler<key>[]} = {}; private messageHandlers: {[key in keyof Interfaces.ServerCommands]?: Interfaces.CommandHandler<key>[]} = {};
private connectionHandlers: {[key in Interfaces.EventType]?: Interfaces.EventHandler[]} = {}; private connectionHandlers: {[key in Interfaces.EventType]?: Interfaces.EventHandler[]} = {};
private errorHandlers: ((error: Error) => void)[] = []; private errorHandlers: ((error: Error) => void)[] = [];
private ticket: string; private ticket = '';
private cleanClose = false; private cleanClose = false;
private reconnectTimer: NodeJS.Timer; private reconnectTimer: NodeJS.Timer | undefined;
private ticketProvider: Interfaces.TicketProvider; private ticketProvider: Interfaces.TicketProvider;
private reconnectDelay = 0; private reconnectDelay = 0;
private isReconnect = false; private isReconnect = false;
@ -86,7 +86,7 @@ export default class Connection implements Interfaces.Connection {
} }
close(): void { close(): void {
clearTimeout(this.reconnectTimer); if(this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
this.cleanClose = true; this.cleanClose = true;
if(this.socket !== undefined) this.socket.close(); if(this.socket !== undefined) this.socket.close();
} }

112
keys.ts Normal file
View File

@ -0,0 +1,112 @@
export const enum Keys {
Backspace = 8,
Tab = 9,
Enter = 13,
Shift = 16,
Ctrl = 17,
Alt = 18,
Pause = 19,
CapsLock = 20,
Escape = 27,
Space = 32,
PageUp = 33,
PageDown = 34,
End = 35,
Home = 36,
ArrowLeft = 37,
ArrowUp = 38,
ArrowRight = 39,
ArrowDown = 40,
PrintScreen = 44,
Insert = 45,
Delete = 46,
Digit0 = 48,
Digit1 = 49,
Digit2 = 50,
Digit3 = 51,
Digit4 = 52,
Digit5 = 53,
Digit6 = 54,
Digit7 = 55,
Digit8 = 56,
Digit9 = 57,
KeyA = 65,
KeyB = 66,
KeyC = 67,
KeyD = 68,
KeyE = 69,
KeyF = 70,
KeyG = 71,
KeyH = 72,
KeyI = 73,
KeyJ = 74,
KeyK = 75,
KeyL = 76,
KeyM = 77,
KeyN = 78,
KeyO = 79,
KeyP = 80,
KeyQ = 81,
KeyR = 82,
KeyS = 83,
KeyT = 84,
KeyU = 85,
KeyV = 86,
KeyW = 87,
KeyX = 88,
KeyY = 89,
KeyZ = 90,
LeftWindowKey = 91,
RightWindowKey = 92,
SelectKey = 93,
Numpad0 = 96,
Numpad1 = 97,
Numpad2 = 98,
Numpad3 = 99,
Numpad4 = 100,
Numpad5 = 101,
Numpad6 = 102,
Numpad7 = 103,
Numpad8 = 104,
Numpad9 = 105,
NumpadMultiply = 106,
NumpadAdd = 107,
NumpadSubtract = 109,
NumpadDecimal = 110,
NumpadDivide = 111,
F1 = 112,
F2 = 113,
F3 = 114,
F4 = 115,
F5 = 116,
F6 = 117,
F7 = 118,
F8 = 119,
F9 = 120,
F10 = 121,
F11 = 122,
F12 = 123,
NumLock = 144,
ScrollLock = 145,
Semicolon = 186,
Equal = 187,
Comma = 188,
Minus = 189,
Period = 190,
ForwardSlash = 191,
Backquote = 192,
BracketLeft = 219,
BracketRight = 221,
Quote = 222
}

View File

@ -1,126 +0,0 @@
.redText {
color: @red-color;
}
.blueText {
color: @blue-color;
}
.greenText {
color: @green-color;
}
.yellowText {
color: @yellow-color;
}
.cyanText {
color: @cyan-color;
}
.purpleText {
color: @purple-color;
}
.brownText {
color: @brown-color;
}
.pinkText {
color: @pink-color;
}
.grayText {
color: @gray-color;
}
.orangeText {
color: @orange-color;
}
.whiteText {
color: @white-color;
}
.blackText {
color: @black-color;
}
/* Tweak these to be consistent with how bootstrap does sizing. */
span.bigText {
font-size: 1.4em;
}
span.smallText {
font-size: 0.8em;
}
span.leftText {
display: block;
text-align: left;
}
span.centerText {
display: block;
text-align: center;
}
span.rightText {
display: block;
text-align: right;
}
span.justifyText {
display: block;
text-align: justify;
}
div.indentText {
padding-left: 3em;
}
.character-avatar {
display: inline;
height: 100px;
width: 100px;
&.icon {
height: 50px;
width: 50px;
}
}
.collapseHeaderText {
font-weight: bold;
cursor: pointer;
width: 100%;
min-height: @line-height-computed;
}
.collapseHeader {
.well;
padding: 5px;
border-color: @collapse-border;
background-color: @collapse-header-bg;
}
.collapseBlock {
max-height: 0;
margin-left: 0.5em;
transition: max-height 0.2s;
overflow-y: hidden;
}
.styledText, .bbcode {
.force-word-wrapping();
max-width: 100%;
a {
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.link-domain {
color: @gray-light;
}

View File

@ -1,42 +0,0 @@
.bbcodeEditorButton {
.btn-default();
padding: (@padding-base-vertical/2.0) (@padding-base-horizontal/2.0);
}
.bbcodeTextAreaTextArea {
textarea& {
min-height: 150px;
}
}
.bbcodePreviewWarnings {
.alert();
.alert-danger();
}
.bbcode-toolbar {
@media (max-width: @screen-xs-max) {
background: @text-background-color;
padding: 10px;
position: absolute;
top: 0;
border-radius: 3px;
z-index: 20;
display: none;
.btn {
margin: 3px;
}
}
@media (min-width: @screen-sm-min) {
.btn-group();
.close {
display:none;
}
}
}
.bbcode-btn {
@media (min-width: @screen-sm-min) {
display: none;
}
}

View File

@ -1,261 +0,0 @@
.bg-solid-text {
background: @text-background-color
}
.link-preview {
background: @text-background-color;
border-top-right-radius: 2px;
bottom: 0;
left: 0;
max-width: 40%;
overflow-x: hidden;
padding: 0.2em 0.5em;
font-size: 12px;
position: fixed;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 100000;
&.right {
left: auto;
right: 0;
border-top-left-radius: 2px;
border-top-right-radius: 0;
}
}
.has-new {
background-color: @state-danger-bg !important;
}
.overlay-disable {
position: absolute;
opacity: 0.8;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
background: #ddd;
color: #000;
}
.sidebar-wrapper {
.modal-backdrop {
display: none;
z-index: 9;
}
&.open {
.modal-backdrop {
display: block;
}
.body {
display: block;
}
}
}
.sidebar {
position: absolute;
top: 0;
bottom: 0;
background: @body-bg;
z-index: 10;
flex-shrink: 0;
margin: -10px;
padding: 10px;
.body {
display: none;
width: 200px;
flex-direction: column;
max-height: 100%;
overflow: auto;
}
.expander {
display: block;
position: absolute;
padding: 5px 6px;
border-color: @btn-default-border;
border-top-right-radius: 0;
border-top-left-radius: 0;
@media (min-width: @screen-sm-min) {
.name {
display: none;
}
&:hover .name {
display: inline;
}
}
}
&.sidebar-left {
border-right: solid 1px @panel-default-border;
left: 0;
margin-right: 0;
padding-right: 0;
.expander {
transform: rotate(270deg) translate3d(0, 0, 0);
transform-origin: 100% 0;
-webkit-transform: rotate(270deg) translate3d(0, 0, 0);
-webkit-transform-origin: 100% 0;
right: 0;
}
}
&.sidebar-right {
border-left: solid 1px @panel-default-border;
right: 0;
margin-left: 0;
padding-left: 0;
.expander {
transform: rotate(90deg) translate3d(0, 0, 0);
transform-origin: 0 0;
-webkit-transform: rotate(90deg) translate3d(0, 0, 0);
-webkit-transform-origin: 0 0;
}
}
}
.sidebar-fixed() {
position: static;
margin: 0;
padding: 0;
height: 100%;
.body {
display: block;
}
.expander {
display: none;
}
}
.chat-text-box {
min-height: initial !important;
max-height: 250px;
resize: none;
}
.ads-text-box {
background-color: @state-info-bg;
}
.border-top {
border-top: solid 1px @panel-default-border;
}
.border-bottom {
border-bottom: solid 1px @panel-default-border;
}
.message {
word-wrap: break-word;
word-break: break-word;
padding-bottom: 1px;
}
.message-block {
padding: 1px 0;
&:not(:last-child) {
border-bottom: solid 1px @panel-default-border;
}
}
.message-warn {
background-color: @state-danger-bg;
color: @state-danger-text;
}
.messages-both {
.message-ad {
background-color: @brand-info;
padding: 0 2px 2px 2px;
box-shadow: @gray -2px -2px 2px inset;
}
}
.message-event {
color: @gray;
}
.message-highlight {
background-color: @state-success-bg;
}
.message-action .bbcode {
font-style: italic;
i, em {
font-style: normal;
}
}
.last-read {
border-bottom: solid 2px @state-success-bg !important;
}
.fa.active {
color: @brand-success;
}
.gender-shemale {
color: #CC66FF;
}
.gender-herm {
color: #9B30FF;
}
.gender-none {
color: @gray;
}
.gender-female {
color: #FF6699;
}
.gender-male {
color: #6699FF;
}
.gender-male-herm {
color: #007FFF;
}
.gender-transgender {
color: #EE8822;
}
.gender-cunt-boy {
color: #00CC66;
}
#character-page-sidebar {
margin-top: 0; // Fix up hack for merging the header on the character page, which doesn't work on chat.
}
.profile-viewer {
width: 98%;
height: 100%;
}
#window-tabs .hasNew > a {
background-color: @state-warning-bg;
border-color: @state-warning-border;
color: @state-warning-text;
&:hover {
background-color: @state-warning-border;
}
}
.btn-text {
margin-left: 3px;
@media (max-width: @screen-xs-max) {
display: none;
}
}

View File

@ -1,14 +0,0 @@
@comment-grid-columns: 50;
.comment-offset-1 {
margin-left: percentage((1 / @comment-grid-columns));
}
.comment-well {
.well();
margin-bottom: 0px;
}
.comment-well.warning {
background-color: @state-warning-bg;
border-color: @state-warning-border;
}

View File

@ -1,57 +0,0 @@
.flash-messages-fixed {
top: 0px;
left: auto;
right: auto;
width: 100%;
height: auto;
position: fixed;
z-index: 9000;
}
.flash-message {
.alert();
position: relative;
border-bottom-color: rgba(0, 0, 0, 0.3);
margin-bottom: 0;
z-index: 150;
}
.flash-message-enter-active, .flash-message-leave-active {
transition: all 0.2s;
}
.flash-message-enter, .flash-message-leave-to {
opacity: 0;
transform: translateX(100px);
}
.character-menu-item {
width: 250px;
.character-link {
display: inline-block;
padding-right: 0;
}
.character-edit-link {
margin-left: auto;
padding: 3px 20px 2px 0;
display: inline-block;
}
}
.sidebar-top-padded {
margin-top: 20px;
}
.force-word-wrapping {
overflow-wrap: break-word;
word-wrap: break-word;
//-ms-word-break: break-all;
word-break: break-word; // Non standard form used in some browsers.
//word-break: break-all;
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}

View File

@ -1,35 +0,0 @@
hr {
margin-top: 5px;
margin-bottom: 5px;
}
.modal-dialog.modal-wide {
width: 95%;
}
.panel-title {
font-weight: bold;
}
// Fix weird style where this is overwritten and cannot be styled inside a well.
.well {
// The default of 19 doesn't match any existing elements, which use either 15 or @padding-vertical/horizontal-base
padding: 15px;
blockquote {
border-color: @blockquote-border-color;
font-size: inherit;
}
}
.well-lg {
padding: 20px;
}
@select-indicator: replace("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 2'%3E%3Cpath fill='@{input-color}' d='M2 2L0 0h4z'/%3E%3C/svg%3E", "#", "%23");
select.form-control {
-webkit-appearance: none;
background: @input-bg url(@select-indicator) no-repeat right 1rem center;
background-size: 8px 10px;
padding-right: 25px;
}

View File

@ -1,53 +0,0 @@
@import "~bootstrap/less/variables.less";
// BBcode colors
@red-color: #f00;
@green-color: #0f0;
@blue-color: #00f;
@yellow-color: #ff0;
@cyan-color: #0ff;
@purple-color: #c0f;
@white-color: #fff;
@black-color: #000;
@brown-color: #8a6d3b;
@pink-color: #faa;
@gray-color: #ccc;
@orange-color: #f60;
@collapse-header-bg: @well-bg;
@collapse-border: darken(@well-border, 25%);
// Character page quick kink comparison
@quick-compare-active-border: @black-color;
@quick-compare-favorite-bg: @state-info-bg;
@quick-compare-yes-bg: @state-success-bg;
@quick-compare-maybe-bg: @state-warning-bg;
@quick-compare-no-bg: @state-danger-bg;
// character page badges
@character-badge-bg: darken(@well-bg, 10%);
@character-badge-border: darken(@well-border, 10%);
@character-badge-subscriber-bg: @alert-info-bg;
@character-badge-subscriber-border: @alert-info-border;
// Character editor
@character-list-selected-border: @brand-success;
@character-image-selected-border: @brand-success;
// Notes conversation view
@note-conversation-you-bg: @alert-info-bg;
@note-conversation-you-text: @alert-info-text;
@note-conversation-you-border: @alert-info-border;
@note-conversation-them-bg: @well-bg;
@note-conversation-them-text: @text-color;
@note-conversation-them-border: @well-border;
@nav-link-hover-color: @link-color;
// General color extensions missing from bootstrap
@text-background-color: @body-bg;
@text-background-color-disabled: @gray-lighter;
@screen-sm-min: 700px;
@screen-md-min: 900px;
@container-sm: 680px;
@container-md: 880px;

View File

@ -1,50 +0,0 @@
.tag-input-control {
display: inline-block;
margin-bottom: 0;
vertical-align: middle;
height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)
width: 100%;
padding: @padding-base-vertical @padding-base-horizontal;
font-size: @font-size-base;
line-height: @line-height-base;
color: @input-color;
background-color: @input-bg;
background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
border: 1px solid @input-border;
border-radius: @input-border-radius; // Note: This has no effect on <select>s in some browsers, due to the limited stylability of <select>s in CSS.
.box-shadow(inset 0 1px 1px rgba(0, 0, 0, .075));
.transition(~"border-color ease-in-out .15s, box-shadow ease-in-out .15s");
.tag-input {
background-color: @input-bg;
border: none;
width: auto;
&:focus {
box-shadow: none;
outline: none;
}
}
}
.form-inline .tag-input-control {
width: auto;
}
.tag-error {
border: 1px solid @state-danger-border;
background-color: @state-danger-bg;
.tag-input {
text-color: @state-danger-text;
background-color: @state-danger-bg;
}
}
.suggestion-important {
font-weight: bold !important;
}
.suggestion-description {
display: block;
font-style: italic;
font-size: @font-size-small;
}

View File

@ -1,40 +0,0 @@
@import "../variables/dark.less";
.nav-tabs > li > a:hover {
background-color: @gray-darker;
}
.modal .nav-tabs > li.active > a {
background-color: @gray-dark;
}
.message-own {
background-color: @gray-dark;
}
// Apply variables to theme.
@import "../theme_base_chat.less";
* {
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 8px @panel-default-border;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.8);
background-color: @gray-dark;
&:hover {
background-color: @gray;
}
&:active {
background-color: @gray-light;
}
}
}

View File

@ -1,40 +0,0 @@
@import "../variables/default.less";
.nav-tabs > li > a:hover {
background-color: @gray-darker;
}
.modal .nav-tabs > li.active > a {
background-color: @gray-dark;
}
.message-own {
background-color: @gray-darker;
}
// Apply variables to theme.
@import "../theme_base_chat.less";
* {
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 8px @panel-default-border;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.8);
background-color: @gray-dark;
&:hover {
background-color: @gray;
}
&:active {
background-color: @gray-light;
}
}
}

View File

@ -1,32 +0,0 @@
@import "../variables/light.less";
.message-own {
background-color: @gray-lighter;
}
// Apply variables to theme.
@import "../theme_base_chat.less";
* {
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 8px @gray;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.8);
background-color: @gray-lighter;
&:hover {
background-color: @gray-light;
}
&:active {
background-color: @gray;
}
}
}

View File

@ -1,4 +0,0 @@
@import "../variables/dark.less";
// Apply variables to theme.
@import "../theme_base.less";

View File

@ -1,4 +0,0 @@
@import "../variables/default.less";
// Apply variables to theme.
@import "../theme_base.less";

View File

@ -1,4 +0,0 @@
@import "../variables/light.less";
// Apply variables to theme.
@import "../theme_base.less";

View File

@ -1,64 +0,0 @@
/*!
* Bootstrap v3.3.5 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
// Core variables and mixins
//@import "variables.less"; // This file should be drawn in through a theme file and then overwritten.
@import "~bootstrap/less/mixins.less";
// Reset and dependencies
@import "~bootstrap/less/normalize.less";
//@import "print.less";
//@import "glyphicons.less";
// Core CSS
@import "~bootstrap/less/scaffolding.less";
@import "~bootstrap/less/type.less";
//@import "code.less";
@import "~bootstrap/less/grid.less";
@import "~bootstrap/less/tables.less";
@import "~bootstrap/less/forms.less";
@import "~bootstrap/less/buttons.less";
// Components
@import "~bootstrap/less/component-animations.less";
@import "~bootstrap/less/dropdowns.less";
@import "~bootstrap/less/button-groups.less";
//@import "input-groups.less";
@import "~bootstrap/less/navs.less";
@import "~bootstrap/less/navbar.less";
//@import "breadcrumbs.less";
@import "~bootstrap/less/pagination.less";
@import "~bootstrap/less/pager.less";
@import "~bootstrap/less/labels.less";
@import "~bootstrap/less/badges.less";
//@import "jumbotron.less";
//@import "thumbnails.less";
@import "~bootstrap/less/alerts.less";
//@import "progress-bars.less";
//@import "media.less";
//@import "list-group.less";
@import "~bootstrap/less/panels.less";
//@import "responsive-embed.less";
@import "~bootstrap/less/wells.less";
@import "~bootstrap/less/close.less";
// Components w/ JavaScript
@import "~bootstrap/less/modals.less";
//@import "tooltip.less";
@import "~bootstrap/less/popovers.less";
//@import "carousel.less";
// Utility classes
@import "~bootstrap/less/utilities.less";
//@import "responsive-utilities.less";
@import "~font-awesome/less/font-awesome.less";
@import "../core.less";
@import "../character_editor.less";
@import "../character_page.less";
@import "../eicons_editor.less";
@import "../bbcode_editor.less";
@import "../bbcode.less";
@import "../comments.less";
@import "../tickets.less";
@import "../notes.less";
@import "../threads.less";
@import "../kink_editor.less";
@import "../flist_overrides.less";
@import "../tag_input.less";

View File

@ -1,61 +0,0 @@
/*!
* Bootstrap v3.3.5 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
// Core variables and mixins
//@import "variables.less"; // This file should be drawn in through a theme file and then overwritten.
@import "~bootstrap/less/mixins.less";
// Reset and dependencies
@import "~bootstrap/less/normalize.less";
//@import "print.less";
//@import "glyphicons.less";
// Core CSS
@import "~bootstrap/less/scaffolding.less";
@import "~bootstrap/less/type.less";
//@import "code.less";
@import "~bootstrap/less/grid.less";
@import "~bootstrap/less/tables.less";
@import "~bootstrap/less/forms.less";
@import "~bootstrap/less/buttons.less";
// Components
@import "~bootstrap/less/component-animations.less";
@import "~bootstrap/less/dropdowns.less";
@import "~bootstrap/less/button-groups.less";
//@import "input-groups.less";
@import "~bootstrap/less/navs.less";
//@import "~bootstrap/less/navbar.less";
//@import "breadcrumbs.less";
//@import "~bootstrap/less/pagination.less";
//@import "~bootstrap/less/pager.less";
@import "~bootstrap/less/labels.less";
//@import "~bootstrap/less/badges.less";
//@import "jumbotron.less";
//@import "thumbnails.less";
@import "~bootstrap/less/alerts.less";
@import "~bootstrap/less/progress-bars.less";
//@import "media.less";
@import "~bootstrap/less/list-group.less";
//@import "~bootstrap/less/panels.less";
//@import "responsive-embed.less";
@import "~bootstrap/less/wells.less";
@import "~bootstrap/less/close.less";
// Components w/ JavaScript
@import "~bootstrap/less/modals.less";
//@import "tooltip.less";
@import "~bootstrap/less/popovers.less";
//@import "carousel.less";
// Utility classes
@import "~bootstrap/less/utilities.less";
//@import "responsive-utilities.less";
@import "~font-awesome/less/font-awesome.less";
@import "../core.less";
@import "../character_page.less";
@import "../bbcode_editor.less";
@import "../bbcode.less";
@import "../flist_overrides.less";
@import "../chat.less";
html {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

View File

@ -1,120 +0,0 @@
//Import variable defaults first.
@import "../../flist_variables.less";
@gray-base: #000000;
@gray-darker: lighten(@gray-base, 5%);
@gray-dark: lighten(@gray-base, 25%);
@gray: lighten(@gray-base, 50%);
@gray-light: lighten(@gray-base, 65%);
@gray-lighter: lighten(@gray-base, 85%);
@body-bg: @gray-darker;
@text-color: @gray-lighter;
@text-color-disabled: @gray;
@link-color: darken(@gray-lighter, 15%);
@brand-warning: #a50;
@brand-danger: #800;
@brand-success: #080;
@brand-info: #228;
@brand-primary: @brand-info;
@blue-color: #36f;
@state-info-bg: darken(@brand-info, 15%);
@state-info-text: lighten(@brand-info, 30%);
@state-success-bg: darken(@brand-success, 15%);
@state-success-text: lighten(@brand-success, 30%);
@state-warning-bg: darken(@brand-warning, 15%);
@state-warning-text: lighten(@brand-warning, 30%);
@state-danger-bg: darken(@brand-danger, 15%);
@state-danger-text: lighten(@brand-danger, 30%);
@text-background-color: @gray-dark;
@text-background-color-disabled: @gray-darker;
@border-color: lighten(spin(@text-background-color, -10), 15%);
@border-color-active: lighten(spin(@text-background-color, -10), 25%);
@border-color-disabled: darken(spin(@text-background-color-disabled, -10), 8%);
@hover-bg: lighten(@gray-dark, 15%);
@hr-border: @text-color;
@panel-bg: @text-background-color;
@panel-default-heading-bg: @gray;
@panel-default-border: @border-color;
@input-color: @gray-lighter;
@input-bg: @text-background-color;
@input-bg-disabled: @text-background-color-disabled;
@input-border: @border-color;
@input-border-focus: @gray;
@dropdown-bg: @text-background-color;
@dropdown-color: @text-color;
@dropdown-link-color: @link-color;
@dropdown-link-hover-color: @gray-dark;
@dropdown-link-hover-bg: @gray-light;
@navbar-default-bg: @text-background-color;
@navbar-default-color: @text-color;
@navbar-default-link-color: @link-color;
@navbar-default-link-hover-color: @link-hover-color;
@nav-link-hover-bg: @gray-dark;
@nav-link-hover-color: @gray-darker;
@nav-tabs-border-color: @border-color;
@nav-tabs-link-hover-border-color: @border-color;
@nav-tabs-active-link-hover-bg: @body-bg;
@nav-tabs-active-link-hover-color: @text-color;
@nav-tabs-active-link-hover-border-color: @border-color;
@component-active-color: @gray-dark;
@component-active-bg: @gray-light;
@list-group-bg: @gray-darker;
@list-group-border: @gray-dark;
@list-group-link-color: @text-color;
@list-group-hover-bg: @gray-dark;
@btn-default-bg: @text-background-color;
@btn-default-color: @text-color;
@btn-default-border: @border-color;
@pagination-bg: @text-background-color;
@pagination-color: @text-color;
@pagination-border: @border-color;
@pagination-disabled-bg: @text-background-color-disabled;
@pagination-disabled-color: @text-color-disabled;
@pagination-disabled-border: @border-color-disabled;
@pagination-active-bg: @gray;
@pagination-active-color: @gray-lighter;
@pagination-active-border: @border-color-active;
@modal-content-bg: @text-background-color;
@modal-footer-border-color: lighten(spin(@modal-content-bg, -10), 15%);
@modal-header-border-color: @modal-footer-border-color;
@popover-bg: @body-bg;
@popover-border-color: @border-color;
@popover-title-bg: @text-background-color;
@badge-color: @gray-darker;
@close-color: saturate(@text-color, 10%);
@close-text-shadow: 0 1px 0 @text-color;
@well-bg: @text-background-color;
@well-border: @border-color;
@blockquote-border-color: @border-color-active;
@collapse-border: desaturate(@well-border, 20%);
@collapse-header-bg: desaturate(@well-bg, 20%);
@white-color: @text-color;
.blackText {
text-shadow: @gray-lighter 1px 1px 1px;
}

View File

@ -1,121 +0,0 @@
//Import variable defaults first.
@import "../../flist_variables.less";
@gray-base: #080810;
@gray-darker: lighten(@gray-base, 15%);
@gray-dark: lighten(@gray-base, 25%);
@gray: lighten(@gray-base, 60%);
@gray-light: lighten(@gray-base, 75%);
@gray-lighter: lighten(@gray-base, 95%);
// @body-bg: #262626;
@body-bg: darken(@text-background-color-disabled, 3%);
@text-color: @gray-lighter;
@text-color-disabled: @gray;
@link-color: darken(@gray-lighter, 15%);
@brand-warning: #c26c00;
@brand-danger: #930300;
@brand-success: #009900;
@brand-info: #0447af;
@brand-primary: @brand-info;
@blue-color: #36f;
@state-info-bg: darken(@brand-info, 15%);
@state-info-text: lighten(@brand-info, 30%);
@state-success-bg: darken(@brand-success, 15%);
@state-success-text: lighten(@brand-success, 30%);
@state-warning-bg: darken(@brand-warning, 15%);
@state-warning-text: lighten(@brand-warning, 30%);
@state-danger-bg: darken(@brand-danger, 15%);
@state-danger-text: lighten(@brand-danger, 30%);
@text-background-color: @gray-dark;
@text-background-color-disabled: @gray-darker;
@border-color: lighten(spin(@text-background-color, -10), 15%);
@border-color-active: lighten(spin(@text-background-color, -10), 25%);
@border-color-disabled: darken(spin(@text-background-color-disabled, -10), 8%);
@hover-bg: lighten(@gray-dark, 15%);
@hr-border: @text-color;
@panel-bg: @text-background-color;
@panel-default-heading-bg: @gray;
@panel-default-border: @border-color;
@input-color: @gray-lighter;
@input-bg: @text-background-color;
@input-bg-disabled: @text-background-color-disabled;
@input-border: @border-color;
@input-border-focus: @gray;
@dropdown-bg: @text-background-color;
@dropdown-color: @text-color;
@dropdown-link-color: @link-color;
@dropdown-link-hover-color: @gray-dark;
@dropdown-link-hover-bg: @gray-light;
@navbar-default-bg: @text-background-color;
@navbar-default-color: @text-color;
@navbar-default-link-color: @link-color;
@navbar-default-link-hover-color: @link-hover-color;
@nav-link-hover-bg: @gray-dark;
@nav-link-hover-color: @gray-darker;
@nav-tabs-border-color: @border-color;
@nav-tabs-link-hover-border-color: @border-color;
@nav-tabs-active-link-hover-bg: @body-bg;
@nav-tabs-active-link-hover-color: @text-color;
@nav-tabs-active-link-hover-border-color: @border-color;
@component-active-color: @gray-dark;
@component-active-bg: @gray-light;
@list-group-bg: @gray-darker;
@list-group-border: @gray-dark;
@list-group-link-color: @text-color;
@list-group-hover-bg: @gray-dark;
@btn-default-bg: @text-background-color;
@btn-default-color: @text-color;
@btn-default-border: @border-color;
@pagination-bg: @text-background-color;
@pagination-color: @text-color;
@pagination-border: @border-color;
@pagination-disabled-bg: @text-background-color-disabled;
@pagination-disabled-color: @text-color-disabled;
@pagination-disabled-border: @border-color-disabled;
@pagination-active-bg: @gray;
@pagination-active-color: @gray-lighter;
@pagination-active-border: @border-color-active;
@modal-content-bg: @text-background-color;
@modal-footer-border-color: lighten(spin(@modal-content-bg, -10), 15%);
@modal-header-border-color: @modal-footer-border-color;
@popover-bg: @body-bg;
@popover-border-color: @border-color;
@popover-title-bg: @text-background-color;
@badge-color: @gray-darker;
@close-color: saturate(@text-color, 10%);
@close-text-shadow: 0 1px 0 @text-color;
@well-bg: @text-background-color;
@well-border: @border-color;
@blockquote-border-color: @border-color-active;
@collapse-border: desaturate(@well-border, 20%);
@collapse-header-bg: desaturate(@well-bg, 20%);
@white-color: @text-color;
.blackText {
text-shadow: @gray-lighter 1px 1px 1px;
}

View File

@ -1,12 +0,0 @@
//Import variable defaults first.
@import "../../flist_variables.less";
// Update variables here.
// @body-bg: #00ff00;
@hr-border: @text-color;
@body-bg: #fafafa;
@brand-warning: #e09d3e;
.whiteText {
text-shadow: @gray-darker 1px 1px 1px;
}

View File

@ -1,9 +0,0 @@
.ticket-reply-well {
.well();
margin-bottom: 0px;
}
.ticket-reply-well.staff {
background-color: @state-info-bg;
border-color: @state-info-border;
}

View File

@ -1,380 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
ajv@^4.9.1:
version "4.11.8"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536"
dependencies:
co "^4.6.0"
json-stable-stringify "^1.0.1"
asap@~2.0.3:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
asn1@~0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86"
assert-plus@1.0.0, assert-plus@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
assert-plus@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
aws-sign2@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
aws4@^1.2.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
bcrypt-pbkdf@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
dependencies:
tweetnacl "^0.14.3"
boom@2.x.x:
version "2.10.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f"
dependencies:
hoek "2.x.x"
bootstrap@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.3.7.tgz#5a389394549f23330875a3b150656574f8a9eb71"
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
combined-stream@^1.0.5, combined-stream@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
dependencies:
delayed-stream "~1.0.0"
core-util-is@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
cryptiles@2.x.x:
version "2.0.5"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8"
dependencies:
boom "2.x.x"
dashdash@^1.12.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
dependencies:
assert-plus "^1.0.0"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
ecc-jsbn@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505"
dependencies:
jsbn "~0.1.0"
errno@^0.1.1:
version "0.1.6"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.6.tgz#c386ce8a6283f14fc09563b71560908c9bf53026"
dependencies:
prr "~1.0.1"
extend@~3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
extsprintf@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
font-awesome@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
form-data@~2.1.1:
version "2.1.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1"
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.5"
mime-types "^2.1.12"
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
dependencies:
assert-plus "^1.0.0"
graceful-fs@^4.1.2:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
har-schema@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
har-validator@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
dependencies:
ajv "^4.9.1"
har-schema "^1.0.5"
hawk@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4"
dependencies:
boom "2.x.x"
cryptiles "2.x.x"
hoek "2.x.x"
sntp "1.x.x"
hoek@2.x.x:
version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
http-signature@~1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf"
dependencies:
assert-plus "^0.2.0"
jsprim "^1.2.2"
sshpk "^1.7.0"
image-size@~0.5.0:
version "0.5.5"
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
json-stable-stringify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
dependencies:
jsonify "~0.0.0"
json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
jsonify@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
jsprim@^1.2.2:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
dependencies:
assert-plus "1.0.0"
extsprintf "1.3.0"
json-schema "0.2.3"
verror "1.10.0"
less-plugin-npm-import@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/less-plugin-npm-import/-/less-plugin-npm-import-2.1.0.tgz#823e6986c93318a98171ca858848b6bead55bf3e"
dependencies:
promise "~7.0.1"
resolve "~1.1.6"
less@^2.7.2:
version "2.7.3"
resolved "https://registry.yarnpkg.com/less/-/less-2.7.3.tgz#cc1260f51c900a9ec0d91fb6998139e02507b63b"
optionalDependencies:
errno "^0.1.1"
graceful-fs "^4.1.2"
image-size "~0.5.0"
mime "^1.2.11"
mkdirp "^0.5.0"
promise "^7.1.1"
request "2.81.0"
source-map "^0.5.3"
mime-db@~1.30.0:
version "1.30.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
mime-types@^2.1.12, mime-types@~2.1.7:
version "2.1.17"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
dependencies:
mime-db "~1.30.0"
mime@^1.2.11:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
minimist@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
mkdirp@^0.5.0:
version "0.5.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
dependencies:
minimist "0.0.8"
oauth-sign@~0.8.1:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
performance-now@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
promise@^7.1.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
dependencies:
asap "~2.0.3"
promise@~7.0.1:
version "7.0.4"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.0.4.tgz#363e84a4c36c8356b890fed62c91ce85d02ed539"
dependencies:
asap "~2.0.3"
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
qs@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
request@2.81.0:
version "2.81.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0"
dependencies:
aws-sign2 "~0.6.0"
aws4 "^1.2.1"
caseless "~0.12.0"
combined-stream "~1.0.5"
extend "~3.0.0"
forever-agent "~0.6.1"
form-data "~2.1.1"
har-validator "~4.2.1"
hawk "~3.1.3"
http-signature "~1.1.0"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.7"
oauth-sign "~0.8.1"
performance-now "^0.2.0"
qs "~6.4.0"
safe-buffer "^5.0.1"
stringstream "~0.0.4"
tough-cookie "~2.3.0"
tunnel-agent "^0.6.0"
uuid "^3.0.0"
resolve@~1.1.6:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
safe-buffer@^5.0.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
sntp@1.x.x:
version "1.0.9"
resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198"
dependencies:
hoek "2.x.x"
source-map@^0.5.3:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
sshpk@^1.7.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3"
dependencies:
asn1 "~0.2.3"
assert-plus "^1.0.0"
dashdash "^1.12.0"
getpass "^0.1.1"
optionalDependencies:
bcrypt-pbkdf "^1.0.0"
ecc-jsbn "~0.1.1"
jsbn "~0.1.0"
tweetnacl "~0.14.0"
stringstream@~0.0.4:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
tough-cookie@~2.3.0:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
dependencies:
punycode "^1.4.1"
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
dependencies:
safe-buffer "^5.0.1"
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
uuid@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
verror@1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
dependencies:
assert-plus "^1.0.0"
core-util-is "1.0.2"
extsprintf "^1.2.0"

View File

@ -2,47 +2,51 @@
<div id="page" style="position: relative; padding: 10px;" v-if="settings"> <div id="page" style="position: relative; padding: 10px;" v-if="settings">
<div v-html="styling"></div> <div v-html="styling"></div>
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;"> <div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
<div class="well well-lg" style="width: 400px;"> <div class="card bg-light" style="width: 400px;">
<h3 style="margin-top:0">{{l('title')}}</h3> <h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<div class="alert alert-danger" v-show="error"> <div class="card-body">
{{error}} <div class="alert alert-danger" v-show="error">
</div> {{error}}
<div class="form-group"> </div>
<label class="control-label" for="account">{{l('login.account')}}</label> <div class="form-group">
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/> <label class="control-label" for="account">{{l('login.account')}}</label>
</div> <input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
<div class="form-group"> </div>
<label class="control-label" for="password">{{l('login.password')}}</label> <div class="form-group">
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login"/> <label class="control-label" for="password">{{l('login.password')}}</label>
</div> <input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login" :disabled="loggingIn"/>
<div class="form-group" v-show="showAdvanced"> </div>
<label class="control-label" for="host">{{l('login.host')}}</label> <div class="form-group" v-show="showAdvanced">
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/> <label class="control-label" for="host">{{l('login.host')}}</label>
</div> <input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
<div class="form-group"> </div>
<label class="control-label" for="theme">{{l('settings.theme')}}</label> <div class="form-group">
<select class="form-control" id="theme" v-model="settings.theme"> <label class="control-label" for="theme">{{l('settings.theme')}}</label>
<option>default</option> <select class="form-control custom-select" id="theme" v-model="settings.theme">
<option>dark</option> <option>default</option>
<option>light</option> <option>dark</option>
</select> <option>light</option>
</div> </select>
<div class="form-group"> </div>
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label> <div class="form-group">
</div> <label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
<div class="form-group"> </div>
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label> <div class="form-group">
</div> <label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
<div class="form-group text-right"> </div>
<button class="btn btn-primary" @click="login" :disabled="loggingIn"> <div class="form-group" style="text-align:right">
{{l(loggingIn ? 'login.working' : 'login.submit')}} <button class="btn btn-primary" @click="login" :disabled="loggingIn">
</button> {{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat> <chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
<modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer"> <modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="false" :oldApi="true" :name="profileName"></character-page> <character-page :authenticated="false" :oldApi="true" :name="profileName"></character-page>
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
</template>
</modal> </modal>
</div> </div>
</template> </template>
@ -61,7 +65,7 @@
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection'; import Connection from '../fchat/connection';
import CharacterPage from '../site/character_page/character_page.vue'; import CharacterPage from '../site/character_page/character_page.vue';
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem'; import {appVersion, GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import Notifications from './notifications'; import Notifications from './notifications';
declare global { declare global {
@ -70,10 +74,15 @@
setTheme(theme: string): void setTheme(theme: string): void
} | undefined; } | undefined;
} }
const NativeBackground: {
start(): void
stop(): void
};
} }
function confirmBack(): void { function confirmBack(e: Event): void {
if(confirm(l('chat.confirmLeave'))) (<Navigator & {app: {exitApp(): void}}>navigator).app.exitApp(); if(!confirm(l('chat.confirmLeave'))) e.preventDefault();
} }
@Component({ @Component({
@ -94,25 +103,26 @@
profileName = ''; profileName = '';
async created(): Promise<void> { async created(): Promise<void> {
const oldOpen = window.open.bind(window); document.addEventListener('open-profile', (e: Event) => {
window.open = (url?: string, target?: string, features?: string, replace?: boolean) => { const profileViewer = <Modal>this.$refs['profileViewer'];
const profileMatch = url !== undefined ? url.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/) : null; this.profileName = (<Event & {detail: string}>e).detail;
if(profileMatch !== null) { profileViewer.show();
const profileViewer = <Modal>this.$refs['profileViewer']; });
this.profileName = profileMatch[2];
profileViewer.show();
return null;
} else return oldOpen(url, target, features, replace);
};
let settings = await getGeneralSettings(); let settings = await getGeneralSettings();
if(settings === undefined) settings = new GeneralSettings(); if(settings === undefined) settings = new GeneralSettings();
if(settings.version !== appVersion) {
alert('Your beta version of F-Chat 3.0 has been updated. If you are experiencing any issues after this update, please perform a full reinstall of the application. If the issue persists, please report it.');
settings.version = appVersion;
await setGeneralSettings(settings);
}
if(settings.account.length > 0) this.saveLogin = true; if(settings.account.length > 0) this.saveLogin = true;
this.settings = settings; this.settings = settings;
} }
get styling(): string { get styling(): string {
if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings!.theme); if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings!.theme);
return `<style>${require(`../less/themes/chat/${this.settings!.theme}.less`)}</style>`; //tslint:disable-next-line:no-require-imports
return `<style>${require('../scss/fa.scss')}${require(`../scss/themes/chat/${this.settings!.theme}.scss`)}</style>`;
} }
async login(): Promise<void> { async login(): Promise<void> {
@ -130,16 +140,17 @@
} }
if(this.saveLogin) await setGeneralSettings(this.settings!); if(this.saveLogin) await setGeneralSettings(this.settings!);
Socket.host = this.settings!.host; Socket.host = this.settings!.host;
const version = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports const connection = new Connection('F-Chat 3.0 (Mobile)', appVersion, Socket,
const connection = new Connection(`F-Chat 3.0 (Mobile)`, version, Socket,
this.settings!.account, this.settings!.password); this.settings!.account, this.settings!.password);
connection.onEvent('connected', () => { connection.onEvent('connected', () => {
Raven.setUserContext({username: core.connection.character}); Raven.setUserContext({username: core.connection.character});
document.addEventListener('backbutton', confirmBack); document.addEventListener('backbutton', confirmBack);
NativeBackground.start();
}); });
connection.onEvent('closed', () => { connection.onEvent('closed', () => {
Raven.setUserContext(); Raven.setUserContext();
document.removeEventListener('backbutton', confirmBack); document.removeEventListener('backbutton', confirmBack);
NativeBackground.stop();
}); });
initCore(connection, Logs, SettingsStore, Notifications); initCore(connection, Logs, SettingsStore, Notifications);
const charNames = Object.keys(data.characters); const charNames = Object.keys(data.characters);
@ -157,6 +168,10 @@
this.loggingIn = false; this.loggingIn = false;
} }
} }
openProfileInBrowser(): void {
window.open(`profile://${this.profileName}`);
}
} }
</script> </script>
@ -164,4 +179,8 @@
html, body, #page { html, body, #page {
height: 100%; height: 100%;
} }
html, .modal {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
</style> </style>

View File

@ -8,8 +8,8 @@ android {
applicationId "net.f_list.fchat" applicationId "net.f_list.fchat"
minSdkVersion 19 minSdkVersion 19
targetSdkVersion 27 targetSdkVersion 27
versionCode 4 versionCode 11
versionName "0.1.0" versionName "0.1.4"
} }
buildTypes { buildTypes {
release { release {

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -13,12 +14,13 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Holo.NoActionBar"> android:theme="@android:style/Theme.Holo.NoActionBar">
<activity android:name=".MainActivity" android:launchMode="singleInstance" <activity android:name=".MainActivity" android:launchMode="singleInstance"
android:configChanges="orientation|screenSize"> android:configChanges="density|fontScale|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".BackgroundService" />
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,20 @@
package net.f_list.fchat
import android.content.Context
import android.content.Intent
import android.webkit.JavascriptInterface
class Background(private val ctx: Context) {
private val serviceIntent: Intent by lazy { Intent(ctx, BackgroundService::class.java) }
@JavascriptInterface
fun start() {
ctx.startService(serviceIntent)
}
@JavascriptInterface
fun stop() {
ctx.stopService(serviceIntent)
}
}

View File

@ -0,0 +1,35 @@
package net.f_list.fchat
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
class BackgroundService : Service() {
override fun onBind(p0: Intent?): IBinder? {
return null
}
override fun onCreate() {
super.onCreate()
val notification = Notification.Builder(this).setContentTitle(getString(R.string.app_name))
.setContentIntent(PendingIntent.getActivity(this, 1, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT))
.setSmallIcon(R.drawable.ic_notification).setAutoCancel(true).setPriority(Notification.PRIORITY_LOW)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
manager.createNotificationChannel(NotificationChannel("background", getString(R.string.channel_background), NotificationManager.IMPORTANCE_LOW));
notification.setChannelId("background");
}
startForeground(1, notification.build())
}
override fun onDestroy() {
super.onDestroy()
stopForeground(true)
}
}

View File

@ -4,48 +4,30 @@ import android.content.Context
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import org.json.JSONArray import org.json.JSONArray
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.*
class File(private val ctx: Context) { class File(private val ctx: Context) {
@JavascriptInterface @JavascriptInterface
fun readFile(name: String, s: Long, l: Int): String? { fun read(name: String): String? {
val file = File(ctx.filesDir, name) val file = File(ctx.filesDir, name)
if(!file.exists()) return null if(!file.exists()) return null
FileInputStream(file).use { fs -> Scanner(file).useDelimiter("\\Z").use { return it.next() }
val start = if(s != -1L) s else 0
fs.channel.position(start)
val maxLength = fs.channel.size() - start
val length = if(l != -1 && l < maxLength) l else maxLength.toInt()
val bytes = ByteArray(length)
fs.read(bytes, 0, length)
return String(bytes)
}
}
@JavascriptInterface
fun readFile(name: String): String? {
return readFile(name, -1, -1)
} }
@JavascriptInterface @JavascriptInterface
fun getSize(name: String) = File(ctx.filesDir, name).length() fun getSize(name: String) = File(ctx.filesDir, name).length()
@JavascriptInterface @JavascriptInterface
fun writeFile(name: String, data: String) { fun write(name: String, data: String) {
FileOutputStream(File(ctx.filesDir, name)).use { it.write(data.toByteArray()) } FileOutputStream(File(ctx.filesDir, name)).use { it.write(data.toByteArray()) }
} }
@JavascriptInterface @JavascriptInterface
fun append(name: String, data: String) { fun listFilesN(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString()
FileOutputStream(File(ctx.filesDir, name), true).use { it.write(data.toByteArray()) }
}
@JavascriptInterface @JavascriptInterface
fun listFiles(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString() fun listDirectoriesN(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
@JavascriptInterface
fun listDirectories(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
@JavascriptInterface @JavascriptInterface
fun ensureDirectory(name: String) { fun ensureDirectory(name: String) {

View File

@ -0,0 +1,172 @@
package net.f_list.fchat
import android.content.Context
import android.webkit.JavascriptInterface
import org.json.JSONArray
import org.json.JSONStringer
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.CharBuffer
import java.util.*
class Logs(private val ctx: Context) {
data class IndexItem(val name: String, val index: MutableMap<Int, Long> = HashMap(), val dates: MutableList<Int> = LinkedList())
private lateinit var index: MutableMap<String, IndexItem>
private lateinit var baseDir: File
private val encoder = Charsets.UTF_8.newEncoder()
private val decoder = Charsets.UTF_8.newDecoder()
private val buffer = ByteBuffer.allocateDirect(51000).order(ByteOrder.LITTLE_ENDIAN)
@JavascriptInterface
fun initN(character: String): String {
baseDir = File(ctx.filesDir, "$character/logs")
baseDir.mkdirs()
val files = baseDir.listFiles({ _, name -> name.endsWith(".idx") })
index = HashMap(files.size)
for(file in files) {
FileInputStream(file).use { stream ->
buffer.clear()
val read = stream.channel.read(buffer)
buffer.rewind()
val nameLength = buffer.get().toInt()
buffer.limit(nameLength + 1)
val cb = CharBuffer.allocate(nameLength)
decoder.reset()
decoder.decode(buffer, cb, true)
decoder.flush(cb)
cb.flip()
val indexItem = IndexItem(cb.toString())
buffer.limit(read)
while(buffer.position() < buffer.limit()) {
val key = buffer.short.toInt()
indexItem.index[key] = buffer.int.toLong() or (buffer.get().toLong() shl 32)
indexItem.dates.add(key)
}
index[file.nameWithoutExtension] = indexItem
}
}
val json = JSONStringer().`object`()
for(item in index) json.key(item.key).`object`().key("name").value(item.value.name).key("dates").value(JSONArray(item.value.dates)).endObject()
return json.endObject().toString()
}
@JavascriptInterface
fun logMessage(key: String, conversation: String, time: Int, type: Int, sender: String, text: String) {
val day = time / 86400
val file = File(baseDir, key)
buffer.clear()
if(!index.containsKey(key)) {
index[key] = IndexItem(conversation, HashMap())
buffer.position(1)
encoder.encode(CharBuffer.wrap(conversation), buffer, true)
buffer.put(0, (buffer.position() - 1).toByte())
}
val item = index[key]!!
if(!item.index.containsKey(day)) {
buffer.putShort(day.toShort())
val size = file.length()
item.index[day] = size
item.dates.add(day)
buffer.putInt((size and 0xffffffffL).toInt())
buffer.put((size shr 32).toByte())
FileOutputStream(File(baseDir, "$key.idx"), true).use { file ->
buffer.flip()
file.channel.write(buffer)
}
}
FileOutputStream(file, true).use { file ->
buffer.clear()
buffer.putInt(time)
buffer.put(type.toByte())
buffer.position(6)
encoder.encode(CharBuffer.wrap(sender), buffer, true)
val senderLength = buffer.position() - 6
buffer.put(5, senderLength.toByte())
buffer.position(8 + senderLength)
encoder.encode(CharBuffer.wrap(text), buffer, true)
buffer.putShort(senderLength + 6, (buffer.position() - senderLength - 8).toShort())
buffer.putShort(buffer.position().toShort())
buffer.flip()
file.channel.write(buffer)
}
}
@JavascriptInterface
fun getBacklogN(key: String): String {
buffer.clear()
val file = File(baseDir, key)
if(!file.exists()) return "[]"
val list = LinkedList<JSONStringer>()
FileInputStream(file).use { stream ->
val channel = stream.channel
val lengthBuffer = ByteBuffer.allocateDirect(4).order(ByteOrder.LITTLE_ENDIAN)
channel.position(channel.size())
while(channel.position() > 0 && list.size < 20) {
lengthBuffer.rewind()
lengthBuffer.limit(2)
channel.position(channel.position() - 2)
channel.read(lengthBuffer)
lengthBuffer.clear()
val length = lengthBuffer.int
channel.position(channel.position() - length - 2)
buffer.rewind()
buffer.limit(length)
channel.read(buffer)
buffer.rewind()
val stringer = JSONStringer()
deserializeMessage(buffer, stringer)
list.addFirst(stringer)
channel.position(channel.position() - length)
}
}
val json = StringBuilder("[")
for(item in list) json.append(item).append(",")
json.setLength(json.length - 1)
return json.append("]").toString()
}
@JavascriptInterface
fun getLogsN(key: String, date: Int): String {
val offset = index[key]?.index?.get(date) ?: return "[]"
val json = JSONStringer()
json.array()
FileInputStream(File(baseDir, key)).use { stream ->
val channel = stream.channel
channel.position(offset)
while(channel.position() < channel.size()) {
buffer.clear()
val oldPosition = channel.position()
channel.read(buffer)
buffer.rewind()
deserializeMessage(buffer, json, date)
if(buffer.position() == 0) break
channel.position(oldPosition + buffer.position() + 2)
}
}
return json.endArray().toString()
}
private fun deserializeMessage(buffer: ByteBuffer, json: JSONStringer, checkDate: Int = -1) {
val date = buffer.int
if(checkDate != -1 && date / 86400 != checkDate) return
json.`object`()
json.key("time")
json.value(date)
json.key("type")
json.value(buffer.get())
json.key("sender")
val senderLength = buffer.get()
buffer.limit(6 + senderLength)
json.value(decoder.decode(buffer))
buffer.limit(buffer.capacity())
val textLength = buffer.short.toInt() and 0xffff
json.key("text")
buffer.limit(8 + senderLength + textLength)
json.value(decoder.decode(buffer))
json.endObject()
}
}

View File

@ -1,31 +1,100 @@
package net.f_list.fchat package net.f_list.fchat
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup
import android.webkit.JsResult
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient
import java.net.URLDecoder
class MainActivity : Activity() { class MainActivity : Activity() {
private lateinit var webView: WebView private lateinit var webView: WebView
private val profileRegex = Regex("^https?://(www\\.)?f-list.net/c/(.+)/?#?")
private val backgroundPlugin = Background(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
if(BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)
webView = findViewById(R.id.webview) webView = findViewById(R.id.webview)
webView.settings.javaScriptEnabled = true webView.settings.javaScriptEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false webView.settings.mediaPlaybackRequiresUserGesture = false
webView.loadUrl("file:///android_asset/www/index.html") webView.loadUrl("file:///android_asset/www/index.html")
webView.addJavascriptInterface(File(this), "NativeFile") webView.addJavascriptInterface(File(this), "NativeFile")
webView.addJavascriptInterface(Notifications(this), "NativeNotification") webView.addJavascriptInterface(Notifications(this), "NativeNotification")
webView.webChromeClient = WebChromeClient() webView.addJavascriptInterface(backgroundPlugin, "NativeBackground")
webView.addJavascriptInterface(Logs(this), "NativeLogs")
webView.webChromeClient = object : WebChromeClient() {
override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean {
AlertDialog.Builder(this@MainActivity).setTitle(R.string.app_name).setMessage(message).setPositiveButton(R.string.ok, { _, _ -> result.confirm() }).show()
return true
}
override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean {
var ok = false
AlertDialog.Builder(this@MainActivity).setTitle(R.string.app_name).setMessage(message).setOnDismissListener({ if(ok) result.confirm() else result.cancel() })
.setPositiveButton(R.string.ok, { _, _ -> ok = true}).setNegativeButton(R.string.cancel, null).show()
return true
}
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
val match = profileRegex.find(url)
if(match != null) {
val char = URLDecoder.decode(match.groupValues[2], "UTF-8")
webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('open-profile',{detail:'$char'}))", null)
} else {
var uri = Uri.parse(url)
if(uri.scheme == "profile") uri = Uri.parse("https://www.f-list.net/c/${uri.authority}")
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
return true
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
webView.evaluateJavascript("(function(n){n.listFiles=function(p){return JSON.parse(n.listFilesN(p))};n.listDirectories=function(p){return JSON.parse(n.listDirectoriesN(p))}})(NativeFile)", null)
webView.evaluateJavascript("(function(n){n.init=function(c){return JSON.parse(n.initN(c))};n.getBacklog=function(k){return JSON.parse(n.getBacklogN(k))};n.getLogs=function(k,d){return JSON.parse(n.getLogsN(k,d))}})(NativeLogs)", null)
}
}
}
override fun onBackPressed() {
webView.evaluateJavascript("var e=new Event('backbutton',{cancelable:true});document.dispatchEvent(e);e.defaultPrevented", {
if(it != "true") super.onBackPressed()
});
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if(intent.action == "notification") { if(intent.action == "notification") {
val data = intent.extras.getString("data") val data = intent.extras.getString("data")
webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('notification-clicked',{detail:{data:'$data'}}))", {}) //TODO webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('notification-clicked',{detail:{data:'$data'}}))", null)
} }
} }
override fun onResume() {
super.onResume()
webView.requestFocus()
}
override fun onPause() {
super.onPause()
webView.clearFocus()
}
override fun onDestroy() {
super.onDestroy()
findViewById<ViewGroup>(R.id.content).removeAllViews()
webView.removeAllViews()
webView.destroy()
backgroundPlugin.stop()
}
} }

View File

@ -1,6 +1,7 @@
package net.f_list.fchat package net.f_list.fchat
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
@ -9,21 +10,30 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaPlayer import android.media.MediaPlayer
import android.net.Uri
import android.os.AsyncTask import android.os.AsyncTask
import android.os.Build
import android.os.Vibrator import android.os.Vibrator
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import java.net.URL import java.net.URL
class Notifications(private val ctx: Context) { class Notifications(private val ctx: Context) {
init {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
manager.createNotificationChannel(NotificationChannel("messages", ctx.getString(R.string.channel_messages), NotificationManager.IMPORTANCE_LOW))
}
}
@JavascriptInterface @JavascriptInterface
fun notify(notify: Boolean, title: String, text: String, icon: String, sound: String?, data: String?): Int { fun notify(notify: Boolean, title: String, text: String, icon: String, sound: String?, data: String?): Int {
val soundUri = if(sound != null) Uri.parse("file://android_asset/www/sounds/$sound.mp3") else null
if(!notify) { if(!notify) {
(ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator).vibrate(400) val vibrator = (ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
vibrator.vibrate(400, Notification.AUDIO_ATTRIBUTES_DEFAULT)
else vibrator.vibrate(400)
return 0 return 0
} }
if(soundUri != null) { if(sound != null) {
val player = MediaPlayer() val player = MediaPlayer()
val asset = ctx.assets.openFd("www/sounds/$sound.mp3") val asset = ctx.assets.openFd("www/sounds/$sound.mp3")
player.setDataSource(asset.fileDescriptor, asset.startOffset, asset.length) player.setDataSource(asset.fileDescriptor, asset.startOffset, asset.length)
@ -34,8 +44,9 @@ class Notifications(private val ctx: Context) {
val intent = Intent(ctx, MainActivity::class.java) val intent = Intent(ctx, MainActivity::class.java)
intent.action = "notification" intent.action = "notification"
intent.putExtra("data", data) intent.putExtra("data", data)
val notification = Notification.Builder(ctx).setContentTitle(title).setContentText(text).setSmallIcon(R.drawable.ic_notification).setDefaults(Notification.DEFAULT_VIBRATE) val notification = Notification.Builder(ctx).setContentTitle(title).setContentText(text).setSmallIcon(R.drawable.ic_notification).setAutoCancel(true)
.setContentIntent(PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)).setAutoCancel(true) .setContentIntent(PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)).setDefaults(Notification.DEFAULT_VIBRATE or Notification.DEFAULT_LIGHTS)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) notification.setChannelId("messages")
object : AsyncTask<String, Void, Bitmap>() { object : AsyncTask<String, Void, Bitmap>() {
override fun doInBackground(vararg args: String): Bitmap { override fun doInBackground(vararg args: String): Bitmap {
val connection = URL(args[0]).openConnection() val connection = URL(args[0]).openConnection()
@ -44,10 +55,10 @@ class Notifications(private val ctx: Context) {
override fun onPostExecute(result: Bitmap?) { override fun onPostExecute(result: Bitmap?) {
notification.setLargeIcon(result) notification.setLargeIcon(result)
(ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(1, notification.build()) (ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(2, notification.build())
} }
}.execute(icon) }.execute(icon)
return 1 return 2
} }
@JavascriptInterface @JavascriptInterface

View File

@ -4,6 +4,9 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:id="@+id/content"
android:focusable="true"
android:focusableInTouchMode="true"
tools:context="net.f_list.fchat.MainActivity"> tools:context="net.f_list.fchat.MainActivity">
<WebView <WebView

View File

@ -1,3 +1,7 @@
<resources> <resources>
<string name="app_name">F-Chat</string> <string name="app_name">F-Chat</string>
<string name="channel_background">Running in Background</string>
<string name="channel_messages">Messages</string>
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
</resources> </resources>

View File

@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.2.10' ext.kotlin_version = '1.2.21'
repositories { repositories {
jcenter() jcenter()
} }

View File

@ -29,9 +29,6 @@
* @version 3.0 * @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo} * @see {@link https://github.com/f-list/exported|GitHub repo}
*/ */
import 'bootstrap/js/dropdown.js';
import 'bootstrap/js/modal.js';
import 'bootstrap/js/tab.js';
import * as Raven from 'raven-js'; import * as Raven from 'raven-js';
import Vue from 'vue'; import Vue from 'vue';
import VueRaven from '../chat/vue-raven'; import VueRaven from '../chat/vue-raven';

View File

@ -1,150 +1,72 @@
import {getByteLength, Message as MessageImpl} from '../chat/common'; import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core'; import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces'; import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
declare global { declare global {
const NativeFile: { const NativeFile: {
readFile(name: string): Promise<string | undefined> read(name: string): Promise<string | undefined>
readFile(name: string, start: number, length: number): Promise<string | undefined> write(name: string, data: string): Promise<void>
writeFile(name: string, data: string): Promise<void> listDirectories(name: string): Promise<string[]>
listDirectories(name: string): Promise<string> listFiles(name: string): Promise<string[]>
listFiles(name: string): Promise<string>
getSize(name: string): Promise<number> getSize(name: string): Promise<number>
append(name: string, data: string): Promise<void>
ensureDirectory(name: string): Promise<void> ensureDirectory(name: string): Promise<void>
}; };
type NativeMessage = {time: number, type: number, sender: string, text: string};
const NativeLogs: {
init(character: string): Promise<Index>
logMessage(key: string, conversation: string, time: number, type: Conversation.Message.Type, sender: string,
message: string): Promise<void>;
getBacklog(key: string): Promise<ReadonlyArray<NativeMessage>>;
getLogs(key: string, date: number): Promise<ReadonlyArray<NativeMessage>>
};
} }
const dayMs = 86400000; const dayMs = 86400000;
export const appVersion = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
export class GeneralSettings { export class GeneralSettings {
account = ''; account = '';
password = ''; password = '';
host = 'wss://chat.f-list.net:9799'; host = 'wss://chat.f-list.net:9799';
theme = 'default'; theme = 'default';
version = appVersion;
} }
type Index = {[key: string]: {name: string, index: {[key: number]: number | undefined}} | undefined}; type Index = {[key: string]: {name: string, dates: number[]} | undefined};
function serializeMessage(message: Conversation.Message): string {
const time = message.time.getTime() / 1000;
let str = String.fromCharCode((time >> 24) % 256) + String.fromCharCode((time >> 16) % 256)
+ String.fromCharCode((time >> 8) % 256) + String.fromCharCode(time % 256);
str += String.fromCharCode(message.type);
if(message.type !== Conversation.Message.Type.Event) {
str += String.fromCharCode(message.sender.name.length);
str += message.sender.name;
} else str += '\0';
const textLength = message.text.length;
str += String.fromCharCode((textLength >> 8) % 256) + String.fromCharCode(textLength % 256);
str += message.text;
const length = getByteLength(str);
str += String.fromCharCode((length >> 8) % 256) + String.fromCharCode(length % 256);
return str;
}
function deserializeMessage(str: string): {message: Conversation.Message, end: number} {
let index = 0;
const time = str.charCodeAt(index++) << 24 | str.charCodeAt(index++) << 16 | str.charCodeAt(index++) << 8 | str.charCodeAt(index++);
const type = str.charCodeAt(index++);
const senderLength = str.charCodeAt(index++);
const sender = str.substring(index, index += senderLength);
const messageLength = str.charCodeAt(index++) << 8 | str.charCodeAt(index++);
const text = str.substring(index, index += messageLength);
const end = str.charCodeAt(index++) << 8 | str.charCodeAt(index);
return {message: new MessageImpl(type, core.characters.get(sender), text, new Date(time * 1000)), end: end + 2};
}
export class Logs implements Logging.Persistent { export class Logs implements Logging.Persistent {
private index: Index = {}; private index: Index = {};
private logDir: string;
constructor() { constructor() {
core.connection.onEvent('connecting', async() => { core.connection.onEvent('connecting', async() => {
this.index = {}; this.index = await NativeLogs.init(core.connection.character);
this.logDir = `${core.connection.character}/logs`;
await NativeFile.ensureDirectory(this.logDir);
const entries = <string[]>JSON.parse(await NativeFile.listFiles(this.logDir));
for(const entry of entries)
if(entry.substr(-4) === '.idx') {
const str = (await NativeFile.readFile(`${this.logDir}/${entry}`))!;
let i = str.charCodeAt(0);
const name = str.substr(1, i++);
const index: {[key: number]: number} = {};
while(i < str.length) {
const key = str.charCodeAt(i++) << 8 | str.charCodeAt(i++);
index[key] = str.charCodeAt(i++) << 32 | str.charCodeAt(i++) << 24 | str.charCodeAt(i++) << 16 |
str.charCodeAt(i++) << 8 | str.charCodeAt(i++);
}
this.index[entry.slice(0, -4).toLowerCase()] = {name, index};
}
}); });
} }
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> { async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
const file = `${this.logDir}/${conversation.key}`; const time = message.time.getTime();
const serialized = serializeMessage(message); const date = Math.floor(time / dayMs);
const date = Math.floor(message.time.getTime() / dayMs);
let indexBuffer: string | undefined;
let index = this.index[conversation.key]; let index = this.index[conversation.key];
if(index !== undefined) { if(index === undefined) index = this.index[conversation.key] = {name: conversation.name, dates: []};
if(index.index[date] === undefined) indexBuffer = ''; if(index.dates[index.dates.length - 1] !== date) index.dates.push(date);
} else { return NativeLogs.logMessage(conversation.key, conversation.name, time / 1000, message.type,
index = this.index[conversation.key] = {name: conversation.name, index: {}}; message.type === Conversation.Message.Type.Event ? '' : message.sender.name, message.text);
const nameLength = getByteLength(conversation.name);
indexBuffer = String.fromCharCode(nameLength) + conversation.name;
}
if(indexBuffer !== undefined) {
const size = await NativeFile.getSize(file);
index.index[date] = size;
indexBuffer += String.fromCharCode((date >> 8) % 256) + String.fromCharCode(date % 256) +
String.fromCharCode((size >> 32) % 256) + String.fromCharCode((size >> 24) % 256) +
String.fromCharCode((size >> 16) % 256) + String.fromCharCode((size >> 8) % 256) + String.fromCharCode(size % 256);
await NativeFile.append(`${file}.idx`, indexBuffer);
}
await NativeFile.append(file, serialized);
} }
async getBacklog(conversation: Conversation): Promise<Conversation.Message[]> { async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
const file = `${this.logDir}/${conversation.key}`; return (await NativeLogs.getBacklog(conversation.key))
let count = 20; .map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
let messages = new Array<Conversation.Message>(count);
let pos = await NativeFile.getSize(file);
while(pos > 0 && count > 0) {
const l = (await NativeFile.readFile(file, pos - 2, pos))!;
const length = (l.charCodeAt(0) << 8 | l.charCodeAt(1));
pos = pos - length - 2;
messages[--count] = deserializeMessage((await NativeFile.readFile(file, pos, length))!).message;
}
if(count !== 0) messages = messages.slice(count);
return messages;
} }
async getLogs(key: string, date: Date): Promise<Conversation.Message[]> { async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
const file = `${this.logDir}/${key}`; return (await NativeLogs.getLogs(key, date.getTime() / dayMs))
const messages: Conversation.Message[] = []; .map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
const day = date.getTime() / dayMs;
const index = this.index[key];
if(index === undefined) return [];
let pos = index.index[date.getTime() / dayMs];
if(pos === undefined) return [];
const size = await NativeFile.getSize(file);
while(pos < size) {
const deserialized = deserializeMessage((await NativeFile.readFile(file, pos, 51000))!);
if(Math.floor(deserialized.message.time.getTime() / dayMs) !== day) break;
messages.push(deserialized.message);
pos += deserialized.end;
}
return messages;
} }
getLogDates(key: string): ReadonlyArray<Date> { getLogDates(key: string): ReadonlyArray<Date> {
const entry = this.index[key]; const entry = this.index[key];
if(entry === undefined) return []; if(entry === undefined) return [];
const dates = []; return entry.dates.map((x) => new Date(x * dayMs));
for(const date in entry.index)
dates.push(new Date(parseInt(date, 10) * dayMs));
return dates;
} }
get conversations(): ReadonlyArray<{id: string, name: string}> { get conversations(): ReadonlyArray<{id: string, name: string}> {
@ -156,27 +78,27 @@ export class Logs implements Logging.Persistent {
} }
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> { export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
const file = await NativeFile.readFile('!settings'); const file = await NativeFile.read('!settings');
if(file === undefined) return undefined; if(file === undefined) return undefined;
return <GeneralSettings>JSON.parse(file); return <GeneralSettings>JSON.parse(file);
} }
export async function setGeneralSettings(value: GeneralSettings): Promise<void> { export async function setGeneralSettings(value: GeneralSettings): Promise<void> {
return NativeFile.writeFile('!settings', JSON.stringify(value)); return NativeFile.write('!settings', JSON.stringify(value));
} }
export class SettingsStore implements Settings.Store { export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character: string = core.connection.character): Promise<Settings.Keys[K] | undefined> { async get<K extends keyof Settings.Keys>(key: K, character: string = core.connection.character): Promise<Settings.Keys[K] | undefined> {
const file = await NativeFile.readFile(`${character}/${key}`); const file = await NativeFile.read(`${character}/${key}`);
if(file === undefined) return undefined; if(file === undefined) return undefined;
return <Settings.Keys[K]>JSON.parse(file); return <Settings.Keys[K]>JSON.parse(file);
} }
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> { async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
return NativeFile.writeFile(`${core.connection.character}/${key}`, JSON.stringify(value)); return NativeFile.write(`${core.connection.character}/${key}`, JSON.stringify(value));
} }
async getAvailableCharacters(): Promise<string[]> { async getAvailableCharacters(): Promise<string[]> {
return <string[]>JSON.parse(await NativeFile.listDirectories('/')); return NativeFile.listDirectories('/');
} }
} }

View File

@ -8,7 +8,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
6C28207F1FF5839A00AB9E78 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6C2820811FF5839A00AB9E78 /* Localizable.strings */; }; 6C28207F1FF5839A00AB9E78 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6C2820811FF5839A00AB9E78 /* Localizable.strings */; };
6C5C1C591FF14432006A3BA1 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5C1C581FF14432006A3BA1 /* View.swift */; }; 6C4C230D201E7DF1009B3460 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4C230C201E7DF1009B3460 /* Background.swift */; };
6C8ED6192024A820007685DA /* Logs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8ED6182024A820007685DA /* Logs.swift */; };
6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */; }; 6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */; };
6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAD1FEFEE7800183A1A /* ViewController.swift */; }; 6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAD1FEFEE7800183A1A /* ViewController.swift */; };
6CA94BB11FEFEE7800183A1A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CA94BAF1FEFEE7800183A1A /* Main.storyboard */; }; 6CA94BB11FEFEE7800183A1A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CA94BAF1FEFEE7800183A1A /* Main.storyboard */; };
@ -22,7 +23,8 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
6C2820801FF5839A00AB9E78 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; 6C2820801FF5839A00AB9E78 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6C5C1C581FF14432006A3BA1 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; }; 6C4C230C201E7DF1009B3460 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = "<group>"; };
6C8ED6182024A820007685DA /* Logs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logs.swift; sourceTree = "<group>"; };
6CA94BA81FEFEE7800183A1A /* F-Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "F-Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 6CA94BA81FEFEE7800183A1A /* F-Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "F-Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
6CA94BAD1FEFEE7800183A1A /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; }; 6CA94BAD1FEFEE7800183A1A /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
@ -66,18 +68,19 @@
6CA94BAA1FEFEE7800183A1A /* F-Chat */ = { 6CA94BAA1FEFEE7800183A1A /* F-Chat */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6C2820811FF5839A00AB9E78 /* Localizable.strings */,
6CA94BBD1FEFF2C200183A1A /* www */, 6CA94BBD1FEFF2C200183A1A /* www */,
6CA94BBF1FEFFC2F00183A1A /* native.js */,
6CA94BB71FEFEE7800183A1A /* Info.plist */,
6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */, 6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */,
6C4C230C201E7DF1009B3460 /* Background.swift */,
6CA94BC11FF009B000183A1A /* File.swift */,
6C8ED6182024A820007685DA /* Logs.swift */,
6CA94BC31FF070C800183A1A /* Notification.swift */,
6CA94BAD1FEFEE7800183A1A /* ViewController.swift */, 6CA94BAD1FEFEE7800183A1A /* ViewController.swift */,
6CA94BAF1FEFEE7800183A1A /* Main.storyboard */,
6CA94BB21FEFEE7800183A1A /* Assets.xcassets */, 6CA94BB21FEFEE7800183A1A /* Assets.xcassets */,
6CA94BB41FEFEE7800183A1A /* LaunchScreen.storyboard */, 6CA94BB41FEFEE7800183A1A /* LaunchScreen.storyboard */,
6CA94BB71FEFEE7800183A1A /* Info.plist */, 6C2820811FF5839A00AB9E78 /* Localizable.strings */,
6CA94BBF1FEFFC2F00183A1A /* native.js */, 6CA94BAF1FEFEE7800183A1A /* Main.storyboard */,
6CA94BC11FF009B000183A1A /* File.swift */,
6CA94BC31FF070C800183A1A /* Notification.swift */,
6C5C1C581FF14432006A3BA1 /* View.swift */,
); );
path = "F-Chat"; path = "F-Chat";
sourceTree = "<group>"; sourceTree = "<group>";
@ -164,9 +167,10 @@
files = ( files = (
6CA94BC41FF070C800183A1A /* Notification.swift in Sources */, 6CA94BC41FF070C800183A1A /* Notification.swift in Sources */,
6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */, 6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */,
6C5C1C591FF14432006A3BA1 /* View.swift in Sources */,
6CA94BC21FF009B000183A1A /* File.swift in Sources */, 6CA94BC21FF009B000183A1A /* File.swift in Sources */,
6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */, 6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */,
6C8ED6192024A820007685DA /* Logs.swift in Sources */,
6C4C230D201E7DF1009B3460 /* Background.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -101,7 +101,7 @@
{ {
"idiom" : "ios-marketing", "idiom" : "ios-marketing",
"size" : "1024x1024", "size" : "1024x1024",
"filename" : "icon-1024.png", "filename" : "icon-1024.jpg",
"scale" : "1x" "scale" : "1x"
} }
], ],

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

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