Initial commit.

This commit is contained in:
MayaWolf 2017-09-02 03:50:31 +02:00
parent c1c4ed82d6
commit 878389f717
129 changed files with 15284 additions and 0 deletions

7
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

BIN
chat/assets/attention.ogg Normal file

Binary file not shown.

BIN
chat/assets/attention.wav Normal file

Binary file not shown.

BIN
chat/assets/chat.mp3 Normal file

Binary file not shown.

BIN
chat/assets/chat.ogg Normal file

Binary file not shown.

BIN
chat/assets/chat.wav Normal file

Binary file not shown.

BIN
chat/assets/login.mp3 Normal file

Binary file not shown.

BIN
chat/assets/login.ogg Normal file

Binary file not shown.

BIN
chat/assets/login.wav Normal file

Binary file not shown.

BIN
chat/assets/logout.mp3 Normal file

Binary file not shown.

BIN
chat/assets/logout.ogg Normal file

Binary file not shown.

BIN
chat/assets/logout.wav Normal file

Binary file not shown.

BIN
chat/assets/modalert.mp3 Normal file

Binary file not shown.

BIN
chat/assets/modalert.ogg Normal file

Binary file not shown.

BIN
chat/assets/modalert.wav Normal file

Binary file not shown.

BIN
chat/assets/newnote.mp3 Normal file

Binary file not shown.

BIN
chat/assets/newnote.ogg Normal file

Binary file not shown.

BIN
chat/assets/newnote.wav Normal file

Binary file not shown.

BIN
chat/assets/system.mp3 Normal file

Binary file not shown.

BIN
chat/assets/system.ogg Normal file

Binary file not shown.

BIN
chat/assets/system.wav Normal file

Binary file not shown.

129
chat/bbcode.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
declare module 'qs' {
export function stringify(data: object): string;
}

352
chat/slash_commands.ts Normal file
View 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
View 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
View 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

View 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
View 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">&times;</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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

BIN
electron/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
electron/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

67
electron/chat.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
import * as qs from 'querystring';
export = qs;

51
electron/spellchecker.ts Normal file
View 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
View 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"
]
}

View 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
View 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

File diff suppressed because it is too large Load Diff

15
export-loader.js Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
const ltRegex = /&lt;/gi, gtRegex = /&gt;/gi, ampRegex = /&amp;/gi;
export function decodeHTML(this: void | never, str: string): string {
return str.replace(ltRegex, '<').replace(gtRegex, '>').replace(ampRegex, '&');
}

160
fchat/connection.ts Normal file
View 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
View 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
View 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
View 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
View 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();
}

View 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
View 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