0.2.17 - Webpack 4, Bootstrap 4, remove jquery
This commit is contained in:
parent
690ae19404
commit
04ab2f96da
|
@ -1,27 +1,32 @@
|
|||
<template>
|
||||
<div class="bbcodeEditorContainer">
|
||||
<div class="bbcode-editor">
|
||||
<slot></slot>
|
||||
<a tabindex="0" class="btn bbcodeEditorButton bbcode-btn" role="button" @click="showToolbar = true" @blur="showToolbar = false">
|
||||
<span class="fa fa-code"></span></a>
|
||||
<div class="bbcode-toolbar" role="toolbar" :style="showToolbar ? 'display:block' : ''" @mousedown.stop.prevent>
|
||||
<a tabindex="0" class="btn btn-secondary bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false"
|
||||
style="border-bottom-left-radius:0;border-bottom-right-radius:0">
|
||||
<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">×</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 class="bbcodeEditorTextarea">
|
||||
<div class="bbcode-editor-text-area">
|
||||
<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>
|
||||
<div class="bbcodePreviewArea" v-show="preview">
|
||||
<div class="bbcodePreviewHeader">
|
||||
<ul class="bbcodePreviewWarnings" v-show="previewWarnings.length">
|
||||
<div ref="sizer"></div>
|
||||
<div class="bbcode-preview" v-show="preview">
|
||||
<div class="bbcode-preview-warnings">
|
||||
<div class="alert alert-danger" v-show="previewWarnings.length">
|
||||
<li v-for="warning in previewWarnings">{{warning}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bbcode" ref="preview-element"></div>
|
||||
</div>
|
||||
|
@ -35,6 +40,7 @@
|
|||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import {BBCodeElement} from '../chat/bbcode';
|
||||
import {getKey} from '../chat/common';
|
||||
import {Keys} from '../keys';
|
||||
import {CoreBBCodeParser, urlRegex} from './core';
|
||||
import {defaultButtons, EditorButton, EditorSelection} from './editor';
|
||||
import {BBCodeParser} from './parser';
|
||||
|
@ -44,7 +50,7 @@
|
|||
@Prop()
|
||||
readonly extras?: EditorButton[];
|
||||
@Prop({default: 1000})
|
||||
readonly maxlength: number;
|
||||
readonly maxlength!: number;
|
||||
@Prop()
|
||||
readonly classes?: string;
|
||||
@Prop()
|
||||
|
@ -53,15 +59,18 @@
|
|||
readonly disabled?: boolean;
|
||||
@Prop()
|
||||
readonly placeholder?: string;
|
||||
@Prop({default: false, type: Boolean})
|
||||
readonly invalid!: boolean;
|
||||
preview = false;
|
||||
previewWarnings: ReadonlyArray<string> = [];
|
||||
previewResult = '';
|
||||
text = this.value !== undefined ? this.value : '';
|
||||
element: HTMLTextAreaElement;
|
||||
maxHeight: number;
|
||||
minHeight: number;
|
||||
element!: HTMLTextAreaElement;
|
||||
sizer!: HTMLElement;
|
||||
maxHeight!: number;
|
||||
minHeight!: number;
|
||||
showToolbar = false;
|
||||
protected parser: BBCodeParser;
|
||||
protected parser!: BBCodeParser;
|
||||
protected defaultButtons = defaultButtons;
|
||||
private isShiftPressed = false;
|
||||
private undoStack: string[] = [];
|
||||
|
@ -74,16 +83,31 @@
|
|||
|
||||
mounted(): void {
|
||||
this.element = <HTMLTextAreaElement>this.$refs['input'];
|
||||
const $element = $(this.element);
|
||||
this.maxHeight = parseInt($element.css('max-height'), 10);
|
||||
const styles = getComputedStyle(this.element);
|
||||
this.maxHeight = parseInt(styles.maxHeight! , 10);
|
||||
//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(() => {
|
||||
if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) {
|
||||
if(this.undoStack.length >= 30) this.undoStack.pop();
|
||||
this.undoStack.unshift(this.text);
|
||||
}
|
||||
}, 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[] {
|
||||
|
@ -169,15 +193,15 @@
|
|||
|
||||
onKeyDown(e: KeyboardEvent): void {
|
||||
const key = getKey(e);
|
||||
if((e.metaKey || e.ctrlKey) && !e.shiftKey && key !== 'control' && key !== 'meta') {
|
||||
if(key === 'z') {
|
||||
if((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
|
||||
if(key === Keys.KeyZ) {
|
||||
e.preventDefault();
|
||||
if(this.undoIndex === 0 && this.undoStack[0] !== this.text) this.undoStack.unshift(this.text);
|
||||
if(this.undoStack.length > this.undoIndex + 1) {
|
||||
this.text = this.undoStack[++this.undoIndex];
|
||||
this.lastInput = Date.now();
|
||||
}
|
||||
} else if(key === 'y') {
|
||||
} else if(key === Keys.KeyY) {
|
||||
e.preventDefault();
|
||||
if(this.undoIndex > 0) {
|
||||
this.text = this.undoStack[--this.undoIndex];
|
||||
|
@ -191,20 +215,20 @@
|
|||
this.apply(button);
|
||||
break;
|
||||
}
|
||||
} else if(key === 'shift') this.isShiftPressed = true;
|
||||
} else if(e.shiftKey) this.isShiftPressed = true;
|
||||
this.$emit('keydown', e);
|
||||
}
|
||||
|
||||
onKeyUp(e: KeyboardEvent): void {
|
||||
if(getKey(e) === 'shift') this.isShiftPressed = false;
|
||||
if(!e.shiftKey) this.isShiftPressed = false;
|
||||
this.$emit('keyup', e);
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
if(this.maxHeight > 0) {
|
||||
this.element.style.height = 'auto';
|
||||
this.element.style.height = `${Math.max(Math.min(this.element.scrollHeight + 5, this.maxHeight), this.minHeight)}px`;
|
||||
}
|
||||
this.sizer.style.fontSize = this.element.style.fontSize;
|
||||
this.sizer.style.lineHeight = this.element.style.lineHeight;
|
||||
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 {
|
||||
|
|
|
@ -54,7 +54,7 @@ export class CoreBBCodeParser extends BBCodeParser {
|
|||
} else if(content.length > 0) url = content;
|
||||
else {
|
||||
parser.warning('url tag contains no url.');
|
||||
element.textContent = ''; //Dafuq!?
|
||||
element.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -78,6 +78,7 @@ export class CoreBBCodeParser extends BBCodeParser {
|
|||
const span = document.createElement('span');
|
||||
span.className = 'link-domain';
|
||||
span.textContent = ` [${domain(url)}]`;
|
||||
(<HTMLElement & {bbcodeHide: true}>span).bbcodeHide = true;
|
||||
element.appendChild(span);
|
||||
}, []));
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import Vue from 'vue';
|
||||
import {Keys} from '../keys';
|
||||
|
||||
export interface EditorButton {
|
||||
title: string;
|
||||
tag: string;
|
||||
icon: string;
|
||||
key?: string;
|
||||
key?: Keys;
|
||||
class?: string;
|
||||
startText?: string;
|
||||
endText?: string;
|
||||
|
@ -23,74 +24,75 @@ export let defaultButtons: ReadonlyArray<EditorButton> = [
|
|||
title: 'Bold (Ctrl+B)\n\nMakes text appear with a bold weight.',
|
||||
tag: 'b',
|
||||
icon: 'fa-bold',
|
||||
key: 'b'
|
||||
key: Keys.KeyB
|
||||
},
|
||||
{
|
||||
title: 'Italic (Ctrl+I)\n\nMakes text appear with an italic style.',
|
||||
tag: 'i',
|
||||
icon: 'fa-italic',
|
||||
key: 'i'
|
||||
key: Keys.KeyI
|
||||
},
|
||||
{
|
||||
title: 'Underline (Ctrl+U)\n\nMakes text appear with an underline beneath it.',
|
||||
tag: 'u',
|
||||
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.',
|
||||
tag: 's',
|
||||
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.',
|
||||
tag: 'color',
|
||||
startText: '[color=]',
|
||||
icon: 'fa-eyedropper',
|
||||
key: 'd'
|
||||
icon: 'fa-eye-dropper',
|
||||
key: Keys.KeyD
|
||||
},
|
||||
{
|
||||
title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',
|
||||
tag: 'sup',
|
||||
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.',
|
||||
tag: 'sub',
|
||||
icon: 'fa-subscript',
|
||||
key: 'arrowdown'
|
||||
key: Keys.ArrowDown
|
||||
},
|
||||
{
|
||||
title: 'URL (Ctrl+L)\n\nCreates a clickable link to another page of your choosing.',
|
||||
tag: 'url',
|
||||
startText: '[url=]',
|
||||
icon: 'fa-link',
|
||||
key: 'l'
|
||||
key: Keys.KeyL
|
||||
},
|
||||
{
|
||||
title: 'User (Ctrl+R)\n\nLinks to a character\'s profile.',
|
||||
tag: 'user',
|
||||
icon: 'fa-user',
|
||||
key: 'r'
|
||||
key: Keys.KeyR
|
||||
},
|
||||
{
|
||||
title: 'Icon (Ctrl+O)\n\nShows a character\'s profile image, linking to their profile.',
|
||||
tag: 'icon',
|
||||
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.',
|
||||
tag: 'eicon',
|
||||
icon: 'fa-smile-o',
|
||||
key: 'e'
|
||||
class: 'far ',
|
||||
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.',
|
||||
tag: 'noparse',
|
||||
icon: 'fa-ban',
|
||||
key: 'n'
|
||||
key: Keys.KeyN
|
||||
}
|
||||
];
|
|
@ -26,7 +26,7 @@ export abstract class 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);
|
||||
}
|
||||
|
||||
|
@ -81,9 +81,9 @@ class ParserTag {
|
|||
export class BBCodeParser {
|
||||
private _warnings: string[] = [];
|
||||
private _tags: {[tag: string]: BBCodeTag | undefined} = {};
|
||||
private _line: number;
|
||||
private _column: number;
|
||||
private _currentTag: ParserTag;
|
||||
private _line = -1;
|
||||
private _column = -1;
|
||||
private _currentTag!: ParserTag;
|
||||
private _storeWarnings = false;
|
||||
|
||||
parseEverything(input: string): HTMLElement {
|
||||
|
@ -103,7 +103,7 @@ export class BBCodeParser {
|
|||
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);
|
||||
}
|
||||
|
||||
|
@ -218,6 +218,8 @@ export class BBCodeParser {
|
|||
quickReset(i);
|
||||
continue;
|
||||
}
|
||||
(<HTMLElement & {bbcodeTag: string}>el).bbcodeTag = tagKey;
|
||||
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>el).bbcodeParam = param;
|
||||
if(!this._tags[tagKey]!.noClosingTag)
|
||||
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
|
||||
} else if(ignoreClosing[tagKey] > 0) {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import * as $ from 'jquery';
|
||||
import {CoreBBCodeParser} from './core';
|
||||
import {InlineDisplayMode} from './interfaces';
|
||||
import {BBCodeCustomTag, BBCodeSimpleTag} from './parser';
|
||||
|
@ -8,6 +7,7 @@ interface InlineImage {
|
|||
hash: string
|
||||
extension: string
|
||||
nsfw: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface StandardParserSettings {
|
||||
|
@ -23,6 +23,18 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
allowInlines = true;
|
||||
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) {
|
||||
super();
|
||||
const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
|
||||
|
@ -54,16 +66,24 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
//return null;
|
||||
}
|
||||
const outer = parser.createElement('div');
|
||||
outer.className = 'collapseHeader';
|
||||
outer.className = 'card bg-light bbcode-collapse';
|
||||
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);
|
||||
const innerText = parser.createElement('span');
|
||||
innerText.appendChild(document.createTextNode(param));
|
||||
headerText.appendChild(innerText);
|
||||
const body = parser.createElement('div');
|
||||
body.className = 'collapseBlock';
|
||||
body.className = 'card-body bbcode-collapse-body closed';
|
||||
body.style.height = '0';
|
||||
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);
|
||||
return body;
|
||||
}));
|
||||
|
@ -122,7 +142,12 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
img.className = 'character-avatar icon';
|
||||
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;
|
||||
if(!this.allowInlines) {
|
||||
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.');
|
||||
return undefined;
|
||||
}
|
||||
let p1: string, p2: string, inline;
|
||||
const displayMode = this.settings.inlineDisplayMode;
|
||||
if(!/^\d+$/.test(param)) {
|
||||
parser.warning('img tag parameters must be numbers.');
|
||||
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.`);
|
||||
return undefined;
|
||||
}
|
||||
inline = parser.inlines[param]!;
|
||||
p1 = inline.hash.substr(0, 2);
|
||||
p2 = inline.hash.substr(2, 2);
|
||||
|
||||
inline.name = content;
|
||||
if(displayMode === InlineDisplayMode.DISPLAY_NONE || (displayMode === InlineDisplayMode.DISPLAY_SFW && inline.nsfw)) {
|
||||
const el = parser.createElement('a');
|
||||
el.className = 'unloadedInline';
|
||||
el.href = '#';
|
||||
el.dataset.inlineId = param;
|
||||
el.onclick = () => {
|
||||
$('.unloadedInline').each((_, element) => {
|
||||
const inlineId = $(element).data('inline-id');
|
||||
if(typeof parser.inlines![inlineId] !== 'object')
|
||||
return;
|
||||
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>`);
|
||||
});
|
||||
Array.prototype.forEach.call(document.getElementsByClassName('unloadedInline'), ((e: HTMLElement) => {
|
||||
const showInline = parser.inlines![e.dataset.inlineId!];
|
||||
if(typeof showInline !== 'object') return;
|
||||
e.parentElement!.replaceChild(parser.createInline(showInline), e);
|
||||
}));
|
||||
return false;
|
||||
};
|
||||
const prefix = inline.nsfw ? '[NSFW Inline] ' : '[Inline] ';
|
||||
el.appendChild(document.createTextNode(prefix));
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
} 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);
|
||||
parent.replaceChild(el, element);
|
||||
} else parent.replaceChild(parser.createInline(inline), element);
|
||||
}, []));
|
||||
}
|
||||
}
|
||||
|
||||
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 function initParser(settings: StandardParserSettings): void {
|
||||
|
|
|
@ -1,22 +1,15 @@
|
|||
<template>
|
||||
<modal :buttons="false" :action="l('chat.channels')" @close="closed">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<ul class="nav nav-tabs" style="flex-shrink:0">
|
||||
<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>
|
||||
<modal :buttons="false" :action="l('chat.channels')" @close="closed" dialog-class="w-100 channel-list">
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<tabs style="flex-shrink:0" :tabs="[l('channelList.public'), l('channelList.private')]" v-model="tab"></tabs>
|
||||
<div style="display: flex; flex-direction: column">
|
||||
<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')"/>
|
||||
<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>
|
||||
</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">
|
||||
<label :for="channel.id">
|
||||
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
|
||||
|
@ -24,7 +17,7 @@
|
|||
</label>
|
||||
</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">
|
||||
<label :for="channel.id">
|
||||
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
|
||||
|
@ -46,13 +39,13 @@
|
|||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Tabs from '../components/tabs';
|
||||
import {Channel} from '../fchat';
|
||||
import core from './core';
|
||||
import {Channel} from './interfaces';
|
||||
import l from './localize';
|
||||
import ListItem = Channel.ListItem;
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal}
|
||||
components: {modal: Modal, tabs: Tabs}
|
||||
})
|
||||
export default class ChannelList extends CustomDialog {
|
||||
privateTabShown = false;
|
||||
|
@ -60,6 +53,7 @@
|
|||
sortCount = true;
|
||||
filter = '';
|
||||
createName = '';
|
||||
tab = '0';
|
||||
|
||||
get openRooms(): ReadonlyArray<Channel.ListItem> {
|
||||
return this.applyFilter(core.channels.openRooms);
|
||||
|
@ -92,8 +86,15 @@
|
|||
this.createName = '';
|
||||
}
|
||||
|
||||
setJoined(channel: ListItem): void {
|
||||
setJoined(channel: Channel.ListItem): void {
|
||||
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>
|
|
@ -12,9 +12,9 @@
|
|||
@Component
|
||||
export default class ChannelView extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly id: string;
|
||||
readonly id!: string;
|
||||
@Prop({required: true})
|
||||
readonly text: string;
|
||||
readonly text!: string;
|
||||
|
||||
joinChannel(): void {
|
||||
if(this.channel === undefined || !this.channel.isJoined)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<div v-if="options && !results">
|
||||
<div v-show="error" class="alert alert-danger">{{error}}</div>
|
||||
|
@ -113,10 +113,9 @@
|
|||
}
|
||||
});
|
||||
core.connection.onMessage('FKS', (data) => {
|
||||
this.results = data.characters.filter((x) => core.state.hiddenUsers.indexOf(x) === -1)
|
||||
.map((x) => core.characters.get(x)).sort(sort);
|
||||
this.results = data.characters.map((x) => core.characters.get(x))
|
||||
.filter((x) => core.state.hiddenUsers.indexOf(x.name) === -1 && !x.isIgnored).sort(sort);
|
||||
});
|
||||
(<Modal>this.$children[0]).fixDropdowns();
|
||||
}
|
||||
|
||||
filterKink(filter: RegExp, kink: Kink): boolean {
|
||||
|
@ -144,7 +143,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
<style lang="scss">
|
||||
.character-search {
|
||||
.dropdown {
|
||||
margin-bottom: 10px;
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
{{l(connecting ? 'login.connecting' : 'login.connect')}}
|
||||
</button>
|
||||
|
@ -37,14 +37,44 @@
|
|||
import core from './core';
|
||||
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({
|
||||
components: {chat: ChatView, modal: Modal}
|
||||
})
|
||||
export default class Chat extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly ownCharacters: string[];
|
||||
readonly ownCharacters!: string[];
|
||||
@Prop({required: true})
|
||||
readonly defaultCharacter: string | undefined;
|
||||
readonly defaultCharacter!: string | undefined;
|
||||
selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions
|
||||
error = '';
|
||||
connecting = false;
|
||||
|
@ -52,6 +82,14 @@
|
|||
l = l;
|
||||
|
||||
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('channels', Channels(core.connection, core.characters));
|
||||
core.register('conversations', Conversations());
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<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"/>
|
||||
{{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>
|
||||
{{l('chat.status')}}
|
||||
<a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn">
|
||||
|
@ -35,9 +35,10 @@
|
|||
<div class="name">
|
||||
<span>{{conversation.character.name}}</span>
|
||||
<div style="text-align:right;line-height:0">
|
||||
<span class="fa"
|
||||
:class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
|
||||
></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}" @mousedown.prevent
|
||||
<span class="fas"
|
||||
:class="{'fa-comment-alt': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
|
||||
></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>
|
||||
<span class="fa fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
|
||||
</div>
|
||||
|
@ -49,7 +50,7 @@
|
|||
<div class="list-group conversation-nav" ref="channelConversations">
|
||||
<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"
|
||||
: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"
|
||||
: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>
|
||||
|
@ -66,7 +67,7 @@
|
|||
<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">
|
||||
<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>
|
||||
</a>
|
||||
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
|
||||
|
@ -93,6 +94,7 @@
|
|||
import Sortable = require('sortablejs');
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Keys} from '../keys';
|
||||
import ChannelList from './ChannelList.vue';
|
||||
import CharacterSearch from './CharacterSearch.vue';
|
||||
import {characterImage, getKey} from './common';
|
||||
|
@ -128,7 +130,7 @@
|
|||
characterImage = characterImage;
|
||||
conversations = core.conversations;
|
||||
getStatusIcon = getStatusIcon;
|
||||
keydownListener: (e: KeyboardEvent) => void;
|
||||
keydownListener!: (e: KeyboardEvent) => void;
|
||||
|
||||
mounted(): void {
|
||||
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
|
||||
|
@ -190,12 +192,22 @@
|
|||
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 {
|
||||
const selected = this.conversations.selectedConversation;
|
||||
const pms = this.conversations.privateConversations;
|
||||
const channels = this.conversations.channelConversations;
|
||||
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(channels.length > 0) channels[channels.length - 1].show();
|
||||
else if(pms.length > 0) pms[pms.length - 1].show();
|
||||
|
@ -210,7 +222,7 @@
|
|||
else console.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(pms.length > 0) pms[0].show();
|
||||
else if(channels.length > 0) channels[0].show();
|
||||
|
@ -263,8 +275,18 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import "../less/flist_variables.less";
|
||||
<style lang="scss">
|
||||
@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 {
|
||||
margin-bottom: 10px;
|
||||
|
@ -317,8 +339,10 @@
|
|||
margin: 0 45px 5px;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
|
||||
@media (max-width: @screen-xs-max) {
|
||||
@media (max-width: breakpoint-max(xs)) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
@ -363,7 +387,7 @@
|
|||
.body a.btn {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@media (min-width: @screen-sm-min) {
|
||||
@media (min-width: breakpoint-min(sm)) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
margin: 0;
|
||||
|
|
|
@ -78,7 +78,8 @@
|
|||
name: `/${key} - ${l(`commands.${key}`)}`,
|
||||
help: l(`commands.${key}.help`),
|
||||
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,
|
||||
syntax
|
||||
});
|
||||
|
@ -87,7 +88,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
<style lang="scss">
|
||||
#command-help {
|
||||
h4 {
|
||||
margin-bottom: 0;
|
||||
|
|
|
@ -50,14 +50,14 @@
|
|||
})
|
||||
export default class ConversationSettings extends CustomDialog {
|
||||
@Prop({required: true})
|
||||
readonly conversation: Conversation;
|
||||
readonly conversation!: Conversation;
|
||||
l = l;
|
||||
setting = Conversation.Setting;
|
||||
notify: Conversation.Setting;
|
||||
highlight: Conversation.Setting;
|
||||
highlightWords: string;
|
||||
joinMessages: Conversation.Setting;
|
||||
defaultHighlights: boolean;
|
||||
notify!: Conversation.Setting;
|
||||
highlight!: Conversation.Setting;
|
||||
highlightWords!: string;
|
||||
joinMessages!: Conversation.Setting;
|
||||
defaultHighlights!: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
|
@ -22,8 +22,8 @@
|
|||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex: 1;">
|
||||
<span v-show="conversation.channel.id.substr(0, 4) !== 'adh-'" class="fa fa-star" :title="l('channel.official')"
|
||||
style="margin-right:5px;"></span>
|
||||
<h4 style="margin: 0; display:inline; vertical-align: middle;">{{conversation.name}}</h4>
|
||||
style="margin-right:5px;vertical-align:sub"></span>
|
||||
<h5 style="margin:0;display:inline;vertical-align:middle">{{conversation.name}}</h5>
|
||||
<a @click="descriptionExpanded = !descriptionExpanded" class="btn">
|
||||
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
|
||||
<span class="btn-text">{{l('channel.description')}}</span>
|
||||
|
@ -37,8 +37,9 @@
|
|||
<span class="btn-text">{{l('chat.report')}}</span></a>
|
||||
</div>
|
||||
<ul class="nav nav-pills mode-switcher">
|
||||
<li v-for="mode in modes" :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}">
|
||||
<a href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a>
|
||||
<li v-for="mode in modes" class="nav-item">
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -51,9 +52,15 @@
|
|||
<h4>{{l('chat.consoleTab')}}</h4>
|
||||
<logs :conversation="conversation"></logs>
|
||||
</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"
|
||||
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"
|
||||
:classes="message == conversation.lastRead ? 'last-read' : ''">
|
||||
</message-view>
|
||||
|
@ -80,20 +87,22 @@
|
|||
<span class="redText" style="flex:1;margin-left:5px">{{conversation.errorText}}</span>
|
||||
</div>
|
||||
<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="onInput"
|
||||
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')" :disabled="adCountdown"
|
||||
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
|
||||
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')"
|
||||
ref="textBox" style="position:relative" :maxlength="conversation.maxMessageLength">
|
||||
<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}}
|
||||
</div>
|
||||
<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'}">
|
||||
<a href="#" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
|
||||
<li class="nav-item">
|
||||
<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 :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}">
|
||||
<a href="#" @click.prevent="setSendingAds(true)">{{l('channel.mode.ads')}}</a>
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -113,6 +122,7 @@
|
|||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import {EditorButton, EditorSelection} from '../bbcode/editor';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {Keys} from '../keys';
|
||||
import {BBCodeView, Editor} from './bbcode';
|
||||
import CommandHelp from './CommandHelp.vue';
|
||||
import {characterImage, getByteLength, getKey} from './common';
|
||||
|
@ -135,16 +145,29 @@
|
|||
})
|
||||
export default class ConversationView extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly reportDialog: ReportDialog;
|
||||
readonly reportDialog!: ReportDialog;
|
||||
modes = channelModes;
|
||||
descriptionExpanded = false;
|
||||
l = l;
|
||||
extraButtons: EditorButton[] = [];
|
||||
getByteLength = getByteLength;
|
||||
tabOptions: string[] | undefined;
|
||||
tabOptionsIndex: number;
|
||||
tabOptionSelection: EditorSelection;
|
||||
tabOptionsIndex!: number;
|
||||
tabOptionSelection!: EditorSelection;
|
||||
showSearch = false;
|
||||
searchInput = '';
|
||||
search = '';
|
||||
lastSearchInput = 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 {
|
||||
this.extraButtons = [{
|
||||
|
@ -153,12 +176,34 @@
|
|||
icon: 'fa-question',
|
||||
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 {
|
||||
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')
|
||||
conversationChanged(): void {
|
||||
(<Editor>this.$refs['textBox']).focus();
|
||||
|
@ -168,14 +213,14 @@
|
|||
messageAdded(newValue: Conversation.Message[]): void {
|
||||
const messageView = <HTMLElement>this.$refs['messages'];
|
||||
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;
|
||||
}
|
||||
|
||||
keepScroll(): boolean {
|
||||
const messageView = <HTMLElement>this.$refs['messages'];
|
||||
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) {
|
||||
setTimeout(() => messageView.scrollTop = messageView.scrollHeight - messageView.offsetHeight, 0);
|
||||
setImmediate(() => messageView.scrollTop = messageView.scrollHeight);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -197,18 +242,9 @@
|
|||
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> {
|
||||
const editor = <Editor>this.$refs['textBox'];
|
||||
if(getKey(e) === 'tab') {
|
||||
if(getKey(e) === Keys.Tab) {
|
||||
e.preventDefault();
|
||||
if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return;
|
||||
if(this.tabOptions === undefined) {
|
||||
|
@ -242,10 +278,10 @@
|
|||
}
|
||||
} else {
|
||||
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)
|
||||
this.conversation.loadLastSent();
|
||||
else if(getKey(e) === 'enter') {
|
||||
else if(getKey(e) === Keys.Enter) {
|
||||
if(e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
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 {
|
||||
if(!this.showAdCountdown) return;
|
||||
const conv = (<Conversation.ChannelConversation>this.conversation);
|
||||
return l('chat.adCountdown', Math.floor(conv.adCountdown / 60).toString(), (conv.adCountdown % 60).toString());
|
||||
if(!Conversation.isChannel(this.conversation) || this.conversation.adCountdown <= 0) return;
|
||||
return l('chat.adCountdown',
|
||||
Math.floor(this.conversation.adCountdown / 60).toString(), (this.conversation.adCountdown % 60).toString());
|
||||
}
|
||||
|
||||
get characterImage(): string {
|
||||
|
@ -301,11 +333,14 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import "../less/flist_variables.less";
|
||||
<style lang="scss">
|
||||
@import "~bootstrap/scss/functions";
|
||||
@import "~bootstrap/scss/variables";
|
||||
@import "~bootstrap/scss/mixins/breakpoints";
|
||||
|
||||
#conversation {
|
||||
.header {
|
||||
@media (min-width: @screen-sm-min) {
|
||||
@media (min-width: breakpoint-min(sm)) {
|
||||
margin-right: 32px;
|
||||
}
|
||||
a.btn {
|
||||
|
@ -317,7 +352,7 @@
|
|||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-xs-max) {
|
||||
@media (max-width: breakpoint-max(xs)) {
|
||||
.mode-switcher a {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
|
|
@ -1,33 +1,35 @@
|
|||
<template>
|
||||
<span>
|
||||
<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>
|
||||
</a>
|
||||
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')" dialogClass="modal-lg"
|
||||
@open="onOpen" class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2">{{l('logs.conversation')}}</label>
|
||||
<div class="col-sm-10">
|
||||
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
|
||||
buttonClass="form-control" :placeholder="l('filter')" @input="loadMessages">
|
||||
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
|
||||
</filterable-select>
|
||||
</div>
|
||||
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')"
|
||||
dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen">
|
||||
<div class="form-group row" style="flex-shrink:0">
|
||||
<label class="col-2 col-form-label">{{l('logs.conversation')}}</label>
|
||||
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
|
||||
:placeholder="l('filter')" @input="loadMessages" class="form-control col-10">
|
||||
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
|
||||
</filterable-select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="date" class="col-sm-2">{{l('logs.date')}}</label>
|
||||
<div class="col-sm-10" style="display:flex">
|
||||
<div class="form-group row" style="flex-shrink:0">
|
||||
<label for="date" class="col-2 col-form-label">{{l('logs.date')}}</label>
|
||||
<div class="col-8">
|
||||
<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>
|
||||
</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 class="messages-both" style="overflow: auto">
|
||||
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
|
||||
</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>
|
||||
</span>
|
||||
</template>
|
||||
|
@ -59,7 +61,7 @@
|
|||
export default class Logs extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop({required: true})
|
||||
readonly conversation: Conversation;
|
||||
readonly conversation!: Conversation;
|
||||
selectedConversation: {id: string, name: string} | null = null;
|
||||
selectedDate: string | null = null;
|
||||
isPersistent = LogInterfaces.isPersistent(core.logs);
|
||||
|
@ -77,7 +79,6 @@
|
|||
}
|
||||
|
||||
mounted(): void {
|
||||
(<Modal>this.$refs['dialog']).fixDropdowns();
|
||||
this.conversationChanged();
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
<a href="#" @click.prevent="openDialog" class="btn">
|
||||
<span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span>
|
||||
</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-'">
|
||||
<label class="control-label" for="isPublic">
|
||||
<input type="checkbox" id="isPublic" v-model="isPublic"/>
|
||||
|
@ -27,13 +28,14 @@
|
|||
<div v-if="isChannelOwner">
|
||||
<h4>{{l('manageChannel.mods')}}</h4>
|
||||
<div v-for="(mod, index) in opList">
|
||||
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn fa fa-times"
|
||||
style="padding:0;vertical-align:baseline"></a>
|
||||
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn" style="padding:0;vertical-align:baseline">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
{{mod}}
|
||||
</div>
|
||||
<div style="display:flex;margin-top:5px">
|
||||
<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>
|
||||
</modal>
|
||||
|
@ -56,7 +58,7 @@
|
|||
})
|
||||
export default class ManageChannel extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly channel: Channel;
|
||||
readonly channel!: Channel;
|
||||
modes = channelModes;
|
||||
isPublic = this.channelIsPublic;
|
||||
mode = this.channel.mode;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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 v-for="recent in recentConversations" style="margin: 3px;">
|
||||
<user-view v-if="recent.character" :character="getCharacter(recent.character)"></user-view>
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
<template>
|
||||
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings">
|
||||
<ul class="nav nav-tabs" style="flex-shrink:0;margin-bottom:10px">
|
||||
<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>
|
||||
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings" dialogClass="w-100">
|
||||
<tabs style="flex-shrink:0;margin-bottom:10px" :tabs="tabs" v-model="selectedTab"></tabs>
|
||||
<div v-show="selectedTab == 'general'">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label>
|
||||
|
@ -96,13 +92,19 @@
|
|||
{{l('settings.alwaysNotify')}}
|
||||
</label>
|
||||
</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 v-show="selectedTab == 'import'" style="display:flex;padding-top:10px">
|
||||
<select id="import" class="form-control" v-model="importCharacter" style="flex:1;">
|
||||
<option value="">{{l('settings.import.selectCharacter')}}</option>
|
||||
<option v-for="character in availableImports" :value="character">{{character}}</option>
|
||||
</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>
|
||||
</modal>
|
||||
</template>
|
||||
|
@ -111,34 +113,36 @@
|
|||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Tabs from '../components/tabs';
|
||||
import core from './core';
|
||||
import {Settings as SettingsInterface} from './interfaces';
|
||||
import l from './localize';
|
||||
|
||||
@Component(
|
||||
{components: {modal: Modal}}
|
||||
{components: {modal: Modal, tabs: Tabs}}
|
||||
)
|
||||
export default class SettingsView extends CustomDialog {
|
||||
l = l;
|
||||
availableImports: ReadonlyArray<string> = [];
|
||||
selectedTab = 'general';
|
||||
importCharacter = '';
|
||||
playSound: boolean;
|
||||
clickOpensMessage: boolean;
|
||||
disallowedTags: string;
|
||||
notifications: boolean;
|
||||
highlight: boolean;
|
||||
highlightWords: string;
|
||||
showAvatars: boolean;
|
||||
animatedEicons: boolean;
|
||||
idleTimer: string;
|
||||
messageSeparators: boolean;
|
||||
eventMessages: boolean;
|
||||
joinMessages: boolean;
|
||||
alwaysNotify: boolean;
|
||||
logMessages: boolean;
|
||||
logAds: boolean;
|
||||
fontSize: number;
|
||||
playSound!: boolean;
|
||||
clickOpensMessage!: boolean;
|
||||
disallowedTags!: string;
|
||||
notifications!: boolean;
|
||||
highlight!: boolean;
|
||||
highlightWords!: string;
|
||||
showAvatars!: boolean;
|
||||
animatedEicons!: boolean;
|
||||
idleTimer!: string;
|
||||
messageSeparators!: boolean;
|
||||
eventMessages!: boolean;
|
||||
joinMessages!: boolean;
|
||||
alwaysNotify!: boolean;
|
||||
logMessages!: boolean;
|
||||
logAds!: boolean;
|
||||
fontSize!: number;
|
||||
showNeedsReply!: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -168,6 +172,7 @@
|
|||
this.logMessages = settings.logMessages;
|
||||
this.logAds = settings.logAds;
|
||||
this.fontSize = settings.fontSize;
|
||||
this.showNeedsReply = settings.showNeedsReply;
|
||||
};
|
||||
|
||||
async doImport(): Promise<void> {
|
||||
|
@ -178,14 +183,18 @@
|
|||
};
|
||||
await importKey('settings');
|
||||
await importKey('pinned');
|
||||
await importKey('modes');
|
||||
await importKey('conversationSettings');
|
||||
this.init();
|
||||
core.reloadSettings();
|
||||
core.conversations.reloadSettings();
|
||||
}
|
||||
|
||||
get tabs(): ReadonlyArray<string> {
|
||||
return this.availableImports.length > 0 ? ['general', 'notifications', 'import'] : ['general', 'notifications'];
|
||||
get tabs(): {readonly [key: string]: string} {
|
||||
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> {
|
||||
|
@ -205,7 +214,8 @@
|
|||
alwaysNotify: this.alwaysNotify,
|
||||
logMessages: this.logMessages,
|
||||
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();
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<div class="sidebar-wrapper" :class="{open: expanded}">
|
||||
<div :class="'sidebar sidebar-' + (right ? 'right' : 'left')">
|
||||
<button @click="expanded = !expanded" class="btn btn-default btn-xs expander" :aria-label="label">
|
||||
<span :class="'fa fa-rotate-270 ' + icon" style="vertical-align: middle" v-if="right"></span>
|
||||
<button @click="expanded = !expanded" class="btn btn-secondary btn-xs expander" :aria-label="label">
|
||||
<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 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>
|
||||
<div class="body">
|
||||
<slot></slot>
|
||||
|
@ -26,9 +26,9 @@
|
|||
@Prop()
|
||||
readonly label?: string;
|
||||
@Prop({required: true})
|
||||
readonly icon: string;
|
||||
readonly icon!: string;
|
||||
@Prop({default: false})
|
||||
readonly open: boolean;
|
||||
readonly open!: boolean;
|
||||
expanded = this.open;
|
||||
|
||||
@Watch('open')
|
||||
|
|
|
@ -1,19 +1,13 @@
|
|||
<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">
|
||||
<label class="control-label">{{l('chat.setStatus.status')}}</label>
|
||||
<div class="dropdown form-control" style="padding: 0;">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true"
|
||||
aria-expanded="false" style="width:100%; text-align:left; display:flex; align-items:center">
|
||||
<span style="flex: 1;"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<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>
|
||||
<dropdown class="dropdown form-control" style="padding:0">
|
||||
<span slot="title"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span>
|
||||
<a href="#" class="dropdown-item" v-for="item in statuses" @click.prevent="status = item">
|
||||
<span class="fa fa-fw" :class="getStatusIcon(item)"></span>{{l('status.' + item)}}
|
||||
</a>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{l('chat.setStatus.message')}}</label>
|
||||
|
@ -29,6 +23,7 @@
|
|||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Dropdown from '../components/Dropdown.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {Editor} from './bbcode';
|
||||
import {getByteLength} from './common';
|
||||
|
@ -38,7 +33,7 @@
|
|||
import {getStatusIcon} from './user_view';
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal, editor: Editor}
|
||||
components: {modal: Modal, editor: Editor, dropdown: Dropdown}
|
||||
})
|
||||
export default class StatusSwitcher extends CustomDialog {
|
||||
//tslint:disable:no-null-keyword
|
||||
|
|
|
@ -1,14 +1,7 @@
|
|||
<template>
|
||||
<sidebar id="user-list" :label="l('users.title')" icon="fa-users" :right="true" :open="expanded">
|
||||
<ul class="nav nav-tabs" style="flex-shrink:0">
|
||||
<li role="presentation" :class="{active: !channel || !memberTabShown}">
|
||||
<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">
|
||||
<tabs style="flex-shrink:0" :tabs="channel ? [l('users.friends'), l('users.members')] : [l('users.friends')]" v-model="tab"></tabs>
|
||||
<div class="users" style="padding-left:10px" v-show="tab == 0">
|
||||
<h4>{{l('users.friends')}}</h4>
|
||||
<div v-for="character in friends" :key="character.name">
|
||||
<user :character="character" :showStatus="true"></user>
|
||||
|
@ -18,7 +11,7 @@
|
|||
<user :character="character" :showStatus="true"></user>
|
||||
</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>
|
||||
<div v-for="member in channel.sortedMembers" :key="member.character.name">
|
||||
<user :character="member.character" :channel="channel" :showStatus="true"></user>
|
||||
|
@ -30,6 +23,7 @@
|
|||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import Tabs from '../components/tabs';
|
||||
import core from './core';
|
||||
import {Channel, Character, Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
|
@ -37,11 +31,11 @@
|
|||
import UserView from './user_view';
|
||||
|
||||
@Component({
|
||||
components: {user: UserView, sidebar: Sidebar}
|
||||
components: {user: UserView, sidebar: Sidebar, tabs: Tabs}
|
||||
})
|
||||
export default class UserList extends Vue {
|
||||
memberTabShown = false;
|
||||
expanded = window.innerWidth >= 900;
|
||||
tab = '0';
|
||||
expanded = window.innerWidth >= 992;
|
||||
l = l;
|
||||
sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0));
|
||||
|
||||
|
@ -59,8 +53,11 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import "../less/flist_variables.less";
|
||||
<style lang="scss">
|
||||
@import "~bootstrap/scss/functions";
|
||||
@import "~bootstrap/scss/variables";
|
||||
@import "~bootstrap/scss/mixins/breakpoints";
|
||||
|
||||
#user-list {
|
||||
flex-direction: column;
|
||||
h4 {
|
||||
|
@ -77,7 +74,7 @@
|
|||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
@media (min-width: @screen-md-min) {
|
||||
@media (min-width: breakpoint-min(md)) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
margin: 0;
|
||||
|
|
|
@ -1,46 +1,37 @@
|
|||
<template>
|
||||
<div>
|
||||
<div id="userMenu" class="dropdown-menu" v-show="showContextMenu" :style="position"
|
||||
style="position:fixed;padding:10px 10px 5px;display:block;width:200px;z-index:1100" ref="menu">
|
||||
<div v-if="character">
|
||||
<div style="min-height: 65px;" @click.stop>
|
||||
<img :src="characterImage" style="width: 60px; height:60px; margin-right: 5px; float: left;" v-if="showAvatars"/>
|
||||
<h4 style="margin:0;">{{character.name}}</h4>
|
||||
{{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 id="userMenu" class="list-group" v-show="showContextMenu" :style="position" v-if="character"
|
||||
style="position:fixed;padding:10px 10px 5px;display:block;width:220px;z-index:1100" ref="menu">
|
||||
<div style="min-height: 65px;padding:5px" class="list-group-item" @click.stop>
|
||||
<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>
|
||||
{{l('status.' + character.status)}}
|
||||
</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>
|
||||
<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>
|
||||
<textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea>
|
||||
</modal>
|
||||
|
@ -65,17 +56,17 @@
|
|||
export default class UserMenu extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop({required: true})
|
||||
readonly reportDialog: ReportDialog;
|
||||
readonly reportDialog!: ReportDialog;
|
||||
l = l;
|
||||
showContextMenu = false;
|
||||
getByteLength = getByteLength;
|
||||
character: Character | null = null;
|
||||
position = {left: '', top: ''};
|
||||
characterImage: string | null = null;
|
||||
touchTimer: number | undefined;
|
||||
touchedElement: HTMLElement | undefined;
|
||||
channel: Channel | null = null;
|
||||
memo = '';
|
||||
memoId: number;
|
||||
memoId = 0;
|
||||
memoLoading = false;
|
||||
|
||||
openConversation(jump: boolean): void {
|
||||
|
@ -159,7 +150,7 @@
|
|||
|
||||
handleEvent(e: MouseEvent | TouchEvent): void {
|
||||
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) {
|
||||
if(e.type !== 'click' && node === this.$refs['menu']) return;
|
||||
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']!);
|
||||
else {
|
||||
this.showContextMenu = false;
|
||||
this.touchedElement = undefined;
|
||||
return;
|
||||
}
|
||||
switch(e.type) {
|
||||
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();
|
||||
break;
|
||||
case 'touchstart':
|
||||
this.touchTimer = window.setTimeout(() => {
|
||||
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);
|
||||
}
|
||||
this.touchedElement = node;
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this.openMenu(touch, node.character, node.channel);
|
||||
|
@ -222,8 +206,13 @@
|
|||
</script>
|
||||
|
||||
<style>
|
||||
#userMenu li a {
|
||||
padding: 3px 0;
|
||||
#userMenu .list-group-item {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
#userMenu .list-group-item-action {
|
||||
border-top: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.user-view {
|
||||
|
|
|
@ -4,7 +4,7 @@ import l from './localize';
|
|||
export default class Socket implements WebSocketConnection {
|
||||
static host = 'wss://chat.f-list.net:9799';
|
||||
private socket: WebSocket;
|
||||
private errorHandler: (error: Error) => void;
|
||||
private errorHandler: ((error: Error) => void) | undefined;
|
||||
private lastHandler: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor() {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {format, isToday} from 'date-fns';
|
||||
import {Keys} from '../keys';
|
||||
import {Character, Conversation, Settings as ISettings} from './interfaces';
|
||||
|
||||
export function profileLink(this: void | never, character: string): string {
|
||||
|
@ -40,6 +41,7 @@ export class Settings implements ISettings {
|
|||
logMessages = true;
|
||||
logAds = false;
|
||||
fontSize = 14;
|
||||
showNeedsReply = false;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export function getKey(e: KeyboardEvent): string {
|
||||
/*tslint:disable-next-line:strict-boolean-expressions no-any*///because of old browsers.
|
||||
return (e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier).toLowerCase();
|
||||
export function getKey(e: KeyboardEvent): Keys {
|
||||
return e.keyCode;
|
||||
}
|
||||
|
||||
/*tslint:disable:no-any no-unsafe-any*///because errors can be any
|
||||
|
|
|
@ -29,10 +29,10 @@ abstract class Conversation implements Interfaces.Conversation {
|
|||
lastRead: Interfaces.Message | undefined = undefined;
|
||||
infoText = '';
|
||||
abstract readonly maxMessageLength: number | undefined;
|
||||
_settings: Interfaces.Settings;
|
||||
_settings: Interfaces.Settings | undefined;
|
||||
protected abstract context: CommandContext;
|
||||
protected maxMessages = 100;
|
||||
protected allMessages: Interfaces.Message[];
|
||||
protected allMessages: Interfaces.Message[] = [];
|
||||
private lastSent = '';
|
||||
|
||||
constructor(readonly key: string, public _isPinned: boolean) {
|
||||
|
@ -199,7 +199,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
|
|||
private chat: Interfaces.Message[] = [];
|
||||
private ads: Interfaces.Message[] = [];
|
||||
private both: Interfaces.Message[] = [];
|
||||
private _mode: Channel.Mode;
|
||||
private _mode!: Channel.Mode;
|
||||
private adEnteredText = '';
|
||||
private chatEnteredText = '';
|
||||
private logPromise = core.logs.getBacklog(this).then((messages) => {
|
||||
|
@ -220,7 +220,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
|
|||
this.mode = value;
|
||||
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 {
|
||||
|
@ -236,6 +236,10 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
|
|||
this.maxMessages = 100;
|
||||
this.allMessages = this[mode];
|
||||
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 {
|
||||
|
@ -272,7 +276,8 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
|
|||
if(message.type !== Interfaces.Message.Type.Event) {
|
||||
if(message.type === Interfaces.Message.Type.Warn) this.addModeMessage('ads', 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;
|
||||
} else this.addModeMessage('ads', message);
|
||||
}
|
||||
|
@ -291,6 +296,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
|
|||
|
||||
protected async doSend(): Promise<void> {
|
||||
const isAd = this.isSendingAds;
|
||||
if(isAd && this.adCountdown > 0) return;
|
||||
core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText});
|
||||
await this.addMessage(
|
||||
createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date()));
|
||||
|
@ -335,12 +341,13 @@ class State implements Interfaces.State {
|
|||
channelConversations: ChannelConversation[] = [];
|
||||
privateMap: {[key: string]: PrivateConversation | undefined} = {};
|
||||
channelMap: {[key: string]: ChannelConversation | undefined} = {};
|
||||
consoleTab: ConsoleConversation;
|
||||
consoleTab!: ConsoleConversation;
|
||||
selectedConversation: Conversation = this.consoleTab;
|
||||
recent: Interfaces.RecentConversation[] = [];
|
||||
pinned: {channels: string[], private: string[]};
|
||||
settings: {[key: string]: Interfaces.Settings};
|
||||
windowFocused: boolean;
|
||||
pinned!: {channels: string[], private: string[]};
|
||||
settings!: {[key: string]: Interfaces.Settings};
|
||||
modes!: {[key: string]: Channel.Mode | undefined};
|
||||
windowFocused = document.hasFocus();
|
||||
|
||||
get hasNew(): boolean {
|
||||
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);
|
||||
}
|
||||
|
||||
async saveModes(): Promise<void> {
|
||||
await core.settingsStore.set('modes', this.modes);
|
||||
}
|
||||
|
||||
async setSettings(key: string, value: Interfaces.Settings): Promise<void> {
|
||||
this.settings[key] = value;
|
||||
await core.settingsStore.set('conversationSettings', this.settings);
|
||||
|
@ -402,6 +413,7 @@ class State implements Interfaces.State {
|
|||
async reloadSettings(): Promise<void> {
|
||||
//tslint:disable:strict-boolean-expressions
|
||||
this.pinned = await core.settingsStore.get('pinned') || {private: [], channels: []};
|
||||
this.modes = await core.settingsStore.get('modes') || {};
|
||||
for(const conversation of this.channelConversations)
|
||||
conversation._isPinned = this.pinned.channels.indexOf(conversation.channel.id) !== -1;
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
state = new State();
|
||||
window.addEventListener('focus', () => {
|
||||
state.windowFocused = true;
|
||||
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;
|
||||
connection.onEvent('connecting', async(isReconnect) => {
|
||||
state.channelConversations = [];
|
||||
|
@ -465,20 +486,23 @@ export default function(this: void): Interfaces.State {
|
|||
state.channelConversations.push(conv);
|
||||
await state.addRecent(conv);
|
||||
} 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 &&
|
||||
!core.state.settings.joinMessages) return;
|
||||
const text = l('events.channelJoin', `[user]${member.character.name}[/user]`);
|
||||
await conv.addMessage(new EventMessage(text));
|
||||
}
|
||||
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);
|
||||
delete state.channelMap[channel.id];
|
||||
await state.savePinned();
|
||||
if(state.selectedConversation === conv) state.show(state.consoleTab);
|
||||
} 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 &&
|
||||
!core.state.settings.joinMessages) return;
|
||||
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) => {
|
||||
const char = core.characters.get(data.character);
|
||||
if(char.isIgnored) return;
|
||||
const conversation = state.channelMap[data.channel.toLowerCase()];
|
||||
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);
|
||||
await conversation.addMessage(message);
|
||||
|
||||
|
@ -512,20 +536,21 @@ export default function(this: void): Interfaces.State {
|
|||
characterImage(data.character), 'attention');
|
||||
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
|
||||
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),
|
||||
characterImage(data.character), 'attention');
|
||||
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
|
||||
}
|
||||
});
|
||||
connection.onMessage('LRP', async(data, time) => {
|
||||
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()];
|
||||
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));
|
||||
});
|
||||
connection.onMessage('RLL', async(data, time) => {
|
||||
const sender = core.characters.get(data.character);
|
||||
if(sender.isIgnored) return;
|
||||
let text: string;
|
||||
if(data.type === 'bottle')
|
||||
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 conversation = state.channelMap[channel];
|
||||
if(conversation === undefined) return core.channels.leave(channel);
|
||||
await conversation.addMessage(message);
|
||||
if(data.type === 'bottle' && data.target === core.connection.character)
|
||||
if(sender.isIgnored && !isOp(conversation)) return;
|
||||
if(data.type === 'bottle' && data.target === core.connection.character) {
|
||||
core.notifications.notify(conversation, conversation.name, messageToString(message),
|
||||
characterImage(data.character), 'attention');
|
||||
if(conversation !== state.selectedConversation || !state.windowFocused)
|
||||
conversation.unread = Interfaces.UnreadState.Mention;
|
||||
message.isHighlight = true;
|
||||
}
|
||||
await conversation.addMessage(message);
|
||||
} else {
|
||||
if(sender.isIgnored) return;
|
||||
const char = core.characters.get(
|
||||
data.character === connection.character ? (<{recipient: string}>data).recipient : 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;
|
||||
return addEventMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('HLO', async(data, time) => addEventMessage(new EventMessage(data.message, time)));
|
||||
connection.onMessage('BRO', async(data, time) => {
|
||||
const text = data.character === undefined ? decodeHTML(data.message) :
|
||||
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;
|
||||
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) => {
|
||||
state.selectedConversation.infoText = data.message;
|
||||
return addEventMessage(new EventMessage(data.message, time));
|
||||
});
|
||||
//TODO connection.onMessage('UPT', data =>
|
||||
return state;
|
||||
}
|
|
@ -18,6 +18,7 @@ export namespace Conversation {
|
|||
readonly type: Message.Type.Event,
|
||||
readonly text: string,
|
||||
readonly time: Date
|
||||
readonly sender?: undefined
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
@ -141,7 +142,8 @@ export namespace Settings {
|
|||
export type Keys = {
|
||||
settings: Settings,
|
||||
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[]
|
||||
hiddenUsers: string[]
|
||||
};
|
||||
|
@ -169,6 +171,7 @@ export namespace Settings {
|
|||
readonly logMessages: boolean;
|
||||
readonly logAds: boolean;
|
||||
readonly fontSize: number;
|
||||
readonly showNeedsReply: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,9 @@ const strings: {[key: string]: string | undefined} = {
|
|||
'help.report': 'How to report a user',
|
||||
'help.changelog': 'Changelog',
|
||||
'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',
|
||||
'title': 'F-Chat',
|
||||
'version': 'Version {0}',
|
||||
|
@ -63,7 +66,6 @@ const strings: {[key: string]: string | undefined} = {
|
|||
'chat.highlight': 'mentioned {0} in {1}:\n{2}',
|
||||
'chat.roll': 'rolls {0}: {1}',
|
||||
'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.typing.typing': '{0} is typing...',
|
||||
'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.title': 'Disconnected',
|
||||
'chat.ignoreList': 'You are currently ignoring: {0}',
|
||||
'chat.search': 'Search in messages...',
|
||||
'logs.title': 'Logs',
|
||||
'logs.conversation': 'Conversation',
|
||||
'logs.date': 'Date',
|
||||
'logs.selectDate': 'Select a date...',
|
||||
'user.profile': 'Profile',
|
||||
'user.message': 'Open conversation',
|
||||
'user.messageJump': 'View conversation',
|
||||
|
@ -150,7 +154,9 @@ Are you sure?`,
|
|||
'settings.logMessages': 'Log messages',
|
||||
'settings.logAds': 'Log ads',
|
||||
'settings.fontSize': 'Font size (experimental)',
|
||||
'settings.showNeedsReply': 'Show indicator on PM tabs with unanswered messages',
|
||||
'settings.defaultHighlights': 'Use global highlight words',
|
||||
'settings.beta': 'Opt-in to test unstable prerelease updates',
|
||||
'conversationSettings.title': 'Tab Settings',
|
||||
'conversationSettings.action': 'Edit settings for {0}',
|
||||
'conversationSettings.default': 'Default',
|
||||
|
@ -160,6 +166,7 @@ Are you sure?`,
|
|||
'channel.mode.ads': 'Ads',
|
||||
'channel.mode.chat': 'Chat',
|
||||
'channel.mode.both': 'Both',
|
||||
'channel.mode.ads.countdown': 'Ads ({0}m{1}s)',
|
||||
'channel.official': 'Official channel',
|
||||
'channel.description': 'Description',
|
||||
'manageChannel.open': 'Manage',
|
||||
|
@ -219,6 +226,7 @@ Are you sure?`,
|
|||
'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_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.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.',
|
||||
|
@ -267,7 +275,7 @@ Are you sure?`,
|
|||
'commands.makeroom.param0': 'Room name',
|
||||
'commands.makeroom.param0.help': 'A name for your new private room. Must be 1-64 in length.',
|
||||
'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.help': 'Removes the given character from your ignore list, and allows them to send you messages again.',
|
||||
'commands.ignorelist': 'Ignore list',
|
||||
|
|
|
@ -8,6 +8,7 @@ export default class Notifications implements Interface {
|
|||
|
||||
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(core.characters.ownCharacter.status === 'dnd') return;
|
||||
this.playSound(sound);
|
||||
if(core.state.settings.notifications) {
|
||||
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import Axios from 'axios';
|
||||
import Vue from 'vue';
|
||||
import Editor from '../bbcode/Editor.vue';
|
||||
import {InlineDisplayMode} from '../bbcode/interfaces';
|
||||
import {initParser, standardParser} from '../bbcode/standard';
|
||||
import CharacterLink from '../components/character_link.vue';
|
||||
import CharacterSelect from '../components/character_select.vue';
|
||||
import {setCharacters} from '../components/character_select/character_list';
|
||||
import DateDisplay from '../components/date_display.vue';
|
||||
import SimplePager from '../components/simple_pager.vue';
|
||||
import {registerMethod, Store} from '../site/character_page/data_store';
|
||||
import {
|
||||
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterKink,
|
||||
|
@ -115,7 +117,7 @@ async function fieldsGet(): Promise<void> {
|
|||
validator: oldInfotag.list,
|
||||
search_field: '',
|
||||
allow_legacy: true,
|
||||
infotag_group: parseInt(oldInfotag.group_id, 10)
|
||||
infotag_group: oldInfotag.group_id
|
||||
};
|
||||
}
|
||||
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-link', CharacterLink);
|
||||
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]})));
|
||||
core.connection.onEvent('connecting', () => {
|
||||
Utils.Settings.defaultCharacter = characters[core.connection.character];
|
||||
|
|
|
@ -9,21 +9,21 @@ import {Channel, Character} from './interfaces';
|
|||
export function getStatusIcon(status: Character.Status): string {
|
||||
switch(status) {
|
||||
case 'online':
|
||||
return 'fa-user-o';
|
||||
return 'far fa-user';
|
||||
case 'looking':
|
||||
return 'fa-eye';
|
||||
return 'fa fa-eye';
|
||||
case 'dnd':
|
||||
return 'fa-minus-circle';
|
||||
return 'fa fa-minus-circle';
|
||||
case 'offline':
|
||||
return 'fa-ban';
|
||||
return 'fa fa-ban';
|
||||
case 'away':
|
||||
return 'fa-circle-o';
|
||||
return 'far fa-circle';
|
||||
case 'busy':
|
||||
return 'fa-cog';
|
||||
return 'fa fa-cog';
|
||||
case 'idle':
|
||||
return 'fa-clock-o';
|
||||
return 'far fa-clock';
|
||||
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);
|
||||
const character = props.character;
|
||||
let rankIcon;
|
||||
if(character.isChatOp) rankIcon = 'fa-diamond';
|
||||
else if(props.channel !== undefined) {
|
||||
const member = props.channel.members[character.name];
|
||||
if(member !== undefined)
|
||||
rankIcon = member.rank === Channel.Rank.Owner ? 'fa-asterisk' :
|
||||
member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-star') : '';
|
||||
else rankIcon = '';
|
||||
} else rankIcon = '';
|
||||
if(character.isChatOp) rankIcon = 'far fa-gem';
|
||||
else if(props.channel !== undefined)
|
||||
rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ?
|
||||
(props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : '';
|
||||
else rankIcon = '';
|
||||
|
||||
const html = (props.showStatus !== undefined || character.status === 'crown'
|
||||
? `<span class="fa fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
|
||||
(rankIcon !== '' ? `<span class="fa ${rankIcon}"></span>` : '') + character.name;
|
||||
? `<span class="fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
|
||||
(rankIcon !== '' ? `<span class="${rankIcon}"></span>` : '') + character.name;
|
||||
return createElement('span', {
|
||||
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'}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
|
@ -1,59 +1,50 @@
|
|||
<template>
|
||||
<div class="dropdown filterable-select">
|
||||
<button class="btn btn-default dropdown-toggle" :class="buttonClass" data-toggle="dropdown">
|
||||
<span style="flex:1">
|
||||
<template v-if="multiple">{{label}}</template>
|
||||
<slot v-else :option="selected">{{label}}</slot>
|
||||
</span>
|
||||
<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>
|
||||
<dropdown class="dropdown filterable-select">
|
||||
<template slot="title" v-if="multiple">{{label}}</template>
|
||||
<slot v-else slot="title" :option="selected">{{label}}</slot>
|
||||
|
||||
<div style="padding:10px;">
|
||||
<input v-model="filter" class="form-control" :placeholder="placeholder"/>
|
||||
</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>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import Dropdown from '../components/Dropdown.vue';
|
||||
|
||||
@Component
|
||||
@Component({
|
||||
components: {dropdown: Dropdown}
|
||||
})
|
||||
export default class FilterableSelect extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop()
|
||||
readonly placeholder?: string;
|
||||
@Prop({required: true})
|
||||
readonly options: object[];
|
||||
readonly options!: object[];
|
||||
@Prop({default: () => ((filter: RegExp, value: string) => filter.test(value))})
|
||||
readonly filterFunc: (filter: RegExp, value: object) => boolean;
|
||||
readonly filterFunc!: (filter: RegExp, value: object) => boolean;
|
||||
@Prop()
|
||||
readonly multiple?: true;
|
||||
@Prop()
|
||||
readonly value?: object | object[];
|
||||
@Prop()
|
||||
readonly title?: string;
|
||||
@Prop()
|
||||
readonly buttonClass?: string;
|
||||
filter = '';
|
||||
selected: object | object[] | null = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : null);
|
||||
|
||||
|
@ -68,10 +59,7 @@
|
|||
const index = selected.indexOf(item);
|
||||
if(index === -1) selected.push(item);
|
||||
else selected.splice(index, 1);
|
||||
} else {
|
||||
this.selected = item;
|
||||
$('.dropdown-toggle', this.$el).dropdown('toggle');
|
||||
}
|
||||
} else this.selected = item;
|
||||
this.$emit('input', this.selected);
|
||||
}
|
||||
|
||||
|
@ -90,17 +78,11 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
<style lang="scss">
|
||||
.filterable-select {
|
||||
ul.dropdown-menu {
|
||||
padding: 0;
|
||||
.dropdown-items {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
position: static;
|
||||
display: block;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
}
|
||||
button {
|
||||
display: flex;
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
<template>
|
||||
<span v-show="isShown">
|
||||
<div tabindex="-1" class="modal flex-modal" @click.self="hideWithCheck"
|
||||
style="align-items:flex-start;padding:30px;justify-content:center;display:flex">
|
||||
<div class="modal-dialog" :class="dialogClass" style="display:flex;flex-direction:column;max-height:100%;margin:0">
|
||||
<div class="modal-content" style="display:flex;flex-direction:column;flex-grow:1">
|
||||
<div tabindex="-1" class="modal" @click.self="hideWithCheck" style="display:flex">
|
||||
<div class="modal-dialog" :class="dialogClass" style="display:flex;align-items:center">
|
||||
<div class="modal-content" style="max-height:100%">
|
||||
<div class="modal-header" style="flex-shrink:0">
|
||||
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">×</button>
|
||||
<h4 class="modal-title">
|
||||
<slot name="title">{{action}}</slot>
|
||||
</h4>
|
||||
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="overflow: auto; display: flex; flex-direction: column">
|
||||
<div class="modal-body" style="overflow:auto">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<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">
|
||||
{{submitText}}
|
||||
</button>
|
||||
|
@ -31,26 +30,34 @@
|
|||
import Component from 'vue-class-component';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
import {getKey} from '../chat/common';
|
||||
import {Keys} from '../keys';
|
||||
|
||||
const dialogStack: Modal[] = [];
|
||||
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
|
||||
export default class Modal extends Vue {
|
||||
@Prop({default: ''})
|
||||
readonly action: string;
|
||||
readonly action!: string;
|
||||
@Prop()
|
||||
readonly dialogClass?: {string: boolean};
|
||||
@Prop({default: true})
|
||||
readonly buttons: boolean;
|
||||
readonly buttons!: boolean;
|
||||
@Prop({default: () => ({'btn-primary': true})})
|
||||
readonly buttonClass: {string: boolean};
|
||||
readonly buttonClass!: {string: boolean};
|
||||
@Prop()
|
||||
readonly disabled?: boolean;
|
||||
@Prop({default: true})
|
||||
readonly showCancel: boolean;
|
||||
readonly showCancel!: boolean;
|
||||
@Prop()
|
||||
readonly buttonText?: string;
|
||||
isShown = false;
|
||||
|
@ -79,37 +86,11 @@
|
|||
dialogStack.pop();
|
||||
}
|
||||
|
||||
private hideWithCheck(): void {
|
||||
hideWithCheck(): void {
|
||||
if(this.keepOpen) return;
|
||||
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 {
|
||||
if(this.isShown) this.hide();
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
@Component
|
||||
export default class CharacterLink extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly character: {name: string, id: number, deleted: boolean} | string;
|
||||
readonly character!: {name: string, id: number, deleted: boolean} | string;
|
||||
|
||||
get deleted(): boolean {
|
||||
return typeof(this.character) === 'string' ? false : this.character.deleted;
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
@Component
|
||||
export default class CharacterSelect extends Vue {
|
||||
@Prop({required: true, type: Number})
|
||||
readonly value: number;
|
||||
readonly value!: number;
|
||||
|
||||
get characters(): SelectItem[] {
|
||||
const characterList = getCharacters();
|
||||
|
|
|
@ -12,9 +12,9 @@
|
|||
@Component
|
||||
export default class DateDisplay extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly time: string | null | number;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
readonly time!: string | null | number;
|
||||
primary: string | undefined;
|
||||
secondary: string | undefined;
|
||||
|
||||
constructor(options?: ComponentOptions<Vue>) {
|
||||
super(options);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">←</span> {{prevLabel}}
|
||||
</a>
|
||||
</slot>
|
||||
<router-link v-if="routed" :to="prevRoute" class="btn btn-secondary" :class="{'disabled': !prev}" role="button">
|
||||
<span aria-hidden="true">←</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">→</span>
|
||||
</a>
|
||||
</slot>
|
||||
<router-link v-if="routed" :to="nextRoute" class="btn btn-secondary" :class="{'disabled': !next}" role="button">
|
||||
{{nextLabel}} <span aria-hidden="true">→</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>
|
|
@ -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;
|
|
@ -1,34 +1,36 @@
|
|||
<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-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
|
||||
<div class="well well-lg" style="width: 400px;">
|
||||
<h3 style="margin-top:0">{{l('title')}}</h3>
|
||||
<div class="alert alert-danger" v-show="error">
|
||||
{{error}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="account">{{l('login.account')}}</label>
|
||||
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="password">{{l('login.password')}}</label>
|
||||
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login"/>
|
||||
</div>
|
||||
<div class="form-group" v-show="showAdvanced">
|
||||
<label class="control-label" for="host">{{l('login.host')}}</label>
|
||||
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
|
||||
</div>
|
||||
<div class="form-group text-right" style="margin:0">
|
||||
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
|
||||
{{l(loggingIn ? 'login.working' : 'login.submit')}}
|
||||
</button>
|
||||
<div class="card bg-light" style="width: 400px;">
|
||||
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-danger" v-show="error">
|
||||
{{error}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="account">{{l('login.account')}}</label>
|
||||
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="password">{{l('login.password')}}</label>
|
||||
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login" :disabled="loggingIn"/>
|
||||
</div>
|
||||
<div class="form-group" v-show="showAdvanced">
|
||||
<label class="control-label" for="host">{{l('login.host')}}</label>
|
||||
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;text-align:right">
|
||||
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
|
||||
{{l(loggingIn ? 'login.working' : 'login.submit')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -42,7 +44,8 @@
|
|||
</modal>
|
||||
<modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -50,6 +53,7 @@
|
|||
<script lang="ts">
|
||||
import Axios from 'axios';
|
||||
import * as electron from 'electron';
|
||||
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as qs from 'querystring';
|
||||
|
@ -71,15 +75,10 @@
|
|||
import * as SlimcatImporter from './importer';
|
||||
import Notifications from './notifications';
|
||||
|
||||
declare module '../chat/interfaces' {
|
||||
interface State {
|
||||
generalSettings?: GeneralSettings
|
||||
}
|
||||
}
|
||||
|
||||
const webContents = electron.remote.getCurrentWebContents();
|
||||
const parent = electron.remote.getCurrentWindow().webContents;
|
||||
|
||||
log.info('About to load keytar');
|
||||
/*tslint:disable:no-any*///because this is hacky
|
||||
const keyStore = nativeRequire<{
|
||||
getPassword(account: string): Promise<string>
|
||||
|
@ -89,6 +88,7 @@
|
|||
}>('keytar/build/Release/keytar.node');
|
||||
for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
|
||||
//tslint:enable
|
||||
log.info('Loaded keytar.');
|
||||
|
||||
@Component({
|
||||
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
|
||||
|
@ -104,7 +104,7 @@
|
|||
error = '';
|
||||
defaultCharacter: string | null = null;
|
||||
l = l;
|
||||
settings: GeneralSettings;
|
||||
settings!: GeneralSettings;
|
||||
importProgress = 0;
|
||||
profileName = '';
|
||||
|
||||
|
@ -115,7 +115,8 @@
|
|||
|
||||
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) => {
|
||||
const profileViewer = <Modal>this.$refs['profileViewer'];
|
||||
this.profileName = name;
|
||||
|
|
|
@ -3,27 +3,31 @@
|
|||
<div v-html="styling"></div>
|
||||
<div style="display:flex;align-items:stretch;" class="border-bottom" id="window-tabs">
|
||||
<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">
|
||||
<li role="presentation" :class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}" v-for="tab in tabs"
|
||||
:key="tab.view.id">
|
||||
<a href="#" @click.prevent="show(tab)">
|
||||
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item">
|
||||
<a href="#" @click.prevent="show(tab)" class="nav-link"
|
||||
: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'"/>
|
||||
{{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"
|
||||
@click.stop="remove(tab)">
|
||||
<a href="#" class="btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit"
|
||||
@click.stop="remove(tab)"><i class="fa fa-times"></i>
|
||||
</a>
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" v-show="canOpenTab" class="addTab" id="addTab">
|
||||
<a href="#" @click.prevent="addTab" class="fa fa-plus"></a>
|
||||
<li v-show="canOpenTab" class="addTab nav-item" id="addTab">
|
||||
<a href="#" @click.prevent="addTab" class="nav-link"><i class="fa fa-plus"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
<div style="flex:1;display:flex;justify-content:flex-end;-webkit-app-region:drag;margin-top:3px" class="btn-group"
|
||||
id="windowButtons">
|
||||
<span class="fa fa-window-minimize btn btn-default" @click.stop="minimize"></span>
|
||||
<span :class="'fa btn btn-default fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></span>
|
||||
<span class="fa fa-close fa-lg btn btn-default" @click.stop="close"></span>
|
||||
<i class="far fa-window-minimize btn btn-light" @click.stop="minimize"></i>
|
||||
<i class="far btn btn-light" :class="'fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></i>
|
||||
<span class="btn btn-light" @click.stop="close">
|
||||
<i class="fa fa-times fa-lg"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -61,7 +65,7 @@
|
|||
@Component
|
||||
export default class Window extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
settings: GeneralSettings;
|
||||
settings!: GeneralSettings;
|
||||
tabs: Tab[] = [];
|
||||
activeTab: Tab | null = null;
|
||||
tabMap: {[key: number]: Tab} = {};
|
||||
|
@ -77,6 +81,7 @@
|
|||
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
|
||||
electron.ipcRenderer.on('open-tab', () => this.addTab());
|
||||
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) => {
|
||||
const tab = this.tabMap[id];
|
||||
tab.user = name;
|
||||
|
@ -87,6 +92,10 @@
|
|||
});
|
||||
electron.ipcRenderer.on('disconnect', (_: Event, id: number) => {
|
||||
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.tray.setToolTip(l('title'));
|
||||
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
|
||||
|
@ -186,6 +195,7 @@
|
|||
remove(tab: Tab, shouldConfirm: boolean = true): void {
|
||||
if(shouldConfirm && tab.user !== undefined && !confirm(l('chat.confirmLeave'))) return;
|
||||
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];
|
||||
tab.tray.destroy();
|
||||
tab.view.webContents.loadURL('about:blank');
|
||||
|
@ -210,12 +220,12 @@
|
|||
}
|
||||
|
||||
openMenu(): void {
|
||||
electron.remote.Menu.getApplicationMenu().popup();
|
||||
electron.remote.Menu.getApplicationMenu()!.popup();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
<style lang="scss">
|
||||
#window-tabs {
|
||||
user-select: none;
|
||||
.btn {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "fchat",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
|
|
|
@ -29,11 +29,8 @@
|
|||
* @version 3.0
|
||||
* @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 fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as qs from 'querystring';
|
||||
import * as Raven from 'raven-js';
|
||||
|
@ -41,12 +38,13 @@ import Vue from 'vue';
|
|||
import {getKey} from '../chat/common';
|
||||
import l from '../chat/localize';
|
||||
import VueRaven from '../chat/vue-raven';
|
||||
import {Keys} from '../keys';
|
||||
import {GeneralSettings, nativeRequire} from './common';
|
||||
import * as SlimcatImporter from './importer';
|
||||
import Index from './Index.vue';
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
@ -54,8 +52,10 @@ process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
|
|||
const sc = nativeRequire<{
|
||||
Spellchecker: {
|
||||
new(): {
|
||||
isMisspelled(x: string): boolean,
|
||||
setDictionary(name: string | undefined, dir: string): void,
|
||||
add(word: string): void
|
||||
remove(word: string): void
|
||||
isMisspelled(x: string): boolean
|
||||
setDictionary(name: string | undefined, dir: string): void
|
||||
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
|
@ -122,14 +122,30 @@ webContents.on('context-menu', (_, props) => {
|
|||
});
|
||||
if(props.misspelledWord !== '') {
|
||||
const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
|
||||
if(corrections.length > 0) {
|
||||
menuTemplate.unshift({type: 'separator'});
|
||||
menuTemplate.unshift({
|
||||
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) => ({
|
||||
label: 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();
|
||||
});
|
||||
|
@ -151,6 +167,10 @@ if(params['import'] !== undefined)
|
|||
}
|
||||
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
|
||||
new Index({
|
||||
el: '#app',
|
||||
|
|
|
@ -11,6 +11,7 @@ export class GeneralSettings {
|
|||
spellcheckLang: string | undefined = 'en-GB';
|
||||
theme = 'default';
|
||||
version = electron.app.getVersion();
|
||||
beta = false;
|
||||
}
|
||||
|
||||
export function mkdir(dir: string): void {
|
||||
|
|
|
@ -6,7 +6,13 @@ import {Message as MessageImpl} from '../chat/common';
|
|||
import core from '../chat/core';
|
||||
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
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;
|
||||
|
||||
|
@ -204,9 +210,12 @@ function getSettingsDir(character: string = core.connection.character): string {
|
|||
|
||||
export class SettingsStore implements Settings.Store {
|
||||
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
|
||||
const file = path.join(getSettingsDir(character), key);
|
||||
if(!fs.existsSync(file)) return undefined;
|
||||
return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
try {
|
||||
const file = path.join(getSettingsDir(character), key);
|
||||
return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch(e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>F-Chat</title>
|
||||
<link href="fa.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
|
@ -100,6 +100,7 @@ async function setDictionary(lang: string | undefined): Promise<void> {
|
|||
}
|
||||
|
||||
const settingsDir = path.join(electron.app.getPath('userData'), 'data');
|
||||
mkdir(settingsDir);
|
||||
const file = path.join(settingsDir, 'settings');
|
||||
const settings = new GeneralSettings();
|
||||
let shouldImportSettings = false;
|
||||
|
@ -137,7 +138,7 @@ async function addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
|
|||
function setUpWebContents(webContents: Electron.WebContents): void {
|
||||
const openLinkExternally = (e: Event, linkUrl: string) => {
|
||||
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]));
|
||||
else electron.shell.openExternal(linkUrl);
|
||||
};
|
||||
|
@ -179,6 +180,7 @@ function showPatchNotes(): void {
|
|||
}
|
||||
|
||||
function onReady(): void {
|
||||
app.setAppUserModelId('net.f-list.f-chat');
|
||||
app.on('open-file', createWindow);
|
||||
|
||||
if(settings.version !== app.getVersion()) {
|
||||
|
@ -188,6 +190,7 @@ function onReady(): void {
|
|||
}
|
||||
|
||||
if(process.env.NODE_ENV === 'production') {
|
||||
if(settings.beta) autoUpdater.channel = 'beta';
|
||||
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
|
||||
const updateTimer = setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
|
||||
let hasUpdate = false;
|
||||
|
@ -195,12 +198,15 @@ function onReady(): void {
|
|||
clearInterval(updateTimer);
|
||||
if(hasUpdate) return;
|
||||
hasUpdate = true;
|
||||
const menu = electron.Menu.getApplicationMenu();
|
||||
const menu = electron.Menu.getApplicationMenu()!;
|
||||
menu.append(new electron.MenuItem({
|
||||
label: l('action.updateAvailable'),
|
||||
submenu: electron.Menu.buildFromTemplate([{
|
||||
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'),
|
||||
click: showPatchNotes
|
||||
|
@ -288,6 +294,13 @@ function onReady(): void {
|
|||
label: x,
|
||||
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'},
|
||||
{role: 'minimize'},
|
||||
|
|
|
@ -5,21 +5,10 @@
|
|||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
"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": {
|
||||
"build": "../node_modules/.bin/webpack",
|
||||
"build:dist": "../node_modules/.bin/webpack --env production",
|
||||
"watch": "../node_modules/.bin/webpack --watch",
|
||||
"build": "node ../webpack development",
|
||||
"build:dist": "node ../webpack production",
|
||||
"watch": "node ../webpack watch",
|
||||
"start": "electron app"
|
||||
},
|
||||
"build": {
|
||||
|
@ -43,8 +32,7 @@
|
|||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://client.f-list.net/",
|
||||
"channel": "latest"
|
||||
"url": "https://client.f-list.net/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"]
|
||||
}
|
|
@ -13,10 +13,5 @@
|
|||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["*.ts", "../**/*.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"app"
|
||||
]
|
||||
"include": ["chat.ts", "window.ts", "../**/*.d.ts"]
|
||||
}
|
|
@ -1,11 +1,8 @@
|
|||
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 fs = require('fs');
|
||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
|
||||
const exportLoader = require('../export-loader');
|
||||
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
|
||||
const mainConfig = {
|
||||
entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'application.json')],
|
||||
|
@ -16,16 +13,16 @@ const mainConfig = {
|
|||
context: __dirname,
|
||||
target: 'electron-main',
|
||||
module: {
|
||||
loaders: [
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
configFile: __dirname + '/tsconfig-main.json',
|
||||
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]'}
|
||||
]
|
||||
},
|
||||
|
@ -34,16 +31,15 @@ const mainConfig = {
|
|||
__filename: false
|
||||
},
|
||||
plugins: [
|
||||
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}),
|
||||
exportLoader.delayTypecheck
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
workers: 2,
|
||||
async: false,
|
||||
tslint: path.join(__dirname, '../tslint.json'),
|
||||
tsconfig: './tsconfig-main.json'
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js']
|
||||
},
|
||||
resolveLoader: {
|
||||
modules: [
|
||||
'node_modules', path.join(__dirname, '../')
|
||||
]
|
||||
}
|
||||
}, rendererConfig = {
|
||||
entry: {
|
||||
|
@ -57,12 +53,11 @@ const mainConfig = {
|
|||
context: __dirname,
|
||||
target: 'electron-renderer',
|
||||
module: {
|
||||
loaders: [
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preLoaders: {ts: 'export-loader'},
|
||||
preserveWhitespace: false
|
||||
}
|
||||
},
|
||||
|
@ -71,7 +66,7 @@ const mainConfig = {
|
|||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
configFile: __dirname + '/tsconfig-renderer.json',
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
|
@ -88,48 +83,45 @@ const mainConfig = {
|
|||
__filename: false
|
||||
},
|
||||
plugins: [
|
||||
new webpack.ProvidePlugin({
|
||||
'$': 'jquery/dist/jquery.slim.js',
|
||||
'jQuery': 'jquery/dist/jquery.slim.js',
|
||||
'window.jQuery': 'jquery/dist/jquery.slim.js'
|
||||
}),
|
||||
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}),
|
||||
new CommonsChunkPlugin({name: 'common', minChunks: 2}),
|
||||
exportLoader.delayTypecheck
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
workers: 2,
|
||||
async: false,
|
||||
tslint: path.join(__dirname, '../tslint.json'),
|
||||
tsconfig: './tsconfig-renderer.json',
|
||||
vue: true
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.vue', '.css'],
|
||||
alias: {qs: path.join(__dirname, 'qs.ts')}
|
||||
},
|
||||
resolveLoader: {
|
||||
modules: [
|
||||
'node_modules', path.join(__dirname, '../')
|
||||
]
|
||||
optimization: {
|
||||
splitChunks: {chunks: 'all', minChunks: 2, name: 'common'}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = function(env) {
|
||||
const dist = env === 'production';
|
||||
const themesDir = path.join(__dirname, '../less/themes/chat');
|
||||
module.exports = function(mode) {
|
||||
const themesDir = path.join(__dirname, '../scss/themes/chat');
|
||||
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) {
|
||||
if(!theme.endsWith('.less')) continue;
|
||||
if(!theme.endsWith('.scss')) continue;
|
||||
const absPath = path.join(themesDir, theme);
|
||||
rendererConfig.entry.chat.push(absPath);
|
||||
const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css');
|
||||
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';
|
||||
const plugins = [new UglifyPlugin({sourceMap: true}),
|
||||
new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify('production')}),
|
||||
new webpack.LoaderOptionsPlugin({minimize: true})];
|
||||
mainConfig.plugins.push(...plugins);
|
||||
rendererConfig.plugins.push(...plugins);
|
||||
rendererConfig.plugins.push(new OptimizeCssAssetsPlugin());
|
||||
} else {
|
||||
//config.devtool = 'cheap-module-eval-source-map';
|
||||
mainConfig.devtool = rendererConfig.devtool = 'none';
|
||||
}
|
||||
return [mainConfig, rendererConfig];
|
||||
};
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>F-Chat</title>
|
||||
<link href="fa.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
1862
electron/yarn.lock
1862
electron/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -33,7 +33,7 @@ function sortMember(this: void | never, array: SortableMember[], member: Sortabl
|
|||
|
||||
class Channel implements Interfaces.Channel {
|
||||
description = '';
|
||||
opList: string[];
|
||||
opList: string[] = [];
|
||||
owner = '';
|
||||
mode: Interfaces.Mode = 'both';
|
||||
members: {[key: string]: SortableMember | undefined} = {};
|
||||
|
@ -163,16 +163,18 @@ export default function(this: void, connection: Connection, characters: Characte
|
|||
const item = state.getChannelItem(data.channel);
|
||||
if(data.character.identity === connection.character) {
|
||||
const id = data.channel.toLowerCase();
|
||||
if(state.joinedMap[id] !== undefined) return;
|
||||
const channel = state.joinedMap[id] = new Channel(id, decodeHTML(data.title));
|
||||
state.joinedChannels.push(channel);
|
||||
if(item !== undefined) item.isJoined = true;
|
||||
} else {
|
||||
const channel = state.getChannel(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));
|
||||
await channel.addMember(member);
|
||||
if(item !== undefined) item.memberCount++;
|
||||
}
|
||||
if(item !== undefined) item.memberCount++;
|
||||
});
|
||||
connection.onMessage('ICH', async(data) => {
|
||||
const channel = state.getChannel(data.channel);
|
||||
|
@ -214,7 +216,6 @@ export default function(this: void, connection: Connection, characters: Characte
|
|||
connection.onMessage('COA', (data) => {
|
||||
const channel = state.getChannel(data.channel);
|
||||
if(channel === undefined) return state.leave(data.channel);
|
||||
channel.opList.push(data.character);
|
||||
const member = channel.members[data.character];
|
||||
if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
|
||||
member.rank = Interfaces.Rank.Op;
|
||||
|
@ -229,7 +230,6 @@ export default function(this: void, connection: Connection, characters: Characte
|
|||
connection.onMessage('COR', (data) => {
|
||||
const channel = state.getChannel(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];
|
||||
if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
|
||||
member.rank = Interfaces.Rank.Member;
|
||||
|
|
|
@ -2,7 +2,7 @@ import {decodeHTML} from './common';
|
|||
import {Character as Interfaces, Connection} from './interfaces';
|
||||
|
||||
class Character implements Interfaces.Character {
|
||||
gender: Interfaces.Gender;
|
||||
gender: Interfaces.Gender = 'None';
|
||||
status: Interfaces.Status = 'offline';
|
||||
statusText = '';
|
||||
isFriend = false;
|
||||
|
|
|
@ -10,15 +10,15 @@ async function queryApi(this: void, endpoint: string, data: object): Promise<Axi
|
|||
}
|
||||
|
||||
export default class Connection implements Interfaces.Connection {
|
||||
character: string;
|
||||
character = '';
|
||||
vars: Interfaces.Vars & {[key: string]: string} = <any>{}; //tslint:disable-line:no-any
|
||||
protected socket: WebSocketConnection | undefined = undefined;
|
||||
private messageHandlers: {[key in keyof Interfaces.ServerCommands]?: Interfaces.CommandHandler<key>[]} = {};
|
||||
private connectionHandlers: {[key in Interfaces.EventType]?: Interfaces.EventHandler[]} = {};
|
||||
private errorHandlers: ((error: Error) => void)[] = [];
|
||||
private ticket: string;
|
||||
private ticket = '';
|
||||
private cleanClose = false;
|
||||
private reconnectTimer: NodeJS.Timer;
|
||||
private reconnectTimer: NodeJS.Timer | undefined;
|
||||
private ticketProvider: Interfaces.TicketProvider;
|
||||
private reconnectDelay = 0;
|
||||
private isReconnect = false;
|
||||
|
@ -86,7 +86,7 @@ export default class Connection implements Interfaces.Connection {
|
|||
}
|
||||
|
||||
close(): void {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
if(this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
|
||||
this.cleanClose = true;
|
||||
if(this.socket !== undefined) this.socket.close();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
126
less/bbcode.less
126
less/bbcode.less
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
261
less/chat.less
261
less/chat.less
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
@import "../variables/dark.less";
|
||||
|
||||
// Apply variables to theme.
|
||||
@import "../theme_base.less";
|
|
@ -1,4 +0,0 @@
|
|||
@import "../variables/default.less";
|
||||
|
||||
// Apply variables to theme.
|
||||
@import "../theme_base.less";
|
|
@ -1,4 +0,0 @@
|
|||
@import "../variables/light.less";
|
||||
|
||||
// Apply variables to theme.
|
||||
@import "../theme_base.less";
|
|
@ -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";
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
380
less/yarn.lock
380
less/yarn.lock
|
@ -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"
|
123
mobile/Index.vue
123
mobile/Index.vue
|
@ -2,47 +2,51 @@
|
|||
<div id="page" style="position: relative; padding: 10px;" v-if="settings">
|
||||
<div v-html="styling"></div>
|
||||
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
|
||||
<div class="well well-lg" style="width: 400px;">
|
||||
<h3 style="margin-top:0">{{l('title')}}</h3>
|
||||
<div class="alert alert-danger" v-show="error">
|
||||
{{error}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="account">{{l('login.account')}}</label>
|
||||
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="password">{{l('login.password')}}</label>
|
||||
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login"/>
|
||||
</div>
|
||||
<div class="form-group" v-show="showAdvanced">
|
||||
<label class="control-label" for="host">{{l('login.host')}}</label>
|
||||
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="theme">{{l('settings.theme')}}</label>
|
||||
<select class="form-control" id="theme" v-model="settings.theme">
|
||||
<option>default</option>
|
||||
<option>dark</option>
|
||||
<option>light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
|
||||
</div>
|
||||
<div class="form-group text-right">
|
||||
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
|
||||
{{l(loggingIn ? 'login.working' : 'login.submit')}}
|
||||
</button>
|
||||
<div class="card bg-light" style="width: 400px;">
|
||||
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-danger" v-show="error">
|
||||
{{error}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="account">{{l('login.account')}}</label>
|
||||
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="password">{{l('login.password')}}</label>
|
||||
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login" :disabled="loggingIn"/>
|
||||
</div>
|
||||
<div class="form-group" v-show="showAdvanced">
|
||||
<label class="control-label" for="host">{{l('login.host')}}</label>
|
||||
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="theme">{{l('settings.theme')}}</label>
|
||||
<select class="form-control custom-select" id="theme" v-model="settings.theme">
|
||||
<option>default</option>
|
||||
<option>dark</option>
|
||||
<option>light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="text-align:right">
|
||||
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
|
||||
{{l(loggingIn ? 'login.working' : 'login.submit')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -61,7 +65,7 @@
|
|||
import Modal from '../components/Modal.vue';
|
||||
import Connection from '../fchat/connection';
|
||||
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';
|
||||
|
||||
declare global {
|
||||
|
@ -70,10 +74,15 @@
|
|||
setTheme(theme: string): void
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
const NativeBackground: {
|
||||
start(): void
|
||||
stop(): void
|
||||
};
|
||||
}
|
||||
|
||||
function confirmBack(): void {
|
||||
if(confirm(l('chat.confirmLeave'))) (<Navigator & {app: {exitApp(): void}}>navigator).app.exitApp();
|
||||
function confirmBack(e: Event): void {
|
||||
if(!confirm(l('chat.confirmLeave'))) e.preventDefault();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
@ -94,25 +103,26 @@
|
|||
profileName = '';
|
||||
|
||||
async created(): Promise<void> {
|
||||
const oldOpen = window.open.bind(window);
|
||||
window.open = (url?: string, target?: string, features?: string, replace?: boolean) => {
|
||||
const profileMatch = url !== undefined ? url.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/) : null;
|
||||
if(profileMatch !== null) {
|
||||
const profileViewer = <Modal>this.$refs['profileViewer'];
|
||||
this.profileName = profileMatch[2];
|
||||
profileViewer.show();
|
||||
return null;
|
||||
} else return oldOpen(url, target, features, replace);
|
||||
};
|
||||
document.addEventListener('open-profile', (e: Event) => {
|
||||
const profileViewer = <Modal>this.$refs['profileViewer'];
|
||||
this.profileName = (<Event & {detail: string}>e).detail;
|
||||
profileViewer.show();
|
||||
});
|
||||
let settings = await getGeneralSettings();
|
||||
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;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
get styling(): string {
|
||||
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> {
|
||||
|
@ -130,16 +140,17 @@
|
|||
}
|
||||
if(this.saveLogin) await setGeneralSettings(this.settings!);
|
||||
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)`, version, Socket,
|
||||
const connection = new Connection('F-Chat 3.0 (Mobile)', appVersion, Socket,
|
||||
this.settings!.account, this.settings!.password);
|
||||
connection.onEvent('connected', () => {
|
||||
Raven.setUserContext({username: core.connection.character});
|
||||
document.addEventListener('backbutton', confirmBack);
|
||||
NativeBackground.start();
|
||||
});
|
||||
connection.onEvent('closed', () => {
|
||||
Raven.setUserContext();
|
||||
document.removeEventListener('backbutton', confirmBack);
|
||||
NativeBackground.stop();
|
||||
});
|
||||
initCore(connection, Logs, SettingsStore, Notifications);
|
||||
const charNames = Object.keys(data.characters);
|
||||
|
@ -157,6 +168,10 @@
|
|||
this.loggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
openProfileInBrowser(): void {
|
||||
window.open(`profile://${this.profileName}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -164,4 +179,8 @@
|
|||
html, body, #page {
|
||||
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>
|
||||
|
|
|
@ -8,8 +8,8 @@ android {
|
|||
applicationId "net.f_list.fchat"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 27
|
||||
versionCode 4
|
||||
versionName "0.1.0"
|
||||
versionCode 11
|
||||
versionName "0.1.4"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
@ -13,12 +14,13 @@
|
|||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Holo.NoActionBar">
|
||||
<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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BackgroundService" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -4,48 +4,30 @@ import android.content.Context
|
|||
import android.webkit.JavascriptInterface
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
class File(private val ctx: Context) {
|
||||
@JavascriptInterface
|
||||
fun readFile(name: String, s: Long, l: Int): String? {
|
||||
fun read(name: String): String? {
|
||||
val file = File(ctx.filesDir, name)
|
||||
if(!file.exists()) return null
|
||||
FileInputStream(file).use { fs ->
|
||||
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)
|
||||
Scanner(file).useDelimiter("\\Z").use { return it.next() }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getSize(name: String) = File(ctx.filesDir, name).length()
|
||||
|
||||
@JavascriptInterface
|
||||
fun writeFile(name: String, data: String) {
|
||||
fun write(name: String, data: String) {
|
||||
FileOutputStream(File(ctx.filesDir, name)).use { it.write(data.toByteArray()) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun append(name: String, data: String) {
|
||||
FileOutputStream(File(ctx.filesDir, name), true).use { it.write(data.toByteArray()) }
|
||||
}
|
||||
fun listFilesN(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString()
|
||||
|
||||
@JavascriptInterface
|
||||
fun listFiles(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString()
|
||||
|
||||
@JavascriptInterface
|
||||
fun listDirectories(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
|
||||
fun listDirectoriesN(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
|
||||
|
||||
@JavascriptInterface
|
||||
fun ensureDirectory(name: String) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -1,31 +1,100 @@
|
|||
package net.f_list.fchat
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JsResult
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import java.net.URLDecoder
|
||||
|
||||
class MainActivity : Activity() {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
if(BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)
|
||||
webView = findViewById(R.id.webview)
|
||||
webView.settings.javaScriptEnabled = true
|
||||
webView.settings.mediaPlaybackRequiresUserGesture = false
|
||||
webView.loadUrl("file:///android_asset/www/index.html")
|
||||
webView.addJavascriptInterface(File(this), "NativeFile")
|
||||
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) {
|
||||
super.onNewIntent(intent)
|
||||
if(intent.action == "notification") {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package net.f_list.fchat
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
|
@ -9,21 +10,30 @@ import android.graphics.Bitmap
|
|||
import android.graphics.BitmapFactory
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.AsyncTask
|
||||
import android.os.Build
|
||||
import android.os.Vibrator
|
||||
import android.webkit.JavascriptInterface
|
||||
import java.net.URL
|
||||
|
||||
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
|
||||
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) {
|
||||
(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
|
||||
}
|
||||
if(soundUri != null) {
|
||||
if(sound != null) {
|
||||
val player = MediaPlayer()
|
||||
val asset = ctx.assets.openFd("www/sounds/$sound.mp3")
|
||||
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)
|
||||
intent.action = "notification"
|
||||
intent.putExtra("data", data)
|
||||
val notification = Notification.Builder(ctx).setContentTitle(title).setContentText(text).setSmallIcon(R.drawable.ic_notification).setDefaults(Notification.DEFAULT_VIBRATE)
|
||||
.setContentIntent(PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)).setAutoCancel(true)
|
||||
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)).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>() {
|
||||
override fun doInBackground(vararg args: String): Bitmap {
|
||||
val connection = URL(args[0]).openConnection()
|
||||
|
@ -44,10 +55,10 @@ class Notifications(private val ctx: Context) {
|
|||
|
||||
override fun onPostExecute(result: Bitmap?) {
|
||||
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)
|
||||
return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/content"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
tools:context="net.f_list.fchat.MainActivity">
|
||||
|
||||
<WebView
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
<resources>
|
||||
<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>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.2.10'
|
||||
ext.kotlin_version = '1.2.21'
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
|
|
|
@ -29,9 +29,6 @@
|
|||
* @version 3.0
|
||||
* @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 Vue from 'vue';
|
||||
import VueRaven from '../chat/vue-raven';
|
||||
|
|
|
@ -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 {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
|
||||
declare global {
|
||||
const NativeFile: {
|
||||
readFile(name: string): Promise<string | undefined>
|
||||
readFile(name: string, start: number, length: number): Promise<string | undefined>
|
||||
writeFile(name: string, data: string): Promise<void>
|
||||
listDirectories(name: string): Promise<string>
|
||||
listFiles(name: string): Promise<string>
|
||||
read(name: string): Promise<string | undefined>
|
||||
write(name: string, data: string): Promise<void>
|
||||
listDirectories(name: string): Promise<string[]>
|
||||
listFiles(name: string): Promise<string[]>
|
||||
getSize(name: string): Promise<number>
|
||||
append(name: string, data: 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;
|
||||
export const appVersion = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
|
||||
|
||||
export class GeneralSettings {
|
||||
account = '';
|
||||
password = '';
|
||||
host = 'wss://chat.f-list.net:9799';
|
||||
theme = 'default';
|
||||
version = appVersion;
|
||||
}
|
||||
|
||||
type Index = {[key: string]: {name: string, index: {[key: number]: number | undefined}} | 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};
|
||||
}
|
||||
type Index = {[key: string]: {name: string, dates: number[]} | undefined};
|
||||
|
||||
export class Logs implements Logging.Persistent {
|
||||
private index: Index = {};
|
||||
private logDir: string;
|
||||
|
||||
constructor() {
|
||||
core.connection.onEvent('connecting', async() => {
|
||||
this.index = {};
|
||||
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};
|
||||
}
|
||||
this.index = await NativeLogs.init(core.connection.character);
|
||||
});
|
||||
}
|
||||
|
||||
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
|
||||
const file = `${this.logDir}/${conversation.key}`;
|
||||
const serialized = serializeMessage(message);
|
||||
const date = Math.floor(message.time.getTime() / dayMs);
|
||||
let indexBuffer: string | undefined;
|
||||
const time = message.time.getTime();
|
||||
const date = Math.floor(time / dayMs);
|
||||
let index = this.index[conversation.key];
|
||||
if(index !== undefined) {
|
||||
if(index.index[date] === undefined) indexBuffer = '';
|
||||
} else {
|
||||
index = this.index[conversation.key] = {name: conversation.name, index: {}};
|
||||
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);
|
||||
if(index === undefined) index = this.index[conversation.key] = {name: conversation.name, dates: []};
|
||||
if(index.dates[index.dates.length - 1] !== date) index.dates.push(date);
|
||||
return NativeLogs.logMessage(conversation.key, conversation.name, time / 1000, message.type,
|
||||
message.type === Conversation.Message.Type.Event ? '' : message.sender.name, message.text);
|
||||
}
|
||||
|
||||
async getBacklog(conversation: Conversation): Promise<Conversation.Message[]> {
|
||||
const file = `${this.logDir}/${conversation.key}`;
|
||||
let count = 20;
|
||||
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 getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
return (await NativeLogs.getBacklog(conversation.key))
|
||||
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
|
||||
}
|
||||
|
||||
async getLogs(key: string, date: Date): Promise<Conversation.Message[]> {
|
||||
const file = `${this.logDir}/${key}`;
|
||||
const messages: Conversation.Message[] = [];
|
||||
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;
|
||||
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
return (await NativeLogs.getLogs(key, date.getTime() / dayMs))
|
||||
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
|
||||
}
|
||||
|
||||
getLogDates(key: string): ReadonlyArray<Date> {
|
||||
const entry = this.index[key];
|
||||
if(entry === undefined) return [];
|
||||
const dates = [];
|
||||
for(const date in entry.index)
|
||||
dates.push(new Date(parseInt(date, 10) * dayMs));
|
||||
return dates;
|
||||
return entry.dates.map((x) => new Date(x * dayMs));
|
||||
}
|
||||
|
||||
get conversations(): ReadonlyArray<{id: string, name: string}> {
|
||||
|
@ -156,27 +78,27 @@ export class Logs implements Logging.Persistent {
|
|||
}
|
||||
|
||||
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
|
||||
const file = await NativeFile.readFile('!settings');
|
||||
const file = await NativeFile.read('!settings');
|
||||
if(file === undefined) return undefined;
|
||||
return <GeneralSettings>JSON.parse(file);
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
return <Settings.Keys[K]>JSON.parse(file);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return <string[]>JSON.parse(await NativeFile.listDirectories('/'));
|
||||
return NativeFile.listDirectories('/');
|
||||
}
|
||||
}
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAD1FEFEE7800183A1A /* ViewController.swift */; };
|
||||
6CA94BB11FEFEE7800183A1A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CA94BAF1FEFEE7800183A1A /* Main.storyboard */; };
|
||||
|
@ -22,7 +23,8 @@
|
|||
|
||||
/* Begin PBXFileReference section */
|
||||
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; };
|
||||
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>"; };
|
||||
|
@ -66,18 +68,19 @@
|
|||
6CA94BAA1FEFEE7800183A1A /* F-Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6C2820811FF5839A00AB9E78 /* Localizable.strings */,
|
||||
6CA94BBD1FEFF2C200183A1A /* www */,
|
||||
6CA94BBF1FEFFC2F00183A1A /* native.js */,
|
||||
6CA94BB71FEFEE7800183A1A /* Info.plist */,
|
||||
6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */,
|
||||
6C4C230C201E7DF1009B3460 /* Background.swift */,
|
||||
6CA94BC11FF009B000183A1A /* File.swift */,
|
||||
6C8ED6182024A820007685DA /* Logs.swift */,
|
||||
6CA94BC31FF070C800183A1A /* Notification.swift */,
|
||||
6CA94BAD1FEFEE7800183A1A /* ViewController.swift */,
|
||||
6CA94BAF1FEFEE7800183A1A /* Main.storyboard */,
|
||||
6CA94BB21FEFEE7800183A1A /* Assets.xcassets */,
|
||||
6CA94BB41FEFEE7800183A1A /* LaunchScreen.storyboard */,
|
||||
6CA94BB71FEFEE7800183A1A /* Info.plist */,
|
||||
6CA94BBF1FEFFC2F00183A1A /* native.js */,
|
||||
6CA94BC11FF009B000183A1A /* File.swift */,
|
||||
6CA94BC31FF070C800183A1A /* Notification.swift */,
|
||||
6C5C1C581FF14432006A3BA1 /* View.swift */,
|
||||
6C2820811FF5839A00AB9E78 /* Localizable.strings */,
|
||||
6CA94BAF1FEFEE7800183A1A /* Main.storyboard */,
|
||||
);
|
||||
path = "F-Chat";
|
||||
sourceTree = "<group>";
|
||||
|
@ -164,9 +167,10 @@
|
|||
files = (
|
||||
6CA94BC41FF070C800183A1A /* Notification.swift in Sources */,
|
||||
6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */,
|
||||
6C5C1C591FF14432006A3BA1 /* View.swift in Sources */,
|
||||
6CA94BC21FF009B000183A1A /* File.swift in Sources */,
|
||||
6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */,
|
||||
6C8ED6192024A820007685DA /* Logs.swift in Sources */,
|
||||
6C4C230D201E7DF1009B3460 /* Background.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
@ -101,7 +101,7 @@
|
|||
{
|
||||
"idiom" : "ios-marketing",
|
||||
"size" : "1024x1024",
|
||||
"filename" : "icon-1024.png",
|
||||
"filename" : "icon-1024.jpg",
|
||||
"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
Loading…
Reference in New Issue