Initial commit.
This commit is contained in:
parent
c1c4ed82d6
commit
878389f717
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
/electron/app
|
||||
/electron/dist
|
||||
/cordova/platforms
|
||||
/cordova/plugins
|
||||
/cordova/www
|
||||
*.vue.ts
|
198
bbcode/Editor.vue
Normal file
198
bbcode/Editor.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="bbcodeEditorContainer">
|
||||
<slot></slot>
|
||||
<div class="btn-group" role="toolbar">
|
||||
<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">
|
||||
<textarea ref="input" :value="text" @input="$emit('input', $event.target.value)" v-show="!preview" :maxlength="maxlength"
|
||||
:class="'bbcodeTextAreaTextArea ' + classes" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste"
|
||||
: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">
|
||||
<li v-for="warning in previewWarnings">{{warning}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bbcode" ref="preview-element"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import {BBCodeElement} from '../chat/bbcode';
|
||||
import {getKey} from '../chat/common';
|
||||
import {CoreBBCodeParser, urlRegex} from './core';
|
||||
import {defaultButtons, EditorButton, EditorSelection} from './editor';
|
||||
|
||||
@Component
|
||||
export default class Editor extends Vue {
|
||||
@Prop()
|
||||
readonly extras?: EditorButton[];
|
||||
@Prop({default: 1000})
|
||||
readonly maxlength: number;
|
||||
@Prop()
|
||||
readonly classes?: string;
|
||||
@Prop()
|
||||
readonly value?: string;
|
||||
@Prop()
|
||||
readonly disabled?: boolean;
|
||||
@Prop()
|
||||
readonly placeholder?: string;
|
||||
preview = false;
|
||||
previewWarnings: ReadonlyArray<string> = [];
|
||||
previewResult = '';
|
||||
text = this.value !== undefined ? this.value : '';
|
||||
element: HTMLTextAreaElement;
|
||||
maxHeight: number;
|
||||
minHeight: number;
|
||||
protected parser = new CoreBBCodeParser();
|
||||
protected defaultButtons = defaultButtons;
|
||||
private isShiftPressed = false;
|
||||
|
||||
mounted(): void {
|
||||
this.element = <HTMLTextAreaElement>this.$refs['input'];
|
||||
const $element = $(this.element);
|
||||
this.maxHeight = parseInt($element.css('max-height'), 10);
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
this.minHeight = parseInt($element.css('min-height'), 10) || $element.outerHeight() || 50;
|
||||
}
|
||||
|
||||
get buttons(): EditorButton[] {
|
||||
const buttons = this.defaultButtons.slice();
|
||||
if(this.extras !== undefined)
|
||||
for(let i = 0, l = this.extras.length; i < l; i++)
|
||||
buttons.push(this.extras[i]);
|
||||
return buttons;
|
||||
}
|
||||
|
||||
@Watch('value')
|
||||
watchValue(newValue: string): void {
|
||||
this.text = newValue;
|
||||
this.$nextTick(() => this.resize());
|
||||
}
|
||||
|
||||
getSelection(): EditorSelection {
|
||||
const length = this.element.selectionEnd - this.element.selectionStart;
|
||||
return {
|
||||
start: this.element.selectionStart,
|
||||
end: this.element.selectionEnd,
|
||||
length,
|
||||
text: this.element.value.substr(this.element.selectionStart, length)
|
||||
};
|
||||
}
|
||||
|
||||
replaceSelection(replacement: string): string {
|
||||
const selection = this.getSelection();
|
||||
const start = this.element.value.substr(0, selection.start) + replacement;
|
||||
const end = this.element.value.substr(selection.end);
|
||||
this.element.value = start + end;
|
||||
this.element.dispatchEvent(new Event('input'));
|
||||
return start + end;
|
||||
}
|
||||
|
||||
setSelection(start: number, end?: number): void {
|
||||
if(end === undefined)
|
||||
end = start;
|
||||
this.element.focus();
|
||||
this.element.setSelectionRange(start, end);
|
||||
}
|
||||
|
||||
applyText(startText: string, endText: string): void {
|
||||
const selection = this.getSelection();
|
||||
if(selection.length > 0) {
|
||||
const replacement = startText + selection.text + endText;
|
||||
this.text = this.replaceSelection(replacement);
|
||||
this.setSelection(selection.start, selection.start + replacement.length);
|
||||
} else {
|
||||
const start = this.text.substr(0, selection.start) + startText;
|
||||
const end = endText + this.text.substr(selection.start);
|
||||
this.text = start + end;
|
||||
this.$nextTick(() => this.setSelection(start.length));
|
||||
}
|
||||
this.$emit('input', this.text);
|
||||
}
|
||||
|
||||
apply(button: EditorButton): void {
|
||||
// Allow emitted variations for custom buttons.
|
||||
this.$once('insert', (startText: string, endText: string) => this.applyText(startText, endText));
|
||||
if(button.handler !== undefined)
|
||||
return <void>button.handler.call(this, this);
|
||||
if(button.startText === undefined)
|
||||
button.startText = `[${button.tag}]`;
|
||||
if(button.endText === undefined)
|
||||
button.endText = `[/${button.tag}]`;
|
||||
this.applyText(button.startText, button.endText);
|
||||
}
|
||||
|
||||
onKeyDown(e: KeyboardEvent): void {
|
||||
const key = getKey(e);
|
||||
if(e.ctrlKey && !e.shiftKey && key !== 'Control') { //tslint:disable-line:curly
|
||||
for(const button of this.buttons)
|
||||
if(button.key === key) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.apply(button);
|
||||
break;
|
||||
}
|
||||
} else if(key === 'Shift') this.isShiftPressed = true;
|
||||
this.$emit('keydown', e);
|
||||
}
|
||||
|
||||
onKeyUp(e: KeyboardEvent): void {
|
||||
if(getKey(e) === 'Shift') 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`;
|
||||
}
|
||||
}
|
||||
|
||||
onPaste(e: ClipboardEvent): void {
|
||||
const data = e.clipboardData.getData('text/plain');
|
||||
if(!this.isShiftPressed && urlRegex.test(data)) {
|
||||
e.preventDefault();
|
||||
this.applyText(`[url=${data}]`, '[/url]');
|
||||
}
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.element.focus();
|
||||
}
|
||||
|
||||
previewBBCode(): void {
|
||||
this.doPreview();
|
||||
}
|
||||
|
||||
protected doPreview(): void {
|
||||
const targetElement = <HTMLElement>this.$refs['preview-element'];
|
||||
if(this.preview) {
|
||||
this.preview = false;
|
||||
this.previewWarnings = [];
|
||||
this.previewResult = '';
|
||||
const previewElement = (<BBCodeElement>targetElement.firstChild);
|
||||
if(previewElement.cleanup !== undefined) previewElement.cleanup();
|
||||
if(targetElement.firstChild !== null) targetElement.removeChild(targetElement.firstChild);
|
||||
} else {
|
||||
this.preview = true;
|
||||
this.parser.storeWarnings = true;
|
||||
targetElement.appendChild(this.parser.parseEverything(this.text));
|
||||
this.previewWarnings = this.parser.warnings;
|
||||
this.parser.storeWarnings = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
86
bbcode/core.ts
Normal file
86
bbcode/core.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {BBCodeCustomTag, BBCodeParser, BBCodeSimpleTag} from './parser';
|
||||
|
||||
const urlFormat = '((?:(?:https?|ftps?|irc):)?\\/\\/[^\\s\\/$.?#"\']+\\.[^\\s"]*)';
|
||||
export const findUrlRegex = new RegExp(`((?!\\[url(?:\\]|=))(?:.{4}[^\\s])\\s+|^.{0,4}\\s|^)${urlFormat}`, 'g');
|
||||
export const urlRegex = new RegExp(`^${urlFormat}$`);
|
||||
|
||||
function domain(url: string): string | undefined {
|
||||
const pieces = urlRegex.exec(url);
|
||||
if(pieces === null) return;
|
||||
const match = pieces[1].match(/(?:(https?|ftps?|irc):)?\/\/(?:www.)?([^\/]+)/);
|
||||
return match !== null ? match[2] : undefined;
|
||||
}
|
||||
|
||||
function fixURL(url: string): string {
|
||||
if(/^www\./.test(url))
|
||||
url = `https://${url}`;
|
||||
return url.replace(/ /g, '%20');
|
||||
}
|
||||
|
||||
export class CoreBBCodeParser extends BBCodeParser {
|
||||
/*tslint:disable-next-line:typedef*///https://github.com/palantir/tslint/issues/711
|
||||
constructor(public makeLinksClickable = true) {
|
||||
super();
|
||||
this.addTag('b', new BBCodeSimpleTag('b', 'strong'));
|
||||
this.addTag('i', new BBCodeSimpleTag('i', 'em'));
|
||||
this.addTag('u', new BBCodeSimpleTag('u', 'u'));
|
||||
this.addTag('s', new BBCodeSimpleTag('s', 'del'));
|
||||
this.addTag('noparse', new BBCodeSimpleTag('noparse', 'span', [], []));
|
||||
this.addTag('sub', new BBCodeSimpleTag('sub', 'sub', [], ['b', 'i', 'u', 's']));
|
||||
this.addTag('sup', new BBCodeSimpleTag('sup', 'sup', [], ['b', 'i', 'u', 's']));
|
||||
this.addTag('color', new BBCodeCustomTag('color', (parser, parent, param) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
const cregex = /^(red|blue|white|yellow|pink|gray|green|orange|purple|black|brown|cyan)$/;
|
||||
if(!cregex.test(param)) {
|
||||
parser.warning('Invalid color parameter provided.');
|
||||
return el;
|
||||
}
|
||||
el.className = `${param}Text`;
|
||||
return el;
|
||||
}));
|
||||
this.addTag('url', new BBCodeCustomTag('url', (parser, parent, _) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, _, param) => {
|
||||
const content = element.innerText.trim();
|
||||
while(element.firstChild !== null) element.removeChild(element.firstChild);
|
||||
|
||||
let url: string, display: string = content;
|
||||
if(param.length > 0) {
|
||||
url = param.trim();
|
||||
if(content.length === 0) display = param;
|
||||
} else if(content.length > 0) url = content;
|
||||
else {
|
||||
parser.warning('url tag contains no url.');
|
||||
element.innerText = ''; //Dafuq!?
|
||||
return;
|
||||
}
|
||||
|
||||
// This fixes problems where content based urls are marked as invalid if they contain spaces.
|
||||
url = fixURL(url);
|
||||
if(!urlRegex.test(url)) {
|
||||
element.innerText = `[BAD URL] ${url}`;
|
||||
return;
|
||||
}
|
||||
const a = parser.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'nofollow noreferrer noopener';
|
||||
a.target = '_blank';
|
||||
a.className = 'link-graphic';
|
||||
a.title = url;
|
||||
a.innerText = display;
|
||||
element.appendChild(a);
|
||||
const span = document.createElement('span');
|
||||
span.className = 'link-domain';
|
||||
span.textContent = ` [${domain(url)}]`;
|
||||
element.appendChild(span);
|
||||
}, []));
|
||||
}
|
||||
|
||||
parseEverything(input: string): HTMLElement {
|
||||
if(this.makeLinksClickable && input.length > 0) input = input.replace(findUrlRegex, '$1[url]$2[/url]');
|
||||
return super.parseEverything(input);
|
||||
}
|
||||
}
|
96
bbcode/editor.ts
Normal file
96
bbcode/editor.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import * as Vue from 'vue';
|
||||
|
||||
export interface EditorButton {
|
||||
title: string;
|
||||
tag: string;
|
||||
icon: string;
|
||||
key?: string;
|
||||
class?: string;
|
||||
startText?: string;
|
||||
endText?: string;
|
||||
handler?(vm: Vue): void;
|
||||
}
|
||||
|
||||
export interface EditorSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
length: number;
|
||||
text: string;
|
||||
}
|
||||
/*tslint:disable:max-line-length*/
|
||||
export let defaultButtons: ReadonlyArray<EditorButton> = [
|
||||
{
|
||||
title: 'Bold (Ctrl+B)\n\nMakes text appear with a bold weight.',
|
||||
tag: 'b',
|
||||
icon: 'fa-bold',
|
||||
key: 'b'
|
||||
},
|
||||
{
|
||||
title: 'Italic (Ctrl+I)\n\nMakes text appear with an italic style.',
|
||||
tag: 'i',
|
||||
icon: 'fa-italic',
|
||||
key: 'i'
|
||||
},
|
||||
{
|
||||
title: 'Underline (Ctrl+U)\n\nMakes text appear with an underline beneath it.',
|
||||
tag: 'u',
|
||||
icon: 'fa-underline',
|
||||
key: 'u'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
{
|
||||
title: 'Color (Ctrl+Q)\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: 'q'
|
||||
},
|
||||
{
|
||||
title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',
|
||||
tag: 'sup',
|
||||
icon: 'fa-superscript',
|
||||
key: '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'
|
||||
},
|
||||
{
|
||||
title: 'URL (Ctrl+L)\n\nCreates a clickable link to another page of your choosing.',
|
||||
tag: 'url',
|
||||
startText: '[url=]',
|
||||
icon: 'fa-link',
|
||||
key: 'l'
|
||||
},
|
||||
{
|
||||
title: 'User (Ctrl+R)\n\nLinks to a character\'s profile.',
|
||||
tag: 'user',
|
||||
icon: 'fa-user',
|
||||
key: 'r'
|
||||
},
|
||||
{
|
||||
title: 'Icon (Ctrl+O)\n\nShows a character\'s profile image, linking to their profile.',
|
||||
tag: 'icon',
|
||||
icon: 'fa-user-circle',
|
||||
key: 'o'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
];
|
252
bbcode/parser.ts
Normal file
252
bbcode/parser.ts
Normal file
@ -0,0 +1,252 @@
|
||||
export abstract class BBCodeTag {
|
||||
noClosingTag = false;
|
||||
allowedTags: {[tag: string]: boolean | undefined} | undefined;
|
||||
|
||||
constructor(public tag: string, tagList?: string[]) {
|
||||
if(tagList !== undefined)
|
||||
this.setAllowedTags(tagList);
|
||||
}
|
||||
|
||||
isAllowed(tag: string): boolean {
|
||||
return this.allowedTags === undefined || this.allowedTags[tag] !== undefined;
|
||||
}
|
||||
|
||||
setAllowedTags(allowed: string[]): void {
|
||||
this.allowedTags = {};
|
||||
for(const tag of allowed)
|
||||
this.allowedTags[tag] = true;
|
||||
}
|
||||
|
||||
//tslint:disable-next-line:no-empty
|
||||
afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement, ____?: string): void {
|
||||
}
|
||||
|
||||
abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement;
|
||||
}
|
||||
|
||||
export class BBCodeSimpleTag extends BBCodeTag {
|
||||
|
||||
constructor(tag: string, private elementName: keyof ElementTagNameMap, private classes?: string[], tagList?: string[]) {
|
||||
super(tag, tagList);
|
||||
}
|
||||
|
||||
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement {
|
||||
if(param.length > 0)
|
||||
parser.warning('Unexpected parameter');
|
||||
const el = parser.createElement(this.elementName);
|
||||
if(this.classes !== undefined)
|
||||
el.className = this.classes.join(' ');
|
||||
parent.appendChild(el);
|
||||
/*tslint:disable-next-line:no-unsafe-any*/// false positive
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
export type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement;
|
||||
export type CustomCloser = (parser: BBCodeParser, current: HTMLElement, parent: HTMLElement, param: string) => void;
|
||||
|
||||
export class BBCodeCustomTag extends BBCodeTag {
|
||||
constructor(tag: string, private customCreator: CustomElementCreator, private customCloser?: CustomCloser, tagList?: string[]) {
|
||||
super(tag, tagList);
|
||||
}
|
||||
|
||||
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement {
|
||||
return this.customCreator(parser, parent, param);
|
||||
}
|
||||
|
||||
afterClose(parser: BBCodeParser, current: HTMLElement, parent: HTMLElement, param: string): void {
|
||||
if(this.customCloser !== undefined)
|
||||
this.customCloser(parser, current, parent, param);
|
||||
}
|
||||
}
|
||||
|
||||
enum BufferType { Raw, Tag }
|
||||
|
||||
class ParserTag {
|
||||
constructor(public tag: string, public param: string, public element: HTMLElement, public parent: HTMLElement,
|
||||
public line: number, public column: number) {
|
||||
}
|
||||
|
||||
appendElement(child: HTMLElement): void {
|
||||
this.element.appendChild(child);
|
||||
}
|
||||
|
||||
append(content: string, start: number, end: number): void {
|
||||
if(content.length === 0)
|
||||
return;
|
||||
this.element.appendChild(document.createTextNode(content.substring(start, end)));
|
||||
}
|
||||
}
|
||||
|
||||
export class BBCodeParser {
|
||||
private _warnings: string[] = [];
|
||||
private _tags: {[tag: string]: BBCodeTag | undefined} = {};
|
||||
private _line: number;
|
||||
private _column: number;
|
||||
private _currentTag: ParserTag;
|
||||
private _storeWarnings = false;
|
||||
|
||||
parseEverything(input: string): HTMLElement {
|
||||
if(input.length === 0)
|
||||
return this.createElement('span');
|
||||
this._warnings = [];
|
||||
this._line = 1;
|
||||
this._column = 1;
|
||||
const stack: ParserTag[] = this.parse(input, 0, input.length);
|
||||
|
||||
for(let i = stack.length - 1; i > 0; i--) {
|
||||
this._currentTag = <ParserTag>stack.pop();
|
||||
this.warning('Automatically closing tag at end of input.');
|
||||
}
|
||||
if(process.env.NODE_ENV !== 'production' && this._warnings.length > 0)
|
||||
console.log(this._warnings);
|
||||
return stack[0].element;
|
||||
}
|
||||
|
||||
createElement<K extends keyof HTMLElementTagNameMap>(tag: K | keyof ElementTagNameMap): HTMLElementTagNameMap[K] {
|
||||
return document.createElement(tag);
|
||||
}
|
||||
|
||||
addTag(tag: string, impl: BBCodeTag): void {
|
||||
this._tags[tag] = impl;
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
delete this._tags[tag];
|
||||
}
|
||||
|
||||
get warnings(): ReadonlyArray<string> {
|
||||
return this._warnings;
|
||||
}
|
||||
|
||||
set storeWarnings(store: boolean) {
|
||||
this._storeWarnings = store;
|
||||
if(!store)
|
||||
this._warnings = [];
|
||||
}
|
||||
|
||||
warning(message: string): void {
|
||||
if(!this._storeWarnings)
|
||||
return;
|
||||
const cur = this._currentTag;
|
||||
const newMessage = `Error on ${this._line}:${this._column} while inside tag [${cur.tag} @ ${cur.line}:${cur.column}]: ${message}`;
|
||||
this._warnings.push(newMessage);
|
||||
}
|
||||
|
||||
private parse(input: string, start: number, end: number): ParserTag[] {
|
||||
const ignoreClosing: {[key: string]: number} = {};
|
||||
|
||||
function ignoreNextClosingTag(tagName: string): void {
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
ignoreClosing[tagName] = (ignoreClosing[tagName] || 0) + 1;
|
||||
}
|
||||
|
||||
const stack: ParserTag[] = [];
|
||||
|
||||
function stackTop(): ParserTag {
|
||||
return stack[stack.length - 1];
|
||||
}
|
||||
|
||||
function quickReset(i: number): void {
|
||||
stackTop().append(input, start, i + 1);
|
||||
start = i + 1;
|
||||
curType = BufferType.Raw;
|
||||
}
|
||||
|
||||
let curType: BufferType = BufferType.Raw;
|
||||
// Root tag collects output.
|
||||
const root = this.createElement('span');
|
||||
const rootTag = new ParserTag('<root>', '', root, root, 1, 1);
|
||||
stack.push(rootTag);
|
||||
this._currentTag = rootTag;
|
||||
let paramStart = -1;
|
||||
for(let i = start; i < end; ++i) {
|
||||
const c = input[i];
|
||||
++this._column;
|
||||
if(c === '\n') {
|
||||
++this._line;
|
||||
this._column = 1;
|
||||
quickReset(i);
|
||||
stackTop().appendElement(this.createElement('br'));
|
||||
}
|
||||
switch(curType) {
|
||||
case BufferType.Raw:
|
||||
if(c === '[') {
|
||||
stackTop().append(input, start, i);
|
||||
start = i;
|
||||
curType = BufferType.Tag;
|
||||
}
|
||||
break;
|
||||
case BufferType.Tag:
|
||||
if(c === '[') {
|
||||
stackTop().append(input, start, i);
|
||||
start = i;
|
||||
} else if(c === '=' && paramStart === -1)
|
||||
paramStart = i;
|
||||
else if(c === ']') {
|
||||
const paramIndex = paramStart === -1 ? i : paramStart;
|
||||
let tagKey = input.substring(start + 1, paramIndex).trim();
|
||||
if(tagKey.length === 0) {
|
||||
quickReset(i);
|
||||
continue;
|
||||
}
|
||||
let param = '';
|
||||
if(paramStart !== -1)
|
||||
param = input.substring(paramStart + 1, i).trim();
|
||||
paramStart = -1;
|
||||
const close = tagKey[0] === '/';
|
||||
if(close) tagKey = tagKey.substr(1).trim();
|
||||
if(typeof this._tags[tagKey] === 'undefined') {
|
||||
quickReset(i);
|
||||
continue;
|
||||
}
|
||||
if(!close) {
|
||||
let allowed = true;
|
||||
for(let k = stack.length - 1; k > 0; --k) {
|
||||
allowed = allowed && this._tags[stack[k].tag]!.isAllowed(tagKey);
|
||||
if(!allowed)
|
||||
break;
|
||||
}
|
||||
if(!allowed) {
|
||||
ignoreNextClosingTag(tagKey);
|
||||
quickReset(i);
|
||||
continue;
|
||||
}
|
||||
const parent = stackTop().element;
|
||||
const el = this._tags[tagKey]!.createElement(this, parent, param);
|
||||
if(!this._tags[tagKey]!.noClosingTag)
|
||||
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
|
||||
} else if(ignoreClosing[tagKey] > 0) {
|
||||
ignoreClosing[tagKey] -= 1;
|
||||
stackTop().append(input, start, i + 1);
|
||||
} else {
|
||||
let closed = false;
|
||||
for(let k = stack.length - 1; k >= 0; --k) {
|
||||
if(stack[k].tag !== tagKey) continue;
|
||||
for(let y = stack.length - 1; y >= k; --y) {
|
||||
const closeTag = <ParserTag>stack.pop();
|
||||
this._currentTag = closeTag;
|
||||
if(y > k)
|
||||
this.warning(`Unexpected closing ${tagKey} tag. Needed ${closeTag.tag} tag instead.`);
|
||||
this._tags[closeTag.tag]!.afterClose(this, closeTag.element, closeTag.parent, closeTag.param);
|
||||
}
|
||||
this._currentTag = stackTop();
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if(!closed) {
|
||||
this.warning(`Found closing ${tagKey} tag that was never opened.`);
|
||||
stackTop().append(input, start, i + 1);
|
||||
}
|
||||
}
|
||||
start = i + 1;
|
||||
curType = BufferType.Raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(start < input.length)
|
||||
stackTop().append(input, start, input.length);
|
||||
|
||||
return stack;
|
||||
}
|
||||
}
|
96
chat/ChannelList.vue
Normal file
96
chat/ChannelList.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<modal :buttons="false" :action="l('chat.channels')">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<ul class="nav nav-tabs">
|
||||
<li role="presentation" :class="{active: !privateTabShown}">
|
||||
<a href="#" @click.prevent="privateTabShown = false">{{l('channelList.public')}}</a>
|
||||
</li>
|
||||
<li role="presentation" :class="{active: privateTabShown}">
|
||||
<a href="#" @click.prevent="privateTabShown = true">{{l('channelList.private')}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div style="display: flex; flex-direction: column">
|
||||
<div style="display:flex; 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>
|
||||
</a>
|
||||
</div>
|
||||
<div style="overflow: auto;" v-show="!privateTabShown">
|
||||
<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)"/>
|
||||
{{channel.name}} ({{channel.memberCount}})
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow: auto;" v-show="privateTabShown">
|
||||
<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)"/>
|
||||
{{channel.name}} ({{channel.memberCount}})
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; padding: 10px 0; flex-shrink: 0;">
|
||||
<input class="form-control" style="flex:1; margin-right:10px;" v-model="createName"
|
||||
:placeholder="l('channelList.createName')"/>
|
||||
<button class="btn btn-primary" @click="create">{{l('channelList.create')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import core from './core';
|
||||
import {Channel} from './interfaces';
|
||||
import l from './localize';
|
||||
import ListItem = Channel.ListItem;
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal}
|
||||
})
|
||||
export default class ChannelList extends CustomDialog {
|
||||
privateTabShown = false;
|
||||
l = l;
|
||||
sortCount = true;
|
||||
filter = '';
|
||||
createName = '';
|
||||
|
||||
get openRooms(): ReadonlyArray<Channel.ListItem> {
|
||||
return this.applyFilter(core.channels.openRooms);
|
||||
}
|
||||
|
||||
get officialChannels(): ReadonlyArray<Channel.ListItem> {
|
||||
return this.applyFilter(core.channels.officialChannels);
|
||||
}
|
||||
|
||||
applyFilter(list: {[key: string]: Channel.ListItem | undefined}): ReadonlyArray<Channel.ListItem> {
|
||||
const channels: Channel.ListItem[] = [];
|
||||
if(this.filter.length > 0) {
|
||||
const search = new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
|
||||
//tslint:disable-next-line:forin
|
||||
for(const key in list) {
|
||||
const item = list[key]!;
|
||||
if(search.test(item.name)) channels.push(item);
|
||||
}
|
||||
} else
|
||||
for(const key in list) channels.push(list[key]!);
|
||||
channels.sort(this.sortCount ? (x, y) => y.memberCount - x.memberCount : (x, y) => x.name.localeCompare(y.name));
|
||||
return channels;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
core.connection.send('CCR', {channel: this.createName});
|
||||
this.hide();
|
||||
}
|
||||
|
||||
setJoined(channel: ListItem): void {
|
||||
channel.isJoined ? core.channels.leave(channel.id) : core.channels.join(channel.id);
|
||||
}
|
||||
}
|
||||
</script>
|
32
chat/ChannelView.vue
Normal file
32
chat/ChannelView.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<a href="#" @click.prevent="joinChannel" :disabled="channel && channel.isJoined"><span class="fa fa-hashtag"></span>{{displayText}}</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
import core from './core';
|
||||
import {Channel} from './interfaces';
|
||||
|
||||
@Component
|
||||
export default class ChannelView extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly id: string;
|
||||
@Prop({required: true})
|
||||
readonly text: string;
|
||||
|
||||
joinChannel(): void {
|
||||
if(this.channel === undefined || !this.channel.isJoined)
|
||||
core.channels.join(this.id);
|
||||
}
|
||||
|
||||
get displayText(): string {
|
||||
return this.channel !== undefined ? `${this.channel.name} (${this.channel.memberCount})` : this.text;
|
||||
}
|
||||
|
||||
get channel(): Channel.ListItem | undefined {
|
||||
return core.channels.getChannelItem(this.id);
|
||||
}
|
||||
}
|
||||
</script>
|
129
chat/CharacterSearch.vue
Normal file
129
chat/CharacterSearch.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<modal :action="l('characterSearch.action')" @submit.prevent="submit" :disabled="!data.kinks.length"
|
||||
:buttonText="results ? l('characterSearch.again') : undefined" class="character-search">
|
||||
<div v-if="options && !results">
|
||||
<div v-show="error" class="alert alert-danger">{{error}}</div>
|
||||
<filterable-select v-model="data.kinks" :multiple="true" :placeholder="l('filter')"
|
||||
:title="l('characterSearch.kinks')" :filterFunc="filterKink" :options="options.kinks">
|
||||
<template scope="s">{{s.option.name}}</template>
|
||||
</filterable-select>
|
||||
<filterable-select v-for="item in ['genders', 'orientations', 'languages', 'furryprefs', 'roles', 'positions']" :multiple="true"
|
||||
v-model="data[item]" :placeholder="l('filter')" :title="l('characterSearch.' + item)" :options="options[item]" :key="item">
|
||||
</filterable-select>
|
||||
<div v-show="!data.kinks.length" class="alert alert-warning">{{l('characterSearch.kinkNotice')}}</div>
|
||||
</div>
|
||||
<div v-else-if="results">
|
||||
<h5>{{l('characterSearch.results')}}</h5>
|
||||
<div v-for="character in results">
|
||||
<user :character="character"></user>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Axios from 'axios';
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import FilterableSelect from '../components/FilterableSelect.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import core from './core';
|
||||
import {Character, Connection} from './interfaces';
|
||||
import l from './localize';
|
||||
import UserView from './user_view';
|
||||
|
||||
type Options = {
|
||||
kinks: {id: number, name: string, description: string}[],
|
||||
listitems: {id: string, name: string, value: string}[]
|
||||
};
|
||||
|
||||
let options: Options | undefined;
|
||||
|
||||
type Kink = {id: number, name: string, description: string};
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal, user: UserView, 'filterable-select': FilterableSelect}
|
||||
})
|
||||
export default class CharacterSearch extends CustomDialog {
|
||||
//tslint:disable:no-null-keyword
|
||||
l = l;
|
||||
kinksFilter = '';
|
||||
error = '';
|
||||
results: Character[] | null = null;
|
||||
options: {
|
||||
kinks: Kink[]
|
||||
genders: string[]
|
||||
orientations: string[]
|
||||
languages: string[]
|
||||
furryprefs: string[]
|
||||
roles: string[]
|
||||
positions: string[]
|
||||
} | null = null;
|
||||
data: {[key: string]: (string | Kink)[]} = {
|
||||
kinks: <Kink[]>[],
|
||||
genders: <string[]>[],
|
||||
orientations: <string[]>[],
|
||||
languages: <string[]>[],
|
||||
furryprefs: <string[]>[],
|
||||
roles: <string[]>[],
|
||||
positions: <string[]>[]
|
||||
};
|
||||
|
||||
async created(): Promise<void> {
|
||||
if(options === undefined)
|
||||
options = <Options | undefined>(await Axios.get('https://www.f-list.net/json/api/mapping-list.php')).data;
|
||||
if(options === undefined) return;
|
||||
this.options = {
|
||||
kinks: options.kinks.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0))),
|
||||
genders: options.listitems.filter((x) => x.name === 'gender').map((x) => x.value),
|
||||
orientations: options.listitems.filter((x) => x.name === 'orientation').map((x) => x.value),
|
||||
languages: options.listitems.filter((x) => x.name === 'languagepreference').map((x) => x.value),
|
||||
furryprefs: options.listitems.filter((x) => x.name === 'furrypref').map((x) => x.value),
|
||||
roles: options.listitems.filter((x) => x.name === 'subdom').map((x) => x.value),
|
||||
positions: options.listitems.filter((x) => x.name === 'position').map((x) => x.value)
|
||||
};
|
||||
this.$nextTick(() => (<Modal>this.$children[0]).fixDropdowns());
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
core.connection.onMessage('ERR', (data) => {
|
||||
switch(data.number) {
|
||||
case 18:
|
||||
this.error = l('characterSearch.error.noResults');
|
||||
break;
|
||||
case 50:
|
||||
this.error = l('characterSearch.error.throttle');
|
||||
break;
|
||||
case 72:
|
||||
this.error = l('characterSearch.error.tooManyResults');
|
||||
}
|
||||
});
|
||||
core.connection.onMessage('FKS', (data) => this.results = data.characters.map((x: string) => core.characters.get(x)));
|
||||
}
|
||||
|
||||
filterKink(filter: RegExp, kink: Kink): boolean {
|
||||
if(this.data.kinks.length >= 5)
|
||||
return this.data.kinks.indexOf(kink) !== -1;
|
||||
return filter.test(kink.name);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if(this.results !== null) {
|
||||
this.results = null;
|
||||
return;
|
||||
}
|
||||
this.error = '';
|
||||
const data: Connection.ClientCommands['FKS'] & {[key: string]: (string | number)[]} = {kinks: []};
|
||||
for(const key in this.data)
|
||||
if(this.data[key].length > 0)
|
||||
data[key] = key === 'kinks' ? (<Kink[]>this.data[key]).map((x) => x.id) : (<string[]>this.data[key]);
|
||||
core.connection.send('FKS', data);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.character-search .dropdown {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
90
chat/Chat.vue
Normal file
90
chat/Chat.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<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="alert alert-danger" v-show="error">{{error}}</div>
|
||||
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
|
||||
<div class="card-block">
|
||||
<h4 class="card-title">{{l('login.selectCharacter')}}</h4>
|
||||
<select v-model="selectedCharacter" class="form-control">
|
||||
<option v-for="character in ownCharacters" :value="character">{{character}}</option>
|
||||
</select>
|
||||
<div style="text-align: right; margin-top: 10px;">
|
||||
<button class="btn btn-primary" @click="connect" :disabled="connecting">
|
||||
{{l(connecting ? 'login.connecting' : 'login.connect')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<chat v-else></chat>
|
||||
<modal :action="l('chat.disconnected.title')" :buttonText="l('action.cancel')" ref="reconnecting" @submit="cancelReconnect"
|
||||
:showCancel="false" buttonClass="btn-danger">
|
||||
<div class="alert alert-danger" v-show="error">{{error}}</div>
|
||||
{{l('chat.disconnected')}}
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Channels from '../fchat/channels';
|
||||
import Characters from '../fchat/characters';
|
||||
import ChatView from './ChatView.vue';
|
||||
import {errorToString, requestNotificationsPermission} from './common';
|
||||
import Conversations from './conversations';
|
||||
import core from './core';
|
||||
import l from './localize';
|
||||
|
||||
@Component({
|
||||
components: {chat: ChatView, modal: Modal}
|
||||
})
|
||||
export default class Chat extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly ownCharacters: string[];
|
||||
@Prop({required: true})
|
||||
readonly defaultCharacter: string;
|
||||
selectedCharacter = this.defaultCharacter;
|
||||
error = '';
|
||||
connecting = false;
|
||||
connected = false;
|
||||
l = l;
|
||||
|
||||
mounted(): void {
|
||||
core.register('characters', Characters(core.connection));
|
||||
core.register('channels', Channels(core.connection, core.characters));
|
||||
core.register('conversations', Conversations());
|
||||
core.connection.onEvent('closed', (isReconnect) => {
|
||||
if(isReconnect) (<Modal>this.$refs['reconnecting']).show(true);
|
||||
if(this.connected) core.notifications.playSound('logout');
|
||||
this.connected = false;
|
||||
});
|
||||
core.connection.onEvent('connecting', async() => {
|
||||
this.connecting = true;
|
||||
if(core.state.settings.notifications) await requestNotificationsPermission();
|
||||
});
|
||||
core.connection.onEvent('connected', () => {
|
||||
(<Modal>this.$refs['reconnecting']).hide();
|
||||
this.error = '';
|
||||
this.connecting = false;
|
||||
this.connected = true;
|
||||
core.notifications.playSound('login');
|
||||
});
|
||||
core.connection.onError((e) => {
|
||||
this.error = errorToString(e);
|
||||
this.connecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
cancelReconnect(): void {
|
||||
core.connection.close();
|
||||
(<Modal>this.$refs['reconnecting']).hide();
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.connecting = true;
|
||||
core.connection.connect(this.selectedCharacter);
|
||||
}
|
||||
}
|
||||
</script>
|
290
chat/ChatView.vue
Normal file
290
chat/ChatView.vue
Normal file
@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div style="height:100%; display: flex; position: relative;" @click="$refs['userMenu'].handleEvent($event)"
|
||||
@contextmenu="$refs['userMenu'].handleEvent($event)" @touchstart="$refs['userMenu'].handleEvent($event)"
|
||||
@touchend="$refs['userMenu'].handleEvent($event)">
|
||||
<div class="sidebar sidebar-left" id="sidebar">
|
||||
<button @click="sidebarExpanded = !sidebarExpanded" class="btn btn-default btn-xs expander" :aria-label="l('chat.menu')">
|
||||
<span class="fa" :class="{'fa-chevron-up': sidebarExpanded, 'fa-chevron-down': !sidebarExpanded}"></span>
|
||||
<span class="fa fa-bars fa-rotate-90" style="vertical-align: middle"></span>
|
||||
</button>
|
||||
<div class="body" :style="sidebarExpanded ? 'display:block' : ''"
|
||||
style="width: 200px; padding-right: 5px; height: 100%; overflow: auto;">
|
||||
<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/>
|
||||
<div>
|
||||
{{l('chat.status')}}
|
||||
<a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn">
|
||||
<span class="fa fa-fw" :class="getStatusIcon(ownCharacter.status)"></span>{{l('status.' + ownCharacter.status)}}
|
||||
</a>
|
||||
</div>
|
||||
<div style="clear:both;">
|
||||
<a href="#" @click.prevent="$refs['searchDialog'].show()" class="btn"><span class="fa fa-search"></span>
|
||||
{{l('characterSearch.open')}}</a>
|
||||
</div>
|
||||
<div><a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn"><span class="fa fa-cog"></span>
|
||||
{{l('settings.open')}}</a></div>
|
||||
<div><a href="#" @click.prevent="$refs['recentDialog'].show()" class="btn"><span class="fa fa-history"></span>
|
||||
{{l('chat.recentConversations')}}</a></div>
|
||||
<div>
|
||||
<div class="list-group conversation-nav">
|
||||
<a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()"
|
||||
class="list-group-item list-group-item-action">
|
||||
{{conversations.consoleTab.name}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{{l('chat.pms')}}
|
||||
<div class="list-group conversation-nav" ref="privateConversations">
|
||||
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
|
||||
:class="getClasses(conversation)"
|
||||
class="list-group-item list-group-item-action item-private" :key="conversation.key">
|
||||
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
|
||||
<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}"
|
||||
@click.stop.prevent="conversation.isPinned = !conversation.isPinned" @mousedown.stop.prevent
|
||||
></span><span class="fa fa-times leave" @click.stop="conversation.close()"></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#" @click.prevent="$refs['channelsDialog'].show()" class="btn"><span class="fa fa-list"></span>
|
||||
{{l('chat.channels')}}</a>
|
||||
<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"
|
||||
:class="{'active': conversation.isPinned}" @click.stop.prevent="conversation.isPinned = !conversation.isPinned"
|
||||
@mousedown.stop.prevent></span><span class="fa fa-times leave" @click.stop="conversation.close()"></span></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 100%; display:flex; flex-direction:column;">
|
||||
<div id="quick-switcher" class="list-group">
|
||||
<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>
|
||||
<div class="name">{{conversation.character.name}}</div>
|
||||
</a>
|
||||
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
|
||||
:class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key">
|
||||
<span class="fa fa-hashtag conversation-icon"></span>
|
||||
<div class="name">{{conversation.name}}</div>
|
||||
</a>
|
||||
</div>
|
||||
<conversation :reportDialog="$refs['reportDialog']"></conversation>
|
||||
</div>
|
||||
<user-list></user-list>
|
||||
<channels ref="channelsDialog"></channels>
|
||||
<status-switcher ref="statusDialog"></status-switcher>
|
||||
<character-search ref="searchDialog"></character-search>
|
||||
<settings ref="settingsDialog"></settings>
|
||||
<report-dialog ref="reportDialog"></report-dialog>
|
||||
<user-menu ref="userMenu" :reportDialog="$refs['reportDialog']"></user-menu>
|
||||
<recent-conversations ref="recentDialog"></recent-conversations>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
//tslint:disable-next-line:no-require-imports
|
||||
import Sortable = require('sortablejs');
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import ChannelList from './ChannelList.vue';
|
||||
import CharacterSearch from './CharacterSearch.vue';
|
||||
import {characterImage} from './common';
|
||||
import ConversationView from './ConversationView.vue';
|
||||
import core from './core';
|
||||
import {Character, Connection, Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
import RecentConversations from './RecentConversations.vue';
|
||||
import ReportDialog from './ReportDialog.vue';
|
||||
import SettingsView from './SettingsView.vue';
|
||||
import StatusSwitcher from './StatusSwitcher.vue';
|
||||
import {getStatusIcon} from './user_view';
|
||||
import UserList from './UserList.vue';
|
||||
import UserMenu from './UserMenu.vue';
|
||||
|
||||
const unreadClasses = {
|
||||
[Conversation.UnreadState.None]: '',
|
||||
[Conversation.UnreadState.Mention]: 'list-group-item-warning',
|
||||
[Conversation.UnreadState.Unread]: 'has-new'
|
||||
};
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'user-list': UserList, channels: ChannelList, 'status-switcher': StatusSwitcher, 'character-search': CharacterSearch,
|
||||
settings: SettingsView, conversation: ConversationView, 'report-dialog': ReportDialog,
|
||||
'user-menu': UserMenu, 'recent-conversations': RecentConversations
|
||||
}
|
||||
})
|
||||
export default class ChatView extends Vue {
|
||||
l = l;
|
||||
sidebarExpanded = false;
|
||||
characterImage = characterImage;
|
||||
conversations = core.conversations;
|
||||
getStatusIcon = getStatusIcon;
|
||||
|
||||
mounted(): void {
|
||||
Sortable.create(this.$refs['privateConversations'], {
|
||||
animation: 50,
|
||||
onEnd: (e: {oldIndex: number, newIndex: number}) => core.conversations.privateConversations[e.oldIndex].sort(e.newIndex)
|
||||
});
|
||||
Sortable.create(this.$refs['channelConversations'], {
|
||||
animation: 50,
|
||||
onEnd: (e: {oldIndex: number, newIndex: number}) => core.conversations.channelConversations[e.oldIndex].sort(e.newIndex)
|
||||
});
|
||||
const ownCharacter = core.characters.ownCharacter;
|
||||
let idleTimer: number | undefined, idleStatus: Connection.ClientCommands['STA'] | undefined, lastUpdate = 0;
|
||||
window.focus = () => {
|
||||
core.notifications.isInBackground = false;
|
||||
if(idleTimer !== undefined) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if(idleStatus !== undefined) {
|
||||
core.connection.send('STA', idleStatus);
|
||||
idleStatus = undefined;
|
||||
}
|
||||
}, Math.max(lastUpdate + 5 /*core.connection.vars.sta_flood*/ * 1000 + 1000 - Date.now(), 0));
|
||||
};
|
||||
window.blur = () => {
|
||||
core.notifications.isInBackground = true;
|
||||
if(idleTimer !== undefined) clearTimeout(idleTimer);
|
||||
if(core.state.settings.idleTimer !== 0)
|
||||
idleTimer = window.setTimeout(() => {
|
||||
lastUpdate = Date.now();
|
||||
idleStatus = {status: ownCharacter.status, statusmsg: ownCharacter.statusText};
|
||||
core.connection.send('STA', {status: 'idle', statusmsg: ownCharacter.statusText});
|
||||
}, core.state.settings.idleTimer * 60000);
|
||||
};
|
||||
}
|
||||
|
||||
logOut(): void {
|
||||
if(confirm(l('chat.confirmLeave'))) core.connection.close();
|
||||
}
|
||||
|
||||
get showAvatars(): boolean {
|
||||
return core.state.settings.showAvatars;
|
||||
}
|
||||
|
||||
get ownCharacter(): Character {
|
||||
return core.characters.ownCharacter;
|
||||
}
|
||||
|
||||
getClasses(conversation: Conversation): string {
|
||||
return unreadClasses[conversation.unread] + (conversation === core.conversations.selectedConversation ? ' active' : '');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '~bootstrap/less/variables.less';
|
||||
|
||||
.list-group.conversation-nav {
|
||||
margin-bottom: 10px;
|
||||
.list-group-item {
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fa {
|
||||
font-size: 16px;
|
||||
padding: 0 3px;
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&.item-private {
|
||||
padding-left: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
img {
|
||||
height: 40px;
|
||||
margin: -1px 5px -1px -1px;
|
||||
}
|
||||
&:first-child img {
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
&:last-child img {
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#quick-switcher {
|
||||
margin: 0 45px 5px;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
|
||||
@media (max-width: @screen-xs-max) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
a {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
padding: 5px 5px 0;
|
||||
&:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
&:last-child {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-icon {
|
||||
font-size: 2em;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
.body a.btn {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@media (min-width: @screen-sm-min) {
|
||||
position: static;
|
||||
.body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expander {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
100
chat/CommandHelp.vue
Normal file
100
chat/CommandHelp.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div style="display: flex; flex-direction: column;" id="command-help">
|
||||
<div style="overflow: auto;">
|
||||
<div v-for="command in filteredCommands">
|
||||
<h4>{{command.name}}</h4>
|
||||
<i>{{l('commands.help.syntax', command.syntax)}}</i>
|
||||
<div>{{command.help}}</div>
|
||||
<div v-if="command.params.length">
|
||||
{{l('commands.help.parameters')}}
|
||||
<div v-for="param in command.params" class="params">
|
||||
<b>{{param.name}}</b> - {{param.help}}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="command.context"><i>{{command.context}}</i></div>
|
||||
<div v-if="command.permission"><i>{{command.permission}}</i></div>
|
||||
</div>
|
||||
</div>
|
||||
<input class="form-control" v-model="filter" :placeholder="l('filter')"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import core from './core';
|
||||
import l from './localize';
|
||||
import commands, {CommandContext, ParamType, Permission} from './slash_commands';
|
||||
|
||||
type CommandItem = {
|
||||
name: string,
|
||||
help: string,
|
||||
context: string | undefined,
|
||||
permission: string | undefined,
|
||||
params: {name: string, help: string}[],
|
||||
syntax: string
|
||||
};
|
||||
|
||||
@Component
|
||||
export default class CommandHelp extends Vue {
|
||||
commands: CommandItem[] = [];
|
||||
filter = '';
|
||||
l = l;
|
||||
|
||||
get filteredCommands(): ReadonlyArray<CommandItem> {
|
||||
if(this.filter.length === 0) return this.commands;
|
||||
const filter = new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
|
||||
return this.commands.filter((x) => filter.test(x.name));
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
const permissions = core.connection.vars.permissions;
|
||||
//tslint:disable-next-line:forin
|
||||
for(const key in commands) {
|
||||
const command = commands[key]!;
|
||||
if(command.documented !== undefined || command.permission !== undefined && (command.permission & permissions) === 0) continue;
|
||||
const params = [];
|
||||
let syntax = `/${key} `;
|
||||
if(command.params !== undefined)
|
||||
for(let i = 0; i < command.params.length; ++i) {
|
||||
const param = command.params[i];
|
||||
const paramKey = param.type === ParamType.Character ? 'param_character' : `${key}.param${i}`;
|
||||
const name = l(`commands.${paramKey}`);
|
||||
const data = {
|
||||
name: param.optional !== undefined ? l('commands.help.paramOptional', name) : name,
|
||||
help: l(`commands.${paramKey}.help`)
|
||||
};
|
||||
params.push(data);
|
||||
syntax += (param.optional !== undefined ? `[${name}]` : `<${name}>`) +
|
||||
(param.delimiter !== undefined ? param.delimiter : ' ');
|
||||
}
|
||||
let context = '';
|
||||
if(command.context !== undefined) {
|
||||
if((command.context & CommandContext.Channel) > 0) context += `${l('commands.help.contextChannel')}\n`;
|
||||
if((command.context & CommandContext.Private) > 0) context += `${l('commands.help.contextPrivate')}\n`;
|
||||
if((command.context & CommandContext.Console) > 0) context += `${l('commands.help.contextConsole')}\n`;
|
||||
}
|
||||
this.commands.push({
|
||||
name: `/${key} - ${l(`commands.${key}`)}`,
|
||||
help: l(`commands.${key}.help`),
|
||||
context,
|
||||
permission: command.permission !== undefined ? l(`commands.help.permission${Permission[command.permission]}`) : undefined,
|
||||
params,
|
||||
syntax
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
#command-help {
|
||||
h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.params {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
83
chat/ConversationSettings.vue
Normal file
83
chat/ConversationSettings.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @close="init()">
|
||||
<div class="form-group">
|
||||
<label class="control-label" :for="'notify' + conversation.key">{{l('conversationSettings.notify')}}</label>
|
||||
<select class="form-control" :id="'notify' + conversation.key" v-model="notify">
|
||||
<option :value="setting.Default">{{l('conversationSettings.default')}}</option>
|
||||
<option :value="setting.True">{{l('conversationSettings.true')}}</option>
|
||||
<option :value="setting.False">{{l('conversationSettings.false')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" :for="'highlight' + conversation.key">{{l('settings.highlight')}}</label>
|
||||
<select class="form-control" :id="'highlight' + conversation.key" v-model="highlight">
|
||||
<option :value="setting.Default">{{l('conversationSettings.default')}}</option>
|
||||
<option :value="setting.True">{{l('conversationSettings.true')}}</option>
|
||||
<option :value="setting.False">{{l('conversationSettings.false')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" :for="'highlightWords' + conversation.key">{{l('settings.highlightWords')}}</label>
|
||||
<input :id="'highlightWords' + conversation.key" class="form-control" v-model="highlightWords"
|
||||
:disabled="highlight == setting.Default"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" :for="'joinMessages' + conversation.key">{{l('settings.joinMessages')}}</label>
|
||||
<select class="form-control" :id="'joinMessages' + conversation.key" v-model="joinMessages">
|
||||
<option :value="setting.Default">{{l('conversationSettings.default')}}</option>
|
||||
<option :value="setting.True">{{l('conversationSettings.true')}}</option>
|
||||
<option :value="setting.False">{{l('conversationSettings.false')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal}
|
||||
})
|
||||
export default class ConversationSettings extends CustomDialog {
|
||||
@Prop({required: true})
|
||||
readonly conversation: Conversation;
|
||||
l = l;
|
||||
setting = Conversation.Setting;
|
||||
notify: Conversation.Setting;
|
||||
highlight: Conversation.Setting;
|
||||
highlightWords: string;
|
||||
joinMessages: Conversation.Setting;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.init();
|
||||
}
|
||||
|
||||
init = function(this: ConversationSettings): void {
|
||||
const settings = this.conversation.settings;
|
||||
this.notify = settings.notify;
|
||||
this.highlight = settings.highlight;
|
||||
this.highlightWords = settings.highlightWords.join(',');
|
||||
this.joinMessages = settings.joinMessages;
|
||||
};
|
||||
|
||||
@Watch('conversation')
|
||||
conversationChanged(): void {
|
||||
this.init();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.conversation.settings = {
|
||||
notify: this.notify,
|
||||
highlight: this.highlight,
|
||||
highlightWords: this.highlightWords.split(',').filter((x) => x.length),
|
||||
joinMessages: this.joinMessages
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
326
chat/ConversationView.vue
Normal file
326
chat/ConversationView.vue
Normal file
@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div style="height:100%; display:flex; flex-direction:column; flex:1; margin:0 5px; position:relative;" id="conversation">
|
||||
<div style="display:flex" v-if="conversation.character" class="header">
|
||||
<img :src="characterImage" style="height:60px; width:60px; margin-right: 10px;" v-if="showAvatars"/>
|
||||
<div style="flex: 1; position: relative; display: flex; flex-direction: column">
|
||||
<div>
|
||||
<user :character="conversation.character"></user>
|
||||
<logs :conversation="conversation"></logs>
|
||||
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
|
||||
<span class="fa fa-cog"></span> {{l('conversationSettings.title')}}
|
||||
</a>
|
||||
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation"></span>
|
||||
{{l('chat.report')}}</a>
|
||||
</div>
|
||||
<div style="overflow: auto">
|
||||
{{l('status.' + conversation.character.status)}}
|
||||
<span v-show="conversation.character.statusText"> – <bbcode :text="conversation.character.statusText"></bbcode></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="conversation.channel" class="header">
|
||||
<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>
|
||||
<a @click="descriptionExpanded = !descriptionExpanded" class="btn">
|
||||
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
|
||||
{{l('channel.description')}}
|
||||
</a>
|
||||
<manage-channel :channel="conversation.channel" v-if="isChannelMod"></manage-channel>
|
||||
<logs :conversation="conversation"></logs>
|
||||
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
|
||||
<span class="fa fa-cog"></span> {{l('conversationSettings.title')}}
|
||||
</a>
|
||||
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation-triangle"></span>
|
||||
{{l('chat.report')}}</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>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="z-index:5; position:absolute; left:0; right:32px; max-height:60%; overflow:auto;"
|
||||
:style="'display:' + (descriptionExpanded ? 'block' : 'none')" class="bg-solid-text">
|
||||
<bbcode :text="conversation.channel.description"></bbcode>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="header" style="display:flex;align-items:center">
|
||||
<h4>{{l('chat.consoleTab')}}</h4>
|
||||
<logs :conversation="conversation"></logs>
|
||||
</div>
|
||||
<div class="border-top messages" :class="'messages-' + conversation.mode" style="flex:1;overflow:auto;margin-top:2px"
|
||||
ref="messages" @scroll="onMessagesScroll">
|
||||
<template v-if="!isConsoleTab">
|
||||
<message-view v-for="message in conversation.messages" :message="message" :channel="conversation.channel"
|
||||
:classes="message == conversation.lastRead ? 'last-read' : ''" :key="message.id">
|
||||
</message-view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="message in conversation.messages" :key="message.id">
|
||||
<message-view :message="message"></message-view>
|
||||
<span v-if="message.sfc && message.sfc.action == 'report'">
|
||||
<a :href="'https://www.f-list.net/fchat/getLog.php?log=' + message.sfc.logid">{{l('events.report.viewLog')}}</a>
|
||||
<span v-show="!message.sfc.confirmed">
|
||||
| <a href="#" @click.prevent="acceptReport(message.sfc)">{{l('events.report.confirm')}}</a>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="conversation.typingStatus && conversation.typingStatus !== 'clear'">
|
||||
{{l('chat.typing.' + conversation.typingStatus, conversation.name)}}
|
||||
</span>
|
||||
<div v-show="conversation.infoText" style="display:flex;align-items:center">
|
||||
<span class="fa fa-times" style="cursor:pointer" @click.stop="conversation.infoText = '';"></span>
|
||||
<span style="flex:1;margin-left:5px">{{conversation.infoText}}</span>
|
||||
</div>
|
||||
<div v-show="conversation.errorText" style="display:flex;align-items:center">
|
||||
<span class="fa fa-times" style="cursor:pointer" @click.stop="conversation.errorText = '';"></span>
|
||||
<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" @keypress="onKeyPress" :extras="extraButtons"
|
||||
classes="form-control chat-text-box" :disabled="adCountdown" 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;">
|
||||
{{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>
|
||||
<li :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}">
|
||||
<a href="#" @click.prevent="setSendingAds(true)">{{l('channel.mode.ads')}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</bbcode-editor>
|
||||
</div>
|
||||
</div>
|
||||
<modal ref="helpDialog" dialogClass="modal-lg" :buttons="false" :action="l('commands.help')">
|
||||
<command-help></command-help>
|
||||
</modal>
|
||||
<settings ref="settingsDialog" :conversation="conversation"></settings>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import {EditorButton, EditorSelection} from '../bbcode/editor';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {BBCodeView, Editor} from './bbcode';
|
||||
import CommandHelp from './CommandHelp.vue';
|
||||
import {characterImage, getByteLength, getKey} from './common';
|
||||
import ConversationSettings from './ConversationSettings.vue';
|
||||
import core from './core';
|
||||
import {Channel, channelModes, Character, Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
import Logs from './Logs.vue';
|
||||
import ManageChannel from './ManageChannel.vue';
|
||||
import MessageView from './message_view';
|
||||
import ReportDialog from './ReportDialog.vue';
|
||||
import {isCommand} from './slash_commands';
|
||||
import UserView from './user_view';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, modal: Modal, settings: ConversationSettings,
|
||||
logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp
|
||||
}
|
||||
})
|
||||
export default class ConversationView extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly reportDialog: ReportDialog;
|
||||
modes = channelModes;
|
||||
descriptionExpanded = false;
|
||||
l = l;
|
||||
extraButtons: EditorButton[] = [];
|
||||
getByteLength = getByteLength;
|
||||
tabOptions: string[] | undefined;
|
||||
tabOptionsIndex: number;
|
||||
tabOptionSelection: EditorSelection;
|
||||
messageCount = 0;
|
||||
|
||||
created(): void {
|
||||
this.extraButtons = [{
|
||||
title: 'Help\n\nClick this button for a quick overview of slash commands.',
|
||||
tag: '?',
|
||||
icon: 'fa-question',
|
||||
handler: () => (<Modal>this.$refs['helpDialog']).show()
|
||||
}];
|
||||
}
|
||||
|
||||
get conversation(): Conversation {
|
||||
return core.conversations.selectedConversation;
|
||||
}
|
||||
|
||||
@Watch('conversation')
|
||||
conversationChanged(): void {
|
||||
(<Editor>this.$refs['textBox']).focus();
|
||||
}
|
||||
|
||||
@Watch('conversation.messages')
|
||||
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);
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onMessagesScroll(): void {
|
||||
const messageView = <HTMLElement>this.$refs['messages'];
|
||||
if(messageView.scrollTop < 50) this.conversation.loadMore();
|
||||
}
|
||||
|
||||
@Watch('conversation.errorText')
|
||||
@Watch('conversation.infoText')
|
||||
textChanged(newValue: string, oldValue: string): void {
|
||||
if(oldValue.length === 0 && newValue.length > 0) this.keepScroll();
|
||||
}
|
||||
|
||||
@Watch('conversation.typingStatus')
|
||||
typingStatusChanged(_: string, oldValue: string): void {
|
||||
if(oldValue === 'clear') this.keepScroll();
|
||||
}
|
||||
|
||||
onKeyPress(e: KeyboardEvent): void {
|
||||
const messageView = <HTMLElement>this.$refs['messages'];
|
||||
const oldHeight = messageView.offsetHeight;
|
||||
setTimeout(() => messageView.scrollTop += oldHeight - messageView.offsetHeight);
|
||||
if(getKey(e) === 'Enter') {
|
||||
if(e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
this.conversation.send();
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(e: KeyboardEvent): void {
|
||||
const editor = <Editor>this.$refs['textBox'];
|
||||
if(getKey(e) === 'Tab') {
|
||||
e.preventDefault();
|
||||
if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return;
|
||||
if(this.tabOptions === undefined) {
|
||||
const selection = editor.getSelection();
|
||||
if(selection.text.length === 0) {
|
||||
const match = /\b[\w]+$/.exec(editor.text.substring(0, selection.end));
|
||||
if(match === null) return;
|
||||
selection.start = match.index < 0 ? 0 : match.index;
|
||||
selection.text = editor.text.substring(selection.start, selection.end);
|
||||
if(selection.text.length === 0) return;
|
||||
}
|
||||
const search = new RegExp(`^${selection.text.replace(/[^\w]/, '\\$&')}`, 'i');
|
||||
const c = (<Conversation.PrivateConversation>this.conversation);
|
||||
let options: ReadonlyArray<{character: Character}>;
|
||||
options = Conversation.isChannel(this.conversation) ? this.conversation.channel.sortedMembers :
|
||||
[{character: c.character}, {character: core.characters.ownCharacter}];
|
||||
this.tabOptions = options.filter((x) => search.test(x.character.name)).map((x) => x.character.name);
|
||||
this.tabOptionsIndex = 0;
|
||||
this.tabOptionSelection = selection;
|
||||
}
|
||||
if(this.tabOptions.length > 0) {
|
||||
const selection = editor.getSelection();
|
||||
if(selection.end !== this.tabOptionSelection.end) return;
|
||||
if(this.tabOptionsIndex >= this.tabOptions.length) this.tabOptionsIndex = 0;
|
||||
const name = this.tabOptions[this.tabOptionsIndex];
|
||||
const userName = (isCommand(this.conversation.enteredText) ? name : `[user]${name}[/user]`);
|
||||
this.tabOptionSelection.end = this.tabOptionSelection.start + userName.length;
|
||||
this.conversation.enteredText = this.conversation.enteredText.substr(0, this.tabOptionSelection.start) + userName +
|
||||
this.conversation.enteredText.substr(selection.end);
|
||||
++this.tabOptionsIndex;
|
||||
}
|
||||
} else {
|
||||
if(this.tabOptions !== undefined) this.tabOptions = undefined;
|
||||
if(getKey(e) === 'ArrowUp' && this.conversation.enteredText.length === 0)
|
||||
this.conversation.loadLastSent();
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode: Channel.Mode): void {
|
||||
const conv = (<Conversation.ChannelConversation>this.conversation);
|
||||
if(conv.channel.mode === 'both') conv.mode = mode;
|
||||
}
|
||||
|
||||
acceptReport(sfc: {callid: number}): void {
|
||||
core.connection.send('SFC', {action: 'confirm', callid: sfc.callid});
|
||||
}
|
||||
|
||||
setSendingAds(is: boolean): void {
|
||||
const conv = (<Conversation.ChannelConversation>this.conversation);
|
||||
if(conv.channel.mode === 'both') {
|
||||
conv.isSendingAds = is;
|
||||
(<Editor>this.$refs['textBox']).focus();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
get characterImage(): string {
|
||||
return characterImage(this.conversation.name);
|
||||
}
|
||||
|
||||
get showAvatars(): boolean {
|
||||
return core.state.settings.showAvatars;
|
||||
}
|
||||
|
||||
get isConsoleTab(): boolean {
|
||||
return this.conversation === core.conversations.consoleTab;
|
||||
}
|
||||
|
||||
get isChannelMod(): boolean {
|
||||
if(core.characters.ownCharacter.isChatOp) return true;
|
||||
const conv = (<Conversation.ChannelConversation>this.conversation);
|
||||
const member = conv.channel.members[core.connection.character];
|
||||
return member !== undefined && member.rank > Channel.Rank.Member;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '~bootstrap/less/variables.less';
|
||||
|
||||
#conversation {
|
||||
.header {
|
||||
@media (min-width: @screen-sm-min) {
|
||||
margin-right: 32px;
|
||||
}
|
||||
a.btn {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.send-ads-switcher a {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-xs-max) {
|
||||
.mode-switcher a {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
148
chat/Logs.vue
Normal file
148
chat/Logs.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<span>
|
||||
<a href="#" @click.prevent="showLogs" class="btn">
|
||||
<span class="fa" :class="isPersistent ? 'fa-file-text-o' : 'fa-download'"></span> {{l('logs.title')}}
|
||||
</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')">
|
||||
<template scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
|
||||
</filterable-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="date" class="col-sm-2">{{l('logs.date')}}</label>
|
||||
<div class="col-sm-10" style="display:flex">
|
||||
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
|
||||
<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>
|
||||
<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"/>
|
||||
</modal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {format} from 'date-fns';
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import FilterableSelect from '../components/FilterableSelect.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {messageToString} from './common';
|
||||
import core from './core';
|
||||
import {Conversation, Logs as LogInterfaces} from './interfaces';
|
||||
import l from './localize';
|
||||
import MessageView from './message_view';
|
||||
|
||||
function formatDate(this: void, date: Date): string {
|
||||
return format(date, 'YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function formatTime(this: void, date: Date): string {
|
||||
return format(date, 'YYYY-MM-DD HH:mm');
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal, 'message-view': MessageView, 'filterable-select': FilterableSelect}
|
||||
})
|
||||
export default class Logs extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop({required: true})
|
||||
readonly conversation: Conversation;
|
||||
selectedConversation: {id: string, name: string} | null = null;
|
||||
selectedDate: Date | null = null;
|
||||
isPersistent = LogInterfaces.isPersistent(core.logs);
|
||||
conversations = LogInterfaces.isPersistent(core.logs) ? core.logs.conversations : undefined;
|
||||
l = l;
|
||||
filter = '';
|
||||
messages: ReadonlyArray<Conversation.Message> = [];
|
||||
formatDate = formatDate;
|
||||
|
||||
get filteredMessages(): ReadonlyArray<Conversation.Message> {
|
||||
if(this.filter.length === 0) return this.messages;
|
||||
const filter = new RegExp(this.filter, 'i');
|
||||
return this.messages.filter(
|
||||
(x) => filter.test(x.text) || x.type !== Conversation.Message.Type.Event && filter.test(x.sender.name));
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
(<Modal>this.$refs['dialog']).fixDropdowns();
|
||||
this.conversationChanged();
|
||||
}
|
||||
|
||||
filterConversation(filter: RegExp, conversation: {id: string, name: string}): boolean {
|
||||
return filter.test(conversation.name);
|
||||
}
|
||||
|
||||
@Watch('conversation')
|
||||
conversationChanged(): void {
|
||||
this.selectedConversation =
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
this.conversations !== undefined && this.conversations.filter((x) => x.id === this.conversation.key)[0] || null;
|
||||
}
|
||||
|
||||
async showLogs(): Promise<void> {
|
||||
if(this.isPersistent) (<Modal>this.$refs['dialog']).show();
|
||||
else this.download(`logs-${this.conversation.name}.txt`, await core.logs.getBacklog(this.conversation));
|
||||
}
|
||||
|
||||
download(file: string, logs: ReadonlyArray<Conversation.Message>): void {
|
||||
const blob = new Blob(logs.map((x) => messageToString(x, formatTime)));
|
||||
//tslint:disable-next-line:strict-type-predicates
|
||||
if(navigator.msSaveBlob !== undefined) {
|
||||
navigator.msSaveBlob(blob, file);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
if('download' in a) {
|
||||
a.href = url;
|
||||
a.setAttribute('download', file);
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
setTimeout(() => {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
} else {
|
||||
const iframe = document.createElement('iframe');
|
||||
document.body.appendChild(iframe);
|
||||
iframe.src = url;
|
||||
setTimeout(() => document.body.removeChild(iframe));
|
||||
}
|
||||
setTimeout(() => self.URL.revokeObjectURL(a.href));
|
||||
}
|
||||
|
||||
downloadDay(): void {
|
||||
if(this.selectedConversation === null || this.selectedDate === null || this.messages.length === 0) return;
|
||||
this.download(`${this.selectedConversation.name}-${formatDate(new Date(this.selectedDate))}.txt`, this.messages);
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.conversations = (<LogInterfaces.Persistent>core.logs).conversations;
|
||||
this.$forceUpdate();
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
get dates(): ReadonlyArray<Date> | undefined {
|
||||
if(!LogInterfaces.isPersistent(core.logs) || this.selectedConversation === null) return;
|
||||
return core.logs.getLogDates(this.selectedConversation.id).slice().reverse();
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
if(this.selectedDate === null || this.selectedConversation === null || !LogInterfaces.isPersistent(core.logs))
|
||||
return this.messages = [];
|
||||
return this.messages = await core.logs.getLogs(this.selectedConversation.id, new Date(this.selectedDate));
|
||||
}
|
||||
}
|
||||
</script>
|
112
chat/ManageChannel.vue
Normal file
112
chat/ManageChannel.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<span>
|
||||
<a href="#" @click.prevent="openDialog" class="btn">
|
||||
<span class="fa fa-edit"></span> {{l('manageChannel.open')}}
|
||||
</a>
|
||||
<modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit">
|
||||
<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"/>
|
||||
{{l('manageChannel.isPublic')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="mode">{{l('manageChannel.mode')}}</label>
|
||||
<select v-model="mode" class="form-control" id="mode">
|
||||
<option v-for="mode in modes" :value="mode">{{l('channel.mode.' + mode)}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{l('manageChannel.description')}}</label>
|
||||
<bbcode-editor classes="form-control" id="description" v-model="description" style="position:relative" :maxlength="50000">
|
||||
<div style="float:right;text-align:right;">
|
||||
{{getByteLength(description)}} / {{maxLength}}
|
||||
</div>
|
||||
</bbcode-editor>
|
||||
</div>
|
||||
<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>
|
||||
{{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>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {Editor} from './bbcode';
|
||||
import {getByteLength} from './common';
|
||||
import core from './core';
|
||||
import {Channel, channelModes} from './interfaces';
|
||||
import l from './localize';
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal, 'bbcode-editor': Editor}
|
||||
})
|
||||
export default class ManageChannel extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly channel: Channel;
|
||||
modes = channelModes;
|
||||
isPublic = this.channelIsPublic;
|
||||
mode = this.channel.mode;
|
||||
description = this.channel.description;
|
||||
l = l;
|
||||
getByteLength = getByteLength;
|
||||
modAddName = '';
|
||||
opList: string[] = [];
|
||||
maxLength = 50000; //core.connection.vars.cds_max;
|
||||
|
||||
@Watch('channel')
|
||||
channelChanged(): void {
|
||||
this.mode = this.channel.mode;
|
||||
this.isPublic = this.channelIsPublic;
|
||||
this.description = this.channel.description;
|
||||
}
|
||||
|
||||
get channelIsPublic(): boolean {
|
||||
return core.channels.openRooms[this.channel.id] !== undefined;
|
||||
}
|
||||
|
||||
get isChannelOwner(): boolean {
|
||||
return this.channel.owner === core.connection.character || core.characters.ownCharacter.isChatOp;
|
||||
}
|
||||
|
||||
modAdd(): void {
|
||||
this.opList.push(this.modAddName);
|
||||
this.modAddName = '';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if(this.isPublic !== this.channelIsPublic) {
|
||||
core.connection.send('RST', {channel: this.channel.id, status: this.isPublic ? 'public' : 'private'});
|
||||
core.connection.send('ORS');
|
||||
}
|
||||
if(this.mode !== this.channel.mode)
|
||||
core.connection.send('RMO', {channel: this.channel.id, mode: this.mode});
|
||||
if(this.description !== this.channel.description)
|
||||
core.connection.send('CDS', {channel: this.channel.id, description: this.description});
|
||||
for(const op of this.channel.opList) {
|
||||
const index = this.opList.indexOf(op);
|
||||
if(index !== -1) this.opList.splice(index, 1);
|
||||
else core.connection.send('COR', {channel: this.channel.id, character: op});
|
||||
}
|
||||
for(const op of this.opList) core.connection.send('COA', {channel: this.channel.id, character: op});
|
||||
}
|
||||
|
||||
openDialog(): void {
|
||||
(<Modal>this.$refs['dialog']).show();
|
||||
this.opList = this.channel.opList.slice();
|
||||
}
|
||||
}
|
||||
</script>
|
36
chat/RecentConversations.vue
Normal file
36
chat/RecentConversations.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<modal :buttons="false" :action="l('chat.recentConversations')">
|
||||
<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>
|
||||
<channel-view v-else :id="recent.channel" :text="recent.name"></channel-view>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import ChannelView from './ChannelView.vue';
|
||||
import core from './core';
|
||||
import {Character, Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
import UserView from './user_view';
|
||||
|
||||
@Component({
|
||||
components: {'user-view': UserView, 'channel-view': ChannelView, modal: Modal}
|
||||
})
|
||||
export default class RecentConversations extends CustomDialog {
|
||||
l = l;
|
||||
|
||||
get recentConversations(): ReadonlyArray<Conversation.RecentConversation> {
|
||||
return core.conversations.recent;
|
||||
}
|
||||
|
||||
getCharacter(name: string): Character {
|
||||
return core.characters.get(name);
|
||||
}
|
||||
}
|
||||
</script>
|
88
chat/ReportDialog.vue
Normal file
88
chat/ReportDialog.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<modal :action="l('chat.report')" @submit.prevent="submit">
|
||||
<div class="alert alert-danger" v-show="error">{{error}}</div>
|
||||
<h4>{{reporting}}</h4>
|
||||
<span v-show="!character">{{l('chat.report.channel.description')}}</span>
|
||||
<div ref="caption"></div>
|
||||
<br/>
|
||||
<div class="form-group">
|
||||
<label>{{l('chat.report.text')}}</label>
|
||||
<textarea class="form-control" v-model="text"></textarea>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import BBCodeParser, {BBCodeElement} from './bbcode';
|
||||
import {errorToString, messageToString} from './common';
|
||||
import core from './core';
|
||||
import {Character, Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal}
|
||||
})
|
||||
export default class ReportDialog extends CustomDialog {
|
||||
//tslint:disable:no-null-keyword
|
||||
character: Character | null = null;
|
||||
text = '';
|
||||
l = l;
|
||||
error = '';
|
||||
|
||||
mounted(): void {
|
||||
(<Element>this.$refs['caption']).appendChild(new BBCodeParser().parseEverything(l('chat.report.description')));
|
||||
}
|
||||
|
||||
beforeDestroy(): void {
|
||||
(<BBCodeElement>(<Element>this.$refs['caption']).firstChild).cleanup!();
|
||||
}
|
||||
|
||||
get reporting(): string {
|
||||
const conversation = core.conversations.selectedConversation;
|
||||
const isChannel = !Conversation.isPrivate(conversation);
|
||||
if(isChannel && this.character === null) return l('chat.report.channel', conversation.name);
|
||||
if(this.character === null) return '';
|
||||
const key = `chat.report.${(isChannel ? 'channel.user' : 'private')}`;
|
||||
return l(key, this.character.name, conversation.name);
|
||||
}
|
||||
|
||||
report(character?: Character): void {
|
||||
this.error = '';
|
||||
this.text = '';
|
||||
const current = core.conversations.selectedConversation;
|
||||
this.character = character !== undefined ? character : Conversation.isPrivate(current) ? current.character : null;
|
||||
this.show();
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const conversation = core.conversations.selectedConversation;
|
||||
/*tslint:disable-next-line:no-unnecessary-callback-wrapper*///https://github.com/palantir/tslint/issues/2430
|
||||
const log = conversation.reportMessages.map((x) => messageToString(x));
|
||||
const tab = (Conversation.isChannel(conversation) ? `${conversation.name} (${conversation.channel.id})`
|
||||
: Conversation.isPrivate(conversation) ? `Conversation with ${conversation.name}` : 'Console');
|
||||
const text = (this.character !== null ? `Reporting user: [user]${this.character.name}[/user] | ` : '') + this.text;
|
||||
const data = {
|
||||
character: core.connection.character,
|
||||
reportText: this.text,
|
||||
log: JSON.stringify(log),
|
||||
channel: tab,
|
||||
text: true,
|
||||
reportUser: <string | undefined>undefined
|
||||
};
|
||||
if(this.character !== null) data.reportUser = this.character.name;
|
||||
try {
|
||||
const report = <{log_id?: number}>(await core.connection.queryApi('report-submit.php', data));
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
if(!report.log_id) return;
|
||||
core.connection.send('SFC', {action: 'report', logid: report.log_id, report: text, tab: conversation.name});
|
||||
this.hide();
|
||||
} catch(e) {
|
||||
this.error = errorToString(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
214
chat/SettingsView.vue
Normal file
214
chat/SettingsView.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings">
|
||||
<ul class="nav nav-tabs">
|
||||
<li role="presentation" v-for="tab in tabs" :class="{active: tab == selectedTab}">
|
||||
<a href="#" @click.prevent="selectedTab = tab">{{l('settings.tabs.' + tab)}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-show="selectedTab == 'general'">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label>
|
||||
<input id="disallowedTags" class="form-control" v-model="disallowedTags"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="clickOpensMessage">
|
||||
<input type="checkbox" id="clickOpensMessage" v-model="clickOpensMessage"/>
|
||||
{{l('settings.clickOpensMessage')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="showAvatars">
|
||||
<input type="checkbox" id="showAvatars" v-model="showAvatars"/>
|
||||
{{l('settings.showAvatars')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="animatedEicons">
|
||||
<input type="checkbox" id="animatedEicons" v-model="animatedEicons"/>
|
||||
{{l('settings.animatedEicons')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="idleTimer">{{l('settings.idleTimer')}}</label>
|
||||
<input id="idleTimer" class="form-control" type="number" v-model="idleTimer"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="messageSeparators">
|
||||
<input type="checkbox" id="messageSeparators" v-model="messageSeparators"/>
|
||||
{{l('settings.messageSeparators')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="logMessages">
|
||||
<input type="checkbox" id="logMessages" v-model="logMessages"/>
|
||||
{{l('settings.logMessages')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="logAds">
|
||||
<input type="checkbox" id="logAds" v-model="logAds"/>
|
||||
{{l('settings.logAds')}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="selectedTab == 'notifications'">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="playSound">
|
||||
<input type="checkbox" id="playSound" v-model="playSound"/>
|
||||
{{l('settings.playSound')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="notifications">
|
||||
<input type="checkbox" id="notifications" v-model="notifications"/>
|
||||
{{l('settings.notifications')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="highlight">
|
||||
<input type="checkbox" id="highlight" v-model="highlight"/>
|
||||
{{l('settings.highlight')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="highlightWords">{{l('settings.highlightWords')}}</label>
|
||||
<input id="highlightWords" class="form-control" v-model="highlightWords"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="eventMessages">
|
||||
<input type="checkbox" id="eventMessages" v-model="eventMessages"/>
|
||||
{{l('settings.eventMessages')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="joinMessages">
|
||||
<input type="checkbox" id="joinMessages" v-model="joinMessages"/>
|
||||
{{l('settings.joinMessages')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="alwaysNotify">
|
||||
<input type="checkbox" id="alwaysNotify" v-model="alwaysNotify"/>
|
||||
{{l('settings.alwaysNotify')}}
|
||||
</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>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {requestNotificationsPermission} from './common';
|
||||
import core from './core';
|
||||
import {Settings as SettingsInterface} from './interfaces';
|
||||
import l from './localize';
|
||||
|
||||
@Component(
|
||||
{components: {modal: Modal}}
|
||||
)
|
||||
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;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.init();
|
||||
}
|
||||
|
||||
async created(): Promise<void> {
|
||||
const available = core.settingsStore.getAvailableCharacters();
|
||||
this.availableImports = available !== undefined ? (await available).filter((x) => x !== core.connection.character) : [];
|
||||
}
|
||||
|
||||
init = function(this: SettingsView): void {
|
||||
const settings = core.state.settings;
|
||||
this.playSound = settings.playSound;
|
||||
this.clickOpensMessage = settings.clickOpensMessage;
|
||||
this.disallowedTags = settings.disallowedTags.join(',');
|
||||
this.notifications = settings.notifications;
|
||||
this.highlight = settings.highlight;
|
||||
this.highlightWords = settings.highlightWords.join(',');
|
||||
this.showAvatars = settings.showAvatars;
|
||||
this.animatedEicons = settings.animatedEicons;
|
||||
this.idleTimer = settings.idleTimer.toString();
|
||||
this.messageSeparators = settings.messageSeparators;
|
||||
this.eventMessages = settings.eventMessages;
|
||||
this.joinMessages = settings.joinMessages;
|
||||
this.alwaysNotify = settings.alwaysNotify;
|
||||
this.logMessages = settings.logMessages;
|
||||
this.logAds = settings.logAds;
|
||||
};
|
||||
|
||||
async doImport(): Promise<void> {
|
||||
if(!confirm(l('settings.import.confirm', this.importCharacter, core.connection.character))) return;
|
||||
const importKey = async(key: keyof SettingsInterface.Keys) => {
|
||||
const settings = await core.settingsStore.get(key, this.importCharacter);
|
||||
if(settings !== undefined) await core.settingsStore.set(key, settings);
|
||||
};
|
||||
await importKey('settings');
|
||||
await importKey('pinned');
|
||||
await importKey('conversationSettings');
|
||||
this.init();
|
||||
core.reloadSettings();
|
||||
core.conversations.reloadSettings();
|
||||
}
|
||||
|
||||
get tabs(): ReadonlyArray<string> {
|
||||
return this.availableImports.length > 0 ? ['general', 'notifications', 'import'] : ['general', 'notifications'];
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
core.state.settings = {
|
||||
playSound: this.playSound,
|
||||
clickOpensMessage: this.clickOpensMessage,
|
||||
disallowedTags: this.disallowedTags.split(',').map((x) => x.trim()).filter((x) => x.length),
|
||||
notifications: this.notifications,
|
||||
highlight: this.highlight,
|
||||
highlightWords: this.highlightWords.split(',').map((x) => x.trim()).filter((x) => x.length),
|
||||
showAvatars: this.showAvatars,
|
||||
animatedEicons: this.animatedEicons,
|
||||
idleTimer: this.idleTimer.length > 0 ? parseInt(this.idleTimer, 10) : 0,
|
||||
messageSeparators: this.messageSeparators,
|
||||
eventMessages: this.eventMessages,
|
||||
joinMessages: this.joinMessages,
|
||||
alwaysNotify: this.alwaysNotify,
|
||||
logMessages: this.logMessages,
|
||||
logAds: this.logAds
|
||||
};
|
||||
if(this.notifications) await requestNotificationsPermission();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#settings .form-group {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
81
chat/StatusSwitcher.vue
Normal file
81
chat/StatusSwitcher.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<modal :action="l('chat.setStatus')" @submit="setStatus" @close="reset">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{l('chat.setStatus.message')}}</label>
|
||||
<editor id="text" v-model="text" classes="form-control" maxlength="255" style="position:relative;">
|
||||
<div style="float:right;text-align:right;">
|
||||
{{getByteLength(text)}} / 255
|
||||
</div>
|
||||
</editor>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {Editor} from './bbcode';
|
||||
import {getByteLength} from './common';
|
||||
import core from './core';
|
||||
import {Character, userStatuses} from './interfaces';
|
||||
import l from './localize';
|
||||
import {getStatusIcon} from './user_view';
|
||||
|
||||
@Component({
|
||||
components: {modal: Modal, editor: Editor}
|
||||
})
|
||||
export default class StatusSwitcher extends CustomDialog {
|
||||
//tslint:disable:no-null-keyword
|
||||
selectedStatus: Character.Status | null = null;
|
||||
enteredText: string | null = null;
|
||||
statuses = userStatuses;
|
||||
l = l;
|
||||
getByteLength = getByteLength;
|
||||
getStatusIcon = getStatusIcon;
|
||||
|
||||
get status(): Character.Status {
|
||||
return this.selectedStatus !== null ? this.selectedStatus : this.character.status;
|
||||
}
|
||||
|
||||
set status(status: Character.Status) {
|
||||
this.selectedStatus = status;
|
||||
}
|
||||
|
||||
get text(): string {
|
||||
return this.enteredText !== null ? this.enteredText : this.character.statusText;
|
||||
}
|
||||
|
||||
set text(text: string) {
|
||||
this.enteredText = text;
|
||||
}
|
||||
|
||||
get character(): Character {
|
||||
return core.characters.ownCharacter;
|
||||
}
|
||||
|
||||
setStatus(): void {
|
||||
core.connection.send('STA', {status: this.status, statusmsg: this.text});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.selectedStatus = null;
|
||||
this.enteredText = null;
|
||||
}
|
||||
}
|
||||
</script>
|
89
chat/UserList.vue
Normal file
89
chat/UserList.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div id="user-list" class="sidebar sidebar-right">
|
||||
<button @click="expanded = !expanded" class="btn btn-default btn-xs expander" :aria-label="l('users.title')">
|
||||
<span class="fa fa-users fa-rotate-270" style="vertical-align: middle"></span>
|
||||
<span class="fa" :class="{'fa-chevron-down': !expanded, 'fa-chevron-up': expanded}"></span>
|
||||
</button>
|
||||
<div class="body" :style="expanded ? 'display:flex' : ''" style="min-width: 200px; flex-direction:column; max-height: 100%;">
|
||||
<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;">
|
||||
<h4>{{l('users.friends')}}</h4>
|
||||
<div v-for="character in friends" :key="character.name">
|
||||
<user :character="character" :showStatus="true"></user>
|
||||
</div>
|
||||
<h4>{{l('users.bookmarks')}}</h4>
|
||||
<div v-for="character in bookmarks" :key="character.name">
|
||||
<user :character="character" :showStatus="true"></user>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="channel" v-show="memberTabShown" class="users" style="padding: 5px;">
|
||||
<div v-for="member in channel.sortedMembers" :key="member.character.name">
|
||||
<user :character="member.character" :channel="channel"></user>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import core from './core';
|
||||
import {Channel, Character, Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
import UserView from './user_view';
|
||||
|
||||
@Component({
|
||||
components: {user: UserView}
|
||||
})
|
||||
export default class UserList extends Vue {
|
||||
memberTabShown = false;
|
||||
expanded = window.innerWidth >= 992;
|
||||
l = l;
|
||||
sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0));
|
||||
|
||||
get friends(): Character[] {
|
||||
return core.characters.friends.slice().sort(this.sorter);
|
||||
}
|
||||
|
||||
get bookmarks(): Character[] {
|
||||
return core.characters.bookmarks.slice().filter((x) => core.characters.friends.indexOf(x) === -1).sort(this.sorter);
|
||||
}
|
||||
|
||||
get channel(): Channel {
|
||||
return (<Conversation.ChannelConversation>core.conversations.selectedConversation).channel;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '~bootstrap/less/variables.less';
|
||||
|
||||
#user-list {
|
||||
flex-direction: column;
|
||||
h4 {
|
||||
margin: 5px 0 0 -5px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.users {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.nav li:first-child a {
|
||||
border-left: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
@media (min-width: @screen-sm-min) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
201
chat/UserMenu.vue
Normal file
201
chat/UserMenu.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<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="report">
|
||||
<span class="fa fa-fw fa-exclamation-triangle"></span>{{l('user.report')}}</a></li>
|
||||
<li v-show="isChannelMod"><a tabindex="-1" href="#" @click.prevent="channelKick">
|
||||
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a></li>
|
||||
<li v-show="isChatOp"><a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00">
|
||||
<span class="fa fa-fw fa-trash-o"></span>{{l('user.chatKick')}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo">
|
||||
<div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div>
|
||||
<textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop} from 'vue-property-decorator';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {BBCodeView} from './bbcode';
|
||||
import {characterImage, errorToString, getByteLength, profileLink} from './common';
|
||||
import core from './core';
|
||||
import {Channel, Character} from './interfaces';
|
||||
import l from './localize';
|
||||
import ReportDialog from './ReportDialog.vue';
|
||||
|
||||
@Component({
|
||||
components: {bbcode: BBCodeView, modal: Modal}
|
||||
})
|
||||
export default class UserMenu extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop({required: true})
|
||||
readonly reportDialog: ReportDialog;
|
||||
l = l;
|
||||
showContextMenu = false;
|
||||
getByteLength = getByteLength;
|
||||
character: Character | null = null;
|
||||
position = {left: '', top: ''};
|
||||
characterImage: string | null = null;
|
||||
touchTimer: number;
|
||||
channel: Channel | null = null;
|
||||
memo = '';
|
||||
memoId: number;
|
||||
memoLoading = false;
|
||||
|
||||
openConversation(jump: boolean): void {
|
||||
const conversation = core.conversations.getPrivate(this.character!);
|
||||
if(jump) conversation.show();
|
||||
}
|
||||
|
||||
setIgnored(): void {
|
||||
core.connection.send('IGN', {action: this.character!.isIgnored ? 'delete' : 'add', character: this.character!.name});
|
||||
}
|
||||
|
||||
setBookmarked(): void {
|
||||
core.connection.queryApi(`bookmark-${this.character!.isBookmarked ? 'remove' : 'add'}.php`, {name: this.character!.name})
|
||||
.catch((e: object) => alert(errorToString(e)));
|
||||
}
|
||||
|
||||
report(): void {
|
||||
this.reportDialog.report(this.character!);
|
||||
}
|
||||
|
||||
channelKick(): void {
|
||||
core.connection.send('CKU', {channel: this.channel!.id, character: this.character!.name});
|
||||
}
|
||||
|
||||
chatKick(): void {
|
||||
core.connection.send('KIK', {character: this.character!.name});
|
||||
}
|
||||
|
||||
async showMemo(): Promise<void> {
|
||||
this.memoLoading = true;
|
||||
this.memo = '';
|
||||
(<Modal>this.$refs['memo']).show();
|
||||
try {
|
||||
const memo = <{note: string, id: number}>await core.connection.queryApi('character-memo-get.php',
|
||||
{target: this.character!.name});
|
||||
this.memoId = memo.id;
|
||||
this.memo = memo.note;
|
||||
this.memoLoading = false;
|
||||
} catch(e) {
|
||||
alert(errorToString(e));
|
||||
}
|
||||
}
|
||||
|
||||
updateMemo(): void {
|
||||
core.connection.queryApi('character-memo-save.php', {target: this.memoId, note: this.memo})
|
||||
.catch((e: object) => alert(errorToString(e)));
|
||||
}
|
||||
|
||||
get isChannelMod(): boolean {
|
||||
if(this.channel === null) return false;
|
||||
if(core.characters.ownCharacter.isChatOp) return true;
|
||||
const member = this.channel.members[core.connection.character];
|
||||
return member !== undefined && member.rank > Channel.Rank.Member;
|
||||
}
|
||||
|
||||
get isChatOp(): boolean {
|
||||
return core.characters.ownCharacter.isChatOp;
|
||||
}
|
||||
|
||||
get showProfileFirst(): boolean {
|
||||
return core.state.settings.clickOpensMessage;
|
||||
}
|
||||
|
||||
get showAvatars(): boolean {
|
||||
return core.state.settings.showAvatars;
|
||||
}
|
||||
|
||||
get profileLink(): string | undefined {
|
||||
return profileLink(this.character!.name);
|
||||
}
|
||||
|
||||
handleEvent(e: MouseEvent | TouchEvent): void {
|
||||
if(e.type === 'touchend') return clearTimeout(this.touchTimer);
|
||||
const touch = e instanceof TouchEvent ? e.touches[0] : e;
|
||||
let node = <Node & {character?: Character, channel?: Channel}>touch.target;
|
||||
while(node !== document.body) {
|
||||
if(node.character !== undefined || node.parentNode === null) break;
|
||||
node = node.parentNode;
|
||||
}
|
||||
if(node.character === undefined) {
|
||||
this.showContextMenu = false;
|
||||
return;
|
||||
}
|
||||
switch(e.type) {
|
||||
case 'click':
|
||||
this.character = node.character;
|
||||
if(core.state.settings.clickOpensMessage) this.openConversation(true);
|
||||
else window.open(this.profileLink);
|
||||
this.showContextMenu = false;
|
||||
break;
|
||||
case 'touchstart':
|
||||
this.touchTimer = window.setTimeout(() => this.openMenu(touch, node.character!, node.channel), 500);
|
||||
break;
|
||||
case 'contextmenu':
|
||||
this.openMenu(touch, node.character, node.channel);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
private openMenu(touch: MouseEvent | Touch, character: Character, channel: Channel | undefined): void {
|
||||
this.channel = channel !== undefined ? channel : null;
|
||||
this.character = character;
|
||||
this.characterImage = null;
|
||||
this.showContextMenu = true;
|
||||
this.position = {left: `${touch.clientX}px`, top: `${touch.clientY}px`};
|
||||
this.$nextTick(() => {
|
||||
const menu = <HTMLElement>this.$refs['menu'];
|
||||
this.characterImage = characterImage(character.name);
|
||||
if((parseInt(this.position.left, 10) + menu.offsetWidth) > window.innerWidth)
|
||||
this.position.left = `${window.innerWidth - menu.offsetWidth - 1}px`;
|
||||
if((parseInt(this.position.top, 10) + menu.offsetHeight) > window.innerHeight)
|
||||
this.position.top = `${window.innerHeight - menu.offsetHeight - 1}px`;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#userMenu li a {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.user-view {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
35
chat/WebSocket.ts
Normal file
35
chat/WebSocket.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import {WebSocketConnection} from '../fchat/interfaces';
|
||||
import l from './localize';
|
||||
|
||||
export default class Socket implements WebSocketConnection {
|
||||
static host = 'wss://chat.f-list.net:9799';
|
||||
socket: WebSocket;
|
||||
|
||||
constructor() {
|
||||
this.socket = new WebSocket(Socket.host);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.close();
|
||||
}
|
||||
|
||||
onMessage(handler: (message: string) => void): void {
|
||||
this.socket.addEventListener('message', (e) => handler(<string>e.data));
|
||||
}
|
||||
|
||||
onOpen(handler: () => void): void {
|
||||
this.socket.addEventListener('open', handler);
|
||||
}
|
||||
|
||||
onClose(handler: () => void): void {
|
||||
this.socket.addEventListener('close', handler);
|
||||
}
|
||||
|
||||
onError(handler: (error: Error) => void): void {
|
||||
this.socket.addEventListener('error', () => handler(new Error(l('login.connectError'))));
|
||||
}
|
||||
|
||||
send(message: string): void {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
BIN
chat/assets/attention.mp3
Normal file
BIN
chat/assets/attention.mp3
Normal file
Binary file not shown.
BIN
chat/assets/attention.ogg
Normal file
BIN
chat/assets/attention.ogg
Normal file
Binary file not shown.
BIN
chat/assets/attention.wav
Normal file
BIN
chat/assets/attention.wav
Normal file
Binary file not shown.
BIN
chat/assets/chat.mp3
Normal file
BIN
chat/assets/chat.mp3
Normal file
Binary file not shown.
BIN
chat/assets/chat.ogg
Normal file
BIN
chat/assets/chat.ogg
Normal file
Binary file not shown.
BIN
chat/assets/chat.wav
Normal file
BIN
chat/assets/chat.wav
Normal file
Binary file not shown.
BIN
chat/assets/login.mp3
Normal file
BIN
chat/assets/login.mp3
Normal file
Binary file not shown.
BIN
chat/assets/login.ogg
Normal file
BIN
chat/assets/login.ogg
Normal file
Binary file not shown.
BIN
chat/assets/login.wav
Normal file
BIN
chat/assets/login.wav
Normal file
Binary file not shown.
BIN
chat/assets/logout.mp3
Normal file
BIN
chat/assets/logout.mp3
Normal file
Binary file not shown.
BIN
chat/assets/logout.ogg
Normal file
BIN
chat/assets/logout.ogg
Normal file
Binary file not shown.
BIN
chat/assets/logout.wav
Normal file
BIN
chat/assets/logout.wav
Normal file
Binary file not shown.
BIN
chat/assets/modalert.mp3
Normal file
BIN
chat/assets/modalert.mp3
Normal file
Binary file not shown.
BIN
chat/assets/modalert.ogg
Normal file
BIN
chat/assets/modalert.ogg
Normal file
Binary file not shown.
BIN
chat/assets/modalert.wav
Normal file
BIN
chat/assets/modalert.wav
Normal file
Binary file not shown.
BIN
chat/assets/newnote.mp3
Normal file
BIN
chat/assets/newnote.mp3
Normal file
Binary file not shown.
BIN
chat/assets/newnote.ogg
Normal file
BIN
chat/assets/newnote.ogg
Normal file
Binary file not shown.
BIN
chat/assets/newnote.wav
Normal file
BIN
chat/assets/newnote.wav
Normal file
Binary file not shown.
BIN
chat/assets/system.mp3
Normal file
BIN
chat/assets/system.mp3
Normal file
Binary file not shown.
BIN
chat/assets/system.ogg
Normal file
BIN
chat/assets/system.ogg
Normal file
Binary file not shown.
BIN
chat/assets/system.wav
Normal file
BIN
chat/assets/system.wav
Normal file
Binary file not shown.
129
chat/bbcode.ts
Normal file
129
chat/bbcode.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import Vue, {Component, CreateElement, RenderContext, VNode} from 'vue';
|
||||
import {CoreBBCodeParser} from '../bbcode/core';
|
||||
//tslint:disable-next-line:match-default-export-name
|
||||
import BaseEditor from '../bbcode/Editor.vue';
|
||||
import {BBCodeCustomTag} from '../bbcode/parser';
|
||||
import ChannelView from './ChannelView.vue';
|
||||
import {characterImage} from './common';
|
||||
import core from './core';
|
||||
import {Character} from './interfaces';
|
||||
import UserView from './user_view';
|
||||
|
||||
export const BBCodeView: Component = {
|
||||
functional: true,
|
||||
render(this: Vue, createElement: CreateElement, context: RenderContext): VNode {
|
||||
/*tslint:disable:no-unsafe-any*///because we're not actually supposed to do any of this
|
||||
context.data.hook = {
|
||||
insert(): void {
|
||||
if(vnode.elm !== undefined)
|
||||
vnode.elm.appendChild(core.bbCodeParser.parseEverything(
|
||||
context.props.text !== undefined ? context.props.text : context.props.unsafeText));
|
||||
},
|
||||
destroy(): void {
|
||||
const element = (<BBCodeElement>(<Element>vnode.elm).firstChild);
|
||||
if(element.cleanup !== undefined) element.cleanup();
|
||||
}
|
||||
};
|
||||
context.data.staticClass = `bbcode${context.data.staticClass !== undefined ? ` ${context.data.staticClass}` : ''}`;
|
||||
const vnode = createElement('span', context.data);
|
||||
vnode.key = context.props.text;
|
||||
return vnode;
|
||||
//tslint:enable
|
||||
}
|
||||
};
|
||||
|
||||
export class Editor extends BaseEditor {
|
||||
parser = core.bbCodeParser;
|
||||
}
|
||||
|
||||
export type BBCodeElement = HTMLElement & {cleanup?(): void};
|
||||
|
||||
export default class BBCodeParser extends CoreBBCodeParser {
|
||||
cleanup: Vue[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addTag('user', new BBCodeCustomTag('user', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, _, param) => {
|
||||
if(param.length > 0)
|
||||
parser.warning('Unexpected parameter on user tag.');
|
||||
const content = element.innerText;
|
||||
element.innerText = '';
|
||||
const uregex = /^[a-zA-Z0-9_\-\s]+$/;
|
||||
if(!uregex.test(content))
|
||||
return;
|
||||
const view = new UserView({el: element, propsData: {character: core.characters.get(content)}});
|
||||
this.cleanup.push(view);
|
||||
}, []));
|
||||
this.addTag('icon', new BBCodeCustomTag('icon', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, parent, param) => {
|
||||
if(param.length > 0)
|
||||
parser.warning('Unexpected parameter on icon tag.');
|
||||
const content = element.innerText;
|
||||
const uregex = /^[a-zA-Z0-9_\-\s]+$/;
|
||||
if(!uregex.test(content))
|
||||
return;
|
||||
const img = parser.createElement('img');
|
||||
img.src = characterImage(content);
|
||||
img.style.cursor = 'pointer';
|
||||
img.className = 'characterAvatarIcon';
|
||||
img.title = img.alt = content;
|
||||
(<HTMLImageElement & {character: Character}>img).character = core.characters.get(content);
|
||||
parent.replaceChild(img, element);
|
||||
}, []));
|
||||
this.addTag('eicon', new BBCodeCustomTag('eicon', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, parent, param) => {
|
||||
if(param.length > 0)
|
||||
parser.warning('Unexpected parameter on eicon tag.');
|
||||
const content = element.innerText;
|
||||
const uregex = /^[a-zA-Z0-9_\-\s]+$/;
|
||||
if(!uregex.test(content))
|
||||
return;
|
||||
const extension = core.state.settings.animatedEicons ? 'gif' : 'png';
|
||||
const img = parser.createElement('img');
|
||||
img.src = `https://static.f-list.net/images/eicon/${content.toLowerCase()}.${extension}`;
|
||||
img.title = img.alt = content;
|
||||
img.className = 'characterAvatarIcon';
|
||||
parent.replaceChild(img, element);
|
||||
}, []));
|
||||
this.addTag('session', new BBCodeCustomTag('session', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (_, element, __, param) => {
|
||||
const content = element.innerText;
|
||||
element.innerText = '';
|
||||
const view = new ChannelView({el: element, propsData: {id: content, text: param}});
|
||||
this.cleanup.push(view);
|
||||
}, []));
|
||||
this.addTag('channel', new BBCodeCustomTag('channel', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (_, element, __, ___) => {
|
||||
const content = element.innerText;
|
||||
element.innerText = '';
|
||||
const view = new ChannelView({el: element, propsData: {id: content, text: content}});
|
||||
this.cleanup.push(view);
|
||||
}, []));
|
||||
}
|
||||
|
||||
parseEverything(input: string): BBCodeElement {
|
||||
const elm = <BBCodeElement>super.parseEverything(input);
|
||||
if(this.cleanup.length > 0)
|
||||
elm.cleanup = ((cleanup: Vue[]) => () => {
|
||||
for(const component of cleanup) component.$destroy();
|
||||
})(this.cleanup);
|
||||
this.cleanup = [];
|
||||
return elm;
|
||||
}
|
||||
}
|
96
chat/common.ts
Normal file
96
chat/common.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import {format, isToday} from 'date-fns';
|
||||
import {Character, Conversation, Settings as ISettings} from './interfaces';
|
||||
|
||||
export function profileLink(this: void | never, character: string): string {
|
||||
return `https://www.f-list.net/c/${character}`;
|
||||
}
|
||||
|
||||
export function characterImage(this: void | never, character: string): string {
|
||||
return `https://static.f-list.net/images/avatar/${character.toLowerCase()}.png`;
|
||||
}
|
||||
|
||||
export function getByteLength(this: void | never, str: string): number {
|
||||
let byteLen = 0;
|
||||
for(let i = 0; i < str.length; i++) {
|
||||
const c = str.charCodeAt(i);
|
||||
byteLen += c < (1 << 7) ? 1 :
|
||||
c < (1 << 11) ? 2 :
|
||||
c < (1 << 16) ? 3 :
|
||||
c < (1 << 21) ? 4 :
|
||||
c < (1 << 26) ? 5 :
|
||||
c < (1 << 31) ? 6 : Number.NaN;
|
||||
}
|
||||
return byteLen;
|
||||
}
|
||||
|
||||
export class Settings implements ISettings {
|
||||
playSound = true;
|
||||
clickOpensMessage = false;
|
||||
disallowedTags: string[] = [];
|
||||
notifications = true;
|
||||
highlight = true;
|
||||
highlightWords: string[] = [];
|
||||
showAvatars = true;
|
||||
animatedEicons = true;
|
||||
idleTimer = 0;
|
||||
messageSeparators = false;
|
||||
eventMessages = true;
|
||||
joinMessages = false;
|
||||
alwaysNotify = false;
|
||||
logMessages = true;
|
||||
logAds = false;
|
||||
}
|
||||
|
||||
export class ConversationSettings implements Conversation.Settings {
|
||||
notify = Conversation.Setting.Default;
|
||||
highlight = Conversation.Setting.Default;
|
||||
highlightWords: string[] = [];
|
||||
joinMessages = Conversation.Setting.Default;
|
||||
}
|
||||
|
||||
export function formatTime(this: void | never, date: Date): string {
|
||||
if(isToday(date)) return format(date, 'HH:mm');
|
||||
return format(date, 'YYYY-MM-DD HH:mm');
|
||||
}
|
||||
|
||||
export function messageToString(this: void | never, msg: Conversation.Message, timeFormatter: (date: Date) => string = formatTime): string {
|
||||
let text = `[${timeFormatter(msg.time)}] `;
|
||||
if(msg.type !== Conversation.Message.Type.Event)
|
||||
text += (msg.type === Conversation.Message.Type.Action ? '*' : '') + msg.sender.name +
|
||||
(msg.type === Conversation.Message.Type.Message ? ':' : '');
|
||||
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 || (<any>e).keyIdentifier;
|
||||
}
|
||||
|
||||
/*tslint:disable:no-any no-unsafe-any*///because errors can be any
|
||||
export function errorToString(e: any): string {
|
||||
return e instanceof Error ? e.message : e !== undefined ? e.toString() : '';
|
||||
}
|
||||
//tslint:enable
|
||||
|
||||
export async function requestNotificationsPermission(): Promise<void> {
|
||||
if(<object | undefined>Notification !== undefined) await Notification.requestPermission();
|
||||
}
|
||||
|
||||
let messageId = 0;
|
||||
|
||||
export class Message implements Conversation.ChatMessage {
|
||||
readonly id = ++messageId;
|
||||
isHighlight = false;
|
||||
|
||||
constructor(readonly type: Conversation.Message.Type, readonly sender: Character, readonly text: string,
|
||||
readonly time: Date = new Date()) {
|
||||
}
|
||||
}
|
||||
|
||||
export class EventMessage implements Conversation.EventMessage {
|
||||
readonly id = ++messageId;
|
||||
readonly type = Conversation.Message.Type.Event;
|
||||
|
||||
constructor(readonly text: string, readonly time: Date = new Date()) {
|
||||
}
|
||||
}
|
682
chat/conversations.ts
Normal file
682
chat/conversations.ts
Normal file
@ -0,0 +1,682 @@
|
||||
//tslint:disable:no-floating-promises
|
||||
import {queuedJoin} from '../fchat/channels';
|
||||
import {decodeHTML} from '../fchat/common';
|
||||
import {characterImage, ConversationSettings, EventMessage, Message, messageToString} from './common';
|
||||
import core from './core';
|
||||
import {Channel, Character, Connection, Conversation as Interfaces} from './interfaces';
|
||||
import l from './localize';
|
||||
import {CommandContext, isCommand, parse as parseCommand} from './slash_commands';
|
||||
import MessageType = Interfaces.Message.Type;
|
||||
function createMessage(this: void, type: MessageType, sender: Character, text: string, time?: Date): Message {
|
||||
if(type === MessageType.Message && text.match(/^\/me\b/) !== null) {
|
||||
type = MessageType.Action;
|
||||
text = text.substr(text.charAt(4) === ' ' ? 4 : 3);
|
||||
}
|
||||
return new Message(type, sender, text, time);
|
||||
}
|
||||
|
||||
function safeAddMessage(this: void, messages: Interfaces.Message[], message: Interfaces.Message, max: number): void {
|
||||
if(messages.length >= max) messages.shift();
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
abstract class Conversation implements Interfaces.Conversation {
|
||||
abstract enteredText: string;
|
||||
abstract readonly name: string;
|
||||
messages: Interfaces.Message[] = [];
|
||||
errorText = '';
|
||||
unread = Interfaces.UnreadState.None;
|
||||
lastRead: Interfaces.Message | undefined = undefined;
|
||||
infoText = '';
|
||||
abstract readonly maxMessageLength: number | undefined;
|
||||
_settings: Interfaces.Settings;
|
||||
protected abstract context: CommandContext;
|
||||
protected maxMessages = 100;
|
||||
protected allMessages: Interfaces.Message[];
|
||||
private lastSent = '';
|
||||
|
||||
constructor(readonly key: string, public _isPinned: boolean) {
|
||||
}
|
||||
|
||||
get settings(): Interfaces.Settings {
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
return this._settings || (this._settings = state.settings[this.key] || new ConversationSettings());
|
||||
}
|
||||
|
||||
set settings(value: Interfaces.Settings) {
|
||||
this._settings = value;
|
||||
state.setSettings(this.key, value);
|
||||
}
|
||||
|
||||
get isPinned(): boolean {
|
||||
return this._isPinned;
|
||||
}
|
||||
|
||||
set isPinned(value: boolean) {
|
||||
if(value === this._isPinned) return;
|
||||
this._isPinned = value;
|
||||
state.savePinned();
|
||||
}
|
||||
|
||||
get reportMessages(): ReadonlyArray<Interfaces.Message> {
|
||||
return this.allMessages;
|
||||
}
|
||||
|
||||
send(): void {
|
||||
if(this.enteredText.length === 0) return;
|
||||
if(isCommand(this.enteredText)) {
|
||||
const parsed = parseCommand(this.enteredText, this.context);
|
||||
if(typeof parsed === 'string') this.errorText = parsed;
|
||||
else {
|
||||
parsed.call(this);
|
||||
this.lastSent = this.enteredText;
|
||||
this.enteredText = '';
|
||||
}
|
||||
} else {
|
||||
this.lastSent = this.enteredText;
|
||||
this.doSend();
|
||||
}
|
||||
}
|
||||
|
||||
abstract addMessage(message: Interfaces.Message): void;
|
||||
|
||||
loadLastSent(): void {
|
||||
this.enteredText = this.lastSent;
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if(this.messages.length >= this.allMessages.length) return;
|
||||
this.maxMessages += 100;
|
||||
this.messages = this.allMessages.slice(-this.maxMessages);
|
||||
}
|
||||
|
||||
show(): void {
|
||||
state.show(this);
|
||||
}
|
||||
|
||||
onHide(): void {
|
||||
this.errorText = '';
|
||||
this.lastRead = this.messages[this.messages.length - 1];
|
||||
this.maxMessages = 100;
|
||||
this.messages = this.allMessages.slice(-this.maxMessages);
|
||||
}
|
||||
|
||||
abstract close(): void;
|
||||
|
||||
protected safeAddMessage(message: Interfaces.Message): void {
|
||||
safeAddMessage(this.allMessages, message, 500);
|
||||
safeAddMessage(this.messages, message, this.maxMessages);
|
||||
}
|
||||
|
||||
protected abstract doSend(): void;
|
||||
}
|
||||
|
||||
class PrivateConversation extends Conversation implements Interfaces.PrivateConversation {
|
||||
readonly name = this.character.name;
|
||||
readonly context = CommandContext.Private;
|
||||
typingStatus: Interfaces.TypingStatus = 'clear';
|
||||
readonly maxMessageLength = core.connection.vars.priv_max;
|
||||
private _enteredText = '';
|
||||
private ownTypingStatus: Interfaces.TypingStatus = 'clear';
|
||||
private timer: number | undefined;
|
||||
private logPromise = core.logs.getBacklog(this).then((messages) => {
|
||||
this.allMessages.unshift(...messages);
|
||||
this.messages = this.allMessages.slice();
|
||||
});
|
||||
|
||||
constructor(readonly character: Character) {
|
||||
super(character.name.toLowerCase(), state.pinned.private.indexOf(character.name) !== -1);
|
||||
this.lastRead = this.messages[this.messages.length - 1];
|
||||
this.allMessages = [];
|
||||
}
|
||||
|
||||
get enteredText(): string {
|
||||
return this._enteredText;
|
||||
}
|
||||
|
||||
set enteredText(value: string) {
|
||||
this._enteredText = value;
|
||||
if(this.timer !== undefined) clearTimeout(this.timer);
|
||||
if(value.length > 0) {
|
||||
if(this.ownTypingStatus !== 'typing') this.setOwnTyping('typing');
|
||||
this.timer = window.setTimeout(() => this.setOwnTyping('paused'), 5000);
|
||||
} else if(this.ownTypingStatus !== 'clear') this.setOwnTyping('clear');
|
||||
}
|
||||
|
||||
addMessage(message: Interfaces.Message): void {
|
||||
this.safeAddMessage(message);
|
||||
if(message.type !== Interfaces.Message.Type.Event) {
|
||||
if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
|
||||
if(this.settings.notify !== Interfaces.Setting.False)
|
||||
core.notifications.notify(this, message.sender.name, message.text, characterImage(message.sender.name), 'attention');
|
||||
if(this !== state.selectedConversation)
|
||||
this.unread = Interfaces.UnreadState.Mention;
|
||||
this.typingStatus = 'clear';
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
state.privateConversations.splice(state.privateConversations.indexOf(this), 1);
|
||||
delete state.privateMap[this.character.name.toLowerCase()];
|
||||
state.savePinned();
|
||||
if(state.selectedConversation === this) state.show(state.consoleTab);
|
||||
}
|
||||
|
||||
sort(newIndex: number): void {
|
||||
state.privateConversations.splice(state.privateConversations.indexOf(this), 1);
|
||||
state.privateConversations.splice(newIndex, 0, this);
|
||||
state.savePinned();
|
||||
}
|
||||
|
||||
protected doSend(): void {
|
||||
if(this.character.status === 'offline') {
|
||||
this.errorText = l('chat.errorOffline', this.character.name);
|
||||
return;
|
||||
} else if(this.character.isIgnored) {
|
||||
this.errorText = l('chat.errorIgnored', this.character.name);
|
||||
return;
|
||||
}
|
||||
core.connection.send('PRI', {recipient: this.name, message: this.enteredText});
|
||||
const message = createMessage(MessageType.Message, core.characters.ownCharacter, this.enteredText);
|
||||
this.safeAddMessage(message);
|
||||
core.logs.logMessage(this, message);
|
||||
this.enteredText = '';
|
||||
}
|
||||
|
||||
private setOwnTyping(status: Interfaces.TypingStatus): void {
|
||||
this.ownTypingStatus = status;
|
||||
core.connection.send('TPN', {character: this.name, status});
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelConversation extends Conversation implements Interfaces.ChannelConversation {
|
||||
readonly context = CommandContext.Channel;
|
||||
readonly name = this.channel.name;
|
||||
isSendingAds = this.channel.mode === 'ads';
|
||||
adCountdown = 0;
|
||||
private chat: Interfaces.Message[] = [];
|
||||
private ads: Interfaces.Message[] = [];
|
||||
private both: Interfaces.Message[] = [];
|
||||
private _mode: Channel.Mode;
|
||||
private adEnteredText = '';
|
||||
private chatEnteredText = '';
|
||||
private logPromise = core.logs.getBacklog(this).then((messages) => {
|
||||
this.both.unshift(...messages);
|
||||
this.chat.unshift(...this.both.filter((x) => x.type !== MessageType.Ad));
|
||||
this.ads.unshift(...this.both.filter((x) => x.type === MessageType.Ad));
|
||||
this.lastRead = this.messages[this.messages.length - 1];
|
||||
this.mode = this.channel.mode;
|
||||
});
|
||||
|
||||
constructor(readonly channel: Channel) {
|
||||
super(`#${channel.id.replace(/[^\w- ]/gi, '')}`, state.pinned.channels.indexOf(channel.id) !== -1);
|
||||
core.watch(function(): Channel.Mode | undefined {
|
||||
const c = this.channels.getChannel(channel.id);
|
||||
return c !== undefined ? c.mode : undefined;
|
||||
}, (value) => {
|
||||
if(value === undefined) return;
|
||||
this.mode = value;
|
||||
if(value !== 'both') this.isSendingAds = value === 'ads';
|
||||
});
|
||||
}
|
||||
|
||||
get maxMessageLength(): number {
|
||||
return core.connection.vars[this.isSendingAds ? 'lfrp_max' : 'chat_max'];
|
||||
}
|
||||
|
||||
get mode(): Channel.Mode {
|
||||
return this._mode;
|
||||
}
|
||||
|
||||
set mode(mode: Channel.Mode) {
|
||||
this._mode = mode;
|
||||
this.maxMessages = 100;
|
||||
this.allMessages = this[mode];
|
||||
this.messages = this.allMessages.slice(-this.maxMessages);
|
||||
}
|
||||
|
||||
get enteredText(): string {
|
||||
return this.isSendingAds ? this.adEnteredText : this.chatEnteredText;
|
||||
}
|
||||
|
||||
set enteredText(value: string) {
|
||||
if(this.isSendingAds) this.adEnteredText = value;
|
||||
else this.chatEnteredText = value;
|
||||
}
|
||||
|
||||
get reportMessages(): ReadonlyArray<Interfaces.Message> {
|
||||
return this.both;
|
||||
}
|
||||
|
||||
addModeMessage(mode: Channel.Mode, message: Interfaces.Message): void {
|
||||
if(this._mode === mode) this.safeAddMessage(message);
|
||||
else safeAddMessage(this[mode], message, 500);
|
||||
}
|
||||
|
||||
addMessage(message: Interfaces.Message): void {
|
||||
if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null
|
||||
&& (this.channel.members[message.sender.name]!.rank > Channel.Rank.Member || message.sender.isChatOp))
|
||||
message = new Message(MessageType.Warn, message.sender, message.text.substr(6), message.time);
|
||||
|
||||
if(message.type === MessageType.Ad) {
|
||||
this.addModeMessage('ads', message);
|
||||
if(core.state.settings.logAds) this.logPromise.then(() => core.logs.logMessage(this, message));
|
||||
} else {
|
||||
this.addModeMessage('chat', message);
|
||||
if(message.type !== Interfaces.Message.Type.Event) {
|
||||
if(message.type === Interfaces.Message.Type.Warn) this.addModeMessage('ads', message);
|
||||
if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
|
||||
if(this !== state.selectedConversation && this.unread === Interfaces.UnreadState.None)
|
||||
this.unread = Interfaces.UnreadState.Unread;
|
||||
} else this.addModeMessage('ads', message);
|
||||
}
|
||||
this.addModeMessage('both', message);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
core.connection.send('LCH', {channel: this.channel.id});
|
||||
}
|
||||
|
||||
sort(newIndex: number): void {
|
||||
state.channelConversations.splice(state.channelConversations.indexOf(this), 1);
|
||||
state.channelConversations.splice(newIndex, 0, this);
|
||||
state.savePinned();
|
||||
}
|
||||
|
||||
protected doSend(): void {
|
||||
const isAd = this.isSendingAds;
|
||||
core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText});
|
||||
this.addMessage(
|
||||
createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date()));
|
||||
if(isAd) {
|
||||
this.adCountdown = core.connection.vars.lfrp_flood;
|
||||
const interval = setInterval(() => {
|
||||
this.adCountdown -= 1;
|
||||
if(this.adCountdown === 0) clearInterval(interval);
|
||||
}, 1000);
|
||||
} else this.enteredText = '';
|
||||
}
|
||||
}
|
||||
|
||||
class ConsoleConversation extends Conversation {
|
||||
readonly context = CommandContext.Console;
|
||||
readonly name = l('chat.consoleTab');
|
||||
readonly maxMessageLength = undefined;
|
||||
enteredText = '';
|
||||
|
||||
constructor() {
|
||||
super('_', false);
|
||||
this.allMessages = [];
|
||||
}
|
||||
|
||||
//tslint:disable-next-line:no-empty
|
||||
close(): void {
|
||||
}
|
||||
|
||||
addMessage(message: Interfaces.Message): void {
|
||||
this.safeAddMessage(message);
|
||||
if(core.state.settings.logMessages) core.logs.logMessage(this, message);
|
||||
if(this !== state.selectedConversation) this.unread = Interfaces.UnreadState.Unread;
|
||||
}
|
||||
|
||||
protected doSend(): void {
|
||||
this.errorText = l('chat.consoleChat');
|
||||
}
|
||||
}
|
||||
|
||||
class State implements Interfaces.State {
|
||||
privateConversations: PrivateConversation[] = [];
|
||||
channelConversations: ChannelConversation[] = [];
|
||||
privateMap: {[key: string]: PrivateConversation | undefined} = {};
|
||||
channelMap: {[key: string]: ChannelConversation | undefined} = {};
|
||||
consoleTab: ConsoleConversation;
|
||||
selectedConversation: Conversation = this.consoleTab;
|
||||
recent: Interfaces.RecentConversation[] = [];
|
||||
pinned: {channels: string[], private: string[]};
|
||||
settings: {[key: string]: Interfaces.Settings};
|
||||
|
||||
getPrivate(character: Character): PrivateConversation {
|
||||
const key = character.name.toLowerCase();
|
||||
let conv = state.privateMap[key];
|
||||
if(conv !== undefined) return conv;
|
||||
conv = new PrivateConversation(character);
|
||||
this.privateConversations.push(conv);
|
||||
this.privateMap[key] = conv;
|
||||
state.addRecent(conv);
|
||||
return conv;
|
||||
}
|
||||
|
||||
byKey(key: string): Conversation | undefined {
|
||||
if(key === '_') return this.consoleTab;
|
||||
return (key[0] === '#' ? this.channelMap : this.privateMap)[key];
|
||||
}
|
||||
|
||||
savePinned(): void {
|
||||
this.pinned.channels = this.channelConversations.filter((x) => x.isPinned).map((x) => x.channel.id);
|
||||
this.pinned.private = this.privateConversations.filter((x) => x.isPinned).map((x) => x.name);
|
||||
core.settingsStore.set('pinned', this.pinned);
|
||||
}
|
||||
|
||||
setSettings(key: string, value: Interfaces.Settings): void {
|
||||
this.settings[key] = value;
|
||||
core.settingsStore.set('conversationSettings', this.settings);
|
||||
}
|
||||
|
||||
addRecent(conversation: Conversation): void {
|
||||
/*tslint:disable-next-line:no-any*///TS isn't smart enough for this
|
||||
const remove = (predicate: (item: any) => boolean) => {
|
||||
for(let i = 0; i < this.recent.length; ++i)
|
||||
if(predicate(this.recent[i])) {
|
||||
this.recent.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if(Interfaces.isChannel(conversation)) {
|
||||
remove((c) => c.channel === conversation.channel.id);
|
||||
this.recent.unshift({channel: conversation.channel.id, name: conversation.channel.name});
|
||||
} else {
|
||||
remove((c) => c.character === conversation.name);
|
||||
state.recent.unshift({character: conversation.name});
|
||||
}
|
||||
if(this.recent.length >= 50) this.recent.pop();
|
||||
core.settingsStore.set('recent', this.recent);
|
||||
}
|
||||
|
||||
show(conversation: Conversation): void {
|
||||
this.selectedConversation.onHide();
|
||||
conversation.unread = Interfaces.UnreadState.None;
|
||||
this.selectedConversation = conversation;
|
||||
}
|
||||
|
||||
async reloadSettings(): Promise<void> {
|
||||
//tslint:disable:strict-boolean-expressions
|
||||
this.pinned = await core.settingsStore.get('pinned') || {private: [], channels: []};
|
||||
for(const conversation of this.channelConversations)
|
||||
conversation._isPinned = this.pinned.channels.indexOf(conversation.channel.id) !== -1;
|
||||
for(const conversation of this.privateConversations)
|
||||
conversation._isPinned = this.pinned.private.indexOf(conversation.name) !== -1;
|
||||
this.recent = await core.settingsStore.get('recent') || [];
|
||||
const settings = <{[key: string]: ConversationSettings}> await core.settingsStore.get('conversationSettings') || {};
|
||||
//tslint:disable-next-line:forin
|
||||
for(const key in settings) {
|
||||
const settingsItem = new ConversationSettings();
|
||||
for(const itemKey in settings[key])
|
||||
settingsItem[<keyof ConversationSettings>itemKey] = settings[key][<keyof ConversationSettings>itemKey];
|
||||
settings[key] = settingsItem;
|
||||
const conv = (key[0] === '#' ? this.channelMap : this.privateMap)[key];
|
||||
if(conv !== undefined) conv._settings = settingsItem;
|
||||
}
|
||||
this.settings = settings;
|
||||
//tslint:enable
|
||||
}
|
||||
}
|
||||
|
||||
let state: State;
|
||||
|
||||
function addEventMessage(this: void, message: Interfaces.Message): void {
|
||||
state.consoleTab.addMessage(message);
|
||||
if(core.state.settings.eventMessages && state.selectedConversation !== state.consoleTab) state.selectedConversation.addMessage(message);
|
||||
}
|
||||
|
||||
function isOfInterest(this: void, character: Character): boolean {
|
||||
return character.isFriend || character.isBookmarked || state.privateMap[character.name.toLowerCase()] !== undefined;
|
||||
}
|
||||
|
||||
export default function(this: void): Interfaces.State {
|
||||
state = new State();
|
||||
const connection = core.connection;
|
||||
connection.onEvent('connecting', async(isReconnect) => {
|
||||
state.channelConversations = [];
|
||||
state.channelMap = {};
|
||||
if(!isReconnect) state.consoleTab = new ConsoleConversation();
|
||||
state.selectedConversation = state.consoleTab;
|
||||
await state.reloadSettings();
|
||||
});
|
||||
connection.onEvent('connected', (isReconnect) => {
|
||||
if(isReconnect) return;
|
||||
for(const item of state.pinned.private) state.getPrivate(core.characters.get(item));
|
||||
queuedJoin(state.pinned.channels.slice());
|
||||
});
|
||||
core.channels.onEvent((type, channel) => {
|
||||
const key = channel.id.toLowerCase();
|
||||
if(type === 'join') {
|
||||
const conv = new ChannelConversation(channel);
|
||||
state.channelMap[key] = conv;
|
||||
state.channelConversations.push(conv);
|
||||
state.addRecent(conv);
|
||||
} else {
|
||||
const conv = state.channelMap[key]!;
|
||||
state.channelConversations.splice(state.channelConversations.indexOf(conv), 1);
|
||||
delete state.channelMap[key];
|
||||
state.savePinned();
|
||||
if(state.selectedConversation === conv) state.show(state.consoleTab);
|
||||
}
|
||||
});
|
||||
|
||||
connection.onMessage('PRI', (data, time) => {
|
||||
const char = core.characters.get(data.character);
|
||||
if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character});
|
||||
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
|
||||
const conv = state.getPrivate(char);
|
||||
conv.addMessage(message);
|
||||
});
|
||||
connection.onMessage('MSG', (data, time) => {
|
||||
const char = core.characters.get(data.character);
|
||||
if(char.isIgnored) return;
|
||||
const conversation = state.channelMap[data.channel.toLowerCase()]!;
|
||||
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
|
||||
conversation.addMessage(message);
|
||||
|
||||
let words: string[];
|
||||
if(conversation.settings.highlight !== Interfaces.Setting.Default) {
|
||||
words = conversation.settings.highlightWords.slice();
|
||||
if(conversation.settings.highlight === Interfaces.Setting.True) words.push(core.connection.character);
|
||||
} else {
|
||||
words = core.state.settings.highlightWords.slice();
|
||||
if(core.state.settings.highlight) words.push(core.connection.character);
|
||||
}
|
||||
//tslint:disable-next-line:no-null-keyword
|
||||
const results = words.length > 0 ? message.text.match(new RegExp(`\\b(${words.join('|')})\\b`, 'i')) : null;
|
||||
if(results !== null) {
|
||||
core.notifications.notify(conversation, data.character, l('chat.highlight', results[0], conversation.name, message.text),
|
||||
characterImage(data.character), 'attention');
|
||||
if(conversation !== state.selectedConversation) conversation.unread = Interfaces.UnreadState.Mention;
|
||||
message.isHighlight = true;
|
||||
} else if(conversation.settings.notify === Interfaces.Setting.True)
|
||||
core.notifications.notify(conversation, conversation.name, messageToString(message),
|
||||
characterImage(data.character), 'attention');
|
||||
});
|
||||
connection.onMessage('LRP', (data, time) => {
|
||||
const char = core.characters.get(data.character);
|
||||
if(char.isIgnored) return;
|
||||
const conv = state.channelMap[data.channel.toLowerCase()]!;
|
||||
conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
|
||||
});
|
||||
connection.onMessage('RLL', (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]`);
|
||||
else {
|
||||
const results = data.results.length > 1 ? `${data.results.join('+')} = ${data.endresult}` : data.endresult.toString();
|
||||
text = l('chat.roll', data.rolls.join('+'), results);
|
||||
}
|
||||
const message = new Message(MessageType.Roll, sender, text, time);
|
||||
if('channel' in data) {
|
||||
const conversation = state.channelMap[(<{channel: string}>data).channel.toLowerCase()]!;
|
||||
conversation.addMessage(message);
|
||||
if(data.type === 'bottle' && data.target === core.connection.character)
|
||||
core.notifications.notify(conversation, conversation.name, messageToString(message),
|
||||
characterImage(data.character), 'attention');
|
||||
} else {
|
||||
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});
|
||||
const conversation = state.getPrivate(char);
|
||||
conversation.addMessage(message);
|
||||
}
|
||||
});
|
||||
connection.onMessage('NLN', (data, time) => {
|
||||
const message = new EventMessage(l('events.login', `[user]${data.identity}[/user]`), time);
|
||||
if(isOfInterest(core.characters.get(data.identity))) addEventMessage(message);
|
||||
const conv = state.privateMap[data.identity.toLowerCase()];
|
||||
if(conv !== undefined && core.state.settings.eventMessages && conv !== state.selectedConversation) conv.addMessage(message);
|
||||
});
|
||||
connection.onMessage('FLN', (data, time) => {
|
||||
const message = new EventMessage(l('events.logout', `[user]${data.character}[/user]`), time);
|
||||
if(isOfInterest(core.characters.get(data.character))) addEventMessage(message);
|
||||
const conv = state.privateMap[data.character.toLowerCase()];
|
||||
if(conv === undefined) return;
|
||||
conv.typingStatus = 'clear';
|
||||
if(core.state.settings.eventMessages && conv !== state.selectedConversation) conv.addMessage(message);
|
||||
});
|
||||
connection.onMessage('TPN', (data) => {
|
||||
const conv = state.privateMap[data.character.toLowerCase()];
|
||||
if(conv !== undefined) conv.typingStatus = data.status;
|
||||
});
|
||||
connection.onMessage('CBU', (data, time) => {
|
||||
const text = l('events.ban', data.channel, data.character, data.operator);
|
||||
state.channelMap[data.channel.toLowerCase()]!.infoText = text;
|
||||
addEventMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('CKU', (data, time) => {
|
||||
const text = l('events.kick', data.channel, data.character, data.operator);
|
||||
state.channelMap[data.channel.toLowerCase()]!.infoText = text;
|
||||
addEventMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('CTU', (data, time) => {
|
||||
const text = l('events.timeout', data.channel, data.character, data.operator, data.length.toString());
|
||||
state.channelMap[data.channel.toLowerCase()]!.infoText = text;
|
||||
addEventMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('HLO', (data, time) => addEventMessage(new EventMessage(data.message, time)));
|
||||
connection.onMessage('BRO', (data, time) => {
|
||||
const text = l('events.broadcast', `[user]${data.character}[/user]`, decodeHTML(data.message.substr(data.character.length + 23)));
|
||||
addEventMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('CIU', (data, time) => {
|
||||
const text = l('events.invite', `[user]${data.sender}[/user]`, `[session=${data.title}]${data.name}[/session]`);
|
||||
addEventMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('ERR', (data, time) => {
|
||||
state.selectedConversation.errorText = data.message;
|
||||
addEventMessage(new EventMessage(`[color=red]${l('events.error', data.message)}[/color]`, time));
|
||||
});
|
||||
connection.onMessage('RTB', (data, time) => {
|
||||
let url = 'https://www.f-list.net/';
|
||||
let text: string, character: string;
|
||||
if(data.type === 'comment') { //tslint:disable-line:prefer-switch
|
||||
switch(data.target_type) {
|
||||
case 'newspost':
|
||||
url += `newspost/${data.target_id}/#Comment${data.id}`;
|
||||
break;
|
||||
case 'bugreport':
|
||||
url += `view_bugreport.php?id=/${data.target_id}/#${data.id}`;
|
||||
break;
|
||||
case 'changelog':
|
||||
url += `log.php?id=/${data.target_id}/#${data.id}`;
|
||||
break;
|
||||
case 'feature':
|
||||
url += `vote.php?id=/${data.target_id}/#${data.id}`;
|
||||
}
|
||||
const key = `events.rtbComment${(data.parent_id !== 0 ? 'Reply' : '')}`;
|
||||
text = l(key, `[user]${data.name}[/user]`, l(`events.rtbComment_${data.target_type}`), `[url=${url}]${data.target}[/url]`);
|
||||
character = data.name;
|
||||
} else if(data.type === 'note') {
|
||||
text = l('events.rtb_note', `[user]${data.sender}[/user]`, `[url=${url}view_note.php?note_id=${data.id}]${data.subject}[/url]`);
|
||||
character = data.sender;
|
||||
} else if(data.type === 'friendrequest') {
|
||||
text = l(`events.rtb_friendrequest`, `[user]${data.name}[/user]`);
|
||||
character = data.name;
|
||||
} else {
|
||||
switch(data.type) {
|
||||
case 'grouprequest':
|
||||
url += 'panel/group_requests.php';
|
||||
break;
|
||||
case 'bugreport':
|
||||
url += `view_bugreport.php?id=${data.id}`;
|
||||
break;
|
||||
case 'helpdeskticket':
|
||||
url += `view_ticket.php?id=${data.id}`;
|
||||
break;
|
||||
case 'helpdeskreply':
|
||||
url += `view_ticket.php?id=${data.id}`;
|
||||
break;
|
||||
case 'featurerequest':
|
||||
url += `vote.php?fid=${data.id}`;
|
||||
break;
|
||||
default: //TODO
|
||||
return;
|
||||
}
|
||||
text = l(`events.rtb_${data.type}`, `[user]${data.name}[/user]`,
|
||||
data.title !== undefined ? `[url=${url}]${data.title}[/url]` : url);
|
||||
character = data.name;
|
||||
}
|
||||
addEventMessage(new EventMessage(text, time));
|
||||
if(data.type === 'note')
|
||||
core.notifications.notify(state.consoleTab, character, text, characterImage(character), 'newnote');
|
||||
});
|
||||
type SFCMessage = (Interfaces.Message & {sfc: Connection.ServerCommands['SFC'] & {confirmed?: true}});
|
||||
const sfcList: SFCMessage[] = [];
|
||||
connection.onMessage('SFC', (data, time) => {
|
||||
let text: string, message: Interfaces.Message;
|
||||
if(data.action === 'report') {
|
||||
text = l('events.report', `[user]${data.character}[/user]`, decodeHTML(data.tab), decodeHTML(data.report));
|
||||
core.notifications.notify(state.consoleTab, data.character, text, characterImage(data.character), 'modalert');
|
||||
message = new EventMessage(text, time);
|
||||
safeAddMessage(sfcList, message, 500);
|
||||
(<SFCMessage>message).sfc = data;
|
||||
} else {
|
||||
text = l('events.report.confirmed', `[user]${data.moderator}[/user]`, `[user]${data.character}[/user]`);
|
||||
for(const item of sfcList)
|
||||
if(item.sfc.logid === data.logid) {
|
||||
item.sfc.confirmed = true;
|
||||
break;
|
||||
}
|
||||
message = new EventMessage(text, time);
|
||||
}
|
||||
addEventMessage(message);
|
||||
});
|
||||
connection.onMessage('STA', (data, time) => {
|
||||
if(data.character === core.connection.character) {
|
||||
addEventMessage(new EventMessage(l(data.statusmsg.length > 0 ? 'events.status.ownMessage' : 'events.status.own',
|
||||
l(`status.${data.status}`), decodeHTML(data.statusmsg)), time));
|
||||
return;
|
||||
}
|
||||
const char = core.characters.get(data.character);
|
||||
if(!isOfInterest(char)) return;
|
||||
const status = l(`status.${data.status}`);
|
||||
const key = data.statusmsg.length > 0 ? 'events.status.message' : 'events.status';
|
||||
const message = new EventMessage(l(key, `[user]${data.character}[/user]`, status, decodeHTML(data.statusmsg)), time);
|
||||
addEventMessage(message);
|
||||
const conv = state.privateMap[data.character.toLowerCase()];
|
||||
if(conv !== undefined && core.state.settings.eventMessages && conv !== state.selectedConversation) conv.addMessage(message);
|
||||
});
|
||||
connection.onMessage('SYS', (data, time) => {
|
||||
state.selectedConversation.infoText = data.message;
|
||||
addEventMessage(new EventMessage(data.message, time));
|
||||
});
|
||||
connection.onMessage('JCH', (data, time) => {
|
||||
if(data.character.identity === core.connection.character) return;
|
||||
const conv = state.channelMap[data.channel.toLowerCase()]!;
|
||||
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
|
||||
!core.state.settings.joinMessages) return;
|
||||
const text = l('events.channelJoin', `[user]${data.character.identity}[/user]`);
|
||||
conv.addMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('LCH', (data, time) => {
|
||||
if(data.character === core.connection.character) return;
|
||||
const conv = state.channelMap[data.channel.toLowerCase()]!;
|
||||
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
|
||||
!core.state.settings.joinMessages) return;
|
||||
const text = l('events.channelLeave', `[user]${data.character}[/user]`);
|
||||
conv.addMessage(new EventMessage(text, time));
|
||||
});
|
||||
connection.onMessage('ZZZ', (data, time) => {
|
||||
state.selectedConversation.infoText = data.message;
|
||||
addEventMessage(new EventMessage(data.message, time));
|
||||
});
|
||||
//TODO connection.onMessage('UPT', data =>
|
||||
return state;
|
||||
}
|
104
chat/core.ts
Normal file
104
chat/core.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import Vue from 'vue';
|
||||
import {WatchHandler} from 'vue/types/options';
|
||||
import BBCodeParser from './bbcode';
|
||||
import {Settings as SettingsImpl} from './common';
|
||||
import {Channel, Character, Connection, Conversation, Logs, Notifications, Settings, State as StateInterface} from './interfaces';
|
||||
|
||||
function createBBCodeParser(): BBCodeParser {
|
||||
const parser = new BBCodeParser();
|
||||
for(const tag of state.settings.disallowedTags)
|
||||
parser.removeTag(tag);
|
||||
return parser;
|
||||
}
|
||||
|
||||
class State implements StateInterface {
|
||||
_settings: Settings | undefined = undefined;
|
||||
|
||||
get settings(): Settings {
|
||||
if(this._settings === undefined) throw new Error('Settings load failed.');
|
||||
return this._settings;
|
||||
}
|
||||
|
||||
set settings(value: Settings) {
|
||||
this._settings = value;
|
||||
//tslint:disable-next-line:no-floating-promises
|
||||
if(data.settingsStore !== undefined) data.settingsStore.set('settings', value);
|
||||
data.bbCodeParser = createBBCodeParser();
|
||||
}
|
||||
}
|
||||
|
||||
interface VueState {
|
||||
readonly channels: Channel.State
|
||||
readonly characters: Character.State
|
||||
readonly conversations: Conversation.State
|
||||
readonly state: StateInterface
|
||||
}
|
||||
|
||||
const state = new State();
|
||||
|
||||
const vue = <Vue & VueState>new Vue({
|
||||
data: {
|
||||
channels: undefined,
|
||||
characters: undefined,
|
||||
conversations: undefined,
|
||||
state
|
||||
}
|
||||
});
|
||||
|
||||
const data = {
|
||||
connection: <Connection | undefined>undefined,
|
||||
logs: <Logs.Basic | undefined>undefined,
|
||||
settingsStore: <Settings.Store | undefined>undefined,
|
||||
state: vue.state,
|
||||
bbCodeParser: <BBCodeParser | undefined>undefined,
|
||||
conversations: <Conversation.State | undefined>undefined,
|
||||
channels: <Channel.State | undefined>undefined,
|
||||
characters: <Character.State | undefined>undefined,
|
||||
notifications: <Notifications | undefined>undefined,
|
||||
register(this: void | never, module: 'characters' | 'conversations' | 'channels',
|
||||
subState: Channel.State | Character.State | Conversation.State): void {
|
||||
Vue.set(vue, module, subState);
|
||||
data[module] = subState;
|
||||
},
|
||||
watch<T>(getter: (this: VueState) => T, callback: WatchHandler<VueState, T>): void {
|
||||
vue.$watch(getter, callback);
|
||||
},
|
||||
async reloadSettings(): Promise<void> {
|
||||
const settings = new SettingsImpl();
|
||||
const loadedSettings = <SettingsImpl | undefined>await core.settingsStore.get('settings');
|
||||
if(loadedSettings !== undefined)
|
||||
for(const key in loadedSettings) settings[<keyof Settings>key] = loadedSettings[<keyof Settings>key];
|
||||
state._settings = settings;
|
||||
}
|
||||
};
|
||||
|
||||
export function init(this: void, connection: Connection, logsClass: new() => Logs.Basic, settingsClass: new() => Settings.Store,
|
||||
notificationsClass: new() => Notifications): void {
|
||||
data.connection = connection;
|
||||
data.logs = new logsClass();
|
||||
data.settingsStore = new settingsClass();
|
||||
data.notifications = new notificationsClass();
|
||||
connection.onEvent('connecting', async() => {
|
||||
await data.reloadSettings();
|
||||
data.bbCodeParser = createBBCodeParser();
|
||||
});
|
||||
}
|
||||
|
||||
const core = <{
|
||||
readonly connection: Connection
|
||||
readonly logs: Logs.Basic
|
||||
readonly state: StateInterface
|
||||
readonly settingsStore: Settings.Store
|
||||
readonly conversations: Conversation.State
|
||||
readonly characters: Character.State
|
||||
readonly channels: Channel.State
|
||||
readonly bbCodeParser: BBCodeParser
|
||||
readonly notifications: Notifications
|
||||
register(module: 'conversations', state: Conversation.State): void
|
||||
register(module: 'channels', state: Channel.State): void
|
||||
register(module: 'characters', state: Character.State): void
|
||||
reloadSettings(): void
|
||||
watch<T>(getter: (this: VueState) => T, callback: WatchHandler<VueState, T>): void
|
||||
}><any>data; /*tslint:disable-line:no-any*///hack
|
||||
|
||||
export default core;
|
179
chat/interfaces.ts
Normal file
179
chat/interfaces.ts
Normal file
@ -0,0 +1,179 @@
|
||||
//tslint:disable:no-shadowed-variable
|
||||
declare global {
|
||||
interface Function {
|
||||
//tslint:disable-next-line:ban-types no-any
|
||||
bind<T extends Function>(this: T, thisArg: any): T;
|
||||
//tslint:disable-next-line:ban-types no-any
|
||||
bind<T, TReturn>(this: (t: T) => TReturn, thisArg: any, arg: T): () => TReturn;
|
||||
}
|
||||
}
|
||||
|
||||
import {Channel, Character} from '../fchat/interfaces';
|
||||
export {Connection, Channel, Character} from '../fchat/interfaces';
|
||||
export const userStatuses = ['online', 'looking', 'away', 'busy', 'dnd'];
|
||||
export const channelModes = ['chat', 'ads', 'both'];
|
||||
|
||||
export namespace Conversation {
|
||||
export interface EventMessage {
|
||||
readonly type: Message.Type.Event,
|
||||
readonly text: string,
|
||||
readonly time: Date
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
readonly type: Message.Type,
|
||||
readonly sender: Character,
|
||||
readonly text: string,
|
||||
readonly time: Date
|
||||
readonly isHighlight: boolean
|
||||
}
|
||||
|
||||
export type Message = EventMessage | ChatMessage;
|
||||
|
||||
export namespace Message {
|
||||
export enum Type {
|
||||
Message,
|
||||
Action,
|
||||
Ad,
|
||||
Roll,
|
||||
Warn,
|
||||
Event
|
||||
}
|
||||
}
|
||||
|
||||
export type RecentConversation = {readonly channel: string, readonly name: string} | {readonly character: string};
|
||||
|
||||
export type TypingStatus = 'typing' | 'paused' | 'clear';
|
||||
|
||||
interface TabConversation extends Conversation {
|
||||
isPinned: boolean
|
||||
readonly maxMessageLength: number
|
||||
close(): void
|
||||
sort(newIndex: number): void
|
||||
}
|
||||
|
||||
export interface PrivateConversation extends TabConversation {
|
||||
readonly character: Character
|
||||
readonly typingStatus: TypingStatus
|
||||
}
|
||||
|
||||
export interface ChannelConversation extends TabConversation {
|
||||
readonly channel: Channel
|
||||
mode: Channel.Mode
|
||||
readonly adCountdown: number
|
||||
isSendingAds: boolean
|
||||
}
|
||||
|
||||
export function isPrivate(conversation: Conversation): conversation is PrivateConversation {
|
||||
return (<Partial<PrivateConversation>>conversation).character !== undefined;
|
||||
}
|
||||
|
||||
export function isChannel(conversation: Conversation): conversation is ChannelConversation {
|
||||
return (<Partial<ChannelConversation>>conversation).channel !== undefined;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
readonly privateConversations: ReadonlyArray<PrivateConversation>
|
||||
readonly channelConversations: ReadonlyArray<ChannelConversation>
|
||||
readonly consoleTab: Conversation
|
||||
readonly recent: ReadonlyArray<RecentConversation>
|
||||
readonly selectedConversation: Conversation
|
||||
byKey(key: string): Conversation | undefined
|
||||
getPrivate(character: Character): PrivateConversation
|
||||
reloadSettings(): void
|
||||
}
|
||||
|
||||
export enum Setting {
|
||||
True, False, Default
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
readonly notify: Setting;
|
||||
readonly highlight: Setting;
|
||||
readonly highlightWords: ReadonlyArray<string>;
|
||||
readonly joinMessages: Setting;
|
||||
}
|
||||
|
||||
export const enum UnreadState { None, Unread, Mention }
|
||||
|
||||
export interface Conversation {
|
||||
enteredText: string;
|
||||
infoText: string;
|
||||
readonly name: string;
|
||||
readonly messages: ReadonlyArray<Message>;
|
||||
readonly reportMessages: ReadonlyArray<Message>;
|
||||
readonly lastRead: Message | undefined
|
||||
errorText: string
|
||||
readonly key: string
|
||||
readonly unread: UnreadState
|
||||
settings: Settings
|
||||
send(): void
|
||||
loadLastSent(): void
|
||||
show(): void
|
||||
loadMore(): void
|
||||
}
|
||||
}
|
||||
|
||||
export type Conversation = Conversation.Conversation;
|
||||
|
||||
export namespace Logs {
|
||||
export interface Basic {
|
||||
logMessage(conversation: Conversation, message: Conversation.Message): void
|
||||
getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>>
|
||||
}
|
||||
|
||||
export interface Persistent extends Basic {
|
||||
readonly conversations: ReadonlyArray<{readonly id: string, readonly name: string}>
|
||||
getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>>
|
||||
getLogDates(key: string): ReadonlyArray<Date>
|
||||
}
|
||||
|
||||
export function isPersistent(logs: Basic): logs is Persistent {
|
||||
return (<Partial<Persistent>>logs).getLogs !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Settings {
|
||||
export type Keys = {
|
||||
settings: Settings,
|
||||
pinned: {channels: string[], private: string[]},
|
||||
conversationSettings: {[key: string]: Conversation.Settings}
|
||||
recent: Conversation.RecentConversation[]
|
||||
};
|
||||
|
||||
export interface Store {
|
||||
get<K extends keyof Keys>(key: K, character?: string): Promise<Keys[K] | undefined>
|
||||
getAvailableCharacters(): Promise<ReadonlyArray<string>> | undefined
|
||||
set<K extends keyof Keys>(key: K, value: Keys[K]): Promise<void>
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
readonly playSound: boolean;
|
||||
readonly clickOpensMessage: boolean;
|
||||
readonly disallowedTags: ReadonlyArray<string>;
|
||||
readonly notifications: boolean;
|
||||
readonly highlight: boolean;
|
||||
readonly highlightWords: ReadonlyArray<string>;
|
||||
readonly showAvatars: boolean;
|
||||
readonly animatedEicons: boolean;
|
||||
readonly idleTimer: number;
|
||||
readonly messageSeparators: boolean;
|
||||
readonly eventMessages: boolean;
|
||||
readonly joinMessages: boolean;
|
||||
readonly alwaysNotify: boolean;
|
||||
readonly logMessages: boolean;
|
||||
readonly logAds: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export type Settings = Settings.Settings;
|
||||
|
||||
export interface Notifications {
|
||||
isInBackground: boolean
|
||||
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void
|
||||
playSound(sound: string): void
|
||||
}
|
||||
|
||||
export interface State {
|
||||
settings: Settings
|
||||
}
|
360
chat/localize.ts
Normal file
360
chat/localize.ts
Normal file
@ -0,0 +1,360 @@
|
||||
/*tslint:disable:max-line-length object-literal-sort-keys object-literal-key-quotes*/
|
||||
const strings: {[key: string]: string | undefined} = {
|
||||
'action.edit': 'Edit',
|
||||
'action.view': 'View',
|
||||
'action.cut': 'Cut',
|
||||
'action.copy': 'Copy',
|
||||
'action.paste': 'Paste',
|
||||
'action.copyLink': 'Copy Link',
|
||||
'action.suggestions': 'Suggestions',
|
||||
'action.open': 'Show',
|
||||
'action.quit': 'Exit',
|
||||
'action.updateAvailable': 'UPDATE AVAILABLE',
|
||||
'action.update': 'Restart now!',
|
||||
'action.cancel': 'Cancel',
|
||||
'help.fchat': 'FChat 3.0 Help and Changelog',
|
||||
'help.rules': 'F-List Rules',
|
||||
'help.faq': 'F-List FAQ',
|
||||
'help.report': 'How to report a user',
|
||||
'title': 'FChat 3.0',
|
||||
'version': 'Version {0}',
|
||||
'filter': 'Type to filter...',
|
||||
'login.account': 'Username',
|
||||
'login.password': 'Password',
|
||||
'login.host': 'Host',
|
||||
'login.advanced': 'Show advanced settings',
|
||||
'login.save': 'Save login',
|
||||
'login.error': 'Error logging you in: Could not connect to server',
|
||||
'login.submit': 'Log in',
|
||||
'login.working': 'Logging in...',
|
||||
'login.selectCharacter': 'Select a character',
|
||||
'login.connect': 'Connect',
|
||||
'login.connecting': 'Connecting...',
|
||||
'login.connectError': 'Connection error: Could not connect to server',
|
||||
'channelList.public': 'Official channels',
|
||||
'channelList.private': 'Open rooms',
|
||||
'channelList.create': 'Create room',
|
||||
'channelList.createName': 'Room name',
|
||||
'chat.logout': 'Log out',
|
||||
'chat.status': 'Status:',
|
||||
'chat.setStatus': 'Set status',
|
||||
'chat.setStatus.status': 'Status',
|
||||
'chat.setStatus.message': 'Status message (optional)',
|
||||
'chat.menu': 'Menu',
|
||||
'chat.channels': 'Channels',
|
||||
'chat.pms': 'PMs',
|
||||
'chat.consoleTab': 'Console',
|
||||
'chat.confirmLeave': 'You are still connected to chat. Would you like to disconnect?',
|
||||
'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.',
|
||||
'chat.errorOffline': '{0} is offline, you cannot send them a message right now.',
|
||||
'chat.errorIgnored': 'You are ignoring {0}. If you would like to send them a message, please unignore them first.',
|
||||
'chat.disconnected': 'You were disconnected from chat.\nAttempting to reconnect.',
|
||||
'chat.disconnected.title': 'Disconnected',
|
||||
'chat.ignoreList': 'You are currently ignoring: {0}',
|
||||
'logs.title': 'Logs',
|
||||
'logs.conversation': 'Conversation',
|
||||
'logs.date': 'Date',
|
||||
'user.profile': 'Profile',
|
||||
'user.message': 'Open conversation',
|
||||
'user.messageJump': 'View conversation',
|
||||
'user.bookmark': 'Bookmark',
|
||||
'user.unbookmark': 'Unbookmark',
|
||||
'user.ignore': 'Ignore',
|
||||
'user.unignore': 'Unignore',
|
||||
'user.memo': 'View memo',
|
||||
'user.memo.action': 'Update memo',
|
||||
'user.report': 'Report user',
|
||||
'user.channelKick': 'Kick from channel',
|
||||
'user.chatKick': 'Chat kick',
|
||||
'users.title': 'People',
|
||||
'users.friends': 'Friends',
|
||||
'users.bookmarks': 'Bookmarks',
|
||||
'users.members': 'Members',
|
||||
'chat.report': 'Alert Staff',
|
||||
'chat.report.description': `
|
||||
[color=red]Before you alert the moderators, PLEASE READ:[/color]
|
||||
If you're just having personal trouble with someone, right-click their name and ignore them.
|
||||
Please make sure what you're reporting is a violation of the site's [url=https://wiki.f-list.net/Code_of_Conduct]Code of Conduct[/url] otherwise nothing will be done.
|
||||
|
||||
This tool is intended for chat moderation. If you have a question, please visit our [url=https://wiki.f-list.net/Frequently_Asked_Questions]FAQ[/url] first, and if that doesn't help, join [session=Helpdesk]Helpdesk[/session] and ask your question there.
|
||||
|
||||
If your problem lies anywhere outside of the chat, please send in a Ticket instead.
|
||||
|
||||
For a more comprehensive guide as how and when to report another user, please [url=https://wiki.f-list.net/How_to_Report_a_User]consult this page.[/url]
|
||||
|
||||
Please provide a brief summary of your problem and the rules that have been violated.
|
||||
[color=red]DO NOT PASTE LOGS INTO THIS FIELD.
|
||||
SELECT THE TAB YOU WISH TO REPORT, LOGS ARE AUTOMATICALLY ATTACHED[/color]`,
|
||||
'chat.report.channel.user': 'Reporting user {0} in channel {1}',
|
||||
'chat.report.channel': 'General report for channel {0}',
|
||||
'chat.report.channel.description': 'If you wish to report a specific user, please right-click them and select "Report".',
|
||||
'chat.report.private': 'Reporting private conversation with user {0}',
|
||||
'chat.report.text': 'Report text',
|
||||
'chat.recentConversations': 'Recent conversations',
|
||||
'settings.tabs.general': 'General',
|
||||
'settings.tabs.notifications': 'Notifications',
|
||||
'settings.tabs.import': 'Import',
|
||||
'settings.open': 'Settings',
|
||||
'settings.action': 'Change settings',
|
||||
'settings.import': 'Import settings',
|
||||
'settings.import.selectCharacter': 'Select a character',
|
||||
'settings.import.confirm': `You are importing settings from your character {0}.
|
||||
This will overwrite any and all settings, pinned conversations and conversation settings of character {1}.
|
||||
Logs and recent conversations will not be touched.
|
||||
You may need to log out and back in for some settings to take effect.
|
||||
Are you sure?`,
|
||||
'settings.playSound': 'Play notification sounds',
|
||||
'settings.notifications': 'Display notifications',
|
||||
'settings.clickOpensMessage': 'Clicking users opens messages (instead of their profile)',
|
||||
'settings.disallowedTags': 'Disallowed BBCode tags (comma-separated)',
|
||||
'settings.highlight': 'Notify for messages containing your name',
|
||||
'settings.highlightWords': 'Custom highlight notify words (comma-separated)',
|
||||
'settings.showAvatars': 'Show character avatars',
|
||||
'settings.animatedEicons': 'Animate [eicon]s',
|
||||
'settings.idleTimer': 'Idle timer (minutes, clear to disable)',
|
||||
'settings.messageSeparators': 'Display separators between messages',
|
||||
'settings.eventMessages': 'Also display console messages in current tab',
|
||||
'settings.joinMessages': 'Display join/leave messages in channels',
|
||||
'settings.alwaysNotify': 'Always notify for PMs/highlights, even when looking at the tab',
|
||||
'settings.closeToTray': 'Close to tray',
|
||||
'settings.spellcheck': 'Spellcheck',
|
||||
'settings.spellcheck.disabled': 'Disabled',
|
||||
'settings.theme': 'Theme',
|
||||
'settings.logMessages': 'Log messages',
|
||||
'settings.logAds': 'Log ads',
|
||||
'conversationSettings.title': 'Settings',
|
||||
'conversationSettings.action': 'Edit settings for {0}',
|
||||
'conversationSettings.default': 'Default',
|
||||
'conversationSettings.true': 'Yes',
|
||||
'conversationSettings.false': 'No',
|
||||
'conversationSettings.notify': 'Notify for messages',
|
||||
'channel.mode.ads': 'Ads',
|
||||
'channel.mode.chat': 'Chat',
|
||||
'channel.mode.both': 'Both',
|
||||
'channel.official': 'Official channel',
|
||||
'channel.description': 'Description',
|
||||
'manageChannel.open': 'Manage',
|
||||
'manageChannel.action': 'Manage {0}',
|
||||
'manageChannel.submit': 'Save settings',
|
||||
'manageChannel.mods': 'Channel moderators',
|
||||
'manageChannel.modAdd': 'Add moderator',
|
||||
'manageChannel.modAddName': 'New moderator name',
|
||||
'manageChannel.isPublic': 'Is public (i.e. in the channel list; anyone can join without an invite)',
|
||||
'manageChannel.mode': 'Allowed messages',
|
||||
'manageChannel.description': 'Description',
|
||||
'characterSearch.open': 'Character Search',
|
||||
'characterSearch.action': 'Search characters',
|
||||
'characterSearch.again': 'Start another search',
|
||||
'characterSearch.results': 'Results',
|
||||
'characterSearch.kinks': 'Kinks',
|
||||
'characterSearch.kinkNotice': 'Must select at least one kink.',
|
||||
'characterSearch.genders': 'Genders',
|
||||
'characterSearch.orientations': 'Orientations',
|
||||
'characterSearch.languages': 'Languages',
|
||||
'characterSearch.furryprefs': 'Furry preferences',
|
||||
'characterSearch.roles': 'Dom/sub roles',
|
||||
'characterSearch.positions': 'Positions',
|
||||
'characterSearch.error.noResults': 'There were no search results.',
|
||||
'characterSearch.error.throttle': 'You must wait five seconds between searches.',
|
||||
'characterSearch.error.tooManyResults': 'There are too many search results, please narrow your search.',
|
||||
'events.broadcast': '{0} has broadcast {1}',
|
||||
'events.invite': '{0} has invited you to join {1}',
|
||||
'events.error': 'Error: {0}',
|
||||
'events.rtbCommentReply': '{0} replied to your comment on the {1}: {2}',
|
||||
'events.rtbComment': '{0} commented on your {1}: {2}',
|
||||
'events.rtbComment_bugreport': 'bug report',
|
||||
'events.rtbComment_changelog': 'changelog post',
|
||||
'events.rtbComment_feature': 'feature request',
|
||||
'events.rtbComment_newspost': 'news post',
|
||||
'events.rtb_note': '{0} has sent you a note: {1}',
|
||||
'events.rtb_bugreport': '{0} submitted a bug report: {1}',
|
||||
'events.rtb_featurerequest': '{0} submitted a feature request: {1}',
|
||||
'events.rtb_grouprequest': '{0} requested a group named: {1}',
|
||||
'events.rtb_helpdeskreply': '{0} replied to [url={1}]a help desk ticket you are involved in[/url].',
|
||||
'events.rtb_helpdeskticket': '{0} submitted a help desk ticket: {1}',
|
||||
'events.rtb_friendrequest': '{0} has sent you a friend request.',
|
||||
'events.report': '[b][color=red]MODERATOR ALERT[/color][/b] - Report by {0}:\nCurrent tab: {1}\nReport: {2}',
|
||||
'events.report.confirmed': '{0} is handling {1}\'s report.',
|
||||
'events.report.confirm': 'Confirm report',
|
||||
'events.report.viewLog': 'View log',
|
||||
'events.status': '{0} is now {1}.',
|
||||
'events.status.message': '{0} is now {1}: {2}',
|
||||
'events.status.own': 'You are now {0}.',
|
||||
'events.status.ownMessage': 'You are now {0}: {1}',
|
||||
'events.ban': '{2} has banned {1} from {0}.',
|
||||
'events.timeout': '{2} has timed out {1} from {0} for {3} minutes.',
|
||||
'events.kick': '{2} has kicked {1} from {0}.',
|
||||
'events.login': '{0} has logged in.',
|
||||
'events.logout': '{0} has logged out.',
|
||||
'events.channelJoin': '{0} has joined the channel.',
|
||||
'events.channelLeave': '{0} has left the channel.',
|
||||
'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.',
|
||||
'commands.invalidParam': 'The value for the parameter {0} is invalid. Please use the Help (click the ? button) if you need further information.',
|
||||
'commands.invalidCharacter': 'The character you entered is not online. Put the name in double quotes if you want to override. Please use the Help (click the ? button) if you need further information.',
|
||||
'commands.help': 'Command Help',
|
||||
'commands.help.syntax': 'Syntax: {0}',
|
||||
'commands.help.contextChannel': 'This command can be executed in a channel tab.',
|
||||
'commands.help.contextPrivate': 'This command can be executed in a private conversation tab.',
|
||||
'commands.help.contextChonsole': 'This command can be executed in the console tab.',
|
||||
'commands.help.permissionRoomOp': 'This command requires you to be an operator in the selected channel.',
|
||||
'commands.help.permissionRoomOwner': 'This command requires you to be the owner of the selected channel.',
|
||||
'commands.help.permissionChannelMod': 'This command requires you to be an official channel moderator.',
|
||||
'commands.help.permissionChatOp': 'This command requires you to be a global chat operator.',
|
||||
'commands.help.permissionAdmin': 'This command requires you to be an admin.',
|
||||
'commands.help.parameters': 'Parameters:',
|
||||
'commands.help.paramOptional': '{0} (optional):',
|
||||
'commands.param_character': 'Character',
|
||||
'commands.param_character.help': 'The name of a character. Must be valid and logged in - override by putting in double quotes.',
|
||||
'commands.reward': 'Reward',
|
||||
'commands.reward.help': 'Reward a user, giving them a special status until they change it or log out.',
|
||||
'commands.greports': 'Pending reports',
|
||||
'commands.greports.help': 'Requests a list of pending chat reports from the server.',
|
||||
'commands.join': 'Join channel',
|
||||
'commands.join.help': 'Joins the channel with the given name/ID.',
|
||||
'commands.join.param0': 'Channel ID',
|
||||
'commands.join.param0.help': 'The name/ID of the channel to join. For official channels, this is the name, for private rooms this is the ID.',
|
||||
'commands.close': 'Close tab',
|
||||
'commands.close.help': 'Closes the currently viewed PM or channel tab.',
|
||||
'commands.uptime': 'Uptime',
|
||||
'commands.uptime.help': 'Requests statistics about server uptime.',
|
||||
'commands.status': 'Set status',
|
||||
'commands.status.help': 'Sets your status along with an optional message.',
|
||||
'commands.status.param0': 'Status',
|
||||
'commands.status.param0.help': 'A valid status, namely "online", "busy", "looking", "away", "dnd" or "busy".',
|
||||
'commands.status.param1': 'Message',
|
||||
'commands.status.param1.help': 'An optional status message of up to 255 bytes.',
|
||||
'commands.priv': 'Open conversation',
|
||||
'commands.priv.help': 'Opens a conversation with the given character.',
|
||||
'commands.broadcast': 'Chat broadcast',
|
||||
'commands.broadcast.help': 'Broadcast a message, alerting all currently connected characters.',
|
||||
'commands.broadcast.param0': 'Message',
|
||||
'commands.broadcast.param0.help': 'Broadcast message. May contain valid chat BBCode.',
|
||||
'commands.makeroom': 'Create private room',
|
||||
'commands.makeroom.help': 'Creates a private room. Only people you /invite will be able to join it, and it will not be listed, until you open it with /openroom.',
|
||||
'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.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',
|
||||
'commands.ignorelist.help': 'Lists all of the characters currently on your ignore list.',
|
||||
'commands.roll': 'Dice roll',
|
||||
'commands.roll.help': 'Rolls dice (RNG), displaying the result to all members of the current tab.',
|
||||
'commands.roll.param0': 'Dice',
|
||||
'commands.roll.param0.help': 'Syntax: [1-9]d[1-100]. Addition and subtraction of rolls and fixed numbers is also possible. Example: /roll 1d6+1d20-5',
|
||||
'commands.bottle': 'Spin the bottle',
|
||||
'commands.bottle.help': 'Spins a bottle, randomly selecting a member of the current tab and displaying it to all.',
|
||||
'commands.ad': 'Post as ad',
|
||||
'commands.ad.help': 'A quick way to post an ad in the current channel. You may receive an error if ads are not allowed in that channel.',
|
||||
'commands.ad.param0': 'Message',
|
||||
'commands.ad.param0.help': 'The message to post as an ad.',
|
||||
'commands.me': 'Post as action',
|
||||
'commands.me.help': 'This will cause your message to be formatted differently, as an action your character is performing.',
|
||||
'commands.me.param0': 'Message',
|
||||
'commands.me.param0.help': 'The message to post as an action - the action you would like your character to perform.',
|
||||
'commands.warn': 'Warn channel',
|
||||
'commands.warn.help': 'Provides a way for channel moderators to warn/alert members. This message will be formatted differently, and is often used as a warning before moderator action.',
|
||||
'commands.warn.param0': 'Message',
|
||||
'commands.warn.param0.help': 'The message to post as a warning.',
|
||||
'commands.kick': 'Channel kick',
|
||||
'commands.kick.help': 'Removes a character from the current channel. They are free to rejoin - use /ban or /timeout if you want to get rid of them for a longer period of time.',
|
||||
'commands.ban': 'Channel ban',
|
||||
'commands.ban.help': 'Bans a character from the current channel. They will not be able to rejoin unless and until you undo this with /unban.',
|
||||
'commands.unban': 'Channel unban',
|
||||
'commands.unban.help': 'Unbans a character from the current channel, allowing them to rejoin.',
|
||||
'commands.banlist': 'Channel ban list',
|
||||
'commands.banlist.help': 'Requests the ban list for the current channel. The server will reply with a system response, which you will be able to view in the Console tab.',
|
||||
'commands.timeout': 'Channel timeout',
|
||||
'commands.timeout.help': 'Temporarily bans the given character from the current channel. Mind the comma in the syntax!',
|
||||
'commands.timeout.param1': 'Duration',
|
||||
'commands.timeout.param1.help': 'The number of minutes to ban the character for.',
|
||||
'commands.op': 'Promote to Channel OP',
|
||||
'commands.op.help': 'Promotes a character to channel OP in the current channel.',
|
||||
'commands.deop': 'Demote from Channel OP',
|
||||
'commands.deop.help': 'Demotes a character from channel OP in the current channel.',
|
||||
'commands.oplist': 'List Channel OPs',
|
||||
'commands.oplist.help': 'Lists all the OPs of the current channel.',
|
||||
'commands.setowner': 'Set channel owner',
|
||||
'commands.setowner.help': 'Set the owner of a channel to another character. The previous owner will be demoted to a member.',
|
||||
'commands.invite': 'Invite to room',
|
||||
'commands.invite.help': 'Invites a character to the current channel. This will allow them to join it even if it is a closed room. You can revoke this with /kick, /ban or /timeout.',
|
||||
'commands.closeroom': 'Close room',
|
||||
'commands.closeroom.help': 'Closes the current channel. This will only allow people you /invite to join it, and remove it from the rooms list.',
|
||||
'commands.openroom': 'Open room',
|
||||
'commands.openroom.help': 'Opens the current channel. This will allow anyone to join it, and let it be listed in the rooms list.',
|
||||
'commands.killchannel': 'Destroy room',
|
||||
'commands.killchannel.help': 'PERMANENTLY kills/destroys/removes the current room. All associated settings and prestige will be lost. Make sure this is what you want to do, you cannot undo it.',
|
||||
'commands.createchannel': 'Create official channel',
|
||||
'commands.createchannel.help': 'Creates an official, staff-moderated room.',
|
||||
'commands.createchannel.param0': 'Channel name',
|
||||
'commands.createchannel.param0.help': 'A name for the new official channel.',
|
||||
'commands.setmode': 'Set room mode',
|
||||
'commands.setmode.help': 'Set whether ads and/or chat are allowed in the current channel.',
|
||||
'commands.setmode.param0': 'Mode',
|
||||
'commands.setmode.param0.help': 'A valid room mode, namely "ads", "chat" or "both".',
|
||||
'commands.setdescription': 'Set room description',
|
||||
'commands.setdescription.help': 'Set the description for the current room.',
|
||||
'commands.setdescription.param0': 'Description',
|
||||
'commands.setdescription.param0.help': 'New description for the room. May contain up to 50,000 characters, and valid chat BBCode.',
|
||||
'commands.code': 'Copy channel code',
|
||||
'commands.code.help': 'Copies a BBCode link to the current channel into your clipboard. This can be pasted anywhere else on chat to render a link to this channel.',
|
||||
'commands.code.success': 'Channel code copied to your clipboard.',
|
||||
'commands.gkick': 'Chat kick',
|
||||
'commands.gkick.help': 'Removes a character from the chat. They are free to rejoin - use /gban or /gtimeout if you want to get rid of them for a longer period of time.',
|
||||
'commands.gban': 'Chat ban',
|
||||
'commands.gban.help': 'Bans a character from the chat. They will not be able to reconnect unless and until you undo this with /unban.',
|
||||
'commands.gunban': 'Chat unban',
|
||||
'commands.gunban.help': 'Unbans a character from the chat, allowing them to reconnect.',
|
||||
'commands.gtimeout': 'Chat timeout',
|
||||
'commands.gtimeout.help': 'Temporarily bans the given character from F-Chat. Mind the comma in the syntax!',
|
||||
'commands.gtimeout.param1': 'Duration',
|
||||
'commands.gtimeout.param1.help': 'The number of minutes to ban the character for.',
|
||||
'commands.gtimeout.param2': 'Reason',
|
||||
'commands.gtimeout.param2.help': 'The reason for the chat timeout.',
|
||||
'commands.gop': 'Promote to Chat OP',
|
||||
'commands.gop.help': 'Promotes a character to global chat OP.',
|
||||
'commands.gdeop': 'Demote from Chat OP',
|
||||
'commands.gdeop.help': 'Demotes a character from global chat OP.',
|
||||
'commands.reloadconfig': 'Reload config',
|
||||
'commands.reloadconfig.help': 'Reload server-side config from disk.',
|
||||
'commands.reloadconfig.param0': 'Save?',
|
||||
'commands.reloadconfig.param0.help': 'Save ops, bans and channels to disk first.',
|
||||
'commands.xyzzy': 'Debug',
|
||||
'commands.xyzzy.help': 'Execute debug command on the server.',
|
||||
'commands.xyzzy.param0': 'Command',
|
||||
'commands.xyzzy.param0.help': 'The command to execute.',
|
||||
'commands.xyzzy.param1': 'Arguments',
|
||||
'commands.xyzzy.param1.help': 'The arguments to the command.',
|
||||
'status.online': 'Online',
|
||||
'status.away': 'Away',
|
||||
'status.busy': 'Busy',
|
||||
'status.looking': 'Looking',
|
||||
'status.dnd': 'Do Not Disturb',
|
||||
'status.idle': 'Idle',
|
||||
'status.offline': 'Offline',
|
||||
'status.crown': 'Rewarded by Admin',
|
||||
'importer.importGeneral': 'slimCat data has been detected on your computer.\nWould you like to import general settings?',
|
||||
'importer.importCharacter': 'slimCat data for this character has been detected on your computer.\nWould you like to import settings and logs?\nThis may take a while.\nAny existing FChat 3.0 data for this character will be overwritten.',
|
||||
'importer.importing': 'Importing data',
|
||||
'importer.importingNote': 'Importing logs. This may take a couple of minutes. Please do not close the application, even if it appears to hang for a while, as you may end up with incomplete logs.'
|
||||
};
|
||||
|
||||
export default function l(key: string, ...args: string[]): string {
|
||||
let i = args.length;
|
||||
let str = strings[key];
|
||||
if(str === undefined)
|
||||
if(process.env.NODE_ENV !== 'production') throw new Error(`String ${key} does not exist.`);
|
||||
else return '';
|
||||
while(i-- > 0)
|
||||
str = str.replace(new RegExp(`\\{${i}\\}`, 'igm'), args[i]);
|
||||
return str;
|
||||
}
|
74
chat/localstorage.ts
Normal file
74
chat/localstorage.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {Conversation, Logs as Logging, Settings} from './interfaces';
|
||||
import core from './core';
|
||||
import {Message} from './common';
|
||||
|
||||
export class Logs implements Logging.Basic {
|
||||
logMessage(conversation: Conversation, message: Conversation.Message) {
|
||||
const key = 'logs.' + conversation.key;
|
||||
const previous = window.localStorage.getItem(key);
|
||||
const serialized = this.serialize(message);
|
||||
let data = previous ? previous + serialized : serialized;
|
||||
while(data.length > 100000) {
|
||||
data = data.substr(this.deserialize(data, 0).index);
|
||||
}
|
||||
window.localStorage.setItem(key, data);
|
||||
}
|
||||
|
||||
getBacklog(conversation: Conversation) {
|
||||
let messages: Conversation.Message[] = [];
|
||||
const str = window.localStorage.getItem('logs.' + conversation.key);
|
||||
if(!str) return Promise.resolve(messages);
|
||||
let index = str.length;
|
||||
while(true) {
|
||||
index -= (str.charCodeAt(index - 2) << 8 | str.charCodeAt(index - 1)) + 2;
|
||||
messages.unshift(this.deserialize(str, index).message);
|
||||
if(index == 0) break;
|
||||
}
|
||||
return Promise.resolve(messages);
|
||||
}
|
||||
|
||||
private serialize(message: Conversation.Message) {
|
||||
const time = message.time.getTime() / 1000;
|
||||
let str = String.fromCharCode(time >> 24) + String.fromCharCode(time >> 16) + String.fromCharCode(time >> 8) + 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) + String.fromCharCode(textLength % 256);
|
||||
str += message.text;
|
||||
const length = str.length;
|
||||
str += String.fromCharCode(length >> 8) + String.fromCharCode(length % 256);
|
||||
return str;
|
||||
}
|
||||
|
||||
private deserialize(str: string, index: number): {message: Conversation.Message, index: number} {
|
||||
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 message = str.substring(index, index += messageLength);
|
||||
return {
|
||||
message: new Message(type, core.characters.get(sender), message, new Date(time * 1000)),
|
||||
index: index
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsStore implements Settings.Store {
|
||||
get<K extends keyof Settings.Keys>(key: K) {
|
||||
const stored = window.localStorage.getItem('settings.' + key);
|
||||
return Promise.resolve(stored && JSON.parse(stored));
|
||||
}
|
||||
|
||||
set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]) {
|
||||
window.localStorage.setItem('settings.' + key, JSON.stringify(value));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getAvailableCharacters() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
46
chat/message_view.ts
Normal file
46
chat/message_view.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import Vue, {Component, CreateElement, RenderContext, VNode, VNodeChildren} from 'vue';
|
||||
import {BBCodeView} from './bbcode';
|
||||
import {formatTime} from './common';
|
||||
import core from './core';
|
||||
import {Conversation} from './interfaces';
|
||||
import UserView from './user_view';
|
||||
// TODO convert this to single-file once Vue supports it for functional components.
|
||||
// template:
|
||||
// <span>[{{formatTime(message.time)}}]</span>
|
||||
// <span v-show="message.type == MessageTypes.Action">*</span>
|
||||
// <span><user :character="message.sender" :reportDialog="$refs['reportDialog']"></user></span>
|
||||
// <span v-show="message.type == MessageTypes.Message">:</span>
|
||||
// <bbcode :text="message.text"></bbcode>
|
||||
|
||||
const userPostfix: {[key: number]: string | undefined} = {
|
||||
[Conversation.Message.Type.Message]: ': ',
|
||||
[Conversation.Message.Type.Ad]: ': ',
|
||||
[Conversation.Message.Type.Action]: ''
|
||||
};
|
||||
//tslint:disable-next-line:variable-name
|
||||
const MessageView: Component = {
|
||||
functional: true,
|
||||
render(this: Vue, createElement: CreateElement, context: RenderContext): VNode {
|
||||
/*tslint:disable:no-unsafe-any*///context.props is any
|
||||
const message: Conversation.Message = context.props.message;
|
||||
const children: (VNode | string | VNodeChildren)[] = [`[${formatTime(message.time)}] `];
|
||||
/*tslint:disable-next-line:prefer-template*///unreasonable here
|
||||
let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` +
|
||||
(core.state.settings.messageSeparators ? ' message-block' : '') +
|
||||
(message.type !== Conversation.Message.Type.Event && message.sender.name === core.connection.character ? ' message-own' : '') +
|
||||
((context.props.classes !== undefined) ? ` ${context.props.classes}` : '');
|
||||
if(message.type !== Conversation.Message.Type.Event) {
|
||||
children.push((message.type === Conversation.Message.Type.Action) ? '*' : '',
|
||||
createElement(UserView, {props: {character: message.sender, channel: context.props.channel}}),
|
||||
userPostfix[message.type] !== undefined ? userPostfix[message.type]! : ' ');
|
||||
if(message.isHighlight) classes += ' message-highlight';
|
||||
}
|
||||
children.push(createElement(BBCodeView, {props: {unsafeText: message.text}}));
|
||||
const node = createElement('div', {attrs: {class: classes}}, children);
|
||||
node.key = context.data.key;
|
||||
return node;
|
||||
//tslint:enable
|
||||
}
|
||||
};
|
||||
|
||||
export default MessageView;
|
42
chat/notifications.ts
Normal file
42
chat/notifications.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import core from './core';
|
||||
import {Conversation, Notifications as Interface} from './interfaces';
|
||||
|
||||
const codecs: {[key: string]: string} = {mpeg: 'mp3', wav: 'wav', ogg: 'ogg'};
|
||||
|
||||
export default class Notifications implements Interface {
|
||||
isInBackground = false;
|
||||
|
||||
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
|
||||
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
|
||||
this.playSound(sound);
|
||||
if(core.state.settings.notifications) {
|
||||
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive
|
||||
const notification = new Notification(title, <NotificationOptions & {silent: boolean}>{body, icon, silent: true});
|
||||
notification.onclick = () => {
|
||||
conversation.show();
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
playSound(sound: string): void {
|
||||
if(!core.state.settings.playSound) return;
|
||||
const id = `soundplayer-${sound}`;
|
||||
let audio = <HTMLAudioElement | null>document.getElementById(id);
|
||||
if(audio === null) {
|
||||
audio = document.createElement('audio');
|
||||
audio.id = id;
|
||||
//tslint:disable-next-line:forin
|
||||
for(const name in codecs) {
|
||||
const src = document.createElement('source');
|
||||
src.type = `audio/${name}`;
|
||||
//tslint:disable-next-line:no-require-imports
|
||||
src.src = <string>require(`./assets/${sound}.${codecs[name]}`);
|
||||
audio.appendChild(src);
|
||||
}
|
||||
}
|
||||
//tslint:disable-next-line:no-floating-promises
|
||||
audio.play();
|
||||
}
|
||||
}
|
3
chat/qs.d.ts
vendored
Normal file
3
chat/qs.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module 'qs' {
|
||||
export function stringify(data: object): string;
|
||||
}
|
352
chat/slash_commands.ts
Normal file
352
chat/slash_commands.ts
Normal file
@ -0,0 +1,352 @@
|
||||
import core from './core';
|
||||
import {Character, Conversation, userStatuses} from './interfaces';
|
||||
import l from './localize';
|
||||
import ChannelConversation = Conversation.ChannelConversation;
|
||||
import PrivateConversation = Conversation.PrivateConversation;
|
||||
|
||||
export const enum ParamType {
|
||||
String, Number, Character, Enum
|
||||
}
|
||||
|
||||
const defaultDelimiters: {[key: number]: string | undefined} = {[ParamType.Character]: ',', [ParamType.String]: ''};
|
||||
|
||||
export function isCommand(this: void, text: string): boolean {
|
||||
return text.charAt(0) === '/' && text.substr(1, 2) !== 'me' && text.substr(1, 4) !== 'warn';
|
||||
}
|
||||
|
||||
export function parse(this: void | never, input: string, context: CommandContext): ((this: Conversation) => void) | string {
|
||||
const commandEnd = input.indexOf(' ');
|
||||
const name = input.substring(1, commandEnd !== -1 ? commandEnd : undefined);
|
||||
const command = commands[name];
|
||||
if(command === undefined) return l('commands.unknown');
|
||||
const args = `${commandEnd !== -1 ? input.substr(commandEnd + 1) : ''}`;
|
||||
if(command.context !== undefined && (command.context & context) === 0) return l('commands.badContext');
|
||||
|
||||
let index = 0;
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if(command.params !== undefined)
|
||||
for(let i = 0; i < command.params.length; ++i) {
|
||||
const param = command.params[i];
|
||||
if(index === -1)
|
||||
if(param.optional !== undefined) continue;
|
||||
else return l('commands.tooFewParams');
|
||||
let delimiter = param.delimiter !== undefined ? param.delimiter : defaultDelimiters[param.type];
|
||||
if(delimiter === undefined) delimiter = ' ';
|
||||
const endIndex = delimiter.length > 0 ? args.indexOf(delimiter, index) : args.length;
|
||||
const value = args.substring(index, endIndex !== -1 ? endIndex : undefined);
|
||||
if(value.length === 0)
|
||||
if(param.optional !== undefined) continue;
|
||||
else return l('commands.tooFewParams');
|
||||
values[i] = value;
|
||||
switch(param.type) {
|
||||
case ParamType.String:
|
||||
if(i === command.params.length - 1) values[i] = args.substr(index);
|
||||
continue;
|
||||
case ParamType.Enum:
|
||||
if((param.options !== undefined ? param.options : []).indexOf(value) === -1)
|
||||
return l('commands.invalidParam', l(`commands.${name}.param${i}`));
|
||||
break;
|
||||
case ParamType.Number:
|
||||
console.log(value);
|
||||
const num = parseInt(value, 10);
|
||||
if(isNaN(num))
|
||||
return l('commands.invalidParam', l(`commands.${name}.param${i}`));
|
||||
values[i] = num;
|
||||
break;
|
||||
case ParamType.Character:
|
||||
if(value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') {
|
||||
values[i] = value.substring(1, value.length - 1);
|
||||
break;
|
||||
}
|
||||
const char = core.characters.get(value);
|
||||
if(char.status === 'offline') return l('commands.invalidCharacter');
|
||||
}
|
||||
index = endIndex + 1;
|
||||
}
|
||||
if(command.context !== undefined)
|
||||
return function(this: Conversation): void {
|
||||
command.exec(this, ...values);
|
||||
};
|
||||
else return () => command.exec(...values);
|
||||
}
|
||||
|
||||
export const enum CommandContext {
|
||||
Console = 1 << 0,
|
||||
Channel = 1 << 1,
|
||||
Private = 1 << 2
|
||||
}
|
||||
|
||||
export enum Permission {
|
||||
RoomOp = -1,
|
||||
RoomOwner = -2,
|
||||
ChannelMod = 4,
|
||||
ChatOp = 2,
|
||||
Admin = 1
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
readonly context?: CommandContext, //default implicit Console | Channel | Private
|
||||
readonly permission?: Permission
|
||||
readonly documented?: false, //default true
|
||||
readonly params?: {
|
||||
readonly type: ParamType
|
||||
readonly options?: ReadonlyArray<string>, //default undefined
|
||||
readonly optional?: true, //default false
|
||||
readonly delimiter?: string, //default ' ' (',' for type: Character)
|
||||
validator?(data: string | number): boolean //default undefined
|
||||
}[]
|
||||
exec(context?: Conversation | string | number, ...params: (string | number | undefined)[]): void
|
||||
}
|
||||
|
||||
const commands: {readonly [key: string]: Command | undefined} = {
|
||||
me: {
|
||||
exec: () => 'stub',
|
||||
context: CommandContext.Channel | CommandContext.Private,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
reward: {
|
||||
exec: (character: string) => core.connection.send('RWD', {character}),
|
||||
permission: Permission.Admin,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
greports: {
|
||||
permission: Permission.ChannelMod,
|
||||
exec: () => core.connection.send('PCR')
|
||||
},
|
||||
join: {
|
||||
exec: (channel: string) => core.connection.send('JCH', {channel}),
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
close: {
|
||||
exec: (conv: PrivateConversation | ChannelConversation) => conv.close(),
|
||||
context: CommandContext.Private | CommandContext.Channel
|
||||
},
|
||||
priv: {
|
||||
exec: (character: string) => core.conversations.getPrivate(core.characters.get(character)).show(),
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
uptime: {
|
||||
exec: () => core.connection.send('UPT')
|
||||
},
|
||||
status: {
|
||||
//tslint:disable-next-line:no-inferrable-types
|
||||
exec: (status: Character.Status, statusmsg: string = '') => core.connection.send('STA', {status, statusmsg}),
|
||||
params: [{type: ParamType.Enum, options: userStatuses}, {type: ParamType.String, optional: true}]
|
||||
},
|
||||
ad: {
|
||||
exec: (conv: ChannelConversation, message: string) =>
|
||||
core.connection.send('LRP', {channel: conv.channel.id, message}),
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
roll: {
|
||||
exec: (conv: ChannelConversation | PrivateConversation, dice: string) => {
|
||||
if(Conversation.isChannel(conv)) core.connection.send('RLL', {channel: conv.channel.id, dice});
|
||||
else core.connection.send('RLL', {recipient: conv.character.name, dice});
|
||||
},
|
||||
context: CommandContext.Channel | CommandContext.Private,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
bottle: {
|
||||
exec: (conv: ChannelConversation | PrivateConversation) => {
|
||||
if(Conversation.isChannel(conv)) core.connection.send('RLL', {channel: conv.channel.id, dice: 'bottle'});
|
||||
else core.connection.send('RLL', {recipient: conv.character.name, dice: 'bottle'});
|
||||
},
|
||||
context: CommandContext.Channel | CommandContext.Private
|
||||
},
|
||||
warn: {
|
||||
exec: () => 'stub',
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
kick: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('CKU', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
ban: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('CBU', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
unban: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('CUB', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
banlist: {
|
||||
exec: (conv: ChannelConversation) => core.connection.send('CBL', {channel: conv.channel.id}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel
|
||||
},
|
||||
timeout: {
|
||||
exec: (conv: ChannelConversation, character: string, length: number) =>
|
||||
core.connection.send('CTU', {channel: conv.channel.id, character, length}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character, delimiter: ','}, {type: ParamType.Number, validator: (x) => x >= 1}]
|
||||
},
|
||||
gkick: {
|
||||
exec: (character: string) => core.connection.send('KIK', {character}),
|
||||
permission: Permission.ChatOp,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
gban: {
|
||||
exec: (character: string) => core.connection.send('ACB', {character}),
|
||||
permission: Permission.ChatOp,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
gunban: {
|
||||
exec: (character: string) => core.connection.send('UNB', {character}),
|
||||
permission: Permission.ChatOp,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
gtimeout: {
|
||||
exec: (character: string, time: number, reason: string) =>
|
||||
core.connection.send('TMO', {character, time, reason}),
|
||||
permission: Permission.ChatOp,
|
||||
params: [{type: ParamType.Character, delimiter: ','}, {type: ParamType.Number, validator: (x) => x >= 1}, {type: ParamType.String}]
|
||||
},
|
||||
setowner: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('CSO', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
ignore: {
|
||||
exec: (character: string) => core.connection.send('IGN', {action: 'add', character}),
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
unignore: {
|
||||
exec: (character: string) => core.connection.send('IGN', {action: 'delete', character}),
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
ignorelist: {
|
||||
exec: () => core.conversations.selectedConversation.infoText = l('chat.ignoreList', core.characters.ignoreList.join(', '))
|
||||
},
|
||||
makeroom: {
|
||||
exec: (channel: string) => core.connection.send('CCR', {channel}),
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
gop: {
|
||||
exec: (character: string) => core.connection.send('AOP', {character}),
|
||||
permission: Permission.Admin,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
gdeop: {
|
||||
exec: (character: string) => core.connection.send('DOP', {character}),
|
||||
permission: Permission.Admin,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
op: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('COA', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
deop: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('COR', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
oplist: {
|
||||
exec: (conv: ChannelConversation) => core.connection.send('COL', {channel: conv.channel.id}),
|
||||
context: CommandContext.Channel
|
||||
},
|
||||
invite: {
|
||||
exec: (conv: ChannelConversation, character: string) =>
|
||||
core.connection.send('CIU', {channel: conv.channel.id, character}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Character}]
|
||||
},
|
||||
closeroom: {
|
||||
exec: (conv: ChannelConversation) => {
|
||||
core.connection.send('RST', {channel: conv.channel.id, status: 'private'});
|
||||
core.connection.send('ORS');
|
||||
},
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel
|
||||
},
|
||||
openroom: {
|
||||
exec: (conv: ChannelConversation) => {
|
||||
core.connection.send('RST', {channel: conv.channel.id, status: 'public'});
|
||||
core.connection.send('ORS');
|
||||
},
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel
|
||||
},
|
||||
setmode: {
|
||||
exec: (conv: ChannelConversation, mode: 'ads' | 'chat' | 'both') =>
|
||||
core.connection.send('RMO', {channel: conv.channel.id, mode}),
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.Enum, options: ['ads', 'chat', 'both']}]
|
||||
},
|
||||
setdescription: {
|
||||
exec: (conv: ChannelConversation, description: string) =>
|
||||
core.connection.send('CDS', {channel: conv.channel.id, description}),
|
||||
permission: Permission.RoomOp,
|
||||
context: CommandContext.Channel,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
code: {
|
||||
exec: (conv: ChannelConversation) => {
|
||||
const active = <HTMLElement>document.activeElement;
|
||||
const elm = document.createElement('textarea');
|
||||
elm.value = `[session=${conv.channel.name}]${conv.channel.id}[/session]`;
|
||||
document.body.appendChild(elm);
|
||||
elm.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(elm);
|
||||
active.focus();
|
||||
conv.infoText = l('commands.code.success');
|
||||
},
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel
|
||||
},
|
||||
killchannel: {
|
||||
exec: (conv: ChannelConversation) => core.connection.send('KIC', {channel: conv.channel.id}),
|
||||
permission: Permission.RoomOwner,
|
||||
context: CommandContext.Channel
|
||||
},
|
||||
createchannel: {
|
||||
exec: (channel: string) => core.connection.send('CRC', {channel}),
|
||||
permission: Permission.ChatOp,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
broadcast: {
|
||||
exec: (message: string) => core.connection.send('BRO', {message}),
|
||||
permission: Permission.Admin,
|
||||
params: [{type: ParamType.String}]
|
||||
},
|
||||
reloadconfig: {
|
||||
exec: (save?: 'save') => core.connection.send('RLD', save !== undefined ? {save} : undefined),
|
||||
permission: Permission.Admin,
|
||||
params: [{type: ParamType.Enum, options: ['save'], optional: true}]
|
||||
},
|
||||
xyzzy: {
|
||||
exec: (command: string, arg: string) => core.connection.send('ZZZ', {command, arg}),
|
||||
permission: Permission.Admin,
|
||||
params: [{type: ParamType.String, delimiter: ' '}, {type: ParamType.String}]
|
||||
},
|
||||
elf: {
|
||||
exec: () => core.conversations.selectedConversation.infoText =
|
||||
'Now no one can say there\'s "not enough Elf." It\'s a well-kept secret, but elves love headpets. You should try it sometime.',
|
||||
documented: false
|
||||
}
|
||||
};
|
||||
|
||||
export default commands;
|
57
chat/user_view.ts
Normal file
57
chat/user_view.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// TODO convert this to single-file once Vue supports it for functional components.
|
||||
//template:
|
||||
//<span class="gender" :class="genderClass" @click="click" @contextmenu.prevent="showMenu" style="cursor:pointer;" ref="main"><span
|
||||
//class="fa" :class="statusIcon"></span> <span class="fa" :class="rankIcon"></span>{{character.name}}</span>
|
||||
|
||||
import Vue, {CreateElement, RenderContext, VNode} from 'vue';
|
||||
import {Channel, Character} from './interfaces';
|
||||
|
||||
export function getStatusIcon(status: Character.Status): string {
|
||||
switch(status) {
|
||||
case 'online':
|
||||
return 'fa-user-o';
|
||||
case 'looking':
|
||||
return 'fa-eye';
|
||||
case 'dnd':
|
||||
return 'fa-minus-circle';
|
||||
case 'offline':
|
||||
return 'fa-ban';
|
||||
case 'away':
|
||||
return 'fa-circle-o';
|
||||
case 'busy':
|
||||
return 'fa-cog';
|
||||
case 'idle':
|
||||
return 'fa-hourglass';
|
||||
case 'crown':
|
||||
return 'fa-birthday-cake';
|
||||
}
|
||||
}
|
||||
|
||||
//tslint:disable-next-line:variable-name
|
||||
const UserView = Vue.extend({
|
||||
functional: true,
|
||||
render(this: Vue, createElement: CreateElement, context?: RenderContext): VNode {
|
||||
const props = <{character: Character, channel?: Channel, showStatus?: true}>(
|
||||
/*tslint:disable-next-line:no-unsafe-any*///false positive
|
||||
context !== undefined && context.props !== undefined ? context.props : 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-play') : '';
|
||||
else rankIcon = '';
|
||||
} else rankIcon = '';
|
||||
|
||||
const html = (props.showStatus !== undefined ? `<span class="fa fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
|
||||
(rankIcon !== '' ? `<span class="fa ${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}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default UserView;
|
46
chat/vue-raven.ts
Normal file
46
chat/vue-raven.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {RavenStatic} from 'raven-js';
|
||||
import Vue from 'vue';
|
||||
|
||||
/*tslint:disable:no-unsafe-any no-any*///hack
|
||||
function formatComponentName(vm: any): string {
|
||||
if(vm.$root === vm) return '<root instance>';
|
||||
const name = vm._isVue
|
||||
? vm.$options.name || vm.$options._componentTag
|
||||
: vm.name;
|
||||
return (name ? `component <${name}>` : 'anonymous component') + (vm._isVue && vm.$options.__file ? ` at ${vm.$options.__file}` : '');
|
||||
}
|
||||
//tslint:enable
|
||||
|
||||
/*tslint:disable:no-unbound-method strict-type-predicates*///hack
|
||||
export default function VueRaven(this: void, raven: RavenStatic): RavenStatic {
|
||||
if(typeof Vue.config !== 'object') return raven;
|
||||
const oldOnError = Vue.config.errorHandler;
|
||||
Vue.config.errorHandler = (error: Error, vm: Vue, info: string): void => {
|
||||
raven.captureException(error, {
|
||||
extra: {
|
||||
componentName: formatComponentName(vm),
|
||||
//propsData: vm.$options.propsData,
|
||||
info
|
||||
}
|
||||
});
|
||||
|
||||
if(typeof oldOnError === 'function') oldOnError.call(this, error, vm);
|
||||
else console.log(error);
|
||||
};
|
||||
|
||||
const oldOnWarn = Vue.config.warnHandler;
|
||||
Vue.config.warnHandler = (message: string, vm: Vue, trace: string): void => {
|
||||
raven.captureMessage(message + trace, {
|
||||
extra: {
|
||||
componentName: formatComponentName(vm)
|
||||
//propsData: vm.$options.propsData
|
||||
}
|
||||
});
|
||||
console.warn(`${message}: ${trace}`);
|
||||
if(typeof oldOnWarn === 'function')
|
||||
oldOnWarn.call(this, message, vm, trace);
|
||||
};
|
||||
|
||||
return raven;
|
||||
}
|
||||
//tslint:enable
|
110
components/FilterableSelect.vue
Normal file
110
components/FilterableSelect.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class FilterableSelect extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop()
|
||||
readonly placeholder?: string;
|
||||
@Prop({required: true})
|
||||
readonly options: object[];
|
||||
@Prop({default: () => ((filter: RegExp, value: string) => filter.test(value))})
|
||||
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);
|
||||
|
||||
@Watch('value')
|
||||
watchValue(newValue: object | object[] | null): void {
|
||||
this.selected = newValue;
|
||||
}
|
||||
|
||||
select(item: object): void {
|
||||
if(this.multiple !== undefined) {
|
||||
const selected = <object[]>this.selected;
|
||||
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');
|
||||
}
|
||||
this.$emit('input', this.selected);
|
||||
}
|
||||
|
||||
get filtered(): object[] {
|
||||
return this.options.filter((x) => this.filterFunc(this.filterRegex, x));
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
return this.multiple !== undefined ? `${this.title} - ${(<object[]>this.selected).length}` :
|
||||
(this.selected !== null ? this.selected.toString() : this.title);
|
||||
}
|
||||
|
||||
get filterRegex(): RegExp {
|
||||
return new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.filterable-select {
|
||||
ul.dropdown-menu {
|
||||
padding: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
position: static;
|
||||
display: block;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
}
|
||||
button {
|
||||
display: flex;
|
||||
text-align: left
|
||||
}
|
||||
}
|
||||
</style>
|
114
components/Modal.vue
Normal file
114
components/Modal.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div tabindex="-1" class="modal flex-modal" :style="isShown ? 'display:flex' : ''"
|
||||
style="align-items: flex-start; padding: 30px; justify-content: center;">
|
||||
<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;">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
|
||||
<h4 class="modal-title">{{action}}</h4>
|
||||
</div>
|
||||
<div class="modal-body form-horizontal" style="overflow: auto; display: flex; flex-direction: column">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="modal-footer" v-if="buttons">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" v-if="showCancel">Cancel</button>
|
||||
<button type="button" class="btn" :class="buttonClass" @click="submit" :disabled="disabled">
|
||||
{{submitText}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 Modal extends Vue {
|
||||
@Prop({required: true})
|
||||
readonly action: string;
|
||||
@Prop()
|
||||
readonly dialogClass?: {string: boolean};
|
||||
@Prop({default: true})
|
||||
readonly buttons: boolean;
|
||||
@Prop({default: () => ({'btn-primary': true})})
|
||||
readonly buttonClass: {string: boolean};
|
||||
@Prop()
|
||||
readonly disabled?: boolean;
|
||||
@Prop({default: true})
|
||||
readonly showCancel: boolean;
|
||||
@Prop()
|
||||
readonly buttonText?: string;
|
||||
isShown = false;
|
||||
element: JQuery;
|
||||
|
||||
get submitText(): string {
|
||||
return this.buttonText !== undefined ? this.buttonText : this.action;
|
||||
}
|
||||
|
||||
submit(e: Event): void {
|
||||
this.$emit('submit', e);
|
||||
if(!e.defaultPrevented) this.hide();
|
||||
}
|
||||
|
||||
/*tslint:disable-next-line:typedef*///https://github.com/palantir/tslint/issues/711
|
||||
show(keepOpen = false): void {
|
||||
if(keepOpen) this.element.on('hide.bs.modal', (e) => e.preventDefault());
|
||||
this.element.modal('show');
|
||||
this.isShown = true;
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.element.off('hide.bs.modal');
|
||||
this.element.modal('hide');
|
||||
this.isShown = false;
|
||||
}
|
||||
|
||||
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 {
|
||||
$(document).off('focusin.bs.modal');
|
||||
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';
|
||||
});
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
this.element = $(this.$el);
|
||||
this.element.on('shown.bs.modal', () => this.$emit('open'));
|
||||
this.element.on('hidden.bs.modal', () => this.$emit('close'));
|
||||
}
|
||||
|
||||
beforeDestroy(): void {
|
||||
if(this.isShown) this.hide();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.flex-modal .modal-body > .form-group {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
12
components/custom_dialog.ts
Normal file
12
components/custom_dialog.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import Vue from 'vue';
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
export default class CustomDialog extends Vue {
|
||||
show(): void {
|
||||
(<Modal>this.$children[0]).show();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
(<Modal>this.$children[0]).hide();
|
||||
}
|
||||
}
|
135
cordova/Index.vue
Normal file
135
cordova/Index.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<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">
|
||||
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
|
||||
{{l(loggingIn ? 'login.working' : 'login.submit')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Axios from 'axios';
|
||||
import * as qs from 'qs';
|
||||
import * as Raven from 'raven-js';
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import Chat from '../chat/Chat.vue';
|
||||
import Connection from '../chat/connection';
|
||||
import core, {init as initCore} from '../chat/core';
|
||||
import l from '../chat/localize';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
|
||||
import Notifications from './notifications';
|
||||
|
||||
@Component({
|
||||
components: {chat: Chat, modal: Modal}
|
||||
})
|
||||
export default class Index extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
showAdvanced = false;
|
||||
saveLogin = false;
|
||||
loggingIn = false;
|
||||
characters: ReadonlyArray<string> | null = null;
|
||||
error = '';
|
||||
defaultCharacter: string | null = null;
|
||||
settingsStore = new SettingsStore();
|
||||
l = l;
|
||||
settings: GeneralSettings | null = null;
|
||||
importProgress = 0;
|
||||
|
||||
async created(): Promise<void> {
|
||||
let settings = await getGeneralSettings();
|
||||
if(settings === undefined) settings = new GeneralSettings();
|
||||
if(settings.account.length > 0) this.saveLogin = true;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
get styling(): string {
|
||||
//tslint:disable-next-line:no-require-imports
|
||||
return `<style>${require(`../less/themes/chat/${this.settings!.theme}.less`)}</style>`;
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if(this.loggingIn) return;
|
||||
this.loggingIn = true;
|
||||
try {
|
||||
const data = <{ticket?: string, error: string, characters: string[], default_character: string}>
|
||||
(await Axios.post('https://www.f-list.net/json/getApiTicket.php',
|
||||
qs.stringify({account: this.settings!.account, password: this.settings!.password, no_friends: true, no_bookmarks: true})
|
||||
)).data;
|
||||
if(data.error !== '') {
|
||||
this.error = data.error;
|
||||
return;
|
||||
}
|
||||
if(this.saveLogin)
|
||||
await setGeneralSettings(this.settings!);
|
||||
const connection = new Connection(this.settings!.host, this.settings!.account, this.getTicket.bind(this));
|
||||
connection.onEvent('connected', () => Raven.setUserContext({username: core.connection.character}));
|
||||
connection.onEvent('closed', () => Raven.setUserContext());
|
||||
initCore(connection, Logs, SettingsStore, Notifications);
|
||||
this.characters = data.characters.sort();
|
||||
this.defaultCharacter = data.default_character;
|
||||
} catch(e) {
|
||||
this.error = l('login.error');
|
||||
if(process.env.NODE_ENV !== 'production') throw e;
|
||||
} finally {
|
||||
this.loggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getTicket(): Promise<string> {
|
||||
const data = <{ticket?: string, error: string}>(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify({
|
||||
account: this.settings!.account,
|
||||
password: this.settings!.password,
|
||||
no_friends: true,
|
||||
no_bookmarks: true,
|
||||
no_characters: true
|
||||
}))).data;
|
||||
if(data.ticket !== undefined) return data.ticket;
|
||||
throw new Error(data.error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #page {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
61
cordova/chat.ts
Normal file
61
cordova/chat.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2017 F-List
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* This license header applies to this file and all of the non-third-party assets it includes.
|
||||
* @file The entry point for the Cordova version of F-Chat 3.0.
|
||||
* @copyright 2017 F-List
|
||||
* @author Maya Wolf <maya@f-list.net>
|
||||
* @version 3.0
|
||||
* @see {@link https://github.com/f-list/exported|GitHub repo}
|
||||
*/
|
||||
import 'bootstrap/js/dropdown.js';
|
||||
import 'bootstrap/js/modal.js';
|
||||
import * as Raven from 'raven-js';
|
||||
import Vue from 'vue';
|
||||
import VueRaven from '../chat/vue-raven';
|
||||
import {init as fsInit} from './filesystem';
|
||||
import Index from './Index.vue';
|
||||
|
||||
if(process.env.NODE_ENV === 'production') {
|
||||
Raven.config('https://af3e6032460e418cb794b1799e536f37@sentry.newtsin.space/2', {
|
||||
release: `android-${require('./package.json').version}`, //tslint:disable-line:no-require-imports no-unsafe-any
|
||||
dataCallback: (data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
|
||||
data.culprit = `~${data.culprit.substr(data.culprit.lastIndexOf('/'))}`;
|
||||
for(const ex of data.exception.values)
|
||||
for(const frame of ex.stacktrace.frames) {
|
||||
const index = frame.filename.lastIndexOf('/');
|
||||
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
|
||||
}
|
||||
}
|
||||
}).addPlugin(VueRaven, Vue).install();
|
||||
(<Window & {onunhandledrejection(e: PromiseRejectionEvent): void}>window).onunhandledrejection = (e: PromiseRejectionEvent) => {
|
||||
Raven.captureException(<Error>e.reason);
|
||||
};
|
||||
}
|
||||
|
||||
fsInit().then(() => { //tslint:disable-line:no-floating-promises
|
||||
new Index({ //tslint:disable-line:no-unused-expression
|
||||
el: '#app'
|
||||
});
|
||||
});
|
27
cordova/config.xml
Normal file
27
cordova/config.xml
Normal file
@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<widget id="net.f_list.fchat" version="3.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<name>F-Chat 3.0</name>
|
||||
<description>
|
||||
A cross-platform F-Chat client.
|
||||
</description>
|
||||
<author email="maya@f-list.net" href="https://www.f-list.net">The F-list Team</author>
|
||||
<content src="index.html" />
|
||||
<access origin="*" />
|
||||
<allow-intent href="http://*/*" />
|
||||
<allow-intent href="https://*/*" />
|
||||
<allow-intent href="tel:*" />
|
||||
<allow-intent href="sms:*" />
|
||||
<allow-intent href="mailto:*" />
|
||||
<allow-intent href="geo:*" />
|
||||
<platform name="android">
|
||||
<allow-intent href="market:*" />
|
||||
</platform>
|
||||
<platform name="ios">
|
||||
<allow-intent href="itms:*" />
|
||||
<allow-intent href="itms-apps:*" />
|
||||
</platform>
|
||||
<engine name="android" spec="^6.2.3" />
|
||||
<plugin name="cordova-plugin-file" spec="^4.3.3" />
|
||||
<plugin name="cordova-plugin-whitelist" spec="^1.3.2" />
|
||||
<plugin name="de.appplant.cordova.plugin.local-notification" spec="^0.8.5" />
|
||||
</widget>
|
262
cordova/filesystem.ts
Normal file
262
cordova/filesystem.ts
Normal file
@ -0,0 +1,262 @@
|
||||
import {getByteLength, Message as MessageImpl} from '../chat/common';
|
||||
import core from '../chat/core';
|
||||
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
|
||||
declare global {
|
||||
class TextEncoder {
|
||||
readonly encoding: string;
|
||||
|
||||
encode(input?: string, options?: {stream: boolean}): Uint8Array;
|
||||
}
|
||||
|
||||
class TextDecoder {
|
||||
readonly encoding: string;
|
||||
readonly fatal: boolean;
|
||||
readonly ignoreBOM: boolean;
|
||||
|
||||
constructor(utfLabel?: string, options?: {fatal?: boolean, ignoreBOM?: boolean})
|
||||
|
||||
decode(input?: ArrayBufferView, options?: {stream: boolean}): string;
|
||||
}
|
||||
}
|
||||
|
||||
const dayMs = 86400000;
|
||||
let fs: FileSystem;
|
||||
|
||||
export class GeneralSettings {
|
||||
account = '';
|
||||
password = '';
|
||||
host = 'wss://chat.f-list.net:9799';
|
||||
theme = 'dark';
|
||||
}
|
||||
|
||||
type Index = {[key: string]: {name: string, index: {[key: number]: number | undefined}} | undefined};
|
||||
|
||||
/*tslint:disable:promise-function-async*///all of these are simple wrappers
|
||||
export function init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
document.addEventListener('deviceready', () => {
|
||||
window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, (f) => {
|
||||
fs = f;
|
||||
resolve();
|
||||
}, reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readAsString(file: Blob): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(<string>reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function readAsArrayBuffer(file: Blob): Promise<ArrayBuffer> {
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(<ArrayBuffer>reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
function getFile(root: DirectoryEntry, path: string): Promise<File | undefined> {
|
||||
return new Promise<File | undefined>((resolve, reject) => {
|
||||
root.getFile(path, {create: false}, (entry) => entry.file((file) => {
|
||||
resolve(file);
|
||||
}, reject), (e) => {
|
||||
if(e.code === FileError.NOT_FOUND_ERR) resolve(undefined);
|
||||
else reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getWriter(root: DirectoryEntry, path: string): Promise<FileWriter> {
|
||||
return new Promise<FileWriter>((resolve, reject) => root.getFile(path, {create: true},
|
||||
(file) => file.createWriter(resolve, reject), reject));
|
||||
}
|
||||
|
||||
function getDir(root: DirectoryEntry, name: string): Promise<DirectoryEntry> {
|
||||
return new Promise<DirectoryEntry>((resolve, reject) => root.getDirectory(name, {create: true}, resolve, reject));
|
||||
}
|
||||
|
||||
function getEntries(root: DirectoryEntry): Promise<ReadonlyArray<Entry>> {
|
||||
const reader = root.createReader();
|
||||
return new Promise<ReadonlyArray<Entry>>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
}
|
||||
|
||||
//tslib:enable
|
||||
|
||||
function serializeMessage(message: Conversation.Message): Blob {
|
||||
const name = message.type !== Conversation.Message.Type.Event ? message.sender.name : '';
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const dv = new DataView(buffer);
|
||||
dv.setUint32(0, message.time.getTime() / 1000);
|
||||
dv.setUint8(4, message.type);
|
||||
const senderLength = getByteLength(name);
|
||||
dv.setUint8(5, senderLength);
|
||||
const textLength = getByteLength(message.text);
|
||||
dv.setUint16(6, textLength);
|
||||
return new Blob([buffer, name, message.text, String.fromCharCode(senderLength + textLength + 10)]);
|
||||
}
|
||||
|
||||
function deserializeMessage(buffer: ArrayBuffer): {message: Conversation.Message, end: number} {
|
||||
const dv = new DataView(buffer, 0, 8);
|
||||
const time = dv.getUint32(0);
|
||||
const type = dv.getUint8(4);
|
||||
const senderLength = dv.getUint8(5);
|
||||
const messageLength = dv.getUint16(6);
|
||||
let index = 8;
|
||||
const sender = decoder.decode(new DataView(buffer, index, senderLength));
|
||||
index += senderLength;
|
||||
const text = decoder.decode(new DataView(buffer, index, messageLength));
|
||||
return {message: new MessageImpl(type, core.characters.get(sender), text, new Date(time)), end: index + messageLength + 2};
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf8');
|
||||
|
||||
export class Logs implements Logging.Persistent {
|
||||
private index: Index = {};
|
||||
private logDir: DirectoryEntry;
|
||||
|
||||
constructor() {
|
||||
core.connection.onEvent('connecting', async() => {
|
||||
this.index = {};
|
||||
const charDir = await getDir(fs.root, core.connection.character);
|
||||
this.logDir = await getDir(charDir, 'logs');
|
||||
const entries = await getEntries(this.logDir);
|
||||
for(const entry of entries)
|
||||
if(entry.name.substr(-4) === '.idx') {
|
||||
const file = await new Promise<File>((s, j) => (<FileEntry>entry).file(s, j));
|
||||
const buffer = await readAsArrayBuffer(file);
|
||||
const dv = new DataView(buffer);
|
||||
let offset = dv.getUint8(0);
|
||||
const name = decoder.decode(new DataView(buffer, 1, offset++));
|
||||
const index: {[key: number]: number} = {};
|
||||
for(; offset < dv.byteLength; offset += 7) {
|
||||
const key = dv.getUint16(offset);
|
||||
index[key] = dv.getUint32(offset + 2) << 8 | dv.getUint8(offset + 6);
|
||||
}
|
||||
this.index[entry.name.slice(0, -4).toLowerCase()] = {name, index};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.logDir.getFile(conversation.key, {create: true}, (file) => {
|
||||
const serialized = serializeMessage(message);
|
||||
const date = Math.floor(message.time.getTime() / dayMs);
|
||||
let indexBuffer: {}[] | undefined;
|
||||
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)
|
||||
file.getMetadata((data) => {
|
||||
index!.index[date] = data.size;
|
||||
const dv = new DataView(new ArrayBuffer(7));
|
||||
dv.setUint16(0, date);
|
||||
dv.setUint32(2, data.size >> 8);
|
||||
dv.setUint8(6, data.size % 256);
|
||||
indexBuffer!.push(dv);
|
||||
this.logDir.getFile(`${conversation.key}.idx`, {create: true}, (indexFile) => {
|
||||
indexFile.createWriter((writer) => writer.write(new Blob(indexBuffer)), reject);
|
||||
}, reject);
|
||||
}, reject);
|
||||
file.createWriter((writer) => writer.write(serialized), reject);
|
||||
resolve();
|
||||
}, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async getBacklog(conversation: Conversation): Promise<Conversation.Message[]> {
|
||||
const file = await getFile(this.logDir, conversation.key);
|
||||
if(file === undefined) return [];
|
||||
let count = 20;
|
||||
let messages = new Array<Conversation.Message>(count);
|
||||
let pos = file.size;
|
||||
while(pos > 0 && count > 0) {
|
||||
const length = new DataView(await readAsArrayBuffer(file)).getUint16(0);
|
||||
pos = pos - length - 2;
|
||||
messages[--count] = deserializeMessage(await readAsArrayBuffer(file.slice(pos, pos + length))).message;
|
||||
}
|
||||
if(count !== 0) messages = messages.slice(count);
|
||||
return messages;
|
||||
}
|
||||
|
||||
async getLogs(key: string, date: Date): Promise<Conversation.Message[]> {
|
||||
const file = await getFile(this.logDir, key);
|
||||
if(file === undefined) return [];
|
||||
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 [];
|
||||
while(pos < file.size) {
|
||||
const deserialized = deserializeMessage(await readAsArrayBuffer(file.slice(pos, pos + 51000)));
|
||||
if(Math.floor(deserialized.message.time.getTime() / dayMs) !== day) break;
|
||||
messages.push(deserialized.message);
|
||||
pos += deserialized.end;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
getLogDates(key: string): ReadonlyArray<Date> {
|
||||
const entry = this.index[key];
|
||||
if(entry === undefined) return [];
|
||||
const dates = [];
|
||||
for(const date in entry.index) //tslint:disable-line:forin
|
||||
dates.push(new Date(parseInt(date, 10) * dayMs));
|
||||
return dates;
|
||||
}
|
||||
|
||||
get conversations(): ReadonlyArray<{id: string, name: string}> {
|
||||
const conversations: {id: string, name: string}[] = [];
|
||||
for(const key in this.index) conversations.push({id: key, name: this.index[key]!.name});
|
||||
conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
|
||||
return conversations;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
|
||||
const file = await getFile(fs.root, 'settings');
|
||||
if(file === undefined) return undefined;
|
||||
return <GeneralSettings>JSON.parse(await readAsString(file));
|
||||
}
|
||||
|
||||
export async function setGeneralSettings(value: GeneralSettings): Promise<void> {
|
||||
const writer = await getWriter(fs.root, 'settings');
|
||||
writer.write(new Blob([JSON.stringify(value)]));
|
||||
}
|
||||
|
||||
async function getSettingsDir(character: string = core.connection.character): Promise<DirectoryEntry> {
|
||||
return new Promise<DirectoryEntry>((resolve, reject) => {
|
||||
fs.root.getDirectory(character, {create: true}, resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
export class SettingsStore implements Settings.Store {
|
||||
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
|
||||
const dir = await getSettingsDir(character);
|
||||
const file = await getFile(dir, key);
|
||||
if(file === undefined) return undefined;
|
||||
return <Settings.Keys[K]>JSON.parse(await readAsString(file));
|
||||
}
|
||||
|
||||
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
|
||||
const writer = await getWriter(await getSettingsDir(), key);
|
||||
writer.write(new Blob([JSON.stringify(value)]));
|
||||
}
|
||||
|
||||
async getAvailableCharacters(): Promise<string[]> {
|
||||
return (await getEntries(fs.root)).filter((x) => x.isDirectory).map((x) => x.name);
|
||||
}
|
||||
}
|
14
cordova/index.html
Normal file
14
cordova/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, target-densitydpi=medium-dpi, user-scalable=0" />
|
||||
<title>FChat 3.0</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
</div>
|
||||
<script type="text/javascript" src="cordova.js"></script>
|
||||
<script type="text/javascript" src="chat.js"></script>
|
||||
</body>
|
||||
</html>
|
66
cordova/notifications.ts
Normal file
66
cordova/notifications.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import core from '../chat/core';
|
||||
import {Conversation} from '../chat/interfaces';
|
||||
import BaseNotifications from '../chat/notifications'; //tslint:disable-line:match-default-export-name
|
||||
|
||||
//tslint:disable
|
||||
declare global {
|
||||
interface Options {
|
||||
id?: number
|
||||
title?: string
|
||||
text?: string
|
||||
every?: string
|
||||
at?: Date | null
|
||||
badge?: number
|
||||
sound?: string
|
||||
data?: any
|
||||
icon?: string
|
||||
smallIcon?: string
|
||||
ongoing?: boolean
|
||||
led?: string
|
||||
}
|
||||
|
||||
interface CordovaPlugins {
|
||||
notification: {
|
||||
local: {
|
||||
getDefaults(): Options
|
||||
setDefaults(options: Options): void
|
||||
schedule(notification: Options, callback?: Function, scope?: Object, args?: {skipPermissions: boolean}): void
|
||||
update(notification: Options, callback?: Function, scope?: Object, args?: {skipPermissions: boolean}): void
|
||||
clear(ids: string, callback?: Function, scope?: Object): void
|
||||
clearAll(callback?: Function, scope?: Object): void
|
||||
cancel(ids: string, callback?: Function, scope?: Object): void
|
||||
cancelAll(callback?: Function, scope?: Object): void
|
||||
isPresent(id: string, callback?: Function, scope?: Object): void
|
||||
isTriggered(id: string, callback?: Function, scope?: Object): void
|
||||
getAllIds(callback?: Function, scope?: Object): void
|
||||
getScheduledIds(callback?: Function, scope?: Object): void
|
||||
getTriggeredIds(callback?: Function, scope?: Object): void
|
||||
get(ids?: number[], callback?: Function, scope?: Object): void
|
||||
getScheduled(ids?: number[], callback?: Function, scope?: Object): void
|
||||
getTriggered(ids?: number[], callback?: Function, scope?: Object): void
|
||||
hasPermission(callback?: Function, scope?: Object): void
|
||||
registerPermission(callback?: Function, scope?: Object): void
|
||||
on(event: 'schedule' | 'update' | 'click' | 'trigger', handler: (notification: Options) => void): void
|
||||
un(event: 'schedule' | 'update' | 'click' | 'trigger', handler: (notification: Options) => void): void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//tslint:enable
|
||||
document.addEventListener('deviceready', () => {
|
||||
cordova.plugins.notification.local.on('click', (notification) => {
|
||||
const conv = core.conversations.byKey((<{conversation: string}>notification.data).conversation);
|
||||
if(conv !== undefined) conv.show();
|
||||
});
|
||||
});
|
||||
|
||||
export default class Notifications extends BaseNotifications {
|
||||
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
|
||||
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
|
||||
this.playSound(sound);
|
||||
if(core.state.settings.notifications)
|
||||
cordova.plugins.notification.local.schedule({
|
||||
title, text: body, sound, icon, smallIcon: icon, data: {conversation: conversation.key}
|
||||
});
|
||||
}
|
||||
}
|
36
cordova/package.json
Normal file
36
cordova/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "fchat",
|
||||
"version": "0.1.0",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"cordova": {
|
||||
"plugins": {
|
||||
"cordova-plugin-whitelist": {},
|
||||
"cordova-plugin-file": {},
|
||||
"de.appplant.cordova.plugin.local-notification": {}
|
||||
},
|
||||
"platforms": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "../node_modules/.bin/webpack",
|
||||
"build:dist": "../node_modules/.bin/webpack --env production",
|
||||
"watch": "../node_modules/.bin/webpack --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"cordova-android": "^6.2.3",
|
||||
"cordova-plugin-app-event": "^1.2.1",
|
||||
"cordova-plugin-compat": "^1.0.0",
|
||||
"cordova-plugin-device": "^1.1.6",
|
||||
"cordova-plugin-file": "^4.3.3",
|
||||
"cordova-plugin-whitelist": "^1.3.2",
|
||||
"de.appplant.cordova.plugin.local-notification": "^0.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cordova": "^0.0.34",
|
||||
"qs": "^6.5.0"
|
||||
}
|
||||
}
|
29
cordova/tsconfig.json
Normal file
29
cordova/tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"es5",
|
||||
"scripthost",
|
||||
"es2015.iterable",
|
||||
"es2015.promise"
|
||||
],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowJs": true,
|
||||
"outDir": "build",
|
||||
"noEmitHelpers": true,
|
||||
"importHelpers": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["*.ts", "../**/*.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
73
cordova/webpack.config.js
Normal file
73
cordova/webpack.config.js
Normal file
@ -0,0 +1,73 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const UglifyPlugin = require('uglifyjs-webpack-plugin');
|
||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
|
||||
const exportLoader = require('../export-loader');
|
||||
|
||||
const config = {
|
||||
entry: {
|
||||
chat: [__dirname + '/chat.ts', __dirname + '/index.html']
|
||||
},
|
||||
output: {
|
||||
path: __dirname + '/www',
|
||||
filename: '[name].js'
|
||||
},
|
||||
context: __dirname,
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preLoaders: {ts: 'export-loader'},
|
||||
preserveWhitespace: false
|
||||
}
|
||||
},
|
||||
{test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.(woff|woff2)$/, loader: 'url-loader?prefix=font/&limit=5000'},
|
||||
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?limit=10000&mimetype=application/octet-stream'},
|
||||
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?limit=10000&mimetype=image/svg+xml'},
|
||||
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
|
||||
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
|
||||
{test: /\.less/, use: ['css-loader', 'less-loader']}
|
||||
]
|
||||
},
|
||||
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')}),
|
||||
exportLoader.delayTypecheck
|
||||
],
|
||||
resolve: {
|
||||
'extensions': ['.ts', '.js', '.vue', '.css']
|
||||
},
|
||||
resolveLoader: {
|
||||
modules: [
|
||||
'node_modules', path.join(__dirname, '../')
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = function(env) {
|
||||
const dist = env === 'production';
|
||||
config.plugins.push(new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(dist ? 'production' : 'development')
|
||||
}));
|
||||
if(dist) {
|
||||
config.devtool = 'source-map';
|
||||
config.plugins.push(new UglifyPlugin({sourceMap: true}));
|
||||
}
|
||||
return config;
|
||||
};
|
236
cordova/yarn.lock
Normal file
236
cordova/yarn.lock
Normal file
@ -0,0 +1,236 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@types/cordova@^0.0.34":
|
||||
version "0.0.34"
|
||||
resolved "https://registry.yarnpkg.com/@types/cordova/-/cordova-0.0.34.tgz#ea7addf74ecec3d7629827a0c39e2c9addc73d04"
|
||||
|
||||
abbrev@1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
||||
|
||||
android-versions@^1.2.0:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/android-versions/-/android-versions-1.2.1.tgz#3f50baf693e73a512c3c5403542291cead900063"
|
||||
|
||||
ansi@^0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi/-/ansi-0.3.1.tgz#0c42d4fb17160d5a9af1e484bace1c66922c1b21"
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||
|
||||
base64-js@0.0.8:
|
||||
version "0.0.8"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978"
|
||||
|
||||
big-integer@^1.6.7:
|
||||
version "1.6.25"
|
||||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.25.tgz#1de45a9f57542ac20121c682f8d642220a34e823"
|
||||
|
||||
bplist-parser@^0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.1.1.tgz#d60d5dcc20cba6dc7e1f299b35d3e1f95dafbae6"
|
||||
dependencies:
|
||||
big-integer "^1.6.7"
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.8"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292"
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
|
||||
cordova-android@^6.2.3:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/cordova-android/-/cordova-android-6.3.0.tgz#da5418433d25c75a5977b428244bbe437d0128d2"
|
||||
dependencies:
|
||||
android-versions "^1.2.0"
|
||||
cordova-common "^2.1.0"
|
||||
elementtree "0.1.6"
|
||||
nopt "^3.0.1"
|
||||
properties-parser "^0.2.3"
|
||||
q "^1.4.1"
|
||||
shelljs "^0.5.3"
|
||||
|
||||
cordova-common@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/cordova-common/-/cordova-common-2.1.0.tgz#bb357ee1b9825031ed9db3c56b592efe973d1640"
|
||||
dependencies:
|
||||
ansi "^0.3.1"
|
||||
bplist-parser "^0.1.0"
|
||||
cordova-registry-mapper "^1.1.8"
|
||||
elementtree "0.1.6"
|
||||
glob "^5.0.13"
|
||||
minimatch "^3.0.0"
|
||||
osenv "^0.1.3"
|
||||
plist "^1.2.0"
|
||||
q "^1.4.1"
|
||||
semver "^5.0.1"
|
||||
shelljs "^0.5.3"
|
||||
underscore "^1.8.3"
|
||||
unorm "^1.3.3"
|
||||
|
||||
cordova-plugin-app-event@>=1.1.0, cordova-plugin-app-event@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/cordova-plugin-app-event/-/cordova-plugin-app-event-1.2.1.tgz#0eebb14132aa43bb2e5c081a9abdbd97ca2d8132"
|
||||
|
||||
cordova-plugin-compat@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/cordova-plugin-compat/-/cordova-plugin-compat-1.2.0.tgz#0bc65757276ebd920c012ce920e274177576373e"
|
||||
|
||||
cordova-plugin-device@*, cordova-plugin-device@^1.1.6:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/cordova-plugin-device/-/cordova-plugin-device-1.1.6.tgz#2d21764cad7c9b801523e4e09a30e024b249334b"
|
||||
|
||||
cordova-plugin-file@^4.3.3:
|
||||
version "4.3.3"
|
||||
resolved "https://registry.yarnpkg.com/cordova-plugin-file/-/cordova-plugin-file-4.3.3.tgz#012e97aa1afb91f84916e6341b548366d23de9b9"
|
||||
|
||||
cordova-plugin-whitelist@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/cordova-plugin-whitelist/-/cordova-plugin-whitelist-1.3.2.tgz#5b6335feb9f5301f3c013b9096cb8885bdbd5076"
|
||||
|
||||
cordova-registry-mapper@^1.1.8:
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/cordova-registry-mapper/-/cordova-registry-mapper-1.1.15.tgz#e244b9185b8175473bff6079324905115f83dc7c"
|
||||
|
||||
de.appplant.cordova.plugin.local-notification@^0.8.5:
|
||||
version "0.8.5"
|
||||
resolved "https://registry.yarnpkg.com/de.appplant.cordova.plugin.local-notification/-/de.appplant.cordova.plugin.local-notification-0.8.5.tgz#e0c6a86ea52ac4f41dba67521d91a58a9a42a3bd"
|
||||
dependencies:
|
||||
cordova-plugin-app-event ">=1.1.0"
|
||||
cordova-plugin-device "*"
|
||||
|
||||
elementtree@0.1.6:
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/elementtree/-/elementtree-0.1.6.tgz#2ac4c46ea30516c8c4cbdb5e3ac7418e592de20c"
|
||||
dependencies:
|
||||
sax "0.3.5"
|
||||
|
||||
glob@^5.0.13:
|
||||
version "5.0.15"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
|
||||
dependencies:
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "2 || 3"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
inflight@^1.0.4:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||
dependencies:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
|
||||
|
||||
lodash@^3.5.0:
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
|
||||
|
||||
"minimatch@2 || 3", minimatch@^3.0.0:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
nopt@^3.0.1:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
|
||||
dependencies:
|
||||
abbrev "1"
|
||||
|
||||
once@^1.3.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
os-homedir@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
|
||||
|
||||
os-tmpdir@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||
|
||||
osenv@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644"
|
||||
dependencies:
|
||||
os-homedir "^1.0.0"
|
||||
os-tmpdir "^1.0.0"
|
||||
|
||||
path-is-absolute@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||
|
||||
plist@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/plist/-/plist-1.2.0.tgz#084b5093ddc92506e259f874b8d9b1afb8c79593"
|
||||
dependencies:
|
||||
base64-js "0.0.8"
|
||||
util-deprecate "1.0.2"
|
||||
xmlbuilder "4.0.0"
|
||||
xmldom "0.1.x"
|
||||
|
||||
properties-parser@^0.2.3:
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/properties-parser/-/properties-parser-0.2.3.tgz#f7591255f707abbff227c7b56b637dbb0373a10f"
|
||||
|
||||
q@^1.4.1:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
|
||||
|
||||
qs@^6.5.0:
|
||||
version "6.5.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
|
||||
|
||||
sax@0.3.5:
|
||||
version "0.3.5"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-0.3.5.tgz#88fcfc1f73c0c8bbd5b7c776b6d3f3501eed073d"
|
||||
|
||||
semver@^5.0.1:
|
||||
version "5.4.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"
|
||||
|
||||
shelljs@^0.5.3:
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.5.3.tgz#c54982b996c76ef0c1e6b59fbdc5825f5b713113"
|
||||
|
||||
underscore@^1.8.3:
|
||||
version "1.8.3"
|
||||
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
|
||||
|
||||
unorm@^1.3.3:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/unorm/-/unorm-1.4.1.tgz#364200d5f13646ca8bcd44490271335614792300"
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
|
||||
xmlbuilder@4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.0.0.tgz#98b8f651ca30aa624036f127d11cc66dc7b907a3"
|
||||
dependencies:
|
||||
lodash "^3.5.0"
|
||||
|
||||
xmldom@0.1.x:
|
||||
version "0.1.27"
|
||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
|
352
electron/Index.vue
Normal file
352
electron/Index.vue
Normal file
@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div @mouseover="onMouseOver" id="page" style="position: relative; padding: 10px;">
|
||||
<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="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="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">
|
||||
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
|
||||
{{l(loggingIn ? 'login.working' : 'login.submit')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
|
||||
<div ref="linkPreview" class="link-preview"></div>
|
||||
<modal :action="l('importer.importing')" ref="importModal" :buttons="false">
|
||||
{{l('importer.importingNote')}}
|
||||
<div class="progress" style="margin-top:5px">
|
||||
<div class="progress-bar" :style="{width: importProgress * 100 + '%'}"></div>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Axios from 'axios';
|
||||
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';
|
||||
import {promisify} from 'util';
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import Chat from '../chat/Chat.vue';
|
||||
import {Settings} from '../chat/common';
|
||||
import core, {init as initCore} from '../chat/core';
|
||||
import l from '../chat/localize';
|
||||
import Socket from '../chat/WebSocket';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Connection from '../fchat/connection';
|
||||
import {nativeRequire} from './common';
|
||||
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
|
||||
import * as SlimcatImporter from './importer';
|
||||
import {createAppMenu, createContextMenu} from './menu';
|
||||
import Notifications from './notifications';
|
||||
import * as spellchecker from './spellchecker';
|
||||
|
||||
const webContents = electron.remote.getCurrentWebContents();
|
||||
webContents.on('context-menu', (_, props: Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}) => {
|
||||
const menuTemplate = createContextMenu(props);
|
||||
if(props.misspelledWord !== '') {
|
||||
const corrections = spellchecker.getCorrections(props.misspelledWord);
|
||||
if(corrections.length > 0) {
|
||||
menuTemplate.unshift({type: 'separator'});
|
||||
menuTemplate.unshift(...corrections.map((correction: string) => ({
|
||||
label: correction,
|
||||
click: () => webContents.replaceMisspelling(correction)
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
|
||||
});
|
||||
|
||||
const defaultTrayMenu = [
|
||||
{label: l('action.open'), click: () => mainWindow!.show()},
|
||||
{
|
||||
label: l('action.quit'),
|
||||
click: () => {
|
||||
isClosing = true;
|
||||
mainWindow!.close();
|
||||
mainWindow = undefined;
|
||||
}
|
||||
}
|
||||
];
|
||||
let trayMenu = electron.remote.Menu.buildFromTemplate(defaultTrayMenu);
|
||||
|
||||
let isClosing = false;
|
||||
let mainWindow: Electron.BrowserWindow | undefined = electron.remote.getCurrentWindow(); //TODO
|
||||
//tslint:disable-next-line:no-require-imports
|
||||
const tray = new electron.remote.Tray(path.join(__dirname, <string>require('./build/icon.png')));
|
||||
tray.setToolTip(l('title'));
|
||||
tray.on('click', (_) => mainWindow!.show());
|
||||
tray.setContextMenu(trayMenu);
|
||||
|
||||
/*tslint:disable:no-any*///because this is hacky
|
||||
const keyStore = nativeRequire<{
|
||||
getPassword(account: string): Promise<string>
|
||||
setPassword(account: string, password: string): Promise<void>
|
||||
deletePassword(account: string): Promise<void>
|
||||
[key: string]: (...args: any[]) => Promise<any>
|
||||
}>('keytar/build/Release/keytar.node');
|
||||
for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
|
||||
//tslint:enable
|
||||
|
||||
@Component({
|
||||
components: {chat: Chat, modal: Modal}
|
||||
})
|
||||
export default class Index extends Vue {
|
||||
//tslint:disable:no-null-keyword
|
||||
showAdvanced = false;
|
||||
saveLogin = false;
|
||||
loggingIn = false;
|
||||
account: string;
|
||||
password = '';
|
||||
host: string;
|
||||
characters: string[] | null = null;
|
||||
error = '';
|
||||
defaultCharacter: string | null = null;
|
||||
settings = new SettingsStore();
|
||||
l = l;
|
||||
currentSettings: GeneralSettings;
|
||||
isConnected = false;
|
||||
importProgress = 0;
|
||||
|
||||
constructor(options?: Vue.ComponentOptions<Index>) {
|
||||
super(options);
|
||||
let settings = getGeneralSettings();
|
||||
if(settings === undefined) {
|
||||
if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral')))
|
||||
settings = SlimcatImporter.importGeneral();
|
||||
settings = settings !== undefined ? settings : new GeneralSettings();
|
||||
}
|
||||
this.account = settings.account;
|
||||
this.host = settings.host;
|
||||
this.currentSettings = settings;
|
||||
}
|
||||
|
||||
created(): void {
|
||||
if(this.currentSettings.account.length > 0) {
|
||||
keyStore.getPassword(this.currentSettings.account)
|
||||
.then((value: string) => this.password = value, (err: Error) => this.error = err.message);
|
||||
this.saveLogin = true;
|
||||
}
|
||||
window.onbeforeunload = () => {
|
||||
if(process.env.NODE_ENV !== 'production' || isClosing || !this.isConnected) {
|
||||
tray.destroy();
|
||||
return;
|
||||
}
|
||||
if(!this.currentSettings.closeToTray)
|
||||
return setImmediate(() => {
|
||||
if(confirm(l('chat.confirmLeave'))) {
|
||||
isClosing = true;
|
||||
mainWindow!.close();
|
||||
}
|
||||
});
|
||||
mainWindow!.hide();
|
||||
return false;
|
||||
};
|
||||
|
||||
const appMenu = createAppMenu();
|
||||
const themes = fs.readdirSync(path.join(__dirname, 'themes')).filter((x) => x.substr(-4) === '.css').map((x) => x.slice(0, -4));
|
||||
const setTheme = (theme: string) => {
|
||||
this.currentSettings.theme = theme;
|
||||
setGeneralSettings(this.currentSettings);
|
||||
};
|
||||
const spellcheckerMenu = new electron.remote.Menu();
|
||||
//tslint:disable-next-line:no-floating-promises
|
||||
this.addSpellcheckerItems(spellcheckerMenu);
|
||||
appMenu[0].submenu = [
|
||||
{
|
||||
label: l('settings.closeToTray'), type: 'checkbox', checked: this.currentSettings.closeToTray,
|
||||
click: (item: Electron.MenuItem) => {
|
||||
this.currentSettings.closeToTray = item.checked;
|
||||
setGeneralSettings(this.currentSettings);
|
||||
}
|
||||
},
|
||||
{label: l('settings.spellcheck'), submenu: spellcheckerMenu},
|
||||
{
|
||||
label: l('settings.theme'),
|
||||
submenu: themes.map((x) => ({
|
||||
checked: this.currentSettings.theme === x,
|
||||
click: () => setTheme(x),
|
||||
label: x,
|
||||
type: <'radio'>'radio'
|
||||
}))
|
||||
},
|
||||
{type: 'separator'},
|
||||
{role: 'minimize'},
|
||||
{
|
||||
label: l('action.quit'),
|
||||
click(): void {
|
||||
isClosing = true;
|
||||
mainWindow!.close();
|
||||
}
|
||||
}
|
||||
];
|
||||
electron.remote.Menu.setApplicationMenu(electron.remote.Menu.buildFromTemplate(appMenu));
|
||||
|
||||
let hasUpdate = false;
|
||||
electron.ipcRenderer.on('updater-status', (_: Event, status: string) => {
|
||||
if(status !== 'update-downloaded' || hasUpdate) return;
|
||||
hasUpdate = true;
|
||||
const menu = electron.remote.Menu.getApplicationMenu();
|
||||
menu.append(new electron.remote.MenuItem({
|
||||
label: l('action.updateAvailable'),
|
||||
submenu: electron.remote.Menu.buildFromTemplate([{
|
||||
label: l('action.update'),
|
||||
click: () => {
|
||||
if(!this.isConnected || confirm(l('chat.confirmLeave'))) {
|
||||
isClosing = true;
|
||||
electron.ipcRenderer.send('install-update');
|
||||
}
|
||||
}
|
||||
}])
|
||||
}));
|
||||
electron.remote.Menu.setApplicationMenu(menu);
|
||||
});
|
||||
}
|
||||
|
||||
async addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
|
||||
const dictionaries = await spellchecker.getAvailableDictionaries();
|
||||
const selected = this.currentSettings.spellcheckLang;
|
||||
menu.append(new electron.remote.MenuItem({
|
||||
type: 'radio',
|
||||
label: l('settings.spellcheck.disabled'),
|
||||
click: this.setSpellcheck.bind(this, undefined)
|
||||
}));
|
||||
for(const lang of dictionaries)
|
||||
menu.append(new electron.remote.MenuItem({
|
||||
type: 'radio',
|
||||
label: lang,
|
||||
checked: lang === selected,
|
||||
click: this.setSpellcheck.bind(this, lang)
|
||||
}));
|
||||
electron.webFrame.setSpellCheckProvider('', false, {spellCheck: spellchecker.check});
|
||||
await spellchecker.setDictionary(selected);
|
||||
}
|
||||
|
||||
async setSpellcheck(lang: string | undefined): Promise<void> {
|
||||
this.currentSettings.spellcheckLang = lang;
|
||||
setGeneralSettings(this.currentSettings);
|
||||
await spellchecker.setDictionary(lang);
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if(this.loggingIn) return;
|
||||
this.loggingIn = true;
|
||||
try {
|
||||
if(!this.saveLogin) await keyStore.deletePassword(this.account);
|
||||
const data = <{ticket?: string, error: string, characters: string[], default_character: string}>
|
||||
(await Axios.post('https://www.f-list.net/json/getApiTicket.php',
|
||||
qs.stringify({account: this.account, password: this.password, no_friends: true, no_bookmarks: true}))).data;
|
||||
if(data.error !== '') {
|
||||
this.error = data.error;
|
||||
return;
|
||||
}
|
||||
if(this.saveLogin) {
|
||||
this.currentSettings.account = this.account;
|
||||
await keyStore.setPassword(this.account, this.password);
|
||||
this.currentSettings.host = this.host;
|
||||
setGeneralSettings(this.currentSettings);
|
||||
}
|
||||
Socket.host = this.host;
|
||||
const connection = new Connection(Socket, this.account, this.getTicket.bind(this));
|
||||
connection.onEvent('connecting', async() => {
|
||||
if((await this.settings.get('settings')) === undefined && SlimcatImporter.canImportCharacter(core.connection.character)) {
|
||||
if(!confirm(l('importer.importGeneral'))) return this.settings.set('settings', new Settings());
|
||||
(<Modal>this.$refs['importModal']).show(true);
|
||||
await SlimcatImporter.importCharacter(core.connection.character, (progress) => this.importProgress = progress);
|
||||
(<Modal>this.$refs['importModal']).hide();
|
||||
}
|
||||
});
|
||||
connection.onEvent('connected', () => {
|
||||
this.isConnected = true;
|
||||
tray.setToolTip(document.title = `FChat 3.0 - ${core.connection.character}`);
|
||||
Raven.setUserContext({username: core.connection.character});
|
||||
trayMenu.insert(0, new electron.remote.MenuItem({label: core.connection.character, enabled: false}));
|
||||
trayMenu.insert(1, new electron.remote.MenuItem({type: 'separator'}));
|
||||
});
|
||||
connection.onEvent('closed', () => {
|
||||
this.isConnected = false;
|
||||
tray.setToolTip(document.title = 'FChat 3.0');
|
||||
Raven.setUserContext();
|
||||
tray.setContextMenu(trayMenu = electron.remote.Menu.buildFromTemplate(defaultTrayMenu));
|
||||
});
|
||||
initCore(connection, Logs, SettingsStore, Notifications);
|
||||
this.characters = data.characters.sort();
|
||||
this.defaultCharacter = data.default_character;
|
||||
} catch(e) {
|
||||
this.error = l('login.error');
|
||||
if(process.env.NODE_ENV !== 'production') throw e;
|
||||
} finally {
|
||||
this.loggingIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseOver(e: MouseEvent): void {
|
||||
const preview = (<HTMLDivElement>this.$refs.linkPreview);
|
||||
if((<HTMLElement>e.target).tagName === 'A') {
|
||||
const target = <HTMLAnchorElement>e.target;
|
||||
if(target.hostname !== '') {
|
||||
//tslint:disable-next-line:prefer-template
|
||||
preview.className = 'link-preview ' +
|
||||
(e.clientX < window.innerWidth / 2 && e.clientY > window.innerHeight - 150 ? ' right' : '');
|
||||
preview.textContent = target.href;
|
||||
preview.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
}
|
||||
preview.textContent = '';
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
|
||||
async getTicket(): Promise<string> {
|
||||
const data = <{ticket?: string, error: string}>(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify(
|
||||
{account: this.account, password: this.password, no_friends: true, no_bookmarks: true, no_characters: true}))).data;
|
||||
if(data.ticket !== undefined) return data.ticket;
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
get styling(): string {
|
||||
try {
|
||||
return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.currentSettings.theme}.css`))}</style>`;
|
||||
} catch(e) {
|
||||
if(e.code === 'ENOENT' && this.currentSettings.theme !== 'default') {
|
||||
this.currentSettings.theme = 'default';
|
||||
return this.styling;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #page {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
15
electron/application.json
Normal file
15
electron/application.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "fchat",
|
||||
"version": "0.1.29",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^1.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"keytar": "^4.0.4",
|
||||
"spellchecker": "^3.4.3"
|
||||
}
|
||||
}
|
BIN
electron/build/icon.icns
Normal file
BIN
electron/build/icon.icns
Normal file
Binary file not shown.
BIN
electron/build/icon.ico
Normal file
BIN
electron/build/icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 361 KiB |
BIN
electron/build/icon.png
Normal file
BIN
electron/build/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
67
electron/chat.ts
Normal file
67
electron/chat.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2017 F-List
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* This license header applies to this file and all of the non-third-party assets it includes.
|
||||
* @file The entry point for the Electron renderer of F-Chat 3.0.
|
||||
* @copyright 2017 F-List
|
||||
* @author Maya Wolf <maya@f-list.net>
|
||||
* @version 3.0
|
||||
* @see {@link https://github.com/f-list/exported|GitHub repo}
|
||||
*/
|
||||
import 'bootstrap/js/dropdown.js';
|
||||
import 'bootstrap/js/modal.js';
|
||||
import * as electron from 'electron';
|
||||
import * as Raven from 'raven-js';
|
||||
import Vue from 'vue';
|
||||
import VueRaven from '../chat/vue-raven';
|
||||
import Index from './Index.vue';
|
||||
|
||||
if(process.env.NODE_ENV === 'production') {
|
||||
Raven.config('https://af3e6032460e418cb794b1799e536f37@sentry.newtsin.space/2', {
|
||||
release: electron.remote.app.getVersion(),
|
||||
dataCallback(data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}): void {
|
||||
data.culprit = `~${data.culprit.substr(data.culprit.lastIndexOf('/'))}`;
|
||||
for(const ex of data.exception.values)
|
||||
for(const frame of ex.stacktrace.frames) {
|
||||
const index = frame.filename.lastIndexOf('/');
|
||||
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
|
||||
}
|
||||
}
|
||||
}).addPlugin(VueRaven, Vue).install();
|
||||
(<Window & {onunhandledrejection(e: PromiseRejectionEvent): void}>window).onunhandledrejection = (e: PromiseRejectionEvent) => {
|
||||
Raven.captureException(<Error>e.reason);
|
||||
};
|
||||
}
|
||||
|
||||
//tslint:disable-next-line:no-unused-expression
|
||||
new Index({
|
||||
el: '#app'
|
||||
});
|
||||
|
||||
electron.ipcRenderer.on('focus', (_: Event, message: boolean) => message ? window.focus() : window.blur());
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if(e.which === 123)
|
||||
electron.remote.getCurrentWebContents().toggleDevTools();
|
||||
});
|
31
electron/common.ts
Normal file
31
electron/common.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function mkdir(dir: string): void {
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
} catch(e) {
|
||||
if(!(e instanceof Error)) throw e;
|
||||
switch((<Error & {code: string}>e).code) {
|
||||
case 'ENOENT':
|
||||
mkdir(path.dirname(dir));
|
||||
mkdir(dir);
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
const stat = fs.statSync(dir);
|
||||
if(stat.isDirectory()) return;
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//tslint:disable
|
||||
const Module = require('module');
|
||||
export function nativeRequire<T>(module: string): T {
|
||||
return Module.prototype.require.call({paths: Module._nodeModulePaths(__dirname)}, module);
|
||||
}
|
||||
//tslint:enable
|
233
electron/filesystem.ts
Normal file
233
electron/filesystem.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import * as electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import {Message as MessageImpl} from '../chat/common';
|
||||
import core from '../chat/core';
|
||||
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
import {mkdir} from './common';
|
||||
|
||||
const dayMs = 86400000;
|
||||
const baseDir = path.join(electron.remote.app.getPath('userData'), 'data');
|
||||
mkdir(baseDir);
|
||||
const readFile = promisify(fs.readFile);
|
||||
const writeFile = promisify(fs.writeFile);
|
||||
const readdir = promisify(fs.readdir);
|
||||
const open = promisify(fs.open);
|
||||
const fstat = promisify(fs.fstat);
|
||||
const read = promisify(fs.read);
|
||||
|
||||
const noAssert = process.env.NODE_ENV === 'production';
|
||||
|
||||
export class GeneralSettings {
|
||||
account = '';
|
||||
closeToTray = true;
|
||||
host = 'wss://chat.f-list.net:9799';
|
||||
spellcheckLang: string | undefined = 'en-GB';
|
||||
theme = 'default';
|
||||
}
|
||||
|
||||
export type Message = Conversation.EventMessage | {
|
||||
readonly sender: {readonly name: string}
|
||||
readonly text: string
|
||||
readonly time: Date
|
||||
readonly type: Conversation.Message.Type
|
||||
};
|
||||
|
||||
interface IndexItem {
|
||||
index: {[key: number]: number | undefined}
|
||||
name: string
|
||||
offsets: number[]
|
||||
}
|
||||
|
||||
interface Index {
|
||||
[key: string]: IndexItem | undefined
|
||||
}
|
||||
|
||||
export function getLogDir(this: void, character: string = core.connection.character): string {
|
||||
const dir = path.join(baseDir, character, 'logs');
|
||||
mkdir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getLogFile(this: void, key: string): string {
|
||||
return path.join(getLogDir(), key);
|
||||
}
|
||||
|
||||
export function checkIndex(this: void, index: Index, message: Message, key: string, name: string,
|
||||
size: number | (() => number)): Buffer | undefined {
|
||||
const date = Math.floor(message.time.getTime() / dayMs - message.time.getTimezoneOffset() / 1440);
|
||||
let buffer: Buffer, offset = 0;
|
||||
let item = index[key];
|
||||
if(item !== undefined) {
|
||||
if(item.index[date] !== undefined) return;
|
||||
buffer = Buffer.allocUnsafe(7);
|
||||
} else {
|
||||
index[key] = item = {name, index: {}, offsets: []};
|
||||
const nameLength = Buffer.byteLength(name);
|
||||
buffer = Buffer.allocUnsafe(nameLength + 8);
|
||||
buffer.writeUInt8(nameLength, 0, noAssert);
|
||||
buffer.write(name, 1);
|
||||
offset = nameLength + 1;
|
||||
}
|
||||
const newValue = typeof size === 'function' ? size() : size;
|
||||
item.index[date] = item.offsets.length;
|
||||
item.offsets.push(newValue);
|
||||
buffer.writeUInt16LE(date, offset, noAssert);
|
||||
buffer.writeUIntLE(newValue, offset + 2, 5, noAssert);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function serializeMessage(message: Message): {serialized: Buffer, size: number} {
|
||||
const name = message.type !== Conversation.Message.Type.Event ? message.sender.name : '';
|
||||
const senderLength = Buffer.byteLength(name);
|
||||
const messageLength = Buffer.byteLength(message.text);
|
||||
const buffer = Buffer.allocUnsafe(senderLength + messageLength + 10);
|
||||
buffer.writeUInt32LE(message.time.getTime() / 1000, 0, noAssert);
|
||||
buffer.writeUInt8(message.type, 4, noAssert);
|
||||
buffer.writeUInt8(senderLength, 5, noAssert);
|
||||
buffer.write(name, 6);
|
||||
let offset = senderLength + 6;
|
||||
buffer.writeUInt16LE(messageLength, offset, noAssert);
|
||||
buffer.write(message.text, offset += 2);
|
||||
buffer.writeUInt16LE(offset += messageLength, offset, noAssert);
|
||||
return {serialized: buffer, size: offset + 2};
|
||||
}
|
||||
|
||||
function deserializeMessage(buffer: Buffer): {end: number, message: Conversation.Message} {
|
||||
const time = buffer.readUInt32LE(0, noAssert);
|
||||
const type = buffer.readUInt8(4, noAssert);
|
||||
const senderLength = buffer.readUInt8(5, noAssert);
|
||||
let offset = senderLength + 6;
|
||||
const sender = buffer.toString('utf8', 6, offset);
|
||||
const messageLength = buffer.readUInt16LE(offset, noAssert);
|
||||
offset += 2;
|
||||
const text = buffer.toString('utf8', offset, offset += messageLength);
|
||||
const message = new MessageImpl(type, core.characters.get(sender), text, new Date(time * 1000));
|
||||
return {message, end: offset + 2};
|
||||
}
|
||||
|
||||
export class Logs implements Logging.Persistent {
|
||||
private index: Index = {};
|
||||
|
||||
constructor() {
|
||||
core.connection.onEvent('connecting', () => {
|
||||
this.index = {};
|
||||
const dir = getLogDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
for(const file of files)
|
||||
if(file.substr(-4) === '.idx') {
|
||||
const content = fs.readFileSync(path.join(dir, file));
|
||||
let offset = content.readUInt8(0, noAssert) + 1;
|
||||
const item: IndexItem = {
|
||||
name: content.toString('utf8', 1, offset),
|
||||
index: {},
|
||||
offsets: new Array(content.length - offset)
|
||||
};
|
||||
for(; offset < content.length; offset += 7) {
|
||||
const key = content.readUInt16LE(offset);
|
||||
item.index[key] = item.offsets.length;
|
||||
item.offsets.push(content.readUIntLE(offset + 2, 5));
|
||||
}
|
||||
this.index[file.slice(0, -4).toLowerCase()] = item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
const file = getLogFile(conversation.key);
|
||||
if(!fs.existsSync(file)) return [];
|
||||
let count = 20;
|
||||
let messages = new Array<Conversation.Message>(count);
|
||||
const fd = await open(file, 'r');
|
||||
let pos = (await fstat(fd)).size;
|
||||
const buffer = Buffer.allocUnsafe(65536);
|
||||
while(pos > 0 && count > 0) {
|
||||
await read(fd, buffer, 0, 2, pos - 2);
|
||||
const length = buffer.readUInt16LE(0);
|
||||
pos = pos - length - 2;
|
||||
await read(fd, buffer, 0, length, pos);
|
||||
messages[--count] = deserializeMessage(buffer).message;
|
||||
}
|
||||
if(count !== 0) messages = messages.slice(count);
|
||||
return messages;
|
||||
}
|
||||
|
||||
getLogDates(key: string): ReadonlyArray<Date> {
|
||||
const entry = this.index[key];
|
||||
if(entry === undefined) return [];
|
||||
const dayOffset = new Date().getTimezoneOffset() * 60000;
|
||||
const dates = [];
|
||||
for(const date in entry.index) dates.push(new Date(parseInt(date, 10) * dayMs + dayOffset));
|
||||
return dates;
|
||||
}
|
||||
|
||||
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
const index = this.index[key];
|
||||
if(index === undefined) return [];
|
||||
const dateOffset = index.index[Math.floor(date.getTime() / dayMs - date.getTimezoneOffset() / 1440)];
|
||||
if(dateOffset === undefined) return [];
|
||||
const buffer = Buffer.allocUnsafe(50100);
|
||||
const messages: Conversation.Message[] = [];
|
||||
const file = getLogFile(key);
|
||||
const fd = await open(file, 'r');
|
||||
let pos = index.offsets[dateOffset];
|
||||
const size = dateOffset + 1 < index.offsets.length ? index.offsets[dateOffset + 1] : (await fstat(fd)).size;
|
||||
while(pos < size) {
|
||||
await read(fd, buffer, 0, 50100, pos);
|
||||
const deserialized = deserializeMessage(buffer);
|
||||
messages.push(deserialized.message);
|
||||
pos += deserialized.end;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
logMessage(conversation: {key: string, name: string}, message: Message): void {
|
||||
const file = getLogFile(conversation.key);
|
||||
const buffer = serializeMessage(message).serialized;
|
||||
const hasIndex = this.index[conversation.key] !== undefined;
|
||||
const indexBuffer = checkIndex(this.index, message, conversation.key, conversation.name,
|
||||
() => fs.existsSync(file) ? fs.statSync(file).size : 0);
|
||||
if(indexBuffer !== undefined) fs.writeFileSync(`${file}.idx`, indexBuffer, {flag: hasIndex ? 'a' : 'wx'});
|
||||
fs.writeFileSync(file, buffer, {flag: 'a'});
|
||||
}
|
||||
|
||||
get conversations(): ReadonlyArray<{id: string, name: string}> {
|
||||
const conversations: {id: string, name: string}[] = [];
|
||||
for(const key in this.index) conversations.push({id: key, name: this.index[key]!.name});
|
||||
conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
|
||||
return conversations;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGeneralSettings(): GeneralSettings | undefined {
|
||||
const file = path.join(baseDir, 'settings');
|
||||
if(!fs.existsSync(file)) return undefined;
|
||||
return <GeneralSettings>JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
export function setGeneralSettings(value: GeneralSettings): void {
|
||||
fs.writeFileSync(path.join(baseDir, 'settings'), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function getSettingsDir(character: string = core.connection.character): string {
|
||||
const dir = path.join(baseDir, character, 'settings');
|
||||
mkdir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
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(await readFile(file, 'utf8'));
|
||||
}
|
||||
|
||||
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
|
||||
return (await readdir(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
|
||||
}
|
||||
|
||||
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
|
||||
await writeFile(path.join(getSettingsDir(), key), JSON.stringify(value));
|
||||
}
|
||||
}
|
261
electron/importer.ts
Normal file
261
electron/importer.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import {addMinutes} from 'date-fns';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import {Settings} from '../chat/common';
|
||||
import {Conversation} from '../chat/interfaces';
|
||||
import {checkIndex, GeneralSettings, getLogDir, Message as LogMessage, serializeMessage, SettingsStore} from './filesystem';
|
||||
|
||||
function getRoamingDir(): string | undefined {
|
||||
const appdata = process.env.APPDATA;
|
||||
if(appdata === undefined || appdata.length === 0) return;
|
||||
return path.join(appdata, 'slimCat');
|
||||
}
|
||||
|
||||
function getLocalDir(): string | undefined {
|
||||
const appdata = process.env.LOCALAPPDATA;
|
||||
if(appdata === undefined || appdata.length === 0) return;
|
||||
return path.join(appdata, 'slimCat');
|
||||
}
|
||||
|
||||
function getSettingsDir(character: string): string | undefined {
|
||||
const dir = getRoamingDir();
|
||||
if(dir === undefined) return;
|
||||
let charDir = path.join(dir, character);
|
||||
if(fs.existsSync(charDir)) return charDir;
|
||||
charDir = path.join(dir, '!Defaults');
|
||||
if(fs.existsSync(charDir)) return charDir;
|
||||
return;
|
||||
}
|
||||
|
||||
export function canImportGeneral(): boolean {
|
||||
const dir = getLocalDir();
|
||||
return dir !== undefined && fs.existsSync(dir);
|
||||
}
|
||||
|
||||
export function canImportCharacter(character: string): boolean {
|
||||
return getSettingsDir(character) !== undefined;
|
||||
}
|
||||
|
||||
export function importGeneral(): GeneralSettings | undefined {
|
||||
let dir = getLocalDir();
|
||||
let files: string[] = [];
|
||||
if(dir !== undefined)
|
||||
files = files.concat(...fs.readdirSync(dir).map((x) => {
|
||||
const subdir = path.join(<string>dir, x);
|
||||
return fs.readdirSync(subdir).map((y) => path.join(subdir, y, 'user.config'));
|
||||
}));
|
||||
dir = getRoamingDir();
|
||||
if(dir !== undefined) files.push(path.join(dir, '!preferences.xml'));
|
||||
let file = '';
|
||||
for(let max = 0, i = 0; i < files.length; ++i) {
|
||||
const time = fs.statSync(files[i]).mtime.getTime();
|
||||
if(time > max) {
|
||||
max = time;
|
||||
file = files[i];
|
||||
}
|
||||
}
|
||||
if(file.length === 0) return;
|
||||
let elm = new DOMParser().parseFromString(fs.readFileSync(file, 'utf8'), 'application/xml').firstElementChild;
|
||||
const data = new GeneralSettings();
|
||||
if(file.slice(-3) === 'xml') {
|
||||
if(elm === null) return;
|
||||
let elements;
|
||||
if((elements = elm.getElementsByTagName('Username')).length > 0)
|
||||
data.account = <string>elements[0].textContent;
|
||||
if((elements = elm.getElementsByTagName('Host')).length > 0)
|
||||
data.host = <string>elements[0].textContent;
|
||||
} else {
|
||||
if(elm !== null) elm = elm.firstElementChild;
|
||||
if(elm !== null) elm = elm.firstElementChild;
|
||||
if(elm === null) return;
|
||||
const config = elm.getElementsByTagName('setting');
|
||||
for(const element of config) {
|
||||
if(element.firstElementChild === null || element.firstElementChild.textContent === null) continue;
|
||||
if(element.getAttribute('name') === 'UserName') data.account = element.firstElementChild.textContent;
|
||||
else if(element.getAttribute('name') === 'Host') data.host = element.firstElementChild.textContent;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const charRegex = /([A-Za-z0-9][A-Za-z0-9 \-_]{0,18}[A-Za-z0-9\-_])\b/;
|
||||
|
||||
function createMessage(line: string, ownCharacter: string, name: string, isChannel: boolean, date: Date): LogMessage | undefined {
|
||||
let type = Conversation.Message.Type.Message;
|
||||
let sender: string | null;
|
||||
let text: string;
|
||||
|
||||
let lineIndex = line.indexOf(']');
|
||||
if(lineIndex === -1) return;
|
||||
const time = line.substring(1, lineIndex);
|
||||
let h = parseInt(time.substr(0, 2), 10);
|
||||
const m = parseInt(time.substr(3, 2), 10);
|
||||
if(time.slice(-2) === 'AM') h -= 12;
|
||||
lineIndex += 2;
|
||||
if(line[lineIndex] === '[') {
|
||||
type = Conversation.Message.Type.Roll;
|
||||
let endIndex = line.indexOf('[', lineIndex += 6);
|
||||
if(endIndex - lineIndex > 20) endIndex = lineIndex + 20;
|
||||
sender = line.substring(lineIndex, endIndex);
|
||||
text = line.substring(endIndex + 6);
|
||||
} else {
|
||||
if(lineIndex + ownCharacter.length <= line.length && line.substr(lineIndex, ownCharacter.length) === ownCharacter)
|
||||
sender = ownCharacter;
|
||||
else if(!isChannel && lineIndex + name.length <= line.length && line.substr(lineIndex, name.length) === name)
|
||||
sender = name;
|
||||
else {
|
||||
const matched = charRegex.exec(line.substr(lineIndex, 21));
|
||||
sender = matched !== null && matched.length > 1 ? matched[1] : '';
|
||||
}
|
||||
lineIndex += sender.length;
|
||||
if(line[lineIndex] === ':') {
|
||||
++lineIndex;
|
||||
if(line[lineIndex] === ' ') ++lineIndex;
|
||||
if(line.substr(lineIndex, 3) === '/me') {
|
||||
type = Conversation.Message.Type.Action;
|
||||
lineIndex += 3;
|
||||
}
|
||||
} else type = Conversation.Message.Type.Action;
|
||||
text = line.substr(lineIndex);
|
||||
}
|
||||
return {type, sender: {name: sender}, text, time: addMinutes(date, h * 60 + m)};
|
||||
}
|
||||
|
||||
async function importSettings(dir: string): Promise<void> {
|
||||
const settings = new Settings();
|
||||
const settingsStore = new SettingsStore();
|
||||
const buffer = fs.readFileSync(path.join(dir, 'Global', '!settings.xml'));
|
||||
const content = buffer.toString('utf8', (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) ? 3 : 0);
|
||||
const config = new DOMParser().parseFromString(content, 'application/xml').firstElementChild;
|
||||
if(config === null) return;
|
||||
|
||||
function getValue(name: string): string | undefined {
|
||||
if(config === null) return;
|
||||
const elm = <Element | undefined>config.getElementsByTagName(name)[0];
|
||||
return elm !== undefined && elm.textContent !== null ? elm.textContent : undefined;
|
||||
}
|
||||
|
||||
if(getValue('AllowColors') === 'false') settings.disallowedTags.push('color');
|
||||
if(getValue('AllowIcons') === 'false') settings.disallowedTags.push('icon', 'eicon');
|
||||
if(getValue('AllowSound') === 'false') settings.playSound = false;
|
||||
if(getValue('CheckForOwnName') === 'false') settings.highlight = false;
|
||||
const idleTime = getValue('AutoIdleTime');
|
||||
if(getValue('AllowAutoIdle') === 'true' && idleTime !== undefined)
|
||||
settings.idleTimer = parseInt(idleTime, 10);
|
||||
const highlightWords = getValue('GlobalNotifyTerms');
|
||||
if(highlightWords !== undefined)
|
||||
settings.highlightWords = highlightWords.split(',').map((x) => x.trim()).filter((x) => x.length);
|
||||
if(getValue('ShowNotificationsGlobal') === 'false') settings.notifications = false;
|
||||
if(getValue('ShowAvatars') === 'false') settings.showAvatars = false;
|
||||
if(getValue('PlaySoundEvenWhenTabIsFocused') === 'true') settings.alwaysNotify = true;
|
||||
await settingsStore.set('settings', settings);
|
||||
|
||||
const pinned = {channels: <string[]>[], private: []};
|
||||
const elements = config.getElementsByTagName('SavedChannels')[0].getElementsByTagName('channel');
|
||||
for(const element of elements) {
|
||||
const item = element.textContent;
|
||||
if(item !== null && pinned.channels.indexOf(item) === -1) pinned.channels.push(item);
|
||||
}
|
||||
await settingsStore.set('pinned', pinned);
|
||||
}
|
||||
|
||||
const knownOfficialChannels = ['Canon Characters', 'Monster\'s Lair', 'German IC', 'Humans/Humanoids', 'Warhammer General',
|
||||
'Love and Affection', 'Transformation', 'Hyper Endowed', 'Force/Non-Con', 'Diapers/Infantilism', 'Avians', 'Politics', 'Lesbians',
|
||||
'Superheroes', 'Footplay', 'Sadism/Masochism', 'German Politics', 'Para/Multi-Para RP', 'Micro/Macro', 'Ferals / Bestiality',
|
||||
'Gamers', 'Gay Males', 'Story Driven LFRP', 'Femdom', 'German OOC', 'World of Warcraft', 'Ageplay', 'German Furry', 'Scat Play',
|
||||
'Hermaphrodites', 'RP Dark City', 'All in the Family', 'Inflation', 'Development', 'Fantasy', 'Frontpage', 'Pokefurs', 'Medical Play',
|
||||
'Domination/Submission', 'Latex', 'Fat and Pudgy', 'Muscle Bound', 'Furries', 'RP Bar', 'The Slob Den', 'Artists / Writers',
|
||||
'Mind Control', 'Ass Play', 'Sex Driven LFRP', 'Gay Furry Males', 'Vore', 'Non-Sexual RP', 'Equestria ', 'Sci-fi', 'Watersports',
|
||||
'Straight Roleplay', 'Gore', 'Cuntboys', 'Femboy', 'Bondage', 'Cum Lovers', 'Transgender', 'Pregnancy and Impregnation',
|
||||
'Canon Characters OOC', 'Dragons', 'Helpdesk'];
|
||||
|
||||
export async function importCharacter(ownCharacter: string, progress: (progress: number) => void): Promise<void> {
|
||||
const write = promisify(fs.write);
|
||||
const dir = getSettingsDir(ownCharacter);
|
||||
if(dir === undefined) return;
|
||||
await importSettings(dir);
|
||||
const adRegex = /Ad at \[.*?]:/;
|
||||
const logRegex = /^(Ad at \[.*?]:|\[\d{2}.\d{2}.*] (\[user][A-Za-z0-9 \-_]|[A-Za-z0-9 \-_]))/;
|
||||
const subdirs = fs.readdirSync(dir);
|
||||
for(let i = 0; i < subdirs.length; ++i) {
|
||||
progress(i / subdirs.length);
|
||||
const subdir = subdirs[i];
|
||||
const subdirPath = path.join(dir, subdir);
|
||||
if(subdir === '!Notifications' || subdir === 'Global' || !fs.lstatSync(subdirPath).isDirectory()) continue;
|
||||
|
||||
const channelMarker = subdir.indexOf('(');
|
||||
let key: string, name: string;
|
||||
let isChannel = false;
|
||||
if(channelMarker !== -1) {
|
||||
isChannel = true;
|
||||
key = `#${subdir.slice(channelMarker + 1, -1)}`.toLowerCase();
|
||||
name = subdir.substring(0, channelMarker - 1);
|
||||
} else {
|
||||
name = subdir;
|
||||
if(knownOfficialChannels.indexOf(subdir) !== -1) {
|
||||
key = `#${subdir}`.toLowerCase();
|
||||
isChannel = true;
|
||||
} else key = subdir.toLowerCase();
|
||||
}
|
||||
|
||||
const logFile = path.join(getLogDir(ownCharacter), key);
|
||||
if(fs.existsSync(logFile)) fs.unlinkSync(logFile);
|
||||
if(fs.existsSync(`${logFile}.idx`)) fs.unlinkSync(`${logFile}.idx`);
|
||||
let logFd, indexFd;
|
||||
const logIndex = {};
|
||||
let size = 0;
|
||||
const files = fs.readdirSync(subdirPath);
|
||||
for(const file of files.map((filename) => {
|
||||
const date = path.basename(filename, '.txt').split('-');
|
||||
return {name: filename, date: new Date(parseInt(date[2], 10), parseInt(date[0], 10) - 1, parseInt(date[1], 10))};
|
||||
}).sort((x, y) => x.date.getTime() - y.date.getTime())) {
|
||||
if(isNaN(file.date.getTime())) continue;
|
||||
const content = fs.readFileSync(path.join(subdirPath, file.name), 'utf8');
|
||||
let index = 0, start = 0;
|
||||
let ignoreLine = false;
|
||||
while(index < content.length) {
|
||||
if(index === start && adRegex.test(content.substr(start, 14)))
|
||||
ignoreLine = true;
|
||||
else {
|
||||
const char = content[index];
|
||||
if(ignoreLine) {
|
||||
if(char === '\n') {
|
||||
const nextLine = content.substr(index + 1, 29);
|
||||
if(logRegex.test(nextLine)) {
|
||||
ignoreLine = false;
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
++index;
|
||||
continue;
|
||||
} else if(char === '\r' || char === '\n') {
|
||||
const nextLine = content.substr(index + (char === '\r' ? 2 : 1), 29);
|
||||
if(logRegex.test(nextLine) || content.length - index <= 2) {
|
||||
const line = content.substring(start, index);
|
||||
const message = createMessage(line, ownCharacter, name, isChannel, file.date);
|
||||
if(message === undefined) {
|
||||
index += (char === '\r') ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if(indexFd === undefined || logFd === undefined) {
|
||||
logFd = fs.openSync(logFile, 'a');
|
||||
indexFd = fs.openSync(`${logFile}.idx`, 'a');
|
||||
}
|
||||
const indexBuffer = checkIndex(logIndex, message, key, name, size);
|
||||
if(indexBuffer !== undefined) await write(indexFd, indexBuffer);
|
||||
const serialized = serializeMessage(message);
|
||||
await write(logFd, serialized.serialized);
|
||||
size += serialized.size;
|
||||
if(char === '\r') ++index;
|
||||
start = index + 1;
|
||||
} else if(char === '\r') ++index;
|
||||
}
|
||||
}
|
||||
++index;
|
||||
}
|
||||
}
|
||||
if(indexFd !== undefined) fs.closeSync(indexFd);
|
||||
if(logFd !== undefined) fs.closeSync(logFd);
|
||||
}
|
||||
}
|
12
electron/index.html
Normal file
12
electron/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FChat 3.0</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
</div>
|
||||
<script type="text/javascript" src="chat.js"></script>
|
||||
</body>
|
||||
</html>
|
150
electron/main.ts
Normal file
150
electron/main.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @license
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2017 F-List
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* This license header applies to this file and all of the non-third-party assets it includes.
|
||||
* @file The entry point for the Electron main thread of F-Chat 3.0.
|
||||
* @copyright 2017 F-List
|
||||
* @author Maya Wolf <maya@f-list.net>
|
||||
* @version 3.0
|
||||
* @see {@link https://github.com/f-list/exported|GitHub repo}
|
||||
*/
|
||||
import * as electron from 'electron';
|
||||
import log from 'electron-log';
|
||||
import {autoUpdater} from 'electron-updater';
|
||||
import * as path from 'path';
|
||||
import * as url from 'url';
|
||||
import {mkdir} from './common';
|
||||
import * as windowState from './window_state';
|
||||
|
||||
// Module to control application life.
|
||||
const app = electron.app;
|
||||
const datadir = process.argv.filter((x) => x.startsWith('--datadir='));
|
||||
if(datadir.length > 0) app.setPath('userData', datadir[0].substr('--datadir='.length));
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
let mainWindow: Electron.BrowserWindow | undefined;
|
||||
|
||||
const baseDir = app.getPath('userData');
|
||||
mkdir(baseDir);
|
||||
autoUpdater.logger = log;
|
||||
log.transports.file.level = 'debug';
|
||||
log.transports.console.level = 'debug';
|
||||
log.transports.file.maxSize = 5 * 1024 * 1024;
|
||||
log.transports.file.file = path.join(baseDir, 'log.txt');
|
||||
log.info('Starting application.');
|
||||
|
||||
function sendUpdaterStatusToWindow(status: string, progress?: object): void {
|
||||
log.info(status);
|
||||
mainWindow!.webContents.send('updater-status', status, progress);
|
||||
}
|
||||
|
||||
const updaterEvents = ['checking-for-update', 'update-available', 'update-not-available', 'error', 'update-downloaded'];
|
||||
for(const eventName of updaterEvents)
|
||||
autoUpdater.on(eventName, () => {
|
||||
sendUpdaterStatusToWindow(eventName);
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (_, progress: object) => {
|
||||
sendUpdaterStatusToWindow('download-progress', progress);
|
||||
});
|
||||
|
||||
function runUpdater(): void {
|
||||
//tslint:disable-next-line:no-floating-promises
|
||||
autoUpdater.checkForUpdates();
|
||||
//tslint:disable-next-line:no-floating-promises
|
||||
setInterval(() => { autoUpdater.checkForUpdates(); }, 3600000);
|
||||
electron.ipcMain.on('install-update', () => {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
function bindWindowEvents(window: Electron.BrowserWindow): void {
|
||||
// Prevent page navigation by opening links in an external browser.
|
||||
const openLinkExternally = (e: Event, linkUrl: string) => {
|
||||
e.preventDefault();
|
||||
electron.shell.openExternal(linkUrl);
|
||||
};
|
||||
|
||||
window.webContents.on('will-navigate', openLinkExternally);
|
||||
window.webContents.on('new-window', openLinkExternally);
|
||||
// Fix focus events not properly propagating down to the document.
|
||||
window.on('focus', () => mainWindow!.webContents.send('focus', true));
|
||||
window.on('blur', () => mainWindow!.webContents.send('focus', false));
|
||||
|
||||
// Save window state when it is being closed.
|
||||
window.on('close', () => windowState.setSavedWindowState(window));
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
const lastState = windowState.getSavedWindowState();
|
||||
const windowProperties = {...lastState, center: lastState.x === undefined};
|
||||
// Create the browser window.
|
||||
mainWindow = new electron.BrowserWindow(windowProperties);
|
||||
if(lastState.maximized)
|
||||
mainWindow.maximize();
|
||||
|
||||
// and load the index.html of the app.
|
||||
mainWindow.loadURL(url.format({
|
||||
pathname: path.join(__dirname, 'index.html'),
|
||||
protocol: 'file:',
|
||||
slashes: true
|
||||
}));
|
||||
|
||||
bindWindowEvents(mainWindow);
|
||||
|
||||
// Open the DevTools.
|
||||
// mainWindow.webContents.openDevTools()
|
||||
|
||||
// Emitted when the window is closed.
|
||||
mainWindow.on('closed', () => {
|
||||
// Dereference the window object, usually you would store windows
|
||||
// in an array if your app supports multi windows, this is the time
|
||||
// when you should delete the corresponding element.
|
||||
mainWindow = undefined;
|
||||
});
|
||||
|
||||
if(process.env.NODE_ENV === 'production') runUpdater();
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', createWindow);
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on('window-all-closed', () => {
|
||||
// On OS X it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if(process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if(mainWindow === undefined) createWindow();
|
||||
});
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
94
electron/menu.ts
Normal file
94
electron/menu.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import * as electron from 'electron';
|
||||
import l from '../chat/localize';
|
||||
|
||||
export function createContextMenu(props: Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}):
|
||||
Electron.MenuItemConstructorOptions[] {
|
||||
const hasText = props.selectionText.trim().length > 0;
|
||||
const can = (type: string) => props.editFlags[`can${type}`] && hasText;
|
||||
|
||||
const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
|
||||
if(hasText || props.isEditable)
|
||||
menuTemplate.push({
|
||||
id: 'copy',
|
||||
label: l('action.copy'),
|
||||
role: can('Copy') ? 'copy' : '',
|
||||
enabled: can('Copy')
|
||||
});
|
||||
if(props.isEditable)
|
||||
menuTemplate.push({
|
||||
id: 'cut',
|
||||
label: l('action.cut'),
|
||||
role: can('Cut') ? 'cut' : '',
|
||||
enabled: can('Cut')
|
||||
}, {
|
||||
id: 'paste',
|
||||
label: l('action.paste'),
|
||||
role: props.editFlags.canPaste ? 'paste' : '',
|
||||
enabled: props.editFlags.canPaste
|
||||
});
|
||||
else if(props.linkURL.length > 0 && props.mediaType === 'none' && props.linkURL.substr(0, props.pageURL.length) !== props.pageURL)
|
||||
menuTemplate.push({
|
||||
id: 'copyLink',
|
||||
label: l('action.copyLink'),
|
||||
click(): void {
|
||||
if(process.platform === 'darwin')
|
||||
electron.clipboard.writeBookmark(props.linkText, props.linkURL);
|
||||
else
|
||||
electron.clipboard.writeText(props.linkURL);
|
||||
}
|
||||
});
|
||||
return menuTemplate;
|
||||
}
|
||||
|
||||
export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
|
||||
const viewItem = {
|
||||
label: l('action.view'),
|
||||
submenu: [
|
||||
{role: 'resetzoom'},
|
||||
{role: 'zoomin'},
|
||||
{role: 'zoomout'},
|
||||
{type: 'separator'},
|
||||
{role: 'togglefullscreen'}
|
||||
]
|
||||
};
|
||||
const menu: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: l('title')
|
||||
}, {
|
||||
label: l('action.edit'),
|
||||
submenu: [
|
||||
{role: 'undo'},
|
||||
{role: 'redo'},
|
||||
{type: 'separator'},
|
||||
{role: 'cut'},
|
||||
{role: 'copy'},
|
||||
{role: 'paste'},
|
||||
{role: 'selectall'}
|
||||
]
|
||||
}, viewItem, {
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: l('help.fchat'),
|
||||
click: () => electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0')
|
||||
},
|
||||
{
|
||||
label: l('help.rules'),
|
||||
click: () => electron.shell.openExternal('https://wiki.f-list.net/Rules')
|
||||
},
|
||||
{
|
||||
label: l('help.faq'),
|
||||
click: () => electron.shell.openExternal('https://wiki.f-list.net/Frequently_Asked_Questions')
|
||||
},
|
||||
{
|
||||
label: l('help.report'),
|
||||
click: () => electron.shell.openExternal('https://wiki.f-list.net/How_to_Report_a_User#In_chat')
|
||||
},
|
||||
{label: l('version', electron.remote.app.getVersion()), enabled: false}
|
||||
]
|
||||
}
|
||||
];
|
||||
if(process.env.NODE_ENV !== 'production')
|
||||
viewItem.submenu.unshift({role: 'reload'}, {role: 'forcereload'}, {role: 'toggledevtools'}, {type: 'separator'});
|
||||
return menu;
|
||||
}
|
26
electron/notifications.ts
Normal file
26
electron/notifications.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {remote} from 'electron';
|
||||
import core from '../chat/core';
|
||||
import {Conversation} from '../chat/interfaces';
|
||||
//tslint:disable-next-line:match-default-export-name
|
||||
import BaseNotifications from '../chat/notifications';
|
||||
|
||||
export default class Notifications extends BaseNotifications {
|
||||
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
|
||||
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
|
||||
this.playSound(sound);
|
||||
remote.getCurrentWindow().flashFrame(true);
|
||||
if(core.state.settings.notifications) {
|
||||
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive
|
||||
const notification = new Notification(title, <NotificationOptions & {silent: boolean}>{
|
||||
body,
|
||||
icon: core.state.settings.showAvatars ? icon : undefined,
|
||||
silent: true
|
||||
});
|
||||
notification.onclick = () => {
|
||||
conversation.show();
|
||||
remote.getCurrentWindow().focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
46
electron/package.json
Normal file
46
electron/package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "fchat",
|
||||
"version": "3.0.0",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"keytar": "^4.0.4",
|
||||
"spellchecker": "^3.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^1.8.0",
|
||||
"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",
|
||||
"start": "electron app"
|
||||
},
|
||||
"build": {
|
||||
"appId": "net.f-list.f-chat",
|
||||
"productName": "F-Chat",
|
||||
"files": [
|
||||
"*",
|
||||
"sounds",
|
||||
"themes",
|
||||
"!**/*.map",
|
||||
"!node_modules/",
|
||||
"node_modules/**/*.node"
|
||||
],
|
||||
"asar": false,
|
||||
"linux": {
|
||||
"category": "Network"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://toys.in.newtsin.space/chat-updater",
|
||||
"channel": "latest"
|
||||
}
|
||||
}
|
||||
}
|
2
electron/qs.ts
Normal file
2
electron/qs.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import * as qs from 'querystring';
|
||||
export = qs;
|
51
electron/spellchecker.ts
Normal file
51
electron/spellchecker.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import Axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import {mkdir, nativeRequire} from './common';
|
||||
|
||||
process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
|
||||
const downloadUrl = 'https://github.com/wooorm/dictionaries/raw/master/dictionaries/';
|
||||
const dir = `${__dirname}/spellchecker`;
|
||||
mkdir(dir);
|
||||
//tslint:disable-next-line
|
||||
const sc = nativeRequire<{
|
||||
Spellchecker: {
|
||||
new(): {
|
||||
isMisspelled(x: string): boolean,
|
||||
setDictionary(name: string | undefined, dir: string): void,
|
||||
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
}>('spellchecker/build/Release/spellchecker.node');
|
||||
let availableDictionaries: string[] | undefined;
|
||||
const writeFile = promisify(fs.writeFile);
|
||||
const requestConfig = {responseType: 'arraybuffer'};
|
||||
const spellchecker = new sc.Spellchecker();
|
||||
|
||||
export async function getAvailableDictionaries(): Promise<ReadonlyArray<string>> {
|
||||
if(availableDictionaries !== undefined) return availableDictionaries;
|
||||
const dicts = (<{name: string}[]>(await Axios.get('https://api.github.com/repos/wooorm/dictionaries/contents/dictionaries')).data)
|
||||
.map((x: {name: string}) => x.name);
|
||||
availableDictionaries = dicts;
|
||||
return dicts;
|
||||
}
|
||||
|
||||
export async function setDictionary(lang: string | undefined): Promise<void> {
|
||||
const dictName = lang !== undefined ? lang.replace('-', '_') : undefined;
|
||||
if(dictName !== undefined) {
|
||||
const dicPath = path.join(dir, `${dictName}.dic`);
|
||||
if(!fs.existsSync(dicPath)) {
|
||||
await writeFile(dicPath, new Buffer(<string>(await Axios.get(`${downloadUrl}${lang}/index.dic`, requestConfig)).data));
|
||||
await writeFile(path.join(dir, `${dictName}.aff`),
|
||||
new Buffer(<string>(await Axios.get(`${downloadUrl}${lang}/index.aff`, requestConfig)).data));
|
||||
}
|
||||
}
|
||||
spellchecker.setDictionary(dictName, dir);
|
||||
}
|
||||
|
||||
export function getCorrections(word: string): ReadonlyArray<string> {
|
||||
return spellchecker.getCorrectionsForMisspelling(word);
|
||||
}
|
||||
|
||||
export const check = (text: string) => !spellchecker.isMisspelled(text);
|
24
electron/tsconfig.json
Normal file
24
electron/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowJs": true,
|
||||
"outDir": "build",
|
||||
"noEmitHelpers": true,
|
||||
"importHelpers": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["*.ts", "../**/*.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"app"
|
||||
]
|
||||
}
|
96
electron/webpack.config.js
Normal file
96
electron/webpack.config.js
Normal file
@ -0,0 +1,96 @@
|
||||
const path = require('path');
|
||||
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 config = {
|
||||
entry: {
|
||||
chat: [path.join(__dirname, 'chat.ts')],
|
||||
main: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'index.html'), path.join(__dirname, 'application.json')]
|
||||
},
|
||||
output: {
|
||||
path: __dirname + '/app',
|
||||
filename: '[name].js'
|
||||
},
|
||||
context: __dirname,
|
||||
target: 'electron',
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preLoaders: {ts: 'export-loader'},
|
||||
preserveWhitespace: false
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.(woff|woff2)$/, loader: 'url-loader?prefix=font/&limit=5000'},
|
||||
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?limit=10000&mimetype=application/octet-stream'},
|
||||
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?limit=10000&mimetype=image/svg+xml'},
|
||||
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
|
||||
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
|
||||
{test: /application.json$/, loader: 'file-loader?name=package.json'}
|
||||
]
|
||||
},
|
||||
node: {
|
||||
__dirname: false,
|
||||
__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')}),
|
||||
exportLoader.delayTypecheck
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.vue', '.css'],
|
||||
alias: {qs: path.join(__dirname, 'qs.ts')}
|
||||
},
|
||||
resolveLoader: {
|
||||
modules: [
|
||||
'node_modules', path.join(__dirname, '../')
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = function(env) {
|
||||
const dist = env === 'production';
|
||||
const themesDir = path.join(__dirname, '../less/themes/chat');
|
||||
const themes = fs.readdirSync(themesDir);
|
||||
const cssOptions = {use: [{loader: 'css-loader', options: {minimize: dist}}, 'less-loader']};
|
||||
for(const theme of themes) {
|
||||
const absPath = path.join(themesDir, theme);
|
||||
config.entry.chat.push(absPath);
|
||||
const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css');
|
||||
config.plugins.push(plugin);
|
||||
config.module.loaders.push({test: absPath, use: plugin.extract(cssOptions)});
|
||||
}
|
||||
if(dist) {
|
||||
config.devtool = 'source-map';
|
||||
config.plugins.push(
|
||||
new UglifyPlugin({sourceMap: true}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify('production')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
//config.devtool = 'cheap-module-eval-source-map';
|
||||
}
|
||||
return config;
|
||||
};
|
60
electron/window_state.ts
Normal file
60
electron/window_state.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import {app, screen} from 'electron';
|
||||
import log from 'electron-log';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const baseDir = path.join(app.getPath('userData'), 'data');
|
||||
const windowStatePath = path.join(baseDir, 'window.json');
|
||||
|
||||
interface SavedWindowState {
|
||||
x?: number
|
||||
y?: number
|
||||
height: number
|
||||
width: number
|
||||
maximized: boolean
|
||||
}
|
||||
|
||||
function mapToScreen(state: SavedWindowState): SavedWindowState {
|
||||
let x = state.x !== undefined ? state.x : 0;
|
||||
let y = state.y !== undefined ? state.y : 0;
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const targetDisplay = screen.getDisplayMatching({x, y, height: state.height, width: state.width});
|
||||
if(primaryDisplay.scaleFactor !== 1 && targetDisplay.id !== primaryDisplay.id) {
|
||||
x /= primaryDisplay.scaleFactor;
|
||||
y /= primaryDisplay.scaleFactor;
|
||||
}
|
||||
state.x = x > 0 ? x : undefined;
|
||||
state.y = y > 0 ? y : undefined;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function setSavedWindowState(window: Electron.BrowserWindow): void {
|
||||
const bounds = window.getBounds();
|
||||
const maximized = window.isMaximized();
|
||||
const windowState: SavedWindowState = {
|
||||
height: bounds.height,
|
||||
maximized,
|
||||
width: bounds.width,
|
||||
x: bounds.x,
|
||||
y: bounds.y
|
||||
};
|
||||
fs.writeFileSync(windowStatePath, JSON.stringify(windowState));
|
||||
}
|
||||
|
||||
export function getSavedWindowState(): SavedWindowState {
|
||||
const defaultState = {
|
||||
height: 768,
|
||||
maximized: false,
|
||||
width: 1024
|
||||
};
|
||||
if(!fs.existsSync(windowStatePath))
|
||||
return defaultState;
|
||||
try {
|
||||
let savedState = <SavedWindowState>JSON.parse(fs.readFileSync(windowStatePath, 'utf-8'));
|
||||
savedState = mapToScreen(savedState);
|
||||
return savedState;
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
return defaultState;
|
||||
}
|
||||
}
|
1927
electron/yarn.lock
Normal file
1927
electron/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
15
export-loader.js
Normal file
15
export-loader.js
Normal file
@ -0,0 +1,15 @@
|
||||
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();
|
||||
});
|
||||
};
|
247
fchat/channels.ts
Normal file
247
fchat/channels.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import {decodeHTML} from './common';
|
||||
import {Channel as Interfaces, Character, Connection} from './interfaces';
|
||||
|
||||
export function queuedJoin(this: void, channels: string[]): void {
|
||||
const timer: NodeJS.Timer = setInterval(() => {
|
||||
const channel = channels.shift();
|
||||
if(channel === undefined) return clearInterval(timer);
|
||||
state.join(channel);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function sortMember(this: void | never, array: Interfaces.Member[], member: Interfaces.Member): void {
|
||||
const name = member.character.name;
|
||||
let i = 0;
|
||||
for(; i < array.length; ++i) {
|
||||
const other = array[i];
|
||||
if(other.character.isChatOp && !member.character.isChatOp) continue;
|
||||
if(member.character.isChatOp && !other.character.isChatOp) break;
|
||||
if(other.rank > member.rank) continue;
|
||||
if(member.rank > other.rank) break;
|
||||
if(name < other.character.name) break;
|
||||
}
|
||||
array.splice(i, 0, member);
|
||||
}
|
||||
|
||||
class Channel implements Interfaces.Channel {
|
||||
description = '';
|
||||
opList: string[];
|
||||
owner = '';
|
||||
mode: Interfaces.Mode = 'both';
|
||||
members: {[key: string]: {character: Character, rank: Interfaces.Rank} | undefined} = {};
|
||||
sortedMembers: Interfaces.Member[] = [];
|
||||
|
||||
constructor(readonly id: string, readonly name: string) {
|
||||
}
|
||||
|
||||
addMember(member: Interfaces.Member): void {
|
||||
this.members[member.character.name] = member;
|
||||
sortMember(this.sortedMembers, member);
|
||||
}
|
||||
|
||||
removeMember(name: string): void {
|
||||
const member = this.members[name];
|
||||
if(member !== undefined) {
|
||||
delete this.members[name];
|
||||
this.sortedMembers.splice(this.sortedMembers.indexOf(member), 1);
|
||||
}
|
||||
}
|
||||
|
||||
reSortMember(member: Interfaces.Member): void {
|
||||
this.sortedMembers.splice(this.sortedMembers.indexOf(member), 1);
|
||||
sortMember(this.sortedMembers, member);
|
||||
}
|
||||
|
||||
createMember(character: Character): {character: Character, rank: Interfaces.Rank} {
|
||||
return {
|
||||
character,
|
||||
rank: this.owner === character.name ? Interfaces.Rank.Owner :
|
||||
this.opList.indexOf(character.name) !== -1 ? Interfaces.Rank.Op : Interfaces.Rank.Member
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ListItem implements Interfaces.ListItem {
|
||||
isJoined = false;
|
||||
|
||||
constructor(readonly id: string, readonly name: string, public memberCount: number) {
|
||||
}
|
||||
}
|
||||
|
||||
class State implements Interfaces.State {
|
||||
officialChannels: {readonly [key: string]: ListItem | undefined} = {};
|
||||
openRooms: {readonly [key: string]: ListItem | undefined} = {};
|
||||
joinedChannels: Channel[] = [];
|
||||
handlers: Interfaces.EventHandler[] = [];
|
||||
joinedKeys: {[key: string]: number | undefined} = {};
|
||||
|
||||
constructor(private connection: Connection) {
|
||||
}
|
||||
|
||||
join(channel: string): void {
|
||||
this.connection.send('JCH', {channel});
|
||||
}
|
||||
|
||||
leave(channel: string): void {
|
||||
this.connection.send('LCH', {channel});
|
||||
}
|
||||
|
||||
addChannel(channel: Channel): void {
|
||||
this.joinedKeys[channel.id] = this.joinedChannels.length;
|
||||
this.joinedChannels.push(channel);
|
||||
for(const handler of this.handlers) handler('join', channel);
|
||||
}
|
||||
|
||||
removeChannel(channel: Channel): void {
|
||||
this.joinedChannels.splice(this.joinedKeys[channel.id]!, 1);
|
||||
delete this.joinedKeys[channel.id];
|
||||
for(const handler of this.handlers) handler('leave', channel);
|
||||
}
|
||||
|
||||
getChannelItem(id: string): ListItem | undefined {
|
||||
id = id.toLowerCase();
|
||||
return (id.substr(0, 4) === 'adh-' ? this.openRooms : this.officialChannels)[id];
|
||||
}
|
||||
|
||||
onEvent(handler: Interfaces.EventHandler): void {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
getChannel(id: string): Channel | undefined {
|
||||
const key = this.joinedKeys[id.toLowerCase()];
|
||||
return key !== undefined ? this.joinedChannels[key] : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let state: State;
|
||||
|
||||
export default function(this: void, connection: Connection, characters: Character.State): Interfaces.State {
|
||||
state = new State(connection);
|
||||
let getChannelTimer: NodeJS.Timer | undefined;
|
||||
connection.onEvent('connecting', () => {
|
||||
state.joinedChannels = [];
|
||||
state.joinedKeys = {};
|
||||
});
|
||||
connection.onEvent('connected', (isReconnect) => {
|
||||
if(isReconnect) queuedJoin(Object.keys(state.joinedChannels));
|
||||
const getChannels = () => {
|
||||
connection.send('CHA');
|
||||
connection.send('ORS');
|
||||
};
|
||||
getChannels();
|
||||
if(getChannelTimer !== undefined) clearInterval(getChannelTimer);
|
||||
getChannelTimer = setInterval(getChannels, 60000);
|
||||
});
|
||||
|
||||
connection.onMessage('CHA', (data) => {
|
||||
const channels: {[key: string]: ListItem} = {};
|
||||
for(const channel of data.channels) {
|
||||
const id = channel.name.toLowerCase();
|
||||
const item = new ListItem(id, channel.name, channel.characters);
|
||||
if(state.joinedKeys[id] !== undefined) item.isJoined = true;
|
||||
channels[id] = item;
|
||||
}
|
||||
state.officialChannels = channels;
|
||||
});
|
||||
connection.onMessage('ORS', (data) => {
|
||||
const channels: {[key: string]: ListItem} = {};
|
||||
for(const channel of data.channels) {
|
||||
const id = channel.name.toLowerCase();
|
||||
const item = new ListItem(id, decodeHTML(channel.title), channel.characters);
|
||||
if(state.joinedKeys[id] !== undefined) item.isJoined = true;
|
||||
channels[id] = item;
|
||||
}
|
||||
state.openRooms = channels;
|
||||
});
|
||||
connection.onMessage('JCH', (data) => {
|
||||
const item = state.getChannelItem(data.channel);
|
||||
if(data.character.identity === connection.character) {
|
||||
state.addChannel(new Channel(data.channel.toLowerCase(), decodeHTML(data.title)));
|
||||
if(item !== undefined) item.isJoined = true;
|
||||
} else {
|
||||
const channel = state.getChannel(data.channel)!;
|
||||
channel.addMember(channel.createMember(characters.get(data.character.identity)));
|
||||
if(item !== undefined) item.memberCount++;
|
||||
}
|
||||
});
|
||||
connection.onMessage('ICH', (data) => {
|
||||
const channel = state.getChannel(data.channel)!;
|
||||
channel.mode = data.mode;
|
||||
const members: {[key: string]: Interfaces.Member} = {};
|
||||
const sorted: Interfaces.Member[] = [];
|
||||
for(const user of data.users) {
|
||||
const name = user.identity;
|
||||
const member = channel.createMember(characters.get(name));
|
||||
members[name] = member;
|
||||
sortMember(sorted, member);
|
||||
}
|
||||
channel.members = members;
|
||||
channel.sortedMembers = sorted;
|
||||
const item = state.getChannelItem(data.channel);
|
||||
if(item !== undefined) item.memberCount = data.users.length;
|
||||
});
|
||||
connection.onMessage('CDS', (data) => state.getChannel(data.channel)!.description = decodeHTML(data.description));
|
||||
connection.onMessage('LCH', (data) => {
|
||||
const channel = state.getChannel(data.channel);
|
||||
if(channel === undefined) return;
|
||||
const item = state.getChannelItem(data.channel);
|
||||
if(data.character === connection.character) {
|
||||
state.removeChannel(channel);
|
||||
if(item !== undefined) item.isJoined = false;
|
||||
} else {
|
||||
channel.removeMember(data.character);
|
||||
if(item !== undefined) item.memberCount--;
|
||||
}
|
||||
});
|
||||
connection.onMessage('COA', (data) => {
|
||||
const channel = state.getChannel(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;
|
||||
channel.reSortMember(member);
|
||||
});
|
||||
connection.onMessage('COL', (data) => {
|
||||
const channel = state.getChannel(data.channel)!;
|
||||
channel.owner = data.oplist[0];
|
||||
channel.opList = data.oplist.slice(1);
|
||||
});
|
||||
connection.onMessage('COR', (data) => {
|
||||
const channel = state.getChannel(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;
|
||||
channel.reSortMember(member);
|
||||
});
|
||||
connection.onMessage('CSO', (data) => {
|
||||
const channel = state.getChannel(data.channel)!;
|
||||
const oldOwner = channel.members[channel.owner];
|
||||
if(oldOwner !== undefined) {
|
||||
oldOwner.rank = Interfaces.Rank.Member;
|
||||
channel.reSortMember(oldOwner);
|
||||
}
|
||||
channel.owner = data.character;
|
||||
const newOwner = channel.members[data.character];
|
||||
if(newOwner !== undefined) {
|
||||
newOwner.rank = Interfaces.Rank.Owner;
|
||||
channel.reSortMember(newOwner);
|
||||
}
|
||||
});
|
||||
connection.onMessage('RMO', (data) => state.getChannel(data.channel)!.mode = data.mode);
|
||||
connection.onMessage('FLN', (data) => {
|
||||
for(const key in state.joinedKeys)
|
||||
state.getChannel(key)!.removeMember(data.character);
|
||||
});
|
||||
const globalHandler = (data: Connection.ServerCommands['AOP'] | Connection.ServerCommands['DOP']) => {
|
||||
//tslint:disable-next-line:forin
|
||||
for(const key in state.joinedKeys) {
|
||||
const channel = state.getChannel(key)!;
|
||||
const member = channel.members[data.character];
|
||||
if(member !== undefined) channel.reSortMember(member);
|
||||
}
|
||||
};
|
||||
connection.onMessage('AOP', globalHandler);
|
||||
connection.onMessage('DOP', globalHandler);
|
||||
return state;
|
||||
}
|
150
fchat/characters.ts
Normal file
150
fchat/characters.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import {decodeHTML} from './common';
|
||||
import {Character as Interfaces, Connection} from './interfaces';
|
||||
|
||||
class Character implements Interfaces.Character {
|
||||
gender: Interfaces.Gender;
|
||||
status: Interfaces.Status = 'offline';
|
||||
statusText = '';
|
||||
isFriend = false;
|
||||
isBookmarked = false;
|
||||
isChatOp = false;
|
||||
isIgnored = false;
|
||||
|
||||
constructor(readonly name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
class State implements Interfaces.State {
|
||||
characters: {[key: string]: Character | undefined} = {};
|
||||
ownCharacter: Character = <any>undefined; /*tslint:disable-line:no-any*///hack
|
||||
friends: Character[] = [];
|
||||
bookmarks: Character[] = [];
|
||||
ignoreList: string[] = [];
|
||||
opList: string[] = [];
|
||||
friendList: string[] = [];
|
||||
bookmarkList: string[] = [];
|
||||
|
||||
get(name: string): Character {
|
||||
const key = name.toLowerCase();
|
||||
let char = this.characters[key];
|
||||
if(char === undefined) {
|
||||
char = new Character(name);
|
||||
char.isFriend = this.friendList.indexOf(name) !== -1;
|
||||
char.isBookmarked = this.bookmarkList.indexOf(name) !== -1;
|
||||
char.isChatOp = this.opList.indexOf(name) !== -1;
|
||||
char.isIgnored = this.ignoreList.indexOf(key) !== -1;
|
||||
this.characters[key] = char;
|
||||
}
|
||||
return char;
|
||||
}
|
||||
|
||||
setStatus(character: Character, status: Interfaces.Status, text: string): void {
|
||||
if(character.status === 'offline' && status !== 'offline') {
|
||||
if(character.isFriend) this.friends.push(character);
|
||||
if(character.isBookmarked) this.bookmarks.push(character);
|
||||
} else if(status === 'offline' && character.status !== 'offline') {
|
||||
if(character.isFriend) this.friends.splice(this.friends.indexOf(character), 1);
|
||||
if(character.isBookmarked) this.bookmarks.splice(this.bookmarks.indexOf(character), 1);
|
||||
}
|
||||
character.status = status;
|
||||
character.statusText = decodeHTML(text);
|
||||
}
|
||||
}
|
||||
|
||||
let state: State;
|
||||
|
||||
export default function(this: void, connection: Connection): Interfaces.State {
|
||||
state = new State();
|
||||
let reconnectStatus: Connection.ClientCommands['STA'];
|
||||
connection.onEvent('connecting', async(isReconnect) => {
|
||||
state.friends = [];
|
||||
state.bookmarks = [];
|
||||
state.bookmarkList = (<{characters: string[]}>await connection.queryApi('bookmark-list.php')).characters;
|
||||
state.friendList = ((<{friends: {source: string, dest: string, last_online: number}[]}>await connection.queryApi('friend-list.php'))
|
||||
.friends).map((x) => x.dest);
|
||||
//tslint:disable-next-line:forin
|
||||
for(const key in state.characters) {
|
||||
const character = state.characters[key]!;
|
||||
character.isFriend = state.friendList.indexOf(character.name) !== -1;
|
||||
character.isBookmarked = state.bookmarkList.indexOf(character.name) !== -1;
|
||||
character.status = 'offline';
|
||||
character.statusText = '';
|
||||
}
|
||||
if(isReconnect && (<Character | undefined>state.ownCharacter) !== undefined)
|
||||
reconnectStatus = {status: state.ownCharacter.status, statusmsg: state.ownCharacter.statusText};
|
||||
});
|
||||
connection.onEvent('connected', async(isReconnect) => {
|
||||
if(!isReconnect) return;
|
||||
connection.send('STA', reconnectStatus);
|
||||
//tslint:disable-next-line:forin
|
||||
for(const key in state.characters) {
|
||||
const char = state.characters[key]!;
|
||||
char.isIgnored = state.ignoreList.indexOf(key) !== -1;
|
||||
char.isChatOp = state.opList.indexOf(char.name) !== -1;
|
||||
}
|
||||
});
|
||||
connection.onMessage('IGN', (data) => {
|
||||
switch(data.action) {
|
||||
case 'init':
|
||||
state.ignoreList = data.characters.slice();
|
||||
break;
|
||||
case 'add':
|
||||
state.ignoreList.push(data.character.toLowerCase());
|
||||
state.get(data.character).isIgnored = true;
|
||||
break;
|
||||
case 'delete':
|
||||
state.ignoreList.splice(state.ignoreList.indexOf(data.character.toLowerCase()), 1);
|
||||
state.get(data.character).isIgnored = false;
|
||||
}
|
||||
});
|
||||
connection.onMessage('ADL', (data) => state.opList = data.ops.slice());
|
||||
connection.onMessage('LIS', (data) => {
|
||||
for(const char of data.characters) {
|
||||
const character = state.get(char[0]);
|
||||
character.gender = char[1];
|
||||
state.setStatus(character, char[2], char[3]);
|
||||
}
|
||||
});
|
||||
connection.onMessage('FLN', (data) => {
|
||||
state.setStatus(state.get(data.character), 'offline', '');
|
||||
});
|
||||
connection.onMessage('NLN', (data) => {
|
||||
const character = state.get(data.identity);
|
||||
if(data.identity === connection.character) state.ownCharacter = character;
|
||||
character.gender = data.gender;
|
||||
state.setStatus(character, data.status, '');
|
||||
});
|
||||
connection.onMessage('STA', (data) => {
|
||||
state.setStatus(state.get(data.character), data.status, data.statusmsg);
|
||||
});
|
||||
connection.onMessage('AOP', (data) => {
|
||||
state.opList.push(data.character);
|
||||
const char = state.get(data.character);
|
||||
char.isChatOp = true;
|
||||
});
|
||||
connection.onMessage('DOP', (data) => {
|
||||
state.opList.splice(state.opList.indexOf(data.character), 1);
|
||||
const char = state.get(data.character);
|
||||
char.isChatOp = false;
|
||||
});
|
||||
connection.onMessage('RTB', (data) => {
|
||||
switch(data.type) {
|
||||
case 'trackadd':
|
||||
state.bookmarkList.push(data.name);
|
||||
state.get(data.name).isBookmarked = true;
|
||||
break;
|
||||
case 'trackrem':
|
||||
state.bookmarkList.splice(state.bookmarkList.indexOf(data.name), 1);
|
||||
state.get(data.name).isBookmarked = false;
|
||||
break;
|
||||
case 'friendadd':
|
||||
state.friendList.push(data.name);
|
||||
state.get(data.name).isFriend = true;
|
||||
break;
|
||||
case 'friendremove':
|
||||
state.friendList.splice(state.friendList.indexOf(data.name), 1);
|
||||
state.get(data.name).isFriend = false;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
5
fchat/common.ts
Normal file
5
fchat/common.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const ltRegex = /</gi, gtRegex = />/gi, ampRegex = /&/gi;
|
||||
|
||||
export function decodeHTML(this: void | never, str: string): string {
|
||||
return str.replace(ltRegex, '<').replace(gtRegex, '>').replace(ampRegex, '&');
|
||||
}
|
160
fchat/connection.ts
Normal file
160
fchat/connection.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import Axios, {AxiosResponse} from 'axios';
|
||||
import * as qs from 'qs';
|
||||
import {Connection as Interfaces, WebSocketConnection} from './interfaces';
|
||||
|
||||
const fatalErrors = [2, 3, 4, 9, 30, 31, 33, 39, 40, 62, -4];
|
||||
const dieErrors = [9, 30, 31, 39];
|
||||
|
||||
async function queryApi(this: void, endpoint: string, data: object): Promise<AxiosResponse> {
|
||||
return Axios.post(`https://www.f-list.net/json/api/${endpoint}`, qs.stringify(data));
|
||||
}
|
||||
|
||||
export default class Connection implements Interfaces.Connection {
|
||||
character: string;
|
||||
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 cleanClose = false;
|
||||
private reconnectTimer: NodeJS.Timer;
|
||||
private ticketProvider: Interfaces.TicketProvider;
|
||||
private reconnectDelay = 0;
|
||||
|
||||
constructor(private readonly socketProvider: new() => WebSocketConnection, private readonly account: string,
|
||||
ticketProvider: Interfaces.TicketProvider | string) {
|
||||
this.ticketProvider = typeof ticketProvider === 'string' ? async() => this.getTicket(ticketProvider) : ticketProvider;
|
||||
}
|
||||
|
||||
async connect(character: string): Promise<void> {
|
||||
this.cleanClose = false;
|
||||
const isReconnect = this.character === character;
|
||||
this.character = character;
|
||||
this.ticket = await this.ticketProvider();
|
||||
await this.invokeHandlers('connecting', isReconnect);
|
||||
const socket = this.socket = new this.socketProvider();
|
||||
socket.onOpen(() => {
|
||||
this.send('IDN', {
|
||||
account: this.account,
|
||||
character: this.character,
|
||||
cname: 'F-Chat',
|
||||
cversion: '3.0',
|
||||
method: 'ticket',
|
||||
ticket: this.ticket
|
||||
});
|
||||
});
|
||||
socket.onMessage((msg: string) => {
|
||||
const type = <keyof Interfaces.ServerCommands>msg.substr(0, 3);
|
||||
const data = msg.length > 6 ? <object>JSON.parse(msg.substr(4)) : undefined;
|
||||
this.handleMessage(type, data);
|
||||
});
|
||||
socket.onClose(async() => {
|
||||
if(!this.cleanClose) {
|
||||
setTimeout(async() => this.connect(this.character), this.reconnectDelay);
|
||||
this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000;
|
||||
}
|
||||
this.socket = undefined;
|
||||
await this.invokeHandlers('closed', !this.cleanClose);
|
||||
});
|
||||
socket.onError((error: Error) => {
|
||||
for(const handler of this.errorHandlers) handler(error);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.cleanClose = true;
|
||||
if(this.socket !== undefined) this.socket.close();
|
||||
}
|
||||
|
||||
async queryApi(endpoint: string, data?: {account?: string, ticket?: string}): Promise<object> {
|
||||
if(data === undefined) data = {};
|
||||
data.account = this.account;
|
||||
data.ticket = this.ticket;
|
||||
let res = <{error: string}>(await queryApi(endpoint, data)).data;
|
||||
if(res.error === 'Invalid ticket.' || res.error === 'Your login ticket has expired (five minutes) or no ticket requested.') {
|
||||
data.ticket = this.ticket = await this.ticketProvider();
|
||||
res = <{error: string}>(await queryApi(endpoint, data)).data;
|
||||
}
|
||||
if(res.error !== '') throw new Error(res.error);
|
||||
return res;
|
||||
}
|
||||
|
||||
onError(handler: (error: Error) => void): void {
|
||||
this.errorHandlers.push(handler);
|
||||
}
|
||||
|
||||
onEvent(type: Interfaces.EventType, handler: Interfaces.EventHandler): void {
|
||||
let handlers = this.connectionHandlers[type];
|
||||
if(handlers === undefined) handlers = this.connectionHandlers[type] = [];
|
||||
handlers.push(handler);
|
||||
}
|
||||
|
||||
offEvent(type: Interfaces.EventType, handler: Interfaces.EventHandler): void {
|
||||
const handlers = this.connectionHandlers[type];
|
||||
if(handlers === undefined) return;
|
||||
handlers.splice(handlers.indexOf(handler), 1);
|
||||
}
|
||||
|
||||
onMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
|
||||
let handlers: (Interfaces.CommandHandler<K>[] | undefined) = this.messageHandlers[type];
|
||||
if(handlers === undefined) handlers = this.messageHandlers[type] = [];
|
||||
handlers.push(handler);
|
||||
}
|
||||
|
||||
offMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
|
||||
const handlers: (Interfaces.CommandHandler<K>[] | undefined) = this.messageHandlers[type];
|
||||
if(handlers === undefined) return;
|
||||
handlers.splice(handlers.indexOf(handler), 1);
|
||||
}
|
||||
|
||||
send<K extends keyof Interfaces.ClientCommands>(command: K, data?: Interfaces.ClientCommands[K]): void {
|
||||
if(this.socket !== undefined)
|
||||
this.socket.send(<string>command + (data !== undefined ? ` ${JSON.stringify(data)}` : ''));
|
||||
}
|
||||
|
||||
//tslint:disable:no-unsafe-any no-any
|
||||
protected handleMessage<T extends keyof Interfaces.ServerCommands>(type: T, data: any): void {
|
||||
switch(type) {
|
||||
case 'VAR':
|
||||
this.vars[data.variable] = data.value;
|
||||
break;
|
||||
case 'PIN':
|
||||
this.send('PIN');
|
||||
break;
|
||||
case 'ERR':
|
||||
if(fatalErrors.indexOf(data.number) !== -1) {
|
||||
const error = new Error(data.message);
|
||||
for(const handler of this.errorHandlers) handler(error);
|
||||
if(dieErrors.indexOf(data.number) !== -1) this.close();
|
||||
else this.socket!.close();
|
||||
}
|
||||
break;
|
||||
case 'NLN':
|
||||
if(data.identity === this.character) {
|
||||
this.invokeHandlers('connected', this.reconnectDelay !== 0); //tslint:disable-line:no-floating-promises
|
||||
this.reconnectDelay = 0;
|
||||
}
|
||||
}
|
||||
const time = new Date();
|
||||
const handlers: Interfaces.CommandHandler<T>[] | undefined = this.messageHandlers[type];
|
||||
if(handlers !== undefined)
|
||||
for(const handler of handlers) handler(data, time);
|
||||
}
|
||||
|
||||
//tslint:enable
|
||||
|
||||
private async getTicket(password: string): Promise<string> {
|
||||
const data = <{ticket?: string, error: string}>(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify(
|
||||
{account: this.account, password, no_friends: true, no_bookmarks: true, no_characters: true}))).data;
|
||||
if(data.ticket !== undefined) return data.ticket;
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
private async invokeHandlers(type: Interfaces.EventType, isReconnect: boolean): Promise<void> {
|
||||
const handlers = this.connectionHandlers[type];
|
||||
if(handlers === undefined) return;
|
||||
for(const handler of handlers) await handler(isReconnect);
|
||||
}
|
||||
}
|
5
fchat/index.ts
Normal file
5
fchat/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export {default as Characters} from './characters';
|
||||
export {default as Channels} from './channels';
|
||||
export {default as ChatConnection} from './connection';
|
||||
export {Connection, Character, Channel, WebSocketConnection} from './interfaces';
|
||||
export {decodeHTML} from './common';
|
238
fchat/interfaces.ts
Normal file
238
fchat/interfaces.ts
Normal file
@ -0,0 +1,238 @@
|
||||
//tslint:disable:no-shadowed-variable
|
||||
export namespace Connection {
|
||||
export type ClientCommands = {
|
||||
ACB: {character: string},
|
||||
AOP: {character: string},
|
||||
BRO: {message: string},
|
||||
CBL: {channel: string},
|
||||
CBU: {character: string, channel: string},
|
||||
CCR: {channel: string},
|
||||
CDS: {channel: string, description: string},
|
||||
CHA: undefined,
|
||||
CIU: {channel: string, character: string},
|
||||
CKU: {channel: string, character: string},
|
||||
COA: {channel: string, character: string},
|
||||
COL: {channel: string},
|
||||
COR: {channel: string, character: string},
|
||||
CRC: {channel: string},
|
||||
CSO: {character: string, channel: string},
|
||||
CTU: {channel: string, character: string, length: number},
|
||||
CUB: {channel: string, character: string},
|
||||
DOP: {character: string},
|
||||
FKS: {
|
||||
kinks: ReadonlyArray<number>, genders?: ReadonlyArray<string>, orientations?: ReadonlyArray<string>,
|
||||
languages?: ReadonlyArray<string>, furryprefs?: ReadonlyArray<string>, roles?: ReadonlyArray<string>
|
||||
},
|
||||
FRL: undefined
|
||||
IDN: {method: 'ticket', account: string, ticket: string, character: string, cname: string, cversion: string},
|
||||
IGN: {action: 'add' | 'delete' | 'notify', character: string} | {action: 'list'},
|
||||
JCH: {channel: string},
|
||||
KIC: {channel: string},
|
||||
KIK: {character: string},
|
||||
KIN: {character: string},
|
||||
LCH: {channel: string},
|
||||
LRP: {channel: string, message: string},
|
||||
MSG: {channel: string, message: string},
|
||||
ORS: undefined,
|
||||
PCR: undefined,
|
||||
PIN: undefined,
|
||||
PRI: {recipient: string, message: string},
|
||||
PRO: {character: string},
|
||||
RLD: {save: string} | undefined,
|
||||
RLL: {channel: string, dice: 'bottle' | string} | {recipient: string, dice: 'bottle' | string},
|
||||
RMO: {channel: string, mode: Channel.Mode},
|
||||
RST: {channel: string, status: 'public' | 'private'},
|
||||
RWD: {character: string},
|
||||
SFC: {action: 'report', report: string, tab?: string, logid: number} | {action: 'confirm', callid: number},
|
||||
STA: {status: Character.Status, statusmsg: string},
|
||||
TMO: {character: string, time: number, reason: string},
|
||||
TPN: {character: string, status: Character.TypingStatus},
|
||||
UNB: {character: string},
|
||||
UPT: undefined,
|
||||
ZZZ: {command: string, arg: string}
|
||||
};
|
||||
|
||||
export type ServerCommands = {
|
||||
ADL: {ops: ReadonlyArray<string>},
|
||||
AOP: {character: string},
|
||||
BRO: {message: string, character: string},
|
||||
CBU: {operator: string, channel: string, character: string},
|
||||
CDS: {channel: string, description: string},
|
||||
CHA: {channels: ReadonlyArray<{name: string, mode: Channel.Mode, characters: number}>},
|
||||
CIU: {sender: string, title: string, name: string},
|
||||
CKU: {operator: string, channel: string, character: string},
|
||||
COA: {character: string, channel: string},
|
||||
COL: {channel: string, oplist: ReadonlyArray<string>},
|
||||
CON: {count: number},
|
||||
COR: {character: string, channel: string},
|
||||
CSO: {character: string, channel: string},
|
||||
CTU: {operator: string, channel: string, length: number, character: string},
|
||||
DOP: {character: string},
|
||||
ERR: {number: number, message: string},
|
||||
FKS: {characters: ReadonlyArray<string>, kinks: ReadonlyArray<number>},
|
||||
FLN: {character: string},
|
||||
FRL: {characters: ReadonlyArray<string>},
|
||||
HLO: {message: string},
|
||||
ICH: {users: ReadonlyArray<{identity: string}>, channel: string, mode: Channel.Mode},
|
||||
IDN: {character: string},
|
||||
IGN: {action: 'add' | 'delete', character: string} | {action: 'list' | 'init', characters: ReadonlyArray<string>}
|
||||
JCH: {channel: string, character: {identity: string}, title: string},
|
||||
KID: {type: 'start' | 'end', message: string} | {type: 'custom', key: number, value: number},
|
||||
LCH: {channel: string, character: string},
|
||||
LIS: {characters: ReadonlyArray<[string, Character.Gender, Character.Status, string]>},
|
||||
LRP: {character: string, message: string, channel: string},
|
||||
MSG: {character: string, message: string, channel: string},
|
||||
NLN: {identity: string, gender: Character.Gender, status: 'online'},
|
||||
ORS: {channels: ReadonlyArray<{name: string, title: string, characters: number}>},
|
||||
PIN: undefined,
|
||||
PRD: {type: 'start' | 'end', message: string} | {type: 'info' | 'select', key: string, value: string},
|
||||
PRI: {character: string, message: string},
|
||||
RLL: {
|
||||
type: 'dice', results: ReadonlyArray<number>, message: string, rolls: ReadonlyArray<string>,
|
||||
character: string, endresult: number, channel: string
|
||||
} | {
|
||||
type: 'dice', results: ReadonlyArray<number>, message: string, rolls: ReadonlyArray<string>,
|
||||
character: string, endresult: number, recipient: string
|
||||
} |
|
||||
{type: 'bottle', message: string, character: string, target: string, channel: string} |
|
||||
{type: 'bottle', message: string, character: string, target: string, recipient: string},
|
||||
RMO: {mode: Channel.Mode, channel: string},
|
||||
RTB: {
|
||||
type: 'comment', target_type: 'newspost' | 'bugreport' | 'changelog' | 'feature',
|
||||
id: number, target_id: number, parent_id: number, name: string, target: string
|
||||
} | {type: 'note', sender: string, subject: string, id: number} | {
|
||||
type: 'grouprequest' | 'bugreport' | 'helpdeskticket' | 'helpdeskreply' | 'featurerequest',
|
||||
name: string, id: number, title?: string
|
||||
} | {type: 'trackadd' | 'trackrem' | 'friendadd' | 'friendremove' | 'friendrequest', name: string},
|
||||
SFC: {action: 'confirm', moderator: string, character: string, timestamp: string, tab: string, logid: number} |
|
||||
{callid: number, action: 'report', report: string, timestamp: string, character: string, tab: string, logid: number},
|
||||
STA: {status: Character.Status, character: string, statusmsg: string},
|
||||
SYS: {message: string, channel?: string},
|
||||
TPN: {character: string, status: Character.TypingStatus},
|
||||
UPT: {time: number, starttime: number, startstring: string, accepted: number, channels: number, users: number, maxusers: number},
|
||||
VAR: {variable: string, value: number | ReadonlyArray<string>}
|
||||
ZZZ: {message: string}
|
||||
};
|
||||
|
||||
export type CommandHandler<T extends keyof ServerCommands> = (data: ServerCommands[T], date: Date) => void;
|
||||
export type TicketProvider = () => Promise<string>;
|
||||
export type EventType = 'connecting' | 'connected' | 'closed';
|
||||
export type EventHandler = (isReconnect: boolean) => Promise<void> | void;
|
||||
|
||||
export interface Vars {
|
||||
readonly chat_max: number
|
||||
readonly priv_max: number
|
||||
readonly lfrp_max: number
|
||||
//readonly cds_max: number
|
||||
readonly lfrp_flood: number
|
||||
readonly msg_flood: number
|
||||
//readonly sta_flood: number
|
||||
readonly permissions: number
|
||||
readonly icon_blacklist: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
readonly character: string
|
||||
readonly vars: Vars
|
||||
connect(character: string): void
|
||||
close(): void
|
||||
onMessage<K extends keyof ServerCommands>(type: K, handler: CommandHandler<K>): void
|
||||
offMessage<K extends keyof ServerCommands>(type: K, handler: CommandHandler<K>): void
|
||||
onEvent(type: EventType, handler: EventHandler): void
|
||||
offEvent(type: EventType, handler: EventHandler): void
|
||||
onError(handler: (error: Error) => void): void
|
||||
send(type: 'CHA' | 'FRL' | 'ORS' | 'PCR' | 'PIN' | 'UPT'): void
|
||||
send<K extends keyof ClientCommands>(type: K, data: ClientCommands[K]): void
|
||||
queryApi(endpoint: string, data?: object): Promise<object>
|
||||
}
|
||||
}
|
||||
export type Connection = Connection.Connection;
|
||||
|
||||
export namespace Character {
|
||||
export type Gender = 'None' | 'Male' | 'Female' | 'Shemale' | 'Herm' | 'Male-Herm' | 'Cunt-boy' | 'Transgender';
|
||||
export type Status = 'offline' | 'online' | 'away' | 'idle' | 'looking' | 'busy' | 'dnd' | 'crown';
|
||||
export type TypingStatus = 'typing' | 'paused' | 'clear';
|
||||
|
||||
export interface State {
|
||||
readonly ownCharacter: Character
|
||||
readonly friends: ReadonlyArray<Character>
|
||||
readonly bookmarks: ReadonlyArray<Character>
|
||||
readonly ignoreList: ReadonlyArray<string>
|
||||
readonly opList: ReadonlyArray<string>
|
||||
readonly friendList: ReadonlyArray<string>
|
||||
readonly bookmarkList: ReadonlyArray<string>
|
||||
|
||||
get(name: string): Character
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
readonly name: string
|
||||
readonly gender: Gender | undefined
|
||||
readonly status: Status
|
||||
readonly statusText: string
|
||||
readonly isFriend: boolean
|
||||
readonly isBookmarked: boolean
|
||||
readonly isChatOp: boolean
|
||||
readonly isIgnored: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type Character = Character.Character;
|
||||
|
||||
export namespace Channel {
|
||||
export type EventHandler = (type: 'join' | 'leave', channel: Channel) => void;
|
||||
|
||||
export interface State {
|
||||
readonly officialChannels: {readonly [key: string]: (ListItem | undefined)};
|
||||
readonly openRooms: {readonly [key: string]: (ListItem | undefined)};
|
||||
readonly joinedChannels: ReadonlyArray<Channel>;
|
||||
|
||||
join(name: string): void;
|
||||
leave(name: string): void;
|
||||
onEvent(handler: EventHandler): void
|
||||
getChannelItem(id: string): ListItem | undefined
|
||||
getChannel(id: string): Channel | undefined
|
||||
}
|
||||
|
||||
export const enum Rank {
|
||||
Member,
|
||||
Op,
|
||||
Owner
|
||||
}
|
||||
|
||||
export type Mode = 'chat' | 'ads' | 'both';
|
||||
|
||||
export interface Member {
|
||||
readonly character: Character,
|
||||
readonly rank: Rank
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly memberCount: number;
|
||||
readonly isJoined: boolean;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly mode: Mode;
|
||||
readonly members: {readonly [key: string]: Member | undefined};
|
||||
readonly sortedMembers: ReadonlyArray<Member>;
|
||||
readonly opList: ReadonlyArray<string>;
|
||||
readonly owner: string;
|
||||
}
|
||||
}
|
||||
|
||||
export type Channel = Channel.Channel;
|
||||
|
||||
export interface WebSocketConnection {
|
||||
close(): void
|
||||
onMessage(handler: (message: string) => void): void
|
||||
onOpen(handler: () => void): void
|
||||
onClose(handler: () => void): void
|
||||
onError(handler: (error: Error) => void): void
|
||||
send(message: string): void
|
||||
}
|
122
less/bbcode.less
Normal file
122
less/bbcode.less
Normal file
@ -0,0 +1,122 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.blackColor {
|
||||
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;
|
||||
}
|
||||
|
||||
span.indentText {
|
||||
padding-left: 3em;
|
||||
}
|
||||
|
||||
.characterAvatarIcon {
|
||||
display: inline;
|
||||
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 {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
a {
|
||||
text-decoration: underline;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-domain {
|
||||
color: @gray-light;
|
||||
}
|
15
less/bbcode_editor.less
Normal file
15
less/bbcode_editor.less
Normal file
@ -0,0 +1,15 @@
|
||||
.bbcodeEditorButton {
|
||||
.btn-default();
|
||||
padding: (@padding-base-vertical/2.0) (@padding-base-horizontal/2.0);
|
||||
}
|
||||
|
||||
.bbcodeTextAreaTextArea {
|
||||
textarea& {
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.bbcodePreviewWarnings {
|
||||
.alert();
|
||||
.alert-danger();
|
||||
}
|
78
less/character_editor.less
Normal file
78
less/character_editor.less
Normal file
@ -0,0 +1,78 @@
|
||||
.bbcodeTextArea {
|
||||
max-width: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.kinkChoice.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.characterEditorSidebar {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.characterList.characterListSelected {
|
||||
border-width: 2px;
|
||||
border-color: @characterListSelectedColor;
|
||||
}
|
||||
|
||||
// Character image editor.
|
||||
.characterImage {
|
||||
width: 250px;
|
||||
height: 300px;
|
||||
border-radius: 25px;
|
||||
overflow: hidden;
|
||||
border: 2px #111 solid;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.characterImage.characterImageSelected {
|
||||
border-color: @characterListSelectedColor;
|
||||
}
|
||||
|
||||
.characterImagePreview {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
float: left;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.characterImageActions {
|
||||
width: 46px;
|
||||
float: right;
|
||||
padding-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.characterImageActions a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-block;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.characterImage a img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.characterImageDescription {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
clear: both;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.kink-list-enter-active, .kink-list-leave-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.kink-list-enter, .kink-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
97
less/character_page.less
Normal file
97
less/character_page.less
Normal file
@ -0,0 +1,97 @@
|
||||
// Kinkes
|
||||
.subkinkList.closed {
|
||||
display: none;
|
||||
}
|
||||
.subkink {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.characterPageAvatar {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
// Inline images
|
||||
.imageBlock {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// Quick Compare
|
||||
.stockKink.quickCompareActive {
|
||||
border: 1px solid @quickCompareActiveColor;
|
||||
}
|
||||
.stockKink.quickCompareFave {
|
||||
background-color: @quickCompareFaveColor;
|
||||
}
|
||||
.stockKink.quickCompareYes {
|
||||
background-color: @quickCompareYesColor;
|
||||
}
|
||||
.stockKink.quickCompareMaybe {
|
||||
background-color: @quickCompareMaybeColor;
|
||||
}
|
||||
.stockKink.quickCompareNo {
|
||||
background-color: @quickCompareNoColor;
|
||||
}
|
||||
|
||||
// Kink Group Highlighting
|
||||
.highlightedKink {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
// Guestbook
|
||||
.guestbookPager {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.characterSubTitle {
|
||||
font-size: @font-size-small;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.characterPageName {
|
||||
font-size: @font-size-h3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.characterImages {
|
||||
.container-fluid();
|
||||
}
|
||||
|
||||
.characterPageImage {
|
||||
.col-xs-2();
|
||||
.img-thumbnail();
|
||||
border: none;
|
||||
display: inline-block;
|
||||
img {
|
||||
.center-block();
|
||||
}
|
||||
}
|
||||
|
||||
.guestbook-post {
|
||||
.row();
|
||||
}
|
||||
|
||||
.guestbook-avatar {
|
||||
width: 50px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.guestbook-contents {
|
||||
.well();
|
||||
}
|
||||
|
||||
.guestbook-contents.deleted {
|
||||
.alert-warning();
|
||||
}
|
||||
|
||||
.guestbook-reply {
|
||||
.guestbook-body {
|
||||
:before {
|
||||
content: "Reply: ";
|
||||
}
|
||||
}
|
||||
.well();
|
||||
.alert-info();
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user