0.2.18 - add webchat
This commit is contained in:
parent
04ab2f96da
commit
4a7d97f17a
|
@ -2,4 +2,4 @@ node_modules/
|
|||
/electron/app
|
||||
/electron/dist
|
||||
/mobile/www
|
||||
*.vue.ts
|
||||
/webchat/dist
|
|
@ -76,17 +76,23 @@
|
|||
private undoStack: string[] = [];
|
||||
private undoIndex = 0;
|
||||
private lastInput = 0;
|
||||
//tslint:disable:strict-boolean-expressions
|
||||
private resizeListener!: () => void;
|
||||
|
||||
created(): void {
|
||||
this.parser = new CoreBBCodeParser();
|
||||
this.resizeListener = () => {
|
||||
const styles = getComputedStyle(this.element);
|
||||
this.maxHeight = parseInt(styles.maxHeight!, 10) || 250;
|
||||
this.minHeight = parseInt(styles.minHeight!, 10) || 60;
|
||||
};
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
this.element = <HTMLTextAreaElement>this.$refs['input'];
|
||||
const styles = getComputedStyle(this.element);
|
||||
this.maxHeight = parseInt(styles.maxHeight! , 10);
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
this.minHeight = parseInt(styles.minHeight!, 10) || 50;
|
||||
this.maxHeight = parseInt(styles.maxHeight!, 10) || 250;
|
||||
this.minHeight = parseInt(styles.minHeight!, 10) || 60;
|
||||
setInterval(() => {
|
||||
if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) {
|
||||
if(this.undoStack.length >= 30) this.undoStack.pop();
|
||||
|
@ -101,6 +107,12 @@
|
|||
this.sizer.style.top = '0';
|
||||
this.sizer.style.visibility = 'hidden';
|
||||
this.resize();
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
}
|
||||
//tslint:enable
|
||||
|
||||
destroyed(): void {
|
||||
window.removeEventListener('resize', this.resizeListener);
|
||||
}
|
||||
|
||||
get finalClasses(): string | undefined {
|
||||
|
@ -227,8 +239,10 @@
|
|||
resize(): void {
|
||||
this.sizer.style.fontSize = this.element.style.fontSize;
|
||||
this.sizer.style.lineHeight = this.element.style.lineHeight;
|
||||
this.sizer.style.width = `${this.element.offsetWidth}px`;
|
||||
this.sizer.textContent = this.element.value;
|
||||
this.element.style.height = `${Math.max(Math.min(this.sizer.scrollHeight, this.maxHeight), this.minHeight)}px`;
|
||||
this.sizer.style.width = '0';
|
||||
}
|
||||
|
||||
onPaste(e: ClipboardEvent): void {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {BBCodeCustomTag, BBCodeParser, BBCodeSimpleTag} from './parser';
|
||||
import {BBCodeCustomTag, BBCodeParser, BBCodeSimpleTag, BBCodeTextTag} from './parser';
|
||||
|
||||
const urlFormat = '((?:https?|ftps?|irc)://[^\\s/$.?#"\']+\\.[^\\s"]+)';
|
||||
export const findUrlRegex = new RegExp(`(\\[url[=\\]]\\s*)?${urlFormat}`, 'gi');
|
||||
|
@ -21,31 +21,27 @@ 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);
|
||||
this.addTag(new BBCodeSimpleTag('b', 'strong'));
|
||||
this.addTag(new BBCodeSimpleTag('i', 'em'));
|
||||
this.addTag(new BBCodeSimpleTag('u', 'u'));
|
||||
this.addTag(new BBCodeSimpleTag('s', 'del'));
|
||||
this.addTag(new BBCodeSimpleTag('noparse', 'span', [], []));
|
||||
this.addTag(new BBCodeSimpleTag('sub', 'sub', [], ['b', 'i', 'u', 's']));
|
||||
this.addTag(new BBCodeSimpleTag('sup', 'sup', [], ['b', 'i', 'u', 's']));
|
||||
this.addTag(new BBCodeCustomTag('color', (parser, parent, param) => {
|
||||
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;
|
||||
return undefined;
|
||||
}
|
||||
el.className = `${param}Text`;
|
||||
return el;
|
||||
}));
|
||||
this.addTag('url', new BBCodeCustomTag('url', (parser, parent, _) => {
|
||||
const el = parser.createElement('span');
|
||||
el.className = `${param}Text`;
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, _, param) => {
|
||||
const content = element.textContent!.trim();
|
||||
while(element.firstChild !== null) element.removeChild(element.firstChild);
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('url', (parser, parent, param, content) => {
|
||||
const element = parser.createElement('span');
|
||||
parent.appendChild(element);
|
||||
|
||||
let url: string, display: string = content;
|
||||
if(param.length > 0) {
|
||||
|
@ -80,7 +76,8 @@ export class CoreBBCodeParser extends BBCodeParser {
|
|||
span.textContent = ` [${domain(url)}]`;
|
||||
(<HTMLElement & {bbcodeHide: true}>span).bbcodeHide = true;
|
||||
element.appendChild(span);
|
||||
}, []));
|
||||
return element;
|
||||
}));
|
||||
}
|
||||
|
||||
parseEverything(input: string): HTMLElement {
|
||||
|
|
238
bbcode/parser.ts
238
bbcode/parser.ts
|
@ -1,4 +1,4 @@
|
|||
export abstract class BBCodeTag {
|
||||
abstract class BBCodeTag {
|
||||
noClosingTag = false;
|
||||
allowedTags: {[tag: string]: boolean | undefined} | undefined;
|
||||
|
||||
|
@ -17,11 +17,7 @@ export abstract class BBCodeTag {
|
|||
this.allowedTags[tag] = true;
|
||||
}
|
||||
|
||||
//tslint:disable-next-line:no-empty
|
||||
afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement | undefined, ____?: string): void {
|
||||
}
|
||||
|
||||
abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined;
|
||||
abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string, content: string): HTMLElement | undefined;
|
||||
}
|
||||
|
||||
export class BBCodeSimpleTag extends BBCodeTag {
|
||||
|
@ -42,39 +38,25 @@ export class BBCodeSimpleTag extends BBCodeTag {
|
|||
}
|
||||
}
|
||||
|
||||
export type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement | undefined;
|
||||
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[]) {
|
||||
constructor(tag: string, private customCreator: (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement | undefined,
|
||||
tagList?: string[]) {
|
||||
super(tag, tagList);
|
||||
}
|
||||
|
||||
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined {
|
||||
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 | undefined,
|
||||
public line: number, public column: number) {
|
||||
export class BBCodeTextTag extends BBCodeTag {
|
||||
constructor(tag: string, private customCreator: (parser: BBCodeParser, parent: HTMLElement,
|
||||
param: string, content: string) => HTMLElement | undefined) {
|
||||
super(tag, []);
|
||||
}
|
||||
|
||||
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)));
|
||||
createElement(parser: BBCodeParser, parent: HTMLElement, param: string, content: string): HTMLElement | undefined {
|
||||
return this.customCreator(parser, parent, param, content);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -83,8 +65,8 @@ export class BBCodeParser {
|
|||
private _tags: {[tag: string]: BBCodeTag | undefined} = {};
|
||||
private _line = -1;
|
||||
private _column = -1;
|
||||
private _currentTag!: ParserTag;
|
||||
private _storeWarnings = false;
|
||||
private _currentTag!: {tag: string, line: number, column: number};
|
||||
|
||||
parseEverything(input: string): HTMLElement {
|
||||
if(input.length === 0)
|
||||
|
@ -92,23 +74,22 @@ export class BBCodeParser {
|
|||
this._warnings = [];
|
||||
this._line = 1;
|
||||
this._column = 1;
|
||||
const stack: ParserTag[] = this.parse(input, 0, input.length);
|
||||
const parent = document.createElement('span');
|
||||
parent.className = 'bbcode';
|
||||
this._currentTag = {tag: '<root>', line: 1, column: 1};
|
||||
this.parse(input, 0, undefined, parent, () => true);
|
||||
|
||||
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;
|
||||
//if(process.env.NODE_ENV !== 'production' && this._warnings.length > 0)
|
||||
// console.log(this._warnings);
|
||||
return parent;
|
||||
}
|
||||
|
||||
createElement<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K] {
|
||||
return document.createElement(tag);
|
||||
}
|
||||
|
||||
addTag(tag: string, impl: BBCodeTag): void {
|
||||
this._tags[tag] = impl;
|
||||
addTag(impl: BBCodeTag): void {
|
||||
this._tags[impl.tag] = impl;
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
|
@ -133,126 +114,85 @@ export class BBCodeParser {
|
|||
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;
|
||||
private parse(input: string, start: number, self: BBCodeTag | undefined, parent: HTMLElement | undefined,
|
||||
isAllowed: (tag: string) => boolean): number {
|
||||
let currentTag = this._currentTag;
|
||||
const selfAllowed = self !== undefined ? isAllowed(self.tag) : true;
|
||||
if(self !== undefined) {
|
||||
const parentAllowed = isAllowed;
|
||||
isAllowed = (name) => self.isAllowed(name) && parentAllowed(name);
|
||||
currentTag = this._currentTag = {tag: self.tag, line: this._line, column: this._column};
|
||||
}
|
||||
|
||||
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 rootTag = new ParserTag('<root>', '', this.createElement('span'), undefined, 1, 1);
|
||||
stack.push(rootTag);
|
||||
this._currentTag = rootTag;
|
||||
let paramStart = -1;
|
||||
for(let i = start; i < end; ++i) {
|
||||
let tagStart = -1, paramStart = -1, mark = start;
|
||||
for(let i = start; i < input.length; ++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;
|
||||
if(c === '[') {
|
||||
tagStart = i;
|
||||
paramStart = -1;
|
||||
} else if(c === '=' && paramStart === -1)
|
||||
paramStart = i;
|
||||
else if(c === ']') {
|
||||
const paramIndex = paramStart === -1 ? i : paramStart;
|
||||
let tagKey = input.substring(tagStart + 1, paramIndex).trim().toLowerCase();
|
||||
if(tagKey.length === 0) {
|
||||
tagStart = -1;
|
||||
continue;
|
||||
}
|
||||
const param = paramStart > tagStart ? input.substring(paramStart + 1, i).trim() : '';
|
||||
const close = tagKey[0] === '/';
|
||||
if(close) tagKey = tagKey.substr(1).trim();
|
||||
if(this._tags[tagKey] === undefined) {
|
||||
tagStart = -1;
|
||||
continue;
|
||||
}
|
||||
if(!close) {
|
||||
const tag = this._tags[tagKey]!;
|
||||
const allowed = isAllowed(tagKey);
|
||||
if(parent !== undefined) {
|
||||
parent.appendChild(document.createTextNode(input.substring(mark, allowed ? tagStart : i + 1)));
|
||||
mark = i + 1;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const tag = this._tags[tagKey]!;
|
||||
if(!allowed) {
|
||||
ignoreNextClosingTag(tagKey);
|
||||
quickReset(i);
|
||||
continue;
|
||||
}
|
||||
const parent = stackTop().element;
|
||||
const el: HTMLElement | undefined = tag.createElement(this, parent, param);
|
||||
if(el === undefined) {
|
||||
quickReset(i);
|
||||
continue;
|
||||
}
|
||||
(<HTMLElement & {bbcodeTag: string}>el).bbcodeTag = tagKey;
|
||||
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>el).bbcodeParam = param;
|
||||
if(!this._tags[tagKey]!.noClosingTag)
|
||||
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
|
||||
} else if(ignoreClosing[tagKey] > 0) {
|
||||
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(!allowed || parent === undefined) {
|
||||
i = this.parse(input, i + 1, tag, parent, isAllowed);
|
||||
mark = i + 1;
|
||||
continue;
|
||||
}
|
||||
let element: HTMLElement | undefined;
|
||||
if(tag instanceof BBCodeTextTag) {
|
||||
i = this.parse(input, i + 1, tag, undefined, isAllowed);
|
||||
element = tag.createElement(this, parent, param, input.substring(mark, input.lastIndexOf('[', i)));
|
||||
} else {
|
||||
element = tag.createElement(this, parent, param, '');
|
||||
if(!tag.noClosingTag)
|
||||
i = this.parse(input, i + 1, tag, element !== undefined ? element : parent, isAllowed);
|
||||
}
|
||||
mark = i + 1;
|
||||
this._currentTag = currentTag;
|
||||
if(element === undefined) continue;
|
||||
(<HTMLElement & {bbcodeTag: string}>element).bbcodeTag = tagKey;
|
||||
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>element).bbcodeParam = param;
|
||||
} else if(self !== undefined) { //tslint:disable-line:curly
|
||||
if(self.tag === tagKey) {
|
||||
if(parent !== undefined)
|
||||
parent.appendChild(document.createTextNode(input.substring(mark, selfAllowed ? tagStart : i + 1)));
|
||||
return i;
|
||||
} else if(!selfAllowed)
|
||||
return tagStart - 1;
|
||||
else if(isAllowed(tagKey))
|
||||
this.warning(`Unexpected closing ${tagKey} tag. Needed ${self} tag instead.`);
|
||||
} else if(isAllowed(tagKey)) this.warning(`Found closing ${tagKey} tag that was never opened.`);
|
||||
}
|
||||
}
|
||||
if(start < input.length)
|
||||
stackTop().append(input, start, input.length);
|
||||
|
||||
return stack;
|
||||
if(mark < input.length && parent !== undefined) {
|
||||
parent.appendChild(document.createTextNode(input.substring(mark)));
|
||||
mark = input.length;
|
||||
}
|
||||
if(self !== undefined) this.warning('Automatically closing tag at end of input.');
|
||||
return mark;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import {CoreBBCodeParser} from './core';
|
||||
import {InlineDisplayMode} from './interfaces';
|
||||
import {BBCodeCustomTag, BBCodeSimpleTag} from './parser';
|
||||
import {BBCodeCustomTag, BBCodeSimpleTag, BBCodeTextTag} from './parser';
|
||||
|
||||
interface InlineImage {
|
||||
id: number
|
||||
|
@ -39,8 +39,8 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
super();
|
||||
const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
|
||||
hrTag.noClosingTag = true;
|
||||
this.addTag('hr', hrTag);
|
||||
this.addTag('quote', new BBCodeCustomTag('quote', (parser, parent, param) => {
|
||||
this.addTag(hrTag);
|
||||
this.addTag(new BBCodeCustomTag('quote', (parser, parent, param) => {
|
||||
if(param !== '')
|
||||
parser.warning('Unexpected paramter on quote tag.');
|
||||
const element = parser.createElement('blockquote');
|
||||
|
@ -51,15 +51,23 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
parent.appendChild(element);
|
||||
return element;
|
||||
}));
|
||||
this.addTag('left', new BBCodeSimpleTag('left', 'span', ['leftText']));
|
||||
this.addTag('right', new BBCodeSimpleTag('right', 'span', ['rightText']));
|
||||
this.addTag('center', new BBCodeSimpleTag('center', 'span', ['centerText']));
|
||||
this.addTag('justify', new BBCodeSimpleTag('justify', 'span', ['justifyText']));
|
||||
this.addTag('big', new BBCodeSimpleTag('big', 'span', ['bigText'], ['url', 'i', 'u', 'b', 'color', 's']));
|
||||
this.addTag('small', new BBCodeSimpleTag('small', 'span', ['smallText'], ['url', 'i', 'u', 'b', 'color', 's']));
|
||||
this.addTag('indent', new BBCodeSimpleTag('indent', 'div', ['indentText']));
|
||||
this.addTag('heading', new BBCodeSimpleTag('heading', 'h2', [], ['url', 'i', 'u', 'b', 'color', 's']));
|
||||
this.addTag('collapse', new BBCodeCustomTag('collapse', (parser, parent, param) => {
|
||||
this.addTag(new BBCodeSimpleTag('left', 'span', ['leftText']));
|
||||
this.addTag(new BBCodeSimpleTag('right', 'span', ['rightText']));
|
||||
this.addTag(new BBCodeSimpleTag('center', 'span', ['centerText']));
|
||||
this.addTag(new BBCodeSimpleTag('justify', 'span', ['justifyText']));
|
||||
this.addTag(new BBCodeSimpleTag('big', 'span', ['bigText'], ['url', 'i', 'u', 'b', 'color', 's']));
|
||||
this.addTag(new BBCodeSimpleTag('small', 'span', ['smallText'], ['url', 'i', 'u', 'b', 'color', 's']));
|
||||
this.addTag(new BBCodeSimpleTag('indent', 'div', ['indentText']));
|
||||
this.addTag(new BBCodeSimpleTag('heading', 'h2', [], ['url', 'i', 'u', 'b', 'color', 's']));
|
||||
this.addTag(new BBCodeSimpleTag('row', 'div', ['row']));
|
||||
this.addTag(new BBCodeCustomTag('col', (parser, parent, param) => {
|
||||
const col = parser.createElement('div');
|
||||
col.className = param === '1' ? 'col-lg-3 col-md-4 col-12' : param === '2' ? 'col-lg-4 col-md-6 col-12' :
|
||||
param === '3' ? 'col-lg-6 col-md-8 col-12' : 'col-md';
|
||||
parent.appendChild(col);
|
||||
return col;
|
||||
}));
|
||||
this.addTag(new BBCodeCustomTag('collapse', (parser, parent, param) => {
|
||||
if(param === '') { //tslint:disable-line:curly
|
||||
parser.warning('title parameter is required.');
|
||||
// HACK: Compatability fix with old site. Titles are not trimmed on old site, so empty collapse titles need to be allowed.
|
||||
|
@ -76,25 +84,33 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
headerText.appendChild(document.createTextNode(param));
|
||||
outer.appendChild(headerText);
|
||||
const body = parser.createElement('div');
|
||||
body.className = 'card-body bbcode-collapse-body closed';
|
||||
body.className = 'bbcode-collapse-body';
|
||||
body.style.height = '0';
|
||||
outer.appendChild(body);
|
||||
const inner = parser.createElement('div');
|
||||
inner.className = 'card-body';
|
||||
body.appendChild(inner);
|
||||
let timeout: number;
|
||||
headerText.addEventListener('click', () => {
|
||||
const isCollapsed = parseInt(body.style.height!, 10) === 0;
|
||||
body.style.height = isCollapsed ? `${body.scrollHeight}px` : '0';
|
||||
if(isCollapsed) timeout = window.setTimeout(() => body.style.height = '', 200);
|
||||
else {
|
||||
clearTimeout(timeout);
|
||||
body.style.transition = 'initial';
|
||||
setImmediate(() => {
|
||||
body.style.transition = '';
|
||||
body.style.height = '0';
|
||||
});
|
||||
}
|
||||
body.style.height = `${body.scrollHeight}px`;
|
||||
icon.className = `fas fa-chevron-${isCollapsed ? 'up' : 'down'}`;
|
||||
});
|
||||
parent.appendChild(outer);
|
||||
return body;
|
||||
return inner;
|
||||
}));
|
||||
this.addTag('user', new BBCodeCustomTag('user', (parser, parent, _) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, parent, param) => {
|
||||
this.addTag(new BBCodeTextTag('user', (parser, parent, param, content) => {
|
||||
if(param !== '')
|
||||
parser.warning('Unexpected parameter on user tag.');
|
||||
const content = element.innerText;
|
||||
if(!usernameRegex.test(content))
|
||||
return;
|
||||
const a = parser.createElement('a');
|
||||
|
@ -102,16 +118,12 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
a.target = '_blank';
|
||||
a.className = 'character-link';
|
||||
a.appendChild(document.createTextNode(content));
|
||||
parent.replaceChild(a, element);
|
||||
}, []));
|
||||
this.addTag('icon', new BBCodeCustomTag('icon', (parser, parent, _) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, parent, param) => {
|
||||
parent.appendChild(a);
|
||||
return a;
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('icon', (parser, parent, param, content) => {
|
||||
if(param !== '')
|
||||
parser.warning('Unexpected parameter on icon tag.');
|
||||
const content = element.innerText;
|
||||
if(!usernameRegex.test(content))
|
||||
return;
|
||||
const a = parser.createElement('a');
|
||||
|
@ -120,17 +132,14 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
const img = parser.createElement('img');
|
||||
img.src = `${this.settings.staticDomain}images/avatar/${content.toLowerCase()}.png`;
|
||||
img.className = 'character-avatar icon';
|
||||
img.title = img.alt = content;
|
||||
a.appendChild(img);
|
||||
parent.replaceChild(a, element);
|
||||
}, []));
|
||||
this.addTag('eicon', new BBCodeCustomTag('eicon', (parser, parent, _) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, parent, param) => {
|
||||
parent.appendChild(a);
|
||||
return a;
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('eicon', (parser, parent, param, content) => {
|
||||
if(param !== '')
|
||||
parser.warning('Unexpected parameter on eicon tag.');
|
||||
const content = element.innerText;
|
||||
|
||||
if(!usernameRegex.test(content))
|
||||
return;
|
||||
|
@ -140,14 +149,11 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
const img = parser.createElement('img');
|
||||
img.src = `${this.settings.staticDomain}images/eicon/${content.toLowerCase()}${extension}`;
|
||||
img.className = 'character-avatar icon';
|
||||
parent.replaceChild(img, element);
|
||||
}, []));
|
||||
this.addTag('img', new BBCodeCustomTag('img', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (p, element, parent, param) => {
|
||||
const content = element.textContent!;
|
||||
img.title = img.alt = content;
|
||||
parent.appendChild(img);
|
||||
return img;
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('img', (p, parent, param, content) => {
|
||||
const parser = <StandardBBCodeParser>p;
|
||||
if(!this.allowInlines) {
|
||||
parser.warning('Inline images are not allowed here.');
|
||||
|
@ -168,24 +174,26 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
|
|||
return undefined;
|
||||
}
|
||||
inline.name = content;
|
||||
let element: HTMLElement;
|
||||
if(displayMode === InlineDisplayMode.DISPLAY_NONE || (displayMode === InlineDisplayMode.DISPLAY_SFW && inline.nsfw)) {
|
||||
const el = parser.createElement('a');
|
||||
const el = element = parser.createElement('a');
|
||||
el.className = 'unloadedInline';
|
||||
el.href = '#';
|
||||
el.dataset.inlineId = param;
|
||||
el.onclick = () => {
|
||||
Array.prototype.forEach.call(document.getElementsByClassName('unloadedInline'), ((e: HTMLElement) => {
|
||||
const showInline = parser.inlines![e.dataset.inlineId!];
|
||||
Array.from(document.getElementsByClassName('unloadedInline')).forEach((e) => {
|
||||
const showInline = parser.inlines![(<HTMLElement>e).dataset.inlineId!];
|
||||
if(typeof showInline !== 'object') return;
|
||||
e.parentElement!.replaceChild(parser.createInline(showInline), e);
|
||||
}));
|
||||
});
|
||||
return false;
|
||||
};
|
||||
const prefix = inline.nsfw ? '[NSFW Inline] ' : '[Inline] ';
|
||||
el.appendChild(document.createTextNode(prefix));
|
||||
parent.replaceChild(el, element);
|
||||
} else parent.replaceChild(parser.createInline(inline), element);
|
||||
}, []));
|
||||
parent.appendChild(el);
|
||||
} else parent.appendChild(element = parser.createInline(inline));
|
||||
return element;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,40 +31,43 @@
|
|||
import Modal from '../components/Modal.vue';
|
||||
import Channels from '../fchat/channels';
|
||||
import Characters from '../fchat/characters';
|
||||
import {Keys} from '../keys';
|
||||
import ChatView from './ChatView.vue';
|
||||
import {errorToString} from './common';
|
||||
import {errorToString, getKey} from './common';
|
||||
import Conversations from './conversations';
|
||||
import core from './core';
|
||||
import l from './localize';
|
||||
|
||||
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string, bbcodeHide?: boolean};
|
||||
|
||||
function copyNode(str: string, node: BBCodeNode, range: Range, flags: {endFound?: true, rootFound?: true}): string {
|
||||
function copyNode(str: string, node: BBCodeNode, range: Range, flags: {endFound?: true}): string {
|
||||
if(node === range.endContainer) flags.endFound = true;
|
||||
if(node.bbcodeTag !== undefined)
|
||||
str = `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]${str}[/${node.bbcodeTag}]`;
|
||||
if(node.nextSibling !== null && !flags.endFound) {
|
||||
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\n';
|
||||
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\r\n';
|
||||
str += scanNode(node.nextSibling!, range, flags);
|
||||
}
|
||||
if(node.parentElement === null) flags.rootFound = true;
|
||||
if(flags.rootFound && flags.endFound) return str;
|
||||
if(node.parentElement === null) return str;
|
||||
return copyNode(str, node.parentNode!, range, flags);
|
||||
}
|
||||
|
||||
function scanNode(node: BBCodeNode, range: Range, flags: {endFound?: true}): string {
|
||||
if(node.bbcodeHide) return '';
|
||||
if(node === range.endContainer) {
|
||||
flags.endFound = true;
|
||||
return node.nodeValue!.substr(0, range.endOffset);
|
||||
}
|
||||
function scanNode(node: BBCodeNode, range: Range, flags: {endFound?: true}, hide?: boolean): string {
|
||||
let str = '';
|
||||
hide = hide || node.bbcodeHide;
|
||||
if(node === range.endContainer) {
|
||||
if(node instanceof HTMLElement && node.children.length === 1 && node.firstElementChild instanceof HTMLImageElement)
|
||||
str += scanNode(node.firstElementChild, range, flags, hide);
|
||||
flags.endFound = true;
|
||||
}
|
||||
if(node.bbcodeTag !== undefined) str += `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]`;
|
||||
if(node instanceof Text) str += node.nodeValue;
|
||||
if(node.firstChild !== null) str += scanNode(node.firstChild, range, flags);
|
||||
if(node instanceof Text) str += node === range.endContainer ? node.nodeValue!.substr(0, range.endOffset) : node.nodeValue;
|
||||
else if(node instanceof HTMLImageElement) str += node.alt;
|
||||
if(node.firstChild !== null && !flags.endFound) str += scanNode(node.firstChild, range, flags, hide);
|
||||
if(node.bbcodeTag !== undefined) str += `[/${node.bbcodeTag}]`;
|
||||
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\n';
|
||||
if(node.nextSibling !== null && !flags.endFound) str += scanNode(node.nextSibling, range, flags);
|
||||
return str;
|
||||
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\r\n';
|
||||
if(node.nextSibling !== null && !flags.endFound) str += scanNode(node.nextSibling, range, flags, hide);
|
||||
return hide ? '' : str;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
@ -80,16 +83,36 @@
|
|||
connecting = false;
|
||||
connected = false;
|
||||
l = l;
|
||||
copyPlain = false;
|
||||
|
||||
mounted(): void {
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if(!this.connected) return;
|
||||
e.returnValue = l('chat.confirmLeave');
|
||||
return l('chat.confirmLeave');
|
||||
});
|
||||
document.addEventListener('copy', ((e: ClipboardEvent) => {
|
||||
if(this.copyPlain) {
|
||||
this.copyPlain = false;
|
||||
return;
|
||||
}
|
||||
const selection = document.getSelection();
|
||||
if(selection.isCollapsed) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
e.clipboardData.setData('text/plain', copyNode(range.startContainer.nodeValue!.substr(range.startOffset),
|
||||
range.startContainer, range, {}));
|
||||
const start = range.startContainer;
|
||||
let startValue = start.nodeValue !== null ?
|
||||
start.nodeValue.substring(range.startOffset, start === range.endContainer ? range.endOffset : undefined) : '';
|
||||
if(start instanceof HTMLElement && start.children.length === 1 && start.firstElementChild instanceof HTMLImageElement)
|
||||
startValue += scanNode(start.firstElementChild, range, {});
|
||||
e.clipboardData.setData('text/plain', copyNode(startValue, start, range, {}));
|
||||
e.preventDefault();
|
||||
}) as EventListener);
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if(getKey(e) === Keys.KeyC && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) {
|
||||
this.copyPlain = true;
|
||||
document.execCommand('copy');
|
||||
}
|
||||
});
|
||||
core.register('characters', Characters(core.connection));
|
||||
core.register('channels', Channels(core.connection, core.characters));
|
||||
core.register('conversations', Conversations());
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
@touchend="$refs['userMenu'].handleEvent($event)">
|
||||
<sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars">
|
||||
<img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/>
|
||||
{{ownCharacter.name}}
|
||||
<a href="#" target="_blank" :href="ownCharacterLink" class="btn" style="margin-right:5px">{{ownCharacter.name}}</a>
|
||||
<a href="#" @click.prevent="logOut" class="btn"><i class="fa fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
|
||||
<div>
|
||||
{{l('chat.status')}}
|
||||
|
@ -36,7 +36,7 @@
|
|||
<span>{{conversation.character.name}}</span>
|
||||
<div style="text-align:right;line-height:0">
|
||||
<span class="fas"
|
||||
:class="{'fa-comment-alt': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
|
||||
:class="{'fa-comment-dots': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
|
||||
></span><span class="fa fa-reply" v-show="needsReply(conversation)"></span>
|
||||
<span class="pin fa fa-thumbtack" :class="{'active': conversation.isPinned}" @mousedown.prevent
|
||||
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
|
||||
|
@ -97,7 +97,7 @@
|
|||
import {Keys} from '../keys';
|
||||
import ChannelList from './ChannelList.vue';
|
||||
import CharacterSearch from './CharacterSearch.vue';
|
||||
import {characterImage, getKey} from './common';
|
||||
import {characterImage, getKey, profileLink} from './common';
|
||||
import ConversationView from './ConversationView.vue';
|
||||
import core from './core';
|
||||
import {Character, Connection, Conversation} from './interfaces';
|
||||
|
@ -269,6 +269,10 @@
|
|||
return core.characters.ownCharacter;
|
||||
}
|
||||
|
||||
get ownCharacterLink(): string {
|
||||
return profileLink(core.characters.ownCharacter.name);
|
||||
}
|
||||
|
||||
getClasses(conversation: Conversation): string {
|
||||
return conversation === core.conversations.selectedConversation ? ' active' : unreadClasses[conversation.unread];
|
||||
}
|
||||
|
@ -285,7 +289,7 @@
|
|||
}
|
||||
|
||||
.bbcode, .message, .profile-viewer {
|
||||
user-select: initial;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.list-group.conversation-nav {
|
||||
|
@ -342,7 +346,7 @@
|
|||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
|
||||
@media (max-width: breakpoint-max(xs)) {
|
||||
@media (max-width: breakpoint-max(sm)) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
@ -387,7 +391,7 @@
|
|||
.body a.btn {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@media (min-width: breakpoint-min(sm)) {
|
||||
@media (min-width: breakpoint-min(md)) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
margin: 0;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div style="display: flex; flex-direction: column;" id="command-help">
|
||||
<modal dialogClass="modal-lg" :buttons="false" :action="l('commands.help')" id="command-help">
|
||||
<div style="overflow: auto;">
|
||||
<div v-for="command in filteredCommands">
|
||||
<h4>{{command.name}}</h4>
|
||||
|
@ -16,12 +16,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<input class="form-control" v-model="filter" :placeholder="l('filter')"/>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import CustomDialog from '../components/custom_dialog';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import core from './core';
|
||||
import l from './localize';
|
||||
import commands, {CommandContext, ParamType, Permission} from './slash_commands';
|
||||
|
@ -35,8 +36,10 @@
|
|||
syntax: string
|
||||
};
|
||||
|
||||
@Component
|
||||
export default class CommandHelp extends Vue {
|
||||
@Component({
|
||||
components: {modal: Modal}
|
||||
})
|
||||
export default class CommandHelp extends CustomDialog {
|
||||
commands: CommandItem[] = [];
|
||||
filter = '';
|
||||
l = l;
|
||||
|
@ -97,5 +100,9 @@
|
|||
.params {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,18 +1,20 @@
|
|||
<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">
|
||||
<img :src="characterImage" style="height:60px;width:60px;margin-right:10px" v-if="settings.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['logsDialog'].show()" class="btn">
|
||||
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
|
||||
</a>
|
||||
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
|
||||
<span class="fa fa-cog"></span> <span class="btn-text">{{l('conversationSettings.title')}}</span>
|
||||
</a>
|
||||
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation-triangle"></span>
|
||||
<span class="btn-text">{{l('chat.report')}}</span></a>
|
||||
<a href="#" @click.prevent="reportDialog.report();" class="btn">
|
||||
<span class="fa fa-exclamation-triangle"></span><span class="btn-text">{{l('chat.report')}}</span></a>
|
||||
</div>
|
||||
<div style="overflow: auto">
|
||||
<div style="overflow:auto;max-height:50px">
|
||||
{{l('status.' + conversation.character.status)}}
|
||||
<span v-show="conversation.character.statusText"> – <bbcode :text="conversation.character.statusText"></bbcode></span>
|
||||
</div>
|
||||
|
@ -29,12 +31,14 @@
|
|||
<span class="btn-text">{{l('channel.description')}}</span>
|
||||
</a>
|
||||
<manage-channel :channel="conversation.channel" v-if="isChannelMod"></manage-channel>
|
||||
<logs :conversation="conversation"></logs>
|
||||
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn">
|
||||
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
|
||||
</a>
|
||||
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
|
||||
<span class="fa fa-cog"></span> <span class="btn-text">{{l('conversationSettings.title')}}</span>
|
||||
</a>
|
||||
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation-triangle"></span>
|
||||
<span class="btn-text">{{l('chat.report')}}</span></a>
|
||||
<a href="#" @click.prevent="reportDialog.report();" class="btn">
|
||||
<span class="fa fa-exclamation-triangle"></span><span class="btn-text">{{l('chat.report')}}</span></a>
|
||||
</div>
|
||||
<ul class="nav nav-pills mode-switcher">
|
||||
<li v-for="mode in modes" class="nav-item">
|
||||
|
@ -50,7 +54,9 @@
|
|||
</div>
|
||||
<div v-else class="header" style="display:flex;align-items:center">
|
||||
<h4>{{l('chat.consoleTab')}}</h4>
|
||||
<logs :conversation="conversation"></logs>
|
||||
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn">
|
||||
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="search" v-show="showSearch" style="position:relative">
|
||||
<input v-model="searchInput" @keydown.esc="showSearch = false; searchInput = ''" @keypress="lastSearchInput = Date.now()"
|
||||
|
@ -86,7 +92,7 @@
|
|||
<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 style="position:relative;margin-top:5px">
|
||||
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
|
||||
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')"
|
||||
ref="textBox" style="position:relative" :maxlength="conversation.maxMessageLength">
|
||||
|
@ -94,25 +100,25 @@
|
|||
<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">
|
||||
<ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel"
|
||||
style="position:relative;z-index:10;margin-right:5px">
|
||||
<li class="nav-item">
|
||||
<a href="#" :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
|
||||
class="nav-link" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
|
||||
class="nav-link" @click.prevent="setSendingAds(true)">
|
||||
{{l('channel.mode.ads' + (adCountdown ? '.countdown' : ''), adCountdown)}}</a>
|
||||
class="nav-link" @click.prevent="setSendingAds(true)">{{adsMode}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="btn btn-sm btn-primary" v-show="!settings.enterSend" @click="conversation.send()">{{l('chat.send')}}</div>
|
||||
</div>
|
||||
</bbcode-editor>
|
||||
</div>
|
||||
</div>
|
||||
<modal ref="helpDialog" dialogClass="modal-lg" :buttons="false" :action="l('commands.help')">
|
||||
<command-help></command-help>
|
||||
</modal>
|
||||
<command-help ref="helpDialog"></command-help>
|
||||
<settings ref="settingsDialog" :conversation="conversation"></settings>
|
||||
<logs ref="logsDialog" :conversation="conversation"></logs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -121,14 +127,13 @@
|
|||
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 {Keys} from '../keys';
|
||||
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 {Channel, channelModes, Character, Conversation, Settings} from './interfaces';
|
||||
import l from './localize';
|
||||
import Logs from './Logs.vue';
|
||||
import ManageChannel from './ManageChannel.vue';
|
||||
|
@ -139,7 +144,7 @@
|
|||
|
||||
@Component({
|
||||
components: {
|
||||
user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, modal: Modal, settings: ConversationSettings,
|
||||
user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, settings: ConversationSettings,
|
||||
logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp
|
||||
}
|
||||
})
|
||||
|
@ -174,7 +179,7 @@
|
|||
title: 'Help\n\nClick this button for a quick overview of slash commands.',
|
||||
tag: '?',
|
||||
icon: 'fa-question',
|
||||
handler: () => (<Modal>this.$refs['helpDialog']).show()
|
||||
handler: () => (<CommandHelp>this.$refs['helpDialog']).show()
|
||||
}];
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
window.addEventListener('keydown', this.keydownHandler = ((e: KeyboardEvent) => {
|
||||
|
@ -220,7 +225,7 @@
|
|||
keepScroll(): boolean {
|
||||
const messageView = <HTMLElement>this.$refs['messages'];
|
||||
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) {
|
||||
setImmediate(() => messageView.scrollTop = messageView.scrollHeight);
|
||||
this.$nextTick(() => setTimeout(() => messageView.scrollTop = messageView.scrollHeight, 0));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -282,7 +287,7 @@
|
|||
&& !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey)
|
||||
this.conversation.loadLastSent();
|
||||
else if(getKey(e) === Keys.Enter) {
|
||||
if(e.shiftKey) return;
|
||||
if(e.shiftKey === this.settings.enterSend) return;
|
||||
e.preventDefault();
|
||||
await this.conversation.send();
|
||||
}
|
||||
|
@ -306,9 +311,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
get adCountdown(): string | undefined {
|
||||
if(!Conversation.isChannel(this.conversation) || this.conversation.adCountdown <= 0) return;
|
||||
return l('chat.adCountdown',
|
||||
get adsMode(): string | undefined {
|
||||
if(!Conversation.isChannel(this.conversation)) return;
|
||||
if(this.conversation.adCountdown <= 0) return l('channel.mode.ads');
|
||||
else return l('channel.mode.ads.countdown',
|
||||
Math.floor(this.conversation.adCountdown / 60).toString(), (this.conversation.adCountdown % 60).toString());
|
||||
}
|
||||
|
||||
|
@ -316,8 +322,8 @@
|
|||
return characterImage(this.conversation.name);
|
||||
}
|
||||
|
||||
get showAvatars(): boolean {
|
||||
return core.state.settings.showAvatars;
|
||||
get settings(): Settings {
|
||||
return core.state.settings;
|
||||
}
|
||||
|
||||
get isConsoleTab(): boolean {
|
||||
|
@ -340,10 +346,10 @@
|
|||
|
||||
#conversation {
|
||||
.header {
|
||||
@media (min-width: breakpoint-min(sm)) {
|
||||
@media (min-width: breakpoint-min(md)) {
|
||||
margin-right: 32px;
|
||||
}
|
||||
a.btn {
|
||||
.btn {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
}
|
||||
|
@ -352,7 +358,7 @@
|
|||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: breakpoint-max(xs)) {
|
||||
@media (max-width: breakpoint-max(sm)) {
|
||||
.mode-switcher a {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
|
138
chat/Logs.vue
138
chat/Logs.vue
|
@ -1,49 +1,46 @@
|
|||
<template>
|
||||
<span>
|
||||
<a href="#" @click.prevent="showLogs" class="btn">
|
||||
<span :class="isPersistent ? 'fa fa-file-alt' : 'fa fa-download'"></span>
|
||||
<span class="btn-text">{{l('logs.title')}}</span>
|
||||
</a>
|
||||
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')"
|
||||
dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen">
|
||||
<div class="form-group row" style="flex-shrink:0">
|
||||
<label class="col-2 col-form-label">{{l('logs.conversation')}}</label>
|
||||
<modal :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')"
|
||||
dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen">
|
||||
<div class="form-group row" style="flex-shrink:0">
|
||||
<label class="col-2 col-form-label">{{l('logs.conversation')}}</label>
|
||||
<div class="col-10">
|
||||
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
|
||||
:placeholder="l('filter')" @input="loadMessages" class="form-control col-10">
|
||||
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
|
||||
:placeholder="l('filter')" @input="loadMessages">
|
||||
<template slot-scope="s">
|
||||
{{s.option && ((s.option.key[0] == '#' ? '#' : '') + s.option.name) || l('logs.selectConversation')}}</template>
|
||||
</filterable-select>
|
||||
</div>
|
||||
<div class="form-group row" style="flex-shrink:0">
|
||||
<label for="date" class="col-2 col-form-label">{{l('logs.date')}}</label>
|
||||
<div class="col-8">
|
||||
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
|
||||
<option>{{l('logs.selectDate')}}</option>
|
||||
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button @click="downloadDay" class="btn btn-secondary form-control" :disabled="!selectedDate"><span
|
||||
class="fa fa-download"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row" style="flex-shrink:0">
|
||||
<label for="date" class="col-2 col-form-label">{{l('logs.date')}}</label>
|
||||
<div class="col-8">
|
||||
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
|
||||
<option :value="null">{{l('logs.selectDate')}}</option>
|
||||
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="messages-both" style="overflow: auto">
|
||||
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
|
||||
<div class="col-2">
|
||||
<button @click="downloadDay" class="btn btn-secondary form-control" :disabled="!selectedDate"><span
|
||||
class="fa fa-download"></span></button>
|
||||
</div>
|
||||
<input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages" type="text"/>
|
||||
</modal>
|
||||
</span>
|
||||
</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" type="text"/>
|
||||
</modal>
|
||||
</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 CustomDialog from '../components/custom_dialog';
|
||||
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 {Conversation} from './interfaces';
|
||||
import l from './localize';
|
||||
import MessageView from './message_view';
|
||||
|
||||
|
@ -58,14 +55,14 @@
|
|||
@Component({
|
||||
components: {modal: Modal, 'message-view': MessageView, 'filterable-select': FilterableSelect}
|
||||
})
|
||||
export default class Logs extends Vue {
|
||||
export default class Logs extends CustomDialog {
|
||||
//tslint:disable:no-null-keyword
|
||||
@Prop({required: true})
|
||||
readonly conversation!: Conversation;
|
||||
selectedConversation: {id: string, name: string} | null = null;
|
||||
selectedConversation: {key: string, name: string} | null = null;
|
||||
dates: ReadonlyArray<Date> = [];
|
||||
selectedDate: string | null = null;
|
||||
isPersistent = LogInterfaces.isPersistent(core.logs);
|
||||
conversations = LogInterfaces.isPersistent(core.logs) ? core.logs.conversations : undefined;
|
||||
conversations = core.logs.conversations.slice();
|
||||
l = l;
|
||||
filter = '';
|
||||
messages: ReadonlyArray<Conversation.Message> = [];
|
||||
|
@ -78,51 +75,37 @@
|
|||
(x) => filter.test(x.text) || x.type !== Conversation.Message.Type.Event && filter.test(x.sender.name));
|
||||
}
|
||||
|
||||
mounted(): void {
|
||||
this.conversationChanged();
|
||||
async mounted(): Promise<void> {
|
||||
return this.conversationChanged();
|
||||
}
|
||||
|
||||
filterConversation(filter: RegExp, conversation: {id: string, name: string}): boolean {
|
||||
filterConversation(filter: RegExp, conversation: {key: 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 conversationChanged(): Promise<void> {
|
||||
//tslint:disable-next-line:strict-boolean-expressions
|
||||
this.selectedConversation = this.conversations.filter((x) => x.key === 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));
|
||||
@Watch('selectedConversation')
|
||||
async conversationSelected(): Promise<void> {
|
||||
this.dates = this.selectedConversation === null ? [] :
|
||||
(await core.logs.getLogDates(this.selectedConversation.key)).slice().reverse();
|
||||
}
|
||||
|
||||
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));
|
||||
a.target = '_blank';
|
||||
a.href = `data:${encodeURIComponent(file)},${encodeURIComponent(logs.map((x) => messageToString(x, formatTime)).join(''))}`;
|
||||
a.setAttribute('download', file);
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
setTimeout(() => {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
downloadDay(): void {
|
||||
|
@ -131,20 +114,23 @@
|
|||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.conversations = (<LogInterfaces.Persistent>core.logs).conversations;
|
||||
this.conversations = core.logs.conversations.slice();
|
||||
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
|
||||
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))
|
||||
if(this.selectedDate === null || this.selectedConversation === null)
|
||||
return this.messages = [];
|
||||
return this.messages = await core.logs.getLogs(this.selectedConversation.id, new Date(this.selectedDate));
|
||||
return this.messages = await core.logs.getLogs(this.selectedConversation.key, new Date(this.selectedDate));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#logs-dialog .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<modal :buttons="false" :action="l('chat.recentConversations')" dialogClass="w-100">
|
||||
<modal :buttons="false" :action="l('chat.recentConversations')" dialogClass="w-100 modal-lg">
|
||||
<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>
|
||||
|
|
|
@ -12,6 +12,12 @@
|
|||
{{l('settings.clickOpensMessage')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="enterSend">
|
||||
<input type="checkbox" id="enterSend" v-model="enterSend"/>
|
||||
{{l('settings.enterSend')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="showAvatars">
|
||||
<input type="checkbox" id="showAvatars" v-model="showAvatars"/>
|
||||
|
@ -58,6 +64,12 @@
|
|||
{{l('settings.playSound')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="alwaysNotify">
|
||||
<input type="checkbox" id="alwaysNotify" v-model="alwaysNotify" :disabled="!playSound"/>
|
||||
{{l('settings.alwaysNotify')}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="notifications">
|
||||
<input type="checkbox" id="notifications" v-model="notifications"/>
|
||||
|
@ -86,12 +98,6 @@
|
|||
{{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 class="form-group">
|
||||
<label class="control-label" for="showNeedsReply">
|
||||
<input type="checkbox" id="showNeedsReply" v-model="showNeedsReply"/>
|
||||
|
@ -143,6 +149,7 @@
|
|||
logAds!: boolean;
|
||||
fontSize!: number;
|
||||
showNeedsReply!: boolean;
|
||||
enterSend!: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -150,8 +157,7 @@
|
|||
}
|
||||
|
||||
async created(): Promise<void> {
|
||||
const available = core.settingsStore.getAvailableCharacters();
|
||||
this.availableImports = available !== undefined ? (await available).filter((x) => x !== core.connection.character) : [];
|
||||
this.availableImports = (await core.settingsStore.getAvailableCharacters()).filter((x) => x !== core.connection.character);
|
||||
}
|
||||
|
||||
init = function(this: SettingsView): void {
|
||||
|
@ -173,6 +179,7 @@
|
|||
this.logAds = settings.logAds;
|
||||
this.fontSize = settings.fontSize;
|
||||
this.showNeedsReply = settings.showNeedsReply;
|
||||
this.enterSend = settings.enterSend;
|
||||
};
|
||||
|
||||
async doImport(): Promise<void> {
|
||||
|
@ -215,7 +222,8 @@
|
|||
logMessages: this.logMessages,
|
||||
logAds: this.logAds,
|
||||
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize,
|
||||
showNeedsReply: this.showNeedsReply
|
||||
showNeedsReply: this.showNeedsReply,
|
||||
enterSend: this.enterSend
|
||||
};
|
||||
if(this.notifications) await core.notifications.requestPermission();
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<h5 style="margin:0;line-height:1">{{character.name}}</h5>
|
||||
{{l('status.' + character.status)}}
|
||||
</div>
|
||||
<bbcode :text="character.statusText" v-show="character.statusText" class="list-group-item" @click.stop></bbcode>
|
||||
<bbcode id="userMenuStatus" :text="character.statusText" v-show="character.statusText" class="list-group-item"></bbcode>
|
||||
<a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst" class="list-group-item list-group-item-action">
|
||||
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
|
||||
<a tabindex="-1" href="#" @click.prevent="openConversation(true)" class="list-group-item list-group-item-action">
|
||||
|
@ -152,7 +152,7 @@
|
|||
const touch = e instanceof TouchEvent ? e.changedTouches[0] : e;
|
||||
let node = <HTMLElement & {character?: Character, channel?: Channel, touched?: boolean}>touch.target;
|
||||
while(node !== document.body) {
|
||||
if(e.type !== 'click' && node === this.$refs['menu']) return;
|
||||
if(e.type !== 'click' && node === this.$refs['menu'] || node.id === 'userMenuStatus') return;
|
||||
if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break;
|
||||
node = node.parentElement!;
|
||||
}
|
||||
|
@ -217,5 +217,6 @@
|
|||
|
||||
.user-view {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
|
@ -2,7 +2,7 @@ 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 {BBCodeTextTag} from '../bbcode/parser';
|
||||
import ChannelView from './ChannelView.vue';
|
||||
import {characterImage} from './common';
|
||||
import core from './core';
|
||||
|
@ -14,13 +14,12 @@ export const BBCodeView: Component = {
|
|||
render(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));
|
||||
insert(node: VNode): void {
|
||||
node.elm!.appendChild(core.bbCodeParser.parseEverything(
|
||||
context.props.text !== undefined ? context.props.text : context.props.unsafeText));
|
||||
},
|
||||
destroy(): void {
|
||||
const element = (<BBCodeElement>(<Element>vnode.elm).firstChild);
|
||||
destroy(node: VNode): void {
|
||||
const element = (<BBCodeElement>(<Element>node.elm).firstChild);
|
||||
if(element.cleanup !== undefined) element.cleanup();
|
||||
}
|
||||
};
|
||||
|
@ -43,29 +42,20 @@ export default class BBCodeParser extends CoreBBCodeParser {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
this.addTag('user', new BBCodeCustomTag('user', (parser, parent) => {
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
}, (parser, element, _, param) => {
|
||||
this.addTag(new BBCodeTextTag('user', (parser, parent, param, content) => {
|
||||
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) => {
|
||||
if(!uregex.test(content)) return;
|
||||
const el = parser.createElement('span');
|
||||
parent.appendChild(el);
|
||||
const view = new UserView({el, propsData: {character: core.characters.get(content)}});
|
||||
this.cleanup.push(view);
|
||||
return el;
|
||||
}, (parser, element, parent, param) => {
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('icon', (parser, parent, param, content) => {
|
||||
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;
|
||||
|
@ -75,16 +65,12 @@ export default class BBCodeParser extends CoreBBCodeParser {
|
|||
img.className = 'character-avatar icon';
|
||||
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) => {
|
||||
parent.appendChild(img);
|
||||
return img;
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('eicon', (parser, parent, param, content) => {
|
||||
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;
|
||||
|
@ -93,28 +79,23 @@ export default class BBCodeParser extends CoreBBCodeParser {
|
|||
img.src = `https://static.f-list.net/images/eicon/${content.toLowerCase()}.${extension}`;
|
||||
img.title = img.alt = content;
|
||||
img.className = 'character-avatar icon';
|
||||
parent.replaceChild(img, element);
|
||||
}, []));
|
||||
this.addTag('session', new BBCodeCustomTag('session', (parser, parent) => {
|
||||
parent.appendChild(img);
|
||||
return img;
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('session', (parser, parent, param, content) => {
|
||||
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}});
|
||||
const view = new ChannelView({el, propsData: {id: content, text: param}});
|
||||
this.cleanup.push(view);
|
||||
}, []));
|
||||
this.addTag('channel', new BBCodeCustomTag('channel', (parser, parent) => {
|
||||
return el;
|
||||
}));
|
||||
this.addTag(new BBCodeTextTag('channel', (parser, parent, _, content) => {
|
||||
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}});
|
||||
const view = new ChannelView({el, propsData: {id: content, text: content}});
|
||||
this.cleanup.push(view);
|
||||
}, []));
|
||||
return el;
|
||||
}));
|
||||
}
|
||||
|
||||
parseEverything(input: string): BBCodeElement {
|
||||
|
|
|
@ -42,6 +42,7 @@ export class Settings implements ISettings {
|
|||
logAds = false;
|
||||
fontSize = 14;
|
||||
showNeedsReply = false;
|
||||
enterSend = true;
|
||||
}
|
||||
|
||||
export class ConversationSettings implements Conversation.Settings {
|
||||
|
@ -83,7 +84,7 @@ export class Message implements Conversation.ChatMessage {
|
|||
|
||||
constructor(readonly type: Conversation.Message.Type, readonly sender: Character, readonly text: string,
|
||||
readonly time: Date = new Date()) {
|
||||
if(Conversation.Message.Type[type] === undefined) throw new Error('Unknown type'); /*tslint:disable-line*/ //TODO debug code
|
||||
if(Conversation.Message.Type[type] === undefined) throw new Error('Unknown type'); //tslint:disable-line
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,11 +4,11 @@ import {characterImage, ConversationSettings, EventMessage, Message, messageToSt
|
|||
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 {CommandContext, isAction, isCommand, isWarn, 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) {
|
||||
if(type === MessageType.Message && isAction(text)) {
|
||||
type = MessageType.Action;
|
||||
text = text.substr(text.charAt(4) === ' ' ? 4 : 3);
|
||||
}
|
||||
|
@ -262,7 +262,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
|
|||
|
||||
async addMessage(message: Interfaces.Message): Promise<void> {
|
||||
await this.logPromise;
|
||||
if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null) {
|
||||
if((message.type === MessageType.Message || message.type === MessageType.Ad) && isWarn(message.text)) {
|
||||
const member = this.channel.members[message.sender.name];
|
||||
if(member !== undefined && member.rank > Channel.Rank.Member || message.sender.isChatOp)
|
||||
message = new Message(MessageType.Warn, message.sender, message.text.substr(6), message.time);
|
||||
|
|
|
@ -52,7 +52,7 @@ const vue = <Vue & VueState>new Vue({
|
|||
|
||||
const data = {
|
||||
connection: <Connection | undefined>undefined,
|
||||
logs: <Logs.Basic | undefined>undefined,
|
||||
logs: <Logs | undefined>undefined,
|
||||
settingsStore: <Settings.Store | undefined>undefined,
|
||||
state: vue.state,
|
||||
bbCodeParser: <BBCodeParser | undefined>undefined,
|
||||
|
@ -79,7 +79,7 @@ const data = {
|
|||
}
|
||||
};
|
||||
|
||||
export function init(this: void, connection: Connection, logsClass: new() => Logs.Basic, settingsClass: new() => Settings.Store,
|
||||
export function init(this: void, connection: Connection, logsClass: new() => Logs, settingsClass: new() => Settings.Store,
|
||||
notificationsClass: new() => Notifications): void {
|
||||
data.connection = connection;
|
||||
data.logs = new logsClass();
|
||||
|
@ -93,7 +93,7 @@ export function init(this: void, connection: Connection, logsClass: new() => Log
|
|||
|
||||
export interface Core {
|
||||
readonly connection: Connection
|
||||
readonly logs: Logs.Basic
|
||||
readonly logs: Logs
|
||||
readonly state: StateInterface
|
||||
readonly settingsStore: Settings.Store
|
||||
readonly conversations: Conversation.State
|
||||
|
|
|
@ -121,21 +121,12 @@ export namespace Conversation {
|
|||
|
||||
export type Conversation = Conversation.Conversation;
|
||||
|
||||
export namespace Logs {
|
||||
export interface Basic {
|
||||
logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> | 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 interface Logs {
|
||||
logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> | void
|
||||
getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>>
|
||||
readonly conversations: ReadonlyArray<{readonly key: string, readonly name: string}>
|
||||
getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>>
|
||||
getLogDates(key: string): Promise<ReadonlyArray<Date>>
|
||||
}
|
||||
|
||||
export namespace Settings {
|
||||
|
@ -150,7 +141,7 @@ export namespace Settings {
|
|||
|
||||
export interface Store {
|
||||
get<K extends keyof Keys>(key: K, character?: string): Promise<Keys[K] | undefined>
|
||||
getAvailableCharacters(): Promise<ReadonlyArray<string>> | undefined
|
||||
getAvailableCharacters(): Promise<ReadonlyArray<string>>
|
||||
set<K extends keyof Keys>(key: K, value: Keys[K]): Promise<void>
|
||||
}
|
||||
|
||||
|
@ -172,6 +163,7 @@ export namespace Settings {
|
|||
readonly logAds: boolean;
|
||||
readonly fontSize: number;
|
||||
readonly showNeedsReply: boolean;
|
||||
readonly enterSend: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ const strings: {[key: string]: string | undefined} = {
|
|||
'action.view': 'View',
|
||||
'action.cut': 'Cut',
|
||||
'action.copy': 'Copy',
|
||||
'action.copyWithoutBBCode': 'Copy without BBCode',
|
||||
'action.paste': 'Paste',
|
||||
'action.copyLink': 'Copy Link',
|
||||
'action.suggestions': 'Suggestions',
|
||||
|
@ -75,9 +76,11 @@ const strings: {[key: string]: string | undefined} = {
|
|||
'chat.disconnected.title': 'Disconnected',
|
||||
'chat.ignoreList': 'You are currently ignoring: {0}',
|
||||
'chat.search': 'Search in messages...',
|
||||
'chat.send': 'Send',
|
||||
'logs.title': 'Logs',
|
||||
'logs.conversation': 'Conversation',
|
||||
'logs.date': 'Date',
|
||||
'logs.selectConversation': 'Select a conversation...',
|
||||
'logs.selectDate': 'Select a date...',
|
||||
'user.profile': 'Profile',
|
||||
'user.message': 'Open conversation',
|
||||
|
@ -134,6 +137,7 @@ Are you sure?`,
|
|||
'settings.playSound': 'Play notification sounds',
|
||||
'settings.notifications': 'Display notifications',
|
||||
'settings.clickOpensMessage': 'Clicking users opens messages (instead of their profile)',
|
||||
'settings.enterSend': 'Enter sends messages (shows send button if disabled)',
|
||||
'settings.disallowedTags': 'Disallowed BBCode tags (comma-separated)',
|
||||
'settings.highlight': 'Notify for messages containing your name',
|
||||
'settings.highlightWords': 'Custom highlight notify words (comma-separated)',
|
||||
|
@ -143,7 +147,7 @@ Are you sure?`,
|
|||
'settings.messageSeparators': 'Display separators between messages',
|
||||
'settings.eventMessages': 'Also display console messages (like login/logout, status changes) 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.alwaysNotify': 'Play sounds even when looking at the tab',
|
||||
'settings.closeToTray': 'Close to tray',
|
||||
'settings.spellcheck': 'Spellcheck',
|
||||
'settings.spellcheck.disabled': 'Disabled',
|
||||
|
@ -157,6 +161,14 @@ Are you sure?`,
|
|||
'settings.showNeedsReply': 'Show indicator on PM tabs with unanswered messages',
|
||||
'settings.defaultHighlights': 'Use global highlight words',
|
||||
'settings.beta': 'Opt-in to test unstable prerelease updates',
|
||||
'fixLogs.action': 'Fix corrupted logs',
|
||||
'fixLogs.text': `There are a few reason log files can become corrupted - log files from old versions with bugs that have since been fixed or incomplete file operations caused by computer crashes are the most common.
|
||||
If one of your log files is corrupted, you may get an "Unknown Type" error when you log in or when you open a specific tab. You may also experience other issues.
|
||||
This is not a tool you should use if you're not sure it's absolutely necessary. It will go through and rewrite all of your log files.
|
||||
Once this process has started, do not interrupt it or your logs will get corrupted even worse.`,
|
||||
'fixLogs.character': 'Character',
|
||||
'fixLogs.error': 'An error has occurred while attempting to fix your logs. Please ask in for further assistance in the Helpdesk channel.',
|
||||
'fixLogs.success': 'Your logs have been fixed. If you experience any more issues, please ask in for further assistance in the Helpdesk channel.',
|
||||
'conversationSettings.title': 'Tab Settings',
|
||||
'conversationSettings.action': 'Edit settings for {0}',
|
||||
'conversationSettings.default': 'Default',
|
||||
|
@ -376,7 +388,10 @@ Are you sure?`,
|
|||
'status.offline': 'Offline',
|
||||
'status.crown': 'Rewarded',
|
||||
'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.importCharacter': `slimCat data for this character has been detected on your computer.
|
||||
Would you like to import settings and logs?
|
||||
This may take a while.
|
||||
Any 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.',
|
||||
'importer.error': 'There was an error importing your settings. The defaults will be used.'
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import {Component, CreateElement, RenderContext, VNode, VNodeChildren} from 'vue';
|
||||
import {Component, CreateElement, RenderContext, VNode, VNodeChildrenArrayContents} from 'vue';
|
||||
import {Channel} from '../fchat';
|
||||
import {BBCodeView} from './bbcode';
|
||||
import {formatTime} from './common';
|
||||
|
@ -24,7 +24,8 @@ const MessageView: Component = {
|
|||
render(createElement: CreateElement,
|
||||
context: RenderContext<{message: Conversation.Message, classes?: string, channel?: Channel}>): VNode {
|
||||
const message = context.props.message;
|
||||
const children: (VNode | string | VNodeChildren)[] = [`[${formatTime(message.time)}] `];
|
||||
const children: VNodeChildrenArrayContents =
|
||||
[createElement('span', {staticClass: 'message-time'}, `[${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' : '') +
|
||||
|
|
|
@ -7,8 +7,11 @@ 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;
|
||||
if(core.characters.ownCharacter.status === 'dnd') return;
|
||||
if(!this.isInBackground && conversation === core.conversations.selectedConversation) {
|
||||
if(core.state.settings.alwaysNotify) this.playSound(sound);
|
||||
return;
|
||||
}
|
||||
this.playSound(sound);
|
||||
if(core.state.settings.notifications) {
|
||||
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive
|
||||
|
|
|
@ -17,17 +17,26 @@ import '../site/directives/vue-select'; //tslint:disable-line:no-import-side-eff
|
|||
import * as Utils from '../site/utils';
|
||||
import core from './core';
|
||||
|
||||
const parserSettings = {
|
||||
siteDomain: 'https://www.f-list.net/',
|
||||
staticDomain: 'https://static.f-list.net/',
|
||||
animatedIcons: true,
|
||||
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
|
||||
};
|
||||
|
||||
async function characterData(name: string | undefined): Promise<Character> {
|
||||
const data = await core.connection.queryApi('character-data.php', {name}) as CharacterInfo & {
|
||||
badges: string[]
|
||||
customs_first: boolean
|
||||
character_list: {id: number, name: string}[]
|
||||
current_user: {inline_mode: number, animated_icons: boolean}
|
||||
custom_kinks: {[key: number]: {choice: 'fave' | 'yes' | 'maybe' | 'no', name: string, description: string, children: number[]}}
|
||||
custom_title: string
|
||||
kinks: {[key: string]: string}
|
||||
infotags: {[key: string]: string}
|
||||
memo: {id: number, memo: string}
|
||||
settings: CharacterSettings
|
||||
memo?: {id: number, memo: string}
|
||||
settings: CharacterSettings,
|
||||
timezone: number
|
||||
};
|
||||
const newKinks: {[key: string]: KinkChoiceFull} = {};
|
||||
for(const key in data.kinks)
|
||||
|
@ -52,6 +61,8 @@ async function characterData(name: string | undefined): Promise<Character> {
|
|||
|
||||
newInfotags[key] = infotag.type === 'list' ? {list: parseInt(characterInfotag, 10)} : {string: characterInfotag};
|
||||
}
|
||||
parserSettings.inlineDisplayMode = data.current_user.inline_mode;
|
||||
parserSettings.animatedIcons = core.state.settings.animatedEicons;
|
||||
return {
|
||||
is_self: false,
|
||||
character: {
|
||||
|
@ -67,7 +78,8 @@ async function characterData(name: string | undefined): Promise<Character> {
|
|||
kinks: newKinks,
|
||||
customs: newCustoms,
|
||||
infotags: newInfotags,
|
||||
online_chat: false
|
||||
online_chat: false,
|
||||
timezone: data.timezone
|
||||
},
|
||||
memo: data.memo,
|
||||
character_list: data.character_list,
|
||||
|
@ -166,13 +178,8 @@ async function kinksGet(id: number): Promise<CharacterKink[]> {
|
|||
}
|
||||
|
||||
export function init(characters: {[key: string]: number}): void {
|
||||
Utils.setDomains('https://www.f-list.net/', 'https://static.f-list.net/');
|
||||
initParser({
|
||||
siteDomain: Utils.siteDomain,
|
||||
staticDomain: Utils.staticDomain,
|
||||
animatedIcons: false,
|
||||
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
|
||||
});
|
||||
Utils.setDomains(parserSettings.siteDomain, parserSettings.staticDomain);
|
||||
initParser(parserSettings);
|
||||
|
||||
Vue.component('character-select', CharacterSelect);
|
||||
Vue.component('character-link', CharacterLink);
|
||||
|
|
|
@ -10,13 +10,21 @@ export const enum ParamType {
|
|||
|
||||
const defaultDelimiters: {[key: number]: string | undefined} = {[ParamType.Character]: ',', [ParamType.String]: ''};
|
||||
|
||||
export function isAction(this: void, text: string): boolean {
|
||||
return /^\/me\b/i.test(text);
|
||||
}
|
||||
|
||||
export function isWarn(this: void, text: string): boolean {
|
||||
return /^\/warn\b/i.test(text);
|
||||
}
|
||||
|
||||
export function isCommand(this: void, text: string): boolean {
|
||||
return text.charAt(0) === '/' && text.substr(1, 2) !== 'me' && text.substr(1, 4) !== 'warn';
|
||||
return text.charAt(0) === '/' && !isAction(text) && !isWarn(text);
|
||||
}
|
||||
|
||||
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 name = input.substring(1, commandEnd !== -1 ? commandEnd : undefined).toLowerCase();
|
||||
const command = commands[name];
|
||||
if(command === undefined) return l('commands.unknown');
|
||||
const args = `${commandEnd !== -1 ? input.substr(commandEnd + 1) : ''}`;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
//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';
|
||||
import {Channel, Character} from '../fchat';
|
||||
|
||||
export function getStatusIcon(status: Character.Status): string {
|
||||
switch(status) {
|
||||
|
@ -40,14 +40,14 @@ const UserView = Vue.extend({
|
|||
rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ?
|
||||
(props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : '';
|
||||
else rankIcon = '';
|
||||
|
||||
const html = (props.showStatus !== undefined || character.status === 'crown'
|
||||
? `<span class="fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
|
||||
(rankIcon !== '' ? `<span class="${rankIcon}"></span>` : '') + character.name;
|
||||
const children: (VNode | string)[] = [character.name];
|
||||
if(rankIcon !== '') children.unshift(createElement('span', {staticClass: rankIcon}));
|
||||
if(props.showStatus !== undefined || character.status === 'crown')
|
||||
children.unshift(createElement('span', {staticClass: `fa-fw ${getStatusIcon(character.status)}`}));
|
||||
return createElement('span', {
|
||||
attrs: {class: `user-view gender-${character.gender !== undefined ? character.gender.toLowerCase() : 'none'}`},
|
||||
domProps: {character, channel: props.channel, innerHTML: html, bbcodeTag: 'user'}
|
||||
});
|
||||
domProps: {character, channel: props.channel, bbcodeTag: 'user'}
|
||||
}, children);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<template>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
|
||||
@blur="isOpen = false" style="width:100%;text-align:left;display:flex;align-items:center">
|
||||
<a class="btn btn-secondary dropdown-toggle" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
|
||||
@blur="isOpen = false" style="width:100%;text-align:left;display:flex;align-items:center" role="button" tabindex="-1">
|
||||
<div style="flex:1">
|
||||
<slot name="title" style="flex:1"></slot>
|
||||
</div>
|
||||
</button>
|
||||
<div class="dropdown-menu" :style="isOpen ? 'display:block' : ''" @mousedown.stop.prevent @click="isOpen = false">
|
||||
</a>
|
||||
<div class="dropdown-menu" :style="open ? 'display:block' : ''" @mousedown.stop.prevent @click="isOpen = false"
|
||||
ref="menu">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,9 +16,38 @@
|
|||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {Prop, Watch} from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class Dropdown extends Vue {
|
||||
isOpen = false;
|
||||
@Prop()
|
||||
readonly keepOpen?: boolean;
|
||||
|
||||
get open(): boolean {
|
||||
return this.keepOpen || this.isOpen;
|
||||
}
|
||||
|
||||
@Watch('open')
|
||||
onToggle(): void {
|
||||
const menu = this.$refs['menu'] as HTMLElement;
|
||||
if(!this.isOpen) {
|
||||
menu.style.cssText = '';
|
||||
return;
|
||||
}
|
||||
let element: HTMLElement | null = this.$el;
|
||||
while(element !== null) {
|
||||
if(getComputedStyle(element).position === 'fixed') {
|
||||
menu.style.display = 'block';
|
||||
const offset = menu.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.left = `${offset.left}px`;
|
||||
menu.style.top = (offset.bottom < window.innerHeight) ? menu.style.top = `${offset.top}px` :
|
||||
`${this.$el.getBoundingClientRect().top - offset.bottom + offset.top}px`;
|
||||
break;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<dropdown class="dropdown filterable-select">
|
||||
<dropdown class="filterable-select" :keepOpen="keepOpen">
|
||||
<template slot="title" v-if="multiple">{{label}}</template>
|
||||
<slot v-else slot="title" :option="selected">{{label}}</slot>
|
||||
|
||||
<div style="padding:10px;">
|
||||
<input v-model="filter" class="form-control" :placeholder="placeholder"/>
|
||||
<input v-model="filter" class="form-control" :placeholder="placeholder" @mousedown.stop @focus="keepOpen = true" @blur="keepOpen = false"/>
|
||||
</div>
|
||||
<div class="dropdown-items">
|
||||
<template v-if="multiple">
|
||||
|
@ -47,6 +47,7 @@
|
|||
readonly title?: string;
|
||||
filter = '';
|
||||
selected: object | object[] | null = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : null);
|
||||
keepOpen = false;
|
||||
|
||||
@Watch('value')
|
||||
watchValue(newValue: object | object[] | null): void {
|
||||
|
|
|
@ -3,7 +3,8 @@ import Vue, {CreateElement, VNode} from 'vue';
|
|||
//tslint:disable-next-line:variable-name
|
||||
const Tabs = Vue.extend({
|
||||
props: ['value', 'tabs'],
|
||||
render(this: Vue & {readonly value?: string, tabs: {readonly [key: string]: string}}, createElement: CreateElement): VNode {
|
||||
render(this: Vue & {readonly value?: string, _v?: string, selected?: string, tabs: {readonly [key: string]: string}},
|
||||
createElement: CreateElement): VNode {
|
||||
let children: {[key: string]: string | VNode | undefined};
|
||||
if(<VNode[] | undefined>this.$slots['default'] !== undefined) {
|
||||
children = {};
|
||||
|
@ -12,10 +13,15 @@ const Tabs = Vue.extend({
|
|||
});
|
||||
} else children = this.tabs;
|
||||
const keys = Object.keys(children);
|
||||
if(this.value === undefined || children[this.value] === undefined) this.$emit('input', keys[0]);
|
||||
if(this._v !== this.value)
|
||||
this.selected = this._v = this.value;
|
||||
if(this._v === undefined || children[this._v] === undefined)
|
||||
this.$emit('input', this._v = keys[0]);
|
||||
if(this.selected !== this._v && children[this.selected!] !== undefined)
|
||||
this.$emit('input', this._v = this.selected);
|
||||
return createElement('ul', {staticClass: 'nav nav-tabs'}, keys.map((key) => createElement('li', {staticClass: 'nav-item'},
|
||||
[createElement('a', {
|
||||
staticClass: 'nav-link', class: {active: this.value === key}, on: {
|
||||
staticClass: 'nav-link', class: {active: this._v === key}, on: {
|
||||
click: () => {
|
||||
this.$emit('input', key);
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
<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')}}
|
||||
<span style="white-space:pre-wrap">{{l('importer.importingNote')}}</span>
|
||||
<div class="progress" style="margin-top:5px">
|
||||
<div class="progress-bar" :style="{width: importProgress * 100 + '%'}"></div>
|
||||
</div>
|
||||
|
@ -47,6 +47,15 @@
|
|||
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
|
||||
</template>
|
||||
</modal>
|
||||
<modal :action="l('fixLogs.action')" ref="fixLogsModal" @submit="fixLogs" buttonClass="btn-danger">
|
||||
<span style="white-space:pre-wrap">{{l('fixLogs.text')}}</span>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{l('fixLogs.character')}}</label>
|
||||
<select id="import" class="form-control" v-model="fixCharacter">
|
||||
<option v-for="character in fixCharacters" :value="character">{{character}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -71,7 +80,7 @@
|
|||
import Connection from '../fchat/connection';
|
||||
import CharacterPage from '../site/character_page/character_page.vue';
|
||||
import {GeneralSettings, nativeRequire} from './common';
|
||||
import {Logs, SettingsStore} from './filesystem';
|
||||
import {fixLogs, Logs, SettingsStore} from './filesystem';
|
||||
import * as SlimcatImporter from './importer';
|
||||
import Notifications from './notifications';
|
||||
|
||||
|
@ -107,6 +116,8 @@
|
|||
settings!: GeneralSettings;
|
||||
importProgress = 0;
|
||||
profileName = '';
|
||||
fixCharacters: ReadonlyArray<string> = [];
|
||||
fixCharacter = '';
|
||||
|
||||
async created(): Promise<void> {
|
||||
if(this.settings.account.length > 0) this.saveLogin = true;
|
||||
|
@ -122,6 +133,11 @@
|
|||
this.profileName = name;
|
||||
profileViewer.show();
|
||||
});
|
||||
electron.ipcRenderer.on('fix-logs', async() => {
|
||||
this.fixCharacters = await new SettingsStore().getAvailableCharacters();
|
||||
this.fixCharacter = this.fixCharacters[0];
|
||||
(<Modal>this.$refs['fixLogsModal']).show();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if(this.character !== undefined) electron.ipcRenderer.send('disconnect', this.character);
|
||||
|
@ -142,7 +158,10 @@
|
|||
this.error = data.error;
|
||||
return;
|
||||
}
|
||||
if(this.saveLogin) electron.ipcRenderer.send('save-login', this.settings.account, this.settings.host);
|
||||
if(this.saveLogin) {
|
||||
electron.ipcRenderer.send('save-login', this.settings.account, this.settings.host);
|
||||
await keyStore.setPassword(this.settings.account, this.password);
|
||||
}
|
||||
Socket.host = this.settings.host;
|
||||
const connection = new Connection(`F-Chat 3.0 (${process.platform})`, electron.remote.app.getVersion(), Socket,
|
||||
this.settings.account, this.password);
|
||||
|
@ -151,7 +170,7 @@
|
|||
alert(l('login.alreadyLoggedIn'));
|
||||
return core.connection.close();
|
||||
}
|
||||
this.character = core.connection.character;
|
||||
this.character = connection.character;
|
||||
if((await core.settingsStore.get('settings')) === undefined &&
|
||||
SlimcatImporter.canImportCharacter(core.connection.character)) {
|
||||
if(!confirm(l('importer.importGeneral'))) return core.settingsStore.set('settings', new Settings());
|
||||
|
@ -184,6 +203,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
fixLogs(): void {
|
||||
if(!electron.ipcRenderer.sendSync('connect', this.fixCharacter)) return alert(l('login.alreadyLoggedIn'));
|
||||
try {
|
||||
fixLogs(this.fixCharacter);
|
||||
alert(l('fixLogs.success'));
|
||||
} catch(e) {
|
||||
alert(l('fixLogs.error'));
|
||||
throw e;
|
||||
} finally {
|
||||
electron.ipcRenderer.send('disconnect', this.fixCharacter);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseOver(e: MouseEvent): void {
|
||||
const preview = (<HTMLDivElement>this.$refs.linkPreview);
|
||||
if((<HTMLElement>e.target).tagName === 'A') {
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div style="display:flex;flex-direction:column;height:100%;padding:1px" :class="'platform-' + platform">
|
||||
<div style="display:flex;flex-direction:column;height:100%;padding:1px" :class="'platform-' + platform" @auxclick.prevent>
|
||||
<div v-html="styling"></div>
|
||||
<div style="display:flex;align-items:stretch;" class="border-bottom" id="window-tabs">
|
||||
<h4>F-Chat</h4>
|
||||
<div class="btn" :class="'btn-' + (hasUpdate ? 'warning' : 'light')" @click="openMenu">
|
||||
<div class="btn" :class="'btn-' + (hasUpdate ? 'warning' : 'light')" @click="openMenu" id="settings">
|
||||
<i class="fa fa-cog"></i>
|
||||
</div>
|
||||
<ul class="nav nav-tabs" style="border-bottom:0" ref="tabs">
|
||||
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item">
|
||||
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item" @auxclick="remove(tab)">
|
||||
<a href="#" @click.prevent="show(tab)" class="nav-link"
|
||||
:class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}">
|
||||
<img v-if="tab.user" :src="'https://static.f-list.net/images/avatar/' + tab.user.toLowerCase() + '.png'"/>
|
||||
|
@ -53,6 +53,12 @@
|
|||
return {x: 0, y: height, width: bounds.width, height: bounds.height - height};
|
||||
}
|
||||
|
||||
function destroyTab(tab: Tab): void {
|
||||
tab.tray.destroy();
|
||||
tab.view.webContents.loadURL('about:blank');
|
||||
electron.ipcRenderer.send('tab-closed');
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
user: string | undefined,
|
||||
view: Electron.BrowserView
|
||||
|
@ -80,8 +86,12 @@
|
|||
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => this.settings = settings);
|
||||
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
|
||||
electron.ipcRenderer.on('open-tab', () => this.addTab());
|
||||
electron.ipcRenderer.on('update-available', () => this.hasUpdate = true);
|
||||
electron.ipcRenderer.on('quit', () => this.tabs.forEach((tab) => this.remove(tab, false)));
|
||||
electron.ipcRenderer.on('update-available', (_: Event, available: boolean) => this.hasUpdate = available);
|
||||
electron.ipcRenderer.on('fix-logs', () => this.activeTab!.view.webContents.send('fix-logs'));
|
||||
electron.ipcRenderer.on('quit', () => {
|
||||
this.tabs.forEach(destroyTab);
|
||||
this.tabs = [];
|
||||
});
|
||||
electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => {
|
||||
const tab = this.tabMap[id];
|
||||
tab.user = name;
|
||||
|
@ -130,12 +140,16 @@
|
|||
window.onbeforeunload = () => {
|
||||
const isConnected = this.tabs.reduce((cur, tab) => cur || tab.user !== undefined, false);
|
||||
if(process.env.NODE_ENV !== 'production' || !isConnected) {
|
||||
this.tabs.forEach((tab) => this.remove(tab, false));
|
||||
this.tabs.forEach(destroyTab);
|
||||
return;
|
||||
}
|
||||
if(!this.settings.closeToTray)
|
||||
return setImmediate(() => {
|
||||
if(confirm(l('chat.confirmLeave'))) this.tabs.forEach((tab) => this.remove(tab, false));
|
||||
if(confirm(l('chat.confirmLeave'))) {
|
||||
this.tabs.forEach(destroyTab);
|
||||
this.tabs = [];
|
||||
browserWindow.close();
|
||||
}
|
||||
});
|
||||
browserWindow.hide();
|
||||
return false;
|
||||
|
@ -154,14 +168,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
trayClicked(tab: Tab): void {
|
||||
browserWindow.show();
|
||||
if(this.isMaximized) browserWindow.maximize();
|
||||
this.show(tab);
|
||||
}
|
||||
|
||||
createTrayMenu(tab: Tab): Electron.MenuItemConstructorOptions[] {
|
||||
return [
|
||||
{
|
||||
label: l('action.open'), click: () => {
|
||||
browserWindow.show();
|
||||
this.show(tab);
|
||||
}
|
||||
},
|
||||
{label: l('action.open'), click: () => this.trayClicked(tab)},
|
||||
{label: l('action.quit'), click: () => this.remove(tab, false)}
|
||||
];
|
||||
}
|
||||
|
@ -169,7 +184,7 @@
|
|||
addTab(): void {
|
||||
const tray = new electron.remote.Tray(trayIcon);
|
||||
tray.setToolTip(l('title'));
|
||||
tray.on('click', (_) => browserWindow.show());
|
||||
tray.on('click', (_) => this.trayClicked(tab));
|
||||
const view = new electron.remote.BrowserView();
|
||||
view.setAutoResize({width: true, height: true});
|
||||
view.webContents.loadURL(url.format({
|
||||
|
@ -197,10 +212,7 @@
|
|||
this.tabs.splice(this.tabs.indexOf(tab), 1);
|
||||
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
|
||||
delete this.tabMap[tab.view.webContents.id];
|
||||
tab.tray.destroy();
|
||||
tab.view.webContents.loadURL('about:blank');
|
||||
electron.ipcRenderer.send('tab-closed');
|
||||
delete tab.view;
|
||||
destroyTab(tab);
|
||||
if(this.tabs.length === 0) {
|
||||
if(process.env.NODE_ENV === 'production') browserWindow.close();
|
||||
} else if(this.activeTab === tab) this.show(this.tabs[0]);
|
||||
|
@ -230,7 +242,7 @@
|
|||
user-select: none;
|
||||
.btn {
|
||||
border-radius: 0;
|
||||
padding: 5px 15px;
|
||||
padding: 2px 15px;
|
||||
display: flex;
|
||||
margin: 0px -1px -1px 0;
|
||||
align-items: center;
|
||||
|
@ -245,7 +257,7 @@
|
|||
height: 100%;
|
||||
a {
|
||||
display: flex;
|
||||
padding: 5px 10px;
|
||||
padding: 2px 10px;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
&:first-child {
|
||||
|
@ -270,6 +282,10 @@
|
|||
align-self: center;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.fa {
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
#windowButtons .btn {
|
||||
|
@ -278,12 +294,19 @@
|
|||
}
|
||||
|
||||
.platform-darwin {
|
||||
#windowButtons .btn {
|
||||
#windowButtons .btn, #settings {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#window-tabs h4 {
|
||||
margin: 9px 34px 9px 77px;
|
||||
#window-tabs {
|
||||
h4 {
|
||||
margin: 0 34px 0 77px;
|
||||
}
|
||||
|
||||
.btn, li a {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"name": "fchat",
|
||||
"version": "0.2.17",
|
||||
"version": "0.2.18",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^1.8.1"
|
||||
"electron": "^1.8.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"keytar": "^4.0.4",
|
||||
"spellchecker": "^3.4.3"
|
||||
"keytar": "^4.2.1",
|
||||
"spellchecker": "^3.4.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
* @version 3.0
|
||||
* @see {@link https://github.com/f-list/exported|GitHub repo}
|
||||
*/
|
||||
import {exec} from 'child_process';
|
||||
import * as electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
@ -95,6 +96,7 @@ webContents.on('context-menu', (_, props) => {
|
|||
id: 'copy',
|
||||
label: l('action.copy'),
|
||||
role: can('Copy') ? 'copy' : '',
|
||||
accelerator: 'CmdOrCtrl+C',
|
||||
enabled: can('Copy')
|
||||
});
|
||||
if(props.isEditable)
|
||||
|
@ -102,11 +104,13 @@ webContents.on('context-menu', (_, props) => {
|
|||
id: 'cut',
|
||||
label: l('action.cut'),
|
||||
role: can('Cut') ? 'cut' : '',
|
||||
accelerator: 'CmdOrCtrl+X',
|
||||
enabled: can('Cut')
|
||||
}, {
|
||||
id: 'paste',
|
||||
label: l('action.paste'),
|
||||
role: props.editFlags.canPaste ? 'paste' : '',
|
||||
accelerator: 'CmdOrCtrl+V',
|
||||
enabled: props.editFlags.canPaste
|
||||
});
|
||||
else if(props.linkURL.length > 0 && props.mediaType === 'none' && props.linkURL.substr(0, props.pageURL.length) !== props.pageURL)
|
||||
|
@ -120,6 +124,13 @@ webContents.on('context-menu', (_, props) => {
|
|||
electron.clipboard.writeText(props.linkURL);
|
||||
}
|
||||
});
|
||||
else if(hasText)
|
||||
menuTemplate.push({
|
||||
label: l('action.copyWithoutBBCode'),
|
||||
enabled: can('Copy'),
|
||||
accelerator: 'CmdOrCtrl+Shift+C',
|
||||
click: () => electron.clipboard.writeText(props.selectionText)
|
||||
});
|
||||
if(props.misspelledWord !== '') {
|
||||
const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
|
||||
menuTemplate.unshift({
|
||||
|
@ -150,7 +161,9 @@ webContents.on('context-menu', (_, props) => {
|
|||
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
|
||||
});
|
||||
|
||||
const dictDir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');
|
||||
let dictDir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');
|
||||
if(process.platform === 'win32')
|
||||
exec(`for /d %I in ("${dictDir}") do @echo %~sI`, (_, stdout) => { dictDir = stdout.trim(); });
|
||||
electron.webFrame.setSpellCheckProvider('', false, {spellCheck: (text) => !spellchecker.isMisspelled(text)});
|
||||
electron.ipcRenderer.on('settings', async(_: Event, s: GeneralSettings) => spellchecker.setDictionary(s.spellcheckLang, dictDir));
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ export class GeneralSettings {
|
|||
profileViewer = true;
|
||||
host = 'wss://chat.f-list.net:9799';
|
||||
logDirectory = path.join(electron.app.getPath('userData'), 'data');
|
||||
spellcheckLang: string | undefined = 'en-GB';
|
||||
spellcheckLang: string | undefined = 'en_GB';
|
||||
theme = 'default';
|
||||
version = electron.app.getVersion();
|
||||
beta = false;
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import Axios from 'axios';
|
||||
import * as electron from 'electron';
|
||||
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {promisify} from 'util';
|
||||
import {mkdir} from './common';
|
||||
|
||||
const dictDir = path.join(electron.app.getPath('userData'), 'spellchecker');
|
||||
mkdir(dictDir);
|
||||
const requestConfig = {responseType: 'arraybuffer'};
|
||||
|
||||
const downloadedPath = path.join(dictDir, 'downloaded.json');
|
||||
const downloadUrl = 'https://client.f-list.net/dicts/';
|
||||
type File = {name: string, hash: string};
|
||||
type DictionaryIndex = {[key: string]: {dic: File, aff: File} | undefined};
|
||||
let availableDictionaries: DictionaryIndex | undefined;
|
||||
let downloadedDictionaries: {[key: string]: File | undefined} = {};
|
||||
const writeFile = promisify(fs.writeFile);
|
||||
|
||||
export async function getAvailableDictionaries(): Promise<ReadonlyArray<string>> {
|
||||
if(availableDictionaries === undefined)
|
||||
try {
|
||||
availableDictionaries = (await Axios.get<DictionaryIndex>(`${downloadUrl}index.json`)).data;
|
||||
if(fs.existsSync(downloadedPath))
|
||||
downloadedDictionaries = <{[key: string]: File}>JSON.parse(fs.readFileSync(downloadedPath, 'utf-8'));
|
||||
} catch(e) {
|
||||
availableDictionaries = {};
|
||||
log.error(`Error loading dictionaries: ${e}`);
|
||||
}
|
||||
return Object.keys(availableDictionaries).sort();
|
||||
}
|
||||
|
||||
export async function ensureDictionary(lang: string): Promise<void> {
|
||||
await getAvailableDictionaries();
|
||||
const dict = availableDictionaries![lang];
|
||||
if(dict === undefined) return;
|
||||
async function ensure(type: 'aff' | 'dic'): Promise<void> {
|
||||
const file = dict![type];
|
||||
const filePath = path.join(dictDir, `${lang}.${type}`);
|
||||
const downloaded = downloadedDictionaries[file.name];
|
||||
if(downloaded === undefined || downloaded.hash !== file.hash || !fs.existsSync(filePath)) {
|
||||
await writeFile(filePath, new Buffer((await Axios.get<string>(`${downloadUrl}${file.name}`, requestConfig)).data));
|
||||
downloadedDictionaries[file.name] = file;
|
||||
await writeFile(downloadedPath, JSON.stringify(downloadedDictionaries));
|
||||
}
|
||||
}
|
||||
await ensure('aff');
|
||||
await ensure('dic');
|
||||
}
|
|
@ -4,7 +4,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import {Message as MessageImpl} from '../chat/common';
|
||||
import core from '../chat/core';
|
||||
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
import {Character, Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
import l from '../chat/localize';
|
||||
import {GeneralSettings, mkdir} from './common';
|
||||
|
||||
|
@ -94,20 +94,64 @@ export function serializeMessage(message: Message): {serialized: Buffer, size: n
|
|||
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);
|
||||
function deserializeMessage(buffer: Buffer, characterGetter: (name: string) => Character = (name) => core.characters.get(name),
|
||||
unsafe: boolean = noAssert): {end: number, message: Conversation.Message} {
|
||||
const time = buffer.readUInt32LE(0, unsafe);
|
||||
const type = buffer.readUInt8(4, unsafe);
|
||||
const senderLength = buffer.readUInt8(5, unsafe);
|
||||
let offset = senderLength + 6;
|
||||
const sender = buffer.toString('utf8', 6, offset);
|
||||
const messageLength = buffer.readUInt16LE(offset, noAssert);
|
||||
const messageLength = buffer.readUInt16LE(offset, unsafe);
|
||||
offset += 2;
|
||||
const text = buffer.toString('utf8', offset, offset += messageLength);
|
||||
const message = new MessageImpl(type, core.characters.get(sender), text, new Date(time * 1000));
|
||||
const message = new MessageImpl(type, characterGetter(sender), text, new Date(time * 1000));
|
||||
return {message, end: offset + 2};
|
||||
}
|
||||
|
||||
export class Logs implements Logging.Persistent {
|
||||
export function fixLogs(character: string): void {
|
||||
const dir = getLogDir(character);
|
||||
const files = fs.readdirSync(dir);
|
||||
const buffer = Buffer.allocUnsafe(50100);
|
||||
for(const file of files)
|
||||
if(file.substr(-4) !== '.idx') {
|
||||
const fd = fs.openSync(path.join(dir, file), 'r+');
|
||||
const indexFd = fs.openSync(path.join(dir, `${file}.idx`), 'r+');
|
||||
fs.readSync(indexFd, buffer, 0, 1, 0);
|
||||
let pos = 0, lastDay = 0;
|
||||
const nameEnd = buffer.readUInt8(0, noAssert) + 1;
|
||||
fs.readSync(indexFd, buffer, 0, nameEnd, null); //tslint:disable-line:no-null-keyword
|
||||
buffer.toString('utf8', 1, nameEnd);
|
||||
fs.ftruncateSync(indexFd, nameEnd);
|
||||
const size = (fs.fstatSync(fd)).size;
|
||||
try {
|
||||
while(pos < size) {
|
||||
buffer.fill(-1);
|
||||
fs.readSync(fd, buffer, 0, 50100, pos);
|
||||
const deserialized = deserializeMessage(buffer, (name) => ({
|
||||
gender: 'None', status: 'online', statusText: '', isFriend: false, isBookmarked: false, isChatOp: false,
|
||||
isIgnored: false, name
|
||||
}), false);
|
||||
const time = deserialized.message.time;
|
||||
const day = Math.floor(time.getTime() / dayMs - time.getTimezoneOffset() / 1440);
|
||||
if(day > lastDay) {
|
||||
buffer.writeUInt16LE(day, 0, noAssert);
|
||||
buffer.writeUIntLE(pos, 2, 5, noAssert);
|
||||
fs.writeSync(indexFd, buffer, 0, 7);
|
||||
lastDay = day;
|
||||
}
|
||||
if(buffer.readUInt16LE(deserialized.end - 2) !== deserialized.end - 2) throw new Error();
|
||||
pos += deserialized.end;
|
||||
}
|
||||
} catch {
|
||||
fs.ftruncateSync(fd, pos);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
fs.closeSync(indexFd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Logs implements Logging {
|
||||
private index: Index = {};
|
||||
|
||||
constructor() {
|
||||
|
@ -150,10 +194,11 @@ export class Logs implements Logging.Persistent {
|
|||
messages[--count] = deserializeMessage(buffer).message;
|
||||
}
|
||||
if(count !== 0) messages = messages.slice(count);
|
||||
fs.closeSync(fd);
|
||||
return messages;
|
||||
}
|
||||
|
||||
getLogDates(key: string): ReadonlyArray<Date> {
|
||||
async getLogDates(key: string): Promise<ReadonlyArray<Date>> {
|
||||
const entry = this.index[key];
|
||||
if(entry === undefined) return [];
|
||||
const dates = [];
|
||||
|
@ -181,6 +226,7 @@ export class Logs implements Logging.Persistent {
|
|||
messages.push(deserialized.message);
|
||||
pos += deserialized.end;
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
@ -194,10 +240,9 @@ export class Logs implements Logging.Persistent {
|
|||
writeFile(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)));
|
||||
get conversations(): ReadonlyArray<{key: string, name: string}> {
|
||||
const conversations: {key: string, name: string}[] = [];
|
||||
for(const key in this.index) conversations.push({key, name: this.index[key]!.name});
|
||||
return conversations;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import * as path from 'path';
|
|||
import {promisify} from 'util';
|
||||
import {Settings} from '../chat/common';
|
||||
import {Conversation} from '../chat/interfaces';
|
||||
import {isAction} from '../chat/slash_commands';
|
||||
import {GeneralSettings} from './common';
|
||||
import {checkIndex, getLogDir, Message as LogMessage, serializeMessage, SettingsStore} from './filesystem';
|
||||
|
||||
|
@ -111,7 +112,7 @@ function createMessage(line: string, ownCharacter: string, name: string, isChann
|
|||
if(line[lineIndex] === ':') {
|
||||
++lineIndex;
|
||||
if(line[lineIndex] === ' ') ++lineIndex;
|
||||
if(line.substr(lineIndex, 3) === '/me') {
|
||||
if(isAction(line)) {
|
||||
type = Conversation.Message.Type.Action;
|
||||
lineIndex += 3;
|
||||
}
|
||||
|
|
115
electron/main.ts
115
electron/main.ts
|
@ -29,18 +29,18 @@
|
|||
* @version 3.0
|
||||
* @see {@link https://github.com/f-list/exported|GitHub repo}
|
||||
*/
|
||||
import Axios from 'axios';
|
||||
import * as electron from 'electron';
|
||||
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
|
||||
import {autoUpdater} from 'electron-updater';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as url from 'url';
|
||||
import {promisify} from 'util';
|
||||
import l from '../chat/localize';
|
||||
import {GeneralSettings, mkdir} from './common';
|
||||
import {ensureDictionary, getAvailableDictionaries} from './dictionaries';
|
||||
import * as windowState from './window_state';
|
||||
import BrowserWindow = Electron.BrowserWindow;
|
||||
import MenuItem = Electron.MenuItem;
|
||||
|
||||
// Module to control application life.
|
||||
const app = electron.app;
|
||||
|
@ -60,54 +60,21 @@ log.transports.file.maxSize = 5 * 1024 * 1024;
|
|||
log.transports.file.file = path.join(baseDir, 'log.txt');
|
||||
log.info('Starting application.');
|
||||
|
||||
const dictDir = path.join(baseDir, 'spellchecker');
|
||||
mkdir(dictDir);
|
||||
const downloadUrl = 'https://client.f-list.net/dictionaries/';
|
||||
type DictionaryIndex = {[key: string]: {file: string, time: number} | undefined};
|
||||
let availableDictionaries: DictionaryIndex | undefined;
|
||||
const writeFile = promisify(fs.writeFile);
|
||||
const requestConfig = {responseType: 'arraybuffer'};
|
||||
|
||||
async function getAvailableDictionaries(): Promise<ReadonlyArray<string>> {
|
||||
if(availableDictionaries === undefined) {
|
||||
const indexPath = path.join(dictDir, 'index.json');
|
||||
try {
|
||||
if(!fs.existsSync(indexPath) || fs.statSync(indexPath).mtimeMs + 86400000 * 7 < Date.now()) {
|
||||
availableDictionaries = (await Axios.get<DictionaryIndex>(`${downloadUrl}index.json`)).data;
|
||||
await writeFile(indexPath, JSON.stringify(availableDictionaries));
|
||||
} else availableDictionaries = <DictionaryIndex>JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
} catch(e) {
|
||||
availableDictionaries = {};
|
||||
log.error(`Error loading dictionaries: ${e}`);
|
||||
}
|
||||
}
|
||||
return Object.keys(availableDictionaries).sort();
|
||||
}
|
||||
|
||||
async function setDictionary(lang: string | undefined): Promise<void> {
|
||||
const dict = availableDictionaries![lang!];
|
||||
if(dict !== undefined) {
|
||||
const dicPath = path.join(dictDir, `${lang}.dic`);
|
||||
if(!fs.existsSync(dicPath) || fs.statSync(dicPath).mtimeMs / 1000 < dict.time) {
|
||||
await writeFile(dicPath, new Buffer((await Axios.get<string>(`${downloadUrl}${dict.file}.dic`, requestConfig)).data));
|
||||
await writeFile(path.join(dictDir, `${lang}.aff`),
|
||||
new Buffer((await Axios.get<string>(`${downloadUrl}${dict.file}.aff`, requestConfig)).data));
|
||||
fs.utimesSync(dicPath, dict.time, dict.time);
|
||||
}
|
||||
}
|
||||
if(lang !== undefined) await ensureDictionary(lang);
|
||||
settings.spellcheckLang = lang;
|
||||
setGeneralSettings(settings);
|
||||
}
|
||||
|
||||
const settingsDir = path.join(electron.app.getPath('userData'), 'data');
|
||||
mkdir(settingsDir);
|
||||
const file = path.join(settingsDir, 'settings');
|
||||
const settingsFile = path.join(settingsDir, 'settings');
|
||||
const settings = new GeneralSettings();
|
||||
let shouldImportSettings = false;
|
||||
if(!fs.existsSync(file)) shouldImportSettings = true;
|
||||
if(!fs.existsSync(settingsFile)) shouldImportSettings = true;
|
||||
else
|
||||
try {
|
||||
Object.assign(settings, <GeneralSettings>JSON.parse(fs.readFileSync(file, 'utf8')));
|
||||
Object.assign(settings, <GeneralSettings>JSON.parse(fs.readFileSync(settingsFile, 'utf8')));
|
||||
} catch(e) {
|
||||
log.error(`Error loading settings: ${e}`);
|
||||
}
|
||||
|
@ -119,6 +86,7 @@ function setGeneralSettings(value: GeneralSettings): void {
|
|||
}
|
||||
|
||||
async function addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
|
||||
if(settings.spellcheckLang !== undefined) await ensureDictionary(settings.spellcheckLang);
|
||||
const dictionaries = await getAvailableDictionaries();
|
||||
const selected = settings.spellcheckLang;
|
||||
menu.append(new electron.MenuItem({
|
||||
|
@ -151,13 +119,12 @@ function createWindow(): Electron.BrowserWindow | undefined {
|
|||
if(tabCount >= 3) return;
|
||||
const lastState = windowState.getSavedWindowState();
|
||||
const windowProperties: Electron.BrowserWindowConstructorOptions & {maximized: boolean} = {
|
||||
...lastState, center: lastState.x === undefined
|
||||
...lastState, center: lastState.x === undefined, show: false
|
||||
};
|
||||
if(process.platform === 'darwin') windowProperties.titleBarStyle = 'hiddenInset';
|
||||
else windowProperties.frame = false;
|
||||
const window = new electron.BrowserWindow(windowProperties);
|
||||
windows.push(window);
|
||||
if(lastState.maximized) window.maximize();
|
||||
|
||||
window.loadURL(url.format({
|
||||
pathname: path.join(__dirname, 'window.html'),
|
||||
|
@ -171,7 +138,10 @@ function createWindow(): Electron.BrowserWindow | undefined {
|
|||
// Save window state when it is being closed.
|
||||
window.on('close', () => windowState.setSavedWindowState(window));
|
||||
window.on('closed', () => windows.splice(windows.indexOf(window), 1));
|
||||
|
||||
window.once('ready-to-show', () => {
|
||||
window.show();
|
||||
if(lastState.maximized) window.maximize();
|
||||
});
|
||||
return window;
|
||||
}
|
||||
|
||||
|
@ -190,30 +160,37 @@ function onReady(): void {
|
|||
}
|
||||
|
||||
if(process.env.NODE_ENV === 'production') {
|
||||
if(settings.beta) autoUpdater.channel = 'beta';
|
||||
autoUpdater.channel = settings.beta ? 'beta' : 'latest';
|
||||
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
|
||||
const updateTimer = setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
|
||||
let hasUpdate = false;
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
clearInterval(updateTimer);
|
||||
if(hasUpdate) return;
|
||||
hasUpdate = true;
|
||||
const menu = electron.Menu.getApplicationMenu()!;
|
||||
menu.append(new electron.MenuItem({
|
||||
label: l('action.updateAvailable'),
|
||||
submenu: electron.Menu.buildFromTemplate([{
|
||||
label: l('action.update'),
|
||||
click: () => {
|
||||
for(const w of windows) w.webContents.send('quit');
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
}, {
|
||||
label: l('help.changelog'),
|
||||
click: showPatchNotes
|
||||
}])
|
||||
}));
|
||||
const item = menu.getMenuItemById('update') as MenuItem | null;
|
||||
if(item !== null) item.visible = true;
|
||||
else
|
||||
menu.append(new electron.MenuItem({
|
||||
label: l('action.updateAvailable'),
|
||||
submenu: electron.Menu.buildFromTemplate([{
|
||||
label: l('action.update'),
|
||||
click: () => {
|
||||
for(const w of windows) w.webContents.send('quit');
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
}, {
|
||||
label: l('help.changelog'),
|
||||
click: showPatchNotes
|
||||
}]),
|
||||
id: 'update'
|
||||
}));
|
||||
electron.Menu.setApplicationMenu(menu);
|
||||
for(const w of windows) w.webContents.send('update-available');
|
||||
for(const w of windows) w.webContents.send('update-available', true);
|
||||
});
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
(<any>autoUpdater).downloadedUpdateHelper.clear(); //tslint:disable-line:no-any no-unsafe-any
|
||||
for(const w of windows) w.webContents.send('update-available', false);
|
||||
const item = electron.Menu.getApplicationMenu()!.getMenuItemById('update') as MenuItem | null;
|
||||
if(item !== null) item.visible = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -261,10 +238,7 @@ function onReady(): void {
|
|||
cancelId: 1
|
||||
});
|
||||
if(button === 0) {
|
||||
for(const w of windows) {
|
||||
w.webContents.on('will-prevent-unload', (e) => e.preventDefault());
|
||||
w.close();
|
||||
}
|
||||
for(const w of windows) w.webContents.send('quit');
|
||||
settings.logDirectory = dir[0];
|
||||
setGeneralSettings(settings);
|
||||
app.quit();
|
||||
|
@ -296,11 +270,15 @@ function onReady(): void {
|
|||
}))
|
||||
}, {
|
||||
label: l('settings.beta'), type: 'checkbox', checked: settings.beta,
|
||||
click: (item: Electron.MenuItem) => {
|
||||
click: async(item: Electron.MenuItem) => {
|
||||
settings.beta = item.checked;
|
||||
setGeneralSettings(settings);
|
||||
autoUpdater.channel = item.checked ? 'beta' : 'latest';
|
||||
return autoUpdater.checkForUpdates();
|
||||
}
|
||||
}, {
|
||||
label: l('fixLogs.action'),
|
||||
click: (_, window: BrowserWindow) => window.webContents.send('fix-logs')
|
||||
},
|
||||
{type: 'separator'},
|
||||
{role: 'minimize'},
|
||||
|
@ -309,7 +287,7 @@ function onReady(): void {
|
|||
label: l('action.quit'),
|
||||
click(_: Electron.MenuItem, w: Electron.BrowserWindow): void {
|
||||
if(characters.length === 0) return app.quit();
|
||||
const button = electron.dialog.showMessageBox(w, {
|
||||
const button = electron.dialog.showMessageBox(w, {
|
||||
message: l('chat.confirmLeave'),
|
||||
buttons: [l('confirmYes'), l('confirmNo')],
|
||||
cancelId: 1
|
||||
|
@ -383,12 +361,13 @@ function onReady(): void {
|
|||
const badge = electron.nativeImage.createFromPath(path.join(__dirname, <string>require('./build/badge.png')));
|
||||
electron.ipcMain.on('has-new', (e: Event & {sender: Electron.WebContents}, hasNew: boolean) => {
|
||||
if(process.platform === 'darwin') app.dock.setBadge(hasNew ? '!' : '');
|
||||
electron.BrowserWindow.fromWebContents(e.sender).setOverlayIcon(hasNew ? badge : emptyBadge, hasNew ? 'New messages' : '');
|
||||
const window = electron.BrowserWindow.fromWebContents(e.sender) as BrowserWindow | undefined;
|
||||
if(window !== undefined) window.setOverlayIcon(hasNew ? badge : emptyBadge, hasNew ? 'New messages' : '');
|
||||
});
|
||||
createWindow();
|
||||
}
|
||||
|
||||
const running = app.makeSingleInstance(createWindow);
|
||||
const running = process.env.NODE_ENV === 'production' && app.makeSingleInstance(createWindow);
|
||||
if(running) app.quit();
|
||||
else app.on('ready', onReady);
|
||||
app.on('window-all-closed', () => app.quit());
|
|
@ -9,7 +9,7 @@
|
|||
"build": "node ../webpack development",
|
||||
"build:dist": "node ../webpack production",
|
||||
"watch": "node ../webpack watch",
|
||||
"start": "electron app"
|
||||
"start": "../node_modules/.bin/electron app"
|
||||
},
|
||||
"build": {
|
||||
"appId": "net.f-list.f-chat",
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
import * as qs from 'querystring';
|
||||
export = qs;
|
|
@ -58,7 +58,8 @@ const mainConfig = {
|
|||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preserveWhitespace: false
|
||||
preserveWhitespace: false,
|
||||
cssSourceMap: false
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -71,9 +72,9 @@ const mainConfig = {
|
|||
}
|
||||
},
|
||||
{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: /\.(woff2?)$/, loader: 'file-loader'},
|
||||
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
|
||||
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'}
|
||||
]
|
||||
|
@ -93,7 +94,7 @@ const mainConfig = {
|
|||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.vue', '.css'],
|
||||
alias: {qs: path.join(__dirname, 'qs.ts')}
|
||||
alias: {qs: 'querystring'}
|
||||
},
|
||||
optimization: {
|
||||
splitChunks: {chunks: 'all', minChunks: 2, name: 'common'}
|
||||
|
@ -118,6 +119,7 @@ module.exports = function(mode) {
|
|||
rendererConfig.plugins.push(faPlugin);
|
||||
rendererConfig.module.rules.push({test: faPath, use: faPlugin.extract(cssOptions)});
|
||||
if(mode === 'production') {
|
||||
process.env.NODE_ENV = 'production';
|
||||
mainConfig.devtool = rendererConfig.devtool = 'source-map';
|
||||
rendererConfig.plugins.push(new OptimizeCssAssetsPlugin());
|
||||
} else {
|
||||
|
|
|
@ -22,10 +22,11 @@ function sortMember(this: void | never, array: SortableMember[], member: Sortabl
|
|||
if(member.character.isChatOp && !other.character.isChatOp) break;
|
||||
if(other.rank > member.rank) continue;
|
||||
if(member.rank > other.rank) break;
|
||||
if(other.character.isFriend && !member.character.isFriend) continue;
|
||||
if(member.character.isFriend && !other.character.isFriend) break;
|
||||
if(other.character.isBookmarked && !member.character.isBookmarked) continue;
|
||||
if(member.character.isBookmarked && !other.character.isBookmarked) break;
|
||||
if(!member.character.isFriend) {
|
||||
if(other.character.isFriend) continue;
|
||||
if(other.character.isBookmarked && !member.character.isBookmarked) continue;
|
||||
if(member.character.isBookmarked && !other.character.isBookmarked) break;
|
||||
} else if(!other.character.isFriend) break;
|
||||
if(member.key < other.key) break;
|
||||
}
|
||||
array.splice(i, 0, member);
|
||||
|
|
|
@ -62,6 +62,8 @@ export default function(this: void, connection: Connection): Interfaces.State {
|
|||
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);
|
||||
if(isReconnect && (<Character | undefined>state.ownCharacter) !== undefined)
|
||||
reconnectStatus = {status: state.ownCharacter.status, statusmsg: state.ownCharacter.statusText};
|
||||
for(const key in state.characters) {
|
||||
const character = state.characters[key]!;
|
||||
character.isFriend = state.friendList.indexOf(character.name) !== -1;
|
||||
|
@ -69,8 +71,6 @@ export default function(this: void, connection: Connection): Interfaces.State {
|
|||
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;
|
||||
|
|
|
@ -3,7 +3,7 @@ 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];
|
||||
const dieErrors = [9, 30, 31, 39, 40];
|
||||
|
||||
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));
|
||||
|
@ -158,8 +158,10 @@ export default class Connection implements Interfaces.Connection {
|
|||
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();
|
||||
if(dieErrors.indexOf(data.number) !== -1) {
|
||||
this.close();
|
||||
this.character = '';
|
||||
} else this.socket!.close();
|
||||
}
|
||||
break;
|
||||
case 'NLN':
|
||||
|
|
|
@ -44,8 +44,8 @@
|
|||
</div>
|
||||
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
|
||||
<modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
|
||||
<character-page :authenticated="false" :oldApi="true" :name="profileName"></character-page>
|
||||
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
|
||||
<character-page :authenticated="true" :oldApi="true" :name="profileName"></character-page>
|
||||
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"></i></a>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
|
@ -99,7 +99,6 @@
|
|||
settingsStore = new SettingsStore();
|
||||
l = l;
|
||||
settings: GeneralSettings | null = null;
|
||||
importProgress = 0;
|
||||
profileName = '';
|
||||
|
||||
async created(): Promise<void> {
|
||||
|
|
|
@ -8,8 +8,8 @@ android {
|
|||
applicationId "net.f_list.fchat"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 27
|
||||
versionCode 11
|
||||
versionName "0.1.4"
|
||||
versionCode 12
|
||||
versionName "0.1.5"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
|
@ -1,17 +1,26 @@
|
|||
package net.f_list.fchat
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.DialogInterface
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JsResult
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import org.json.JSONTokener
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLDecoder
|
||||
import java.util.*
|
||||
|
||||
|
||||
class MainActivity : Activity() {
|
||||
private lateinit var webView: WebView
|
||||
|
@ -30,6 +39,20 @@ class MainActivity : Activity() {
|
|||
webView.addJavascriptInterface(Notifications(this), "NativeNotification")
|
||||
webView.addJavascriptInterface(backgroundPlugin, "NativeBackground")
|
||||
webView.addJavascriptInterface(Logs(this), "NativeLogs")
|
||||
webView.setDownloadListener { url, _, _, _, _ ->
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val permission = checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
if(permission != PackageManager.PERMISSION_GRANTED)
|
||||
return@setDownloadListener requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
val index = url.indexOf(',')
|
||||
val name = URLDecoder.decode(url.substring(5, index), Charsets.UTF_8.name())
|
||||
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val file = java.io.File(dir, name)
|
||||
FileOutputStream(file).use { it.write(URLDecoder.decode(url.substring(index + 1), Charsets.UTF_8.name()).toByteArray()) }
|
||||
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
downloadManager.addCompletedDownload(name, name, false, "text/plain", file.absolutePath, file.length(), true)
|
||||
}
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean {
|
||||
AlertDialog.Builder(this@MainActivity).setTitle(R.string.app_name).setMessage(message).setPositiveButton(R.string.ok, { _, _ -> result.confirm() }).show()
|
||||
|
@ -39,7 +62,7 @@ class MainActivity : Activity() {
|
|||
override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean {
|
||||
var ok = false
|
||||
AlertDialog.Builder(this@MainActivity).setTitle(R.string.app_name).setMessage(message).setOnDismissListener({ if(ok) result.confirm() else result.cancel() })
|
||||
.setPositiveButton(R.string.ok, { _, _ -> ok = true}).setNegativeButton(R.string.cancel, null).show()
|
||||
.setPositiveButton(R.string.ok, { _, _ -> ok = true }).setNegativeButton(R.string.cancel, null).show()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +92,7 @@ class MainActivity : Activity() {
|
|||
override fun onBackPressed() {
|
||||
webView.evaluateJavascript("var e=new Event('backbutton',{cancelable:true});document.dispatchEvent(e);e.defaultPrevented", {
|
||||
if(it != "true") super.onBackPressed()
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
@ -93,7 +116,6 @@ class MainActivity : Activity() {
|
|||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
findViewById<ViewGroup>(R.id.content).removeAllViews()
|
||||
webView.removeAllViews()
|
||||
webView.destroy()
|
||||
backgroundPlugin.stop()
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ class Notifications(private val ctx: Context) {
|
|||
player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION)
|
||||
player.prepare()
|
||||
player.start()
|
||||
player.setOnCompletionListener { it.release() }
|
||||
}
|
||||
val intent = Intent(ctx, MainActivity::class.java)
|
||||
intent.action = "notification"
|
||||
|
|
|
@ -34,7 +34,7 @@ export class GeneralSettings {
|
|||
|
||||
type Index = {[key: string]: {name: string, dates: number[]} | undefined};
|
||||
|
||||
export class Logs implements Logging.Persistent {
|
||||
export class Logs implements Logging {
|
||||
private index: Index = {};
|
||||
|
||||
constructor() {
|
||||
|
@ -63,16 +63,15 @@ export class Logs implements Logging.Persistent {
|
|||
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
|
||||
}
|
||||
|
||||
getLogDates(key: string): ReadonlyArray<Date> {
|
||||
async getLogDates(key: string): Promise<ReadonlyArray<Date>> {
|
||||
const entry = this.index[key];
|
||||
if(entry === undefined) return [];
|
||||
return entry.dates.map((x) => new Date(x * dayMs));
|
||||
}
|
||||
|
||||
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)));
|
||||
get conversations(): ReadonlyArray<{key: string, name: string}> {
|
||||
const conversations: {key: string, name: string}[] = [];
|
||||
for(const key in this.index) conversations.push({key, name: this.index[key]!.name});
|
||||
return conversations;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<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, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover" />
|
||||
<title>FChat 3.0</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -252,7 +252,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -303,7 +303,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
|
|
|
@ -23,6 +23,8 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
|
|||
webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.uiDelegate = self
|
||||
view = webView
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow), name: NSNotification.Name.UIKeyboardWillShow, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
|
||||
UIApplication.shared.statusBarStyle = .lightContent
|
||||
(UIApplication.shared.value(forKey: "statusBar") as! UIView).backgroundColor = UIColor(white: 0, alpha: 0.5)
|
||||
}
|
||||
|
@ -40,6 +42,24 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
|
|||
// Dispose of any resources that can be recreated.
|
||||
}
|
||||
|
||||
@objc func keyboardWillShow(notification: NSNotification) {
|
||||
let info = notification.userInfo!
|
||||
let frame = webView.frame
|
||||
let newHeight = view.window!.frame.height - (info[UIKeyboardFrameEndUserInfoKey] as! NSValue).cgRectValue.height
|
||||
UIView.animate(withDuration: (info[UIKeyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue, animations: {
|
||||
self.webView.scrollView.bounds = CGRect(x: 0, y: 0, width: frame.width, height: newHeight)
|
||||
}, completion: { (_: Bool) in self.webView.evaluateJavaScript("window.dispatchEvent(new Event('resize'))", completionHandler: nil) })
|
||||
}
|
||||
|
||||
@objc func keyboardWillHide(notification: NSNotification) {
|
||||
let info = notification.userInfo!
|
||||
let frame = webView.scrollView.bounds
|
||||
let newHeight = frame.height + (info[UIKeyboardFrameEndUserInfoKey] as! NSValue).cgRectValue.height
|
||||
UIView.animate(withDuration: (info[UIKeyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue, animations: {
|
||||
self.webView.scrollView.bounds = CGRect(x: 0, y: 0, width: frame.width, height: newHeight)
|
||||
}, completion: { (_: Bool) in self.webView.evaluateJavaScript("window.dispatchEvent(new Event('resize'))", completionHandler: nil) })
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping () -> Void) {
|
||||
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
|
@ -56,15 +76,27 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
|
|||
}
|
||||
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
let url = navigationAction.request.url!.absoluteString
|
||||
let match = profileRegex.matches(in: url, range: NSRange(location: 0, length: url.count))
|
||||
let url = navigationAction.request.url!
|
||||
let str = url.absoluteString
|
||||
if(url.scheme == "data") {
|
||||
let start = str.index(of: ",")!
|
||||
let file = FileManager.default.temporaryDirectory.appendingPathComponent(str[str.index(str.startIndex, offsetBy: 5)..<start].removingPercentEncoding!)
|
||||
try! str.suffix(from: str.index(after: start)).removingPercentEncoding!.write(to: file, atomically: false, encoding: .utf8)
|
||||
self.present(UIActivityViewController(activityItems: [file], applicationActivities: nil), animated: true, completion: nil)
|
||||
return nil
|
||||
}
|
||||
let match = profileRegex.matches(in: str, range: NSRange(location: 0, length: str.count))
|
||||
if(match.count == 1) {
|
||||
let char = url[Range(match[0].range(at: 2), in: url)!].removingPercentEncoding!;
|
||||
let char = str[Range(match[0].range(at: 2), in: str)!].removingPercentEncoding!;
|
||||
webView.evaluateJavaScript("document.dispatchEvent(new CustomEvent('open-profile',{detail:'\(char)'}))", completionHandler: nil)
|
||||
return nil
|
||||
}
|
||||
UIApplication.shared.open( navigationAction.request.url!)
|
||||
UIApplication.shared.open(navigationAction.request.url!)
|
||||
return nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "net.f_list.fchat",
|
||||
"version": "0.2.15",
|
||||
"version": "0.2.18",
|
||||
"displayName": "F-Chat",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
|
|
|
@ -4,10 +4,8 @@
|
|||
"lib": [
|
||||
"dom",
|
||||
"es5",
|
||||
"es2015.iterable",
|
||||
"es2015.promise"
|
||||
],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
|
|
|
@ -25,13 +25,14 @@ const config = {
|
|||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preserveWhitespace: false
|
||||
preserveWhitespace: false,
|
||||
cssSourceMap: 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: /\.(woff2?)$/, loader: 'file-loader'},
|
||||
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
|
||||
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
|
||||
{test: /\.scss/, use: ['css-loader', 'sass-loader']}
|
||||
|
@ -47,6 +48,7 @@ const config = {
|
|||
|
||||
module.exports = function(mode) {
|
||||
if(mode === 'production') {
|
||||
process.env.NODE_ENV = 'production';
|
||||
config.devtool = 'source-map';
|
||||
} else {
|
||||
config.devtool = 'none';
|
||||
|
|
|
@ -30,7 +30,6 @@
|
|||
"tslib": "^1.7.1",
|
||||
"tslint": "^5.7.0",
|
||||
"typescript": "^2.4.2",
|
||||
"url-loader": "^0.6.2",
|
||||
"vue": "^2.4.2",
|
||||
"vue-class-component": "^6.0.0",
|
||||
"vue-loader": "^14.1.1",
|
||||
|
|
|
@ -76,7 +76,7 @@ span.justifyText {
|
|||
}
|
||||
|
||||
div.indentText {
|
||||
padding-left: 3rem;
|
||||
padding-left: 5%;
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
|
@ -89,6 +89,13 @@ div.indentText {
|
|||
}
|
||||
}
|
||||
|
||||
.bbcode {
|
||||
white-space: pre-wrap;
|
||||
.row > * {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.bbcode-collapse-header {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
|
@ -97,7 +104,6 @@ div.indentText {
|
|||
}
|
||||
|
||||
.bbcode-collapse-body {
|
||||
height: 0;
|
||||
margin-left: 0.5rem;
|
||||
transition: height 0.2s;
|
||||
overflow-y: hidden;
|
||||
|
@ -120,4 +126,9 @@ div.indentText {
|
|||
|
||||
.link-domain {
|
||||
color: $text-muted;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.user-link {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
|
|
@ -1,217 +1,211 @@
|
|||
.character-page-avatar {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
// Inline images
|
||||
.inline-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.character-page {
|
||||
.character-name {
|
||||
font-size: $h3-font-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
.character-title {
|
||||
font-size: $font-size-sm;
|
||||
font-style: italic;
|
||||
}
|
||||
.character-links-block {
|
||||
margin-top: 15px;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.badges-block, .contact-block, .quick-info-block, .character-list-block {
|
||||
margin-top: 15px;
|
||||
.character-name {
|
||||
font-size: $h3-font-size;
|
||||
font-weight: bold;
|
||||
}
|
||||
.character-title {
|
||||
font-size: $font-size-sm;
|
||||
font-style: italic;
|
||||
}
|
||||
.character-links-block {
|
||||
margin-top: 15px;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.badges-block, .contact-block, .quick-info-block, .character-list-block {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.badges-block {
|
||||
.character-badge {
|
||||
background-color: $character-badge-bg;
|
||||
border: 1px solid $character-badge-border;
|
||||
border-radius: $border-radius;
|
||||
@include box-shadow(inset 0 1px 1px rgba(0, 0, 0, .05));
|
||||
.character-badge {
|
||||
background-color: $character-badge-bg;
|
||||
border: 1px solid $character-badge-border;
|
||||
border-radius: $border-radius;
|
||||
@include box-shadow(inset 0 1px 1px rgba(0, 0, 0, .05));
|
||||
|
||||
&.character-badge-subscription-lifetime {
|
||||
background-color: $character-badge-subscriber-bg;
|
||||
border: 2px dashed $character-badge-subscriber-border;
|
||||
font-weight: bold;
|
||||
}
|
||||
&.character-badge-subscription-lifetime {
|
||||
background-color: $character-badge-subscriber-bg;
|
||||
border: 2px dashed $character-badge-subscriber-border;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.infotags {
|
||||
> .infotag-group {
|
||||
.infotag-title {
|
||||
font-size: $h4-font-size;
|
||||
}
|
||||
> .infotag-group {
|
||||
.infotag-title {
|
||||
font-size: $h4-font-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.infotag {
|
||||
.infotag-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.infotag-value {
|
||||
@include force-word-wrapping;
|
||||
}
|
||||
.infotag-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.infotag-value {
|
||||
@include force-word-wrapping;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-method {
|
||||
.contact-value {
|
||||
@include force-word-wrapping;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.contact-value {
|
||||
@include force-word-wrapping;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-info-block {
|
||||
.quick-info-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.quick-info-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.character-kink {
|
||||
position: relative;
|
||||
.subkink-list {
|
||||
//@include well;
|
||||
margin-bottom: 0;
|
||||
padding: 5px 15px;
|
||||
cursor: default;
|
||||
}
|
||||
position: relative;
|
||||
.subkink-list {
|
||||
//@include well;
|
||||
margin-bottom: 0;
|
||||
padding: 5px 15px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.subkink-list.closed {
|
||||
display: none;
|
||||
}
|
||||
.subkink-list.closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.subkink {
|
||||
cursor: pointer;
|
||||
}
|
||||
&.subkink {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@mixin comparison-active {
|
||||
border: 1px solid $quick-compare-active-border;
|
||||
}
|
||||
@mixin comparison-active {
|
||||
border: 1px solid $quick-compare-active-border;
|
||||
}
|
||||
|
||||
&.comparison-favorite {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-favorite-bg;
|
||||
}
|
||||
&.comparison-favorite {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-favorite-bg;
|
||||
}
|
||||
|
||||
&.comparison-yes {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-yes-bg;
|
||||
}
|
||||
&.comparison-yes {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-yes-bg;
|
||||
}
|
||||
|
||||
&.comparison-maybe {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-maybe-bg;
|
||||
}
|
||||
&.comparison-maybe {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-maybe-bg;
|
||||
}
|
||||
|
||||
&.comparison-no {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-no-bg;
|
||||
}
|
||||
&.comparison-no {
|
||||
@include comparison-active;
|
||||
background-color: $quick-compare-no-bg;
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
font-weight: bolder;
|
||||
}
|
||||
&.highlighted {
|
||||
font-weight: bolder;
|
||||
}
|
||||
}
|
||||
|
||||
#character-page-sidebar {
|
||||
height: 100%;
|
||||
.character-image-container {
|
||||
@media (max-width: breakpoint-max(xs)) {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
height: 100%;
|
||||
.btn {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: breakpoint-min(sm)) {
|
||||
.profile-body {
|
||||
padding-left: 0;
|
||||
}
|
||||
@media (min-width: breakpoint-min(md)) {
|
||||
.profile-body {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Character Images
|
||||
.character-images {
|
||||
.character-image {
|
||||
//@include img-thumbnail;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
border: none;
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
img {
|
||||
//@include center-block;
|
||||
}
|
||||
}
|
||||
.character-image {
|
||||
@extend .img-thumbnail;
|
||||
vertical-align: middle;
|
||||
border: none;
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Guestbook
|
||||
.guestbook {
|
||||
.guestbook-pager {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
}
|
||||
.guestbook-pager {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.guestbook-avatar {
|
||||
float: left;
|
||||
}
|
||||
.guestbook-avatar {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.guestbook-contents {
|
||||
//@include well();
|
||||
&.deleted {
|
||||
//@include alert-warning();
|
||||
}
|
||||
.guestbook-contents {
|
||||
//@include well();
|
||||
&.deleted {
|
||||
//@include alert-warning();
|
||||
}
|
||||
}
|
||||
|
||||
.guestbook-reply {
|
||||
&:before {
|
||||
content: "Reply ";
|
||||
font-weight: bold;
|
||||
}
|
||||
.reply-message {
|
||||
//@include well;
|
||||
//@include alert-info;
|
||||
}
|
||||
.guestbook-reply {
|
||||
&:before {
|
||||
content: "Reply ";
|
||||
font-weight: bold;
|
||||
}
|
||||
.reply-message {
|
||||
//@include well;
|
||||
//@include alert-info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#character-friends {
|
||||
.character-friend {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
.character-friend {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
img {
|
||||
padding: 5px;
|
||||
background: white;
|
||||
z-index: 1100;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
img {
|
||||
padding: 5px;
|
||||
background: white;
|
||||
z-index: 1100;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.friend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.date {
|
||||
margin-left: 10px;
|
||||
flex:1;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.date {
|
||||
margin-left: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
|
@ -141,6 +141,9 @@
|
|||
min-height: initial !important;
|
||||
max-height: 250px;
|
||||
resize: none;
|
||||
@media (max-height: 600px) {
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.ads-text-box, .ads-text-box:focus {
|
||||
|
@ -189,6 +192,10 @@
|
|||
color: $gray-500;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.message-highlight {
|
||||
background-color: theme-color-level("success", -8);
|
||||
}
|
||||
|
|
|
@ -17,10 +17,10 @@ $collapse-border: darken($card-border-color, 25%) !default;
|
|||
|
||||
// Character page quick kink comparison
|
||||
$quick-compare-active-border: $black-color !default;
|
||||
$quick-compare-favorite-bg: theme-color-bg("info") !default;
|
||||
$quick-compare-yes-bg: theme-color-bg("success") !default;
|
||||
$quick-compare-maybe-bg: theme-color-bg("warning") !default;
|
||||
$quick-compare-no-bg: theme-color-bg("danger") !default;
|
||||
$quick-compare-favorite-bg: theme-color-level("info", -6) !default;
|
||||
$quick-compare-yes-bg: theme-color-level("success", -6) !default;
|
||||
$quick-compare-maybe-bg: theme-color-level("warning", -6) !default;
|
||||
$quick-compare-no-bg: theme-color-level("danger", -6) !default;
|
||||
|
||||
// character page badges
|
||||
$character-badge-bg: darken($card-bg, 10%) !default;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
$blue-color: #06f;
|
||||
|
||||
.blackText {
|
||||
text-shadow: $gray-800 0 0 4px;
|
||||
text-shadow: $gray-600 1px 1px 1px, $gray-600 -1px 1px 1px, $gray-600 1px -1px 1px, $gray-600 -1px -1px 1px;
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
.purpleText {
|
||||
text-shadow: #306 0 0 4px;
|
||||
text-shadow: #306 1px 1px 1px, #306 -1px 1px 1px, #306 1px -1px 1px, #306 -1px -1px 1px;
|
||||
}
|
||||
|
||||
.blackText {
|
||||
text-shadow: $gray-800 0 0 4px;
|
||||
text-shadow: $gray-600 1px 1px 1px, $gray-600 -1px 1px 1px, $gray-600 1px -1px 1px, $gray-600 -1px -1px 1px;
|
||||
}
|
||||
|
||||
$blue-color: #06f;
|
|
@ -1,3 +1,3 @@
|
|||
.whiteText {
|
||||
text-shadow: $gray-800 0 0 4px;
|
||||
text-shadow: $gray-600 1px 1px 1px, $gray-600 -1px 1px 1px, $gray-600 1px -1px 1px, $gray-600 -1px -1px 1px;
|
||||
}
|
|
@ -2,10 +2,10 @@
|
|||
<div class="row character-page" id="pageBody">
|
||||
<div class="alert alert-info" v-show="loading" style="margin:0 15px;flex:1">Loading character information.</div>
|
||||
<div class="alert alert-danger" v-show="error" style="margin:0 15px;flex:1">{{error}}</div>
|
||||
<div class="col-sm-3 col-md-2" v-if="!loading">
|
||||
<div class="col-md-4 col-lg-3 col-xl-2" v-if="!loading">
|
||||
<sidebar :character="character" @memo="memo" @bookmarked="bookmarked" :oldApi="oldApi"></sidebar>
|
||||
</div>
|
||||
<div class="col-sm-9 col-md-10 profile-body" v-if="!loading">
|
||||
<div class="col-md-8 col-lg-9 col-xl-10 profile-body" v-if="!loading">
|
||||
<div id="characterView">
|
||||
<div>
|
||||
<div v-if="character.ban_reason" id="headerBanReason" class="alert alert-warning">
|
||||
|
@ -26,7 +26,7 @@
|
|||
<span>Overview</span>
|
||||
<span>Info</span>
|
||||
<span v-if="!oldApi">Groups</span>
|
||||
<span>Images ({{ character.character.image_count }}</span>
|
||||
<span>Images ({{ character.character.image_count }})</span>
|
||||
<span v-if="character.settings.guestbook">Guestbook</span>
|
||||
<span v-if="character.is_self || character.settings.show_friends">Friends</span>
|
||||
</tabs>
|
||||
|
@ -34,7 +34,7 @@
|
|||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane" :class="{active: tab == 0}" id="overview">
|
||||
<div v-bbcode="character.character.description"></div>
|
||||
<div v-bbcode="character.character.description" style="margin-bottom: 10px"></div>
|
||||
<character-kinks :character="character" :oldApi="oldApi" ref="tab0"></character-kinks>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" :class="{active: tab == 1}" id="infotags">
|
||||
|
@ -133,6 +133,7 @@
|
|||
|
||||
@Watch('name')
|
||||
async onCharacterSet(): Promise<void> {
|
||||
this.tab = '0';
|
||||
return this._getCharacter();
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="character-images row">
|
||||
<div v-show="loading" class="alert alert-info">Loading images.</div>
|
||||
<template v-if="!loading">
|
||||
<div class="character-image col-xs-6 col-sm-4 col-md-2" v-for="image in images" :key="image.id">
|
||||
<div class="character-image col-6 col-sm-4 col-md-2" v-for="image in images" :key="image.id">
|
||||
<a :href="imageUrl(image)" target="_blank" @click="handleImageClick($event, image)">
|
||||
<img :src="thumbUrl(image)" :title="image.description">
|
||||
</a>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="form-row mt-3">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card bg-light">
|
||||
<div class="card-header">
|
||||
<h4>Favorites</h4>
|
||||
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card bg-light">
|
||||
<div class="card-header">
|
||||
<h4>Yes</h4>
|
||||
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card bg-light">
|
||||
<div class="card-header">
|
||||
<h4>Maybe</h4>
|
||||
|
@ -48,7 +48,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card bg-light">
|
||||
<div class="card-header">
|
||||
<h4>No</h4>
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
<template>
|
||||
<div id="character-page-sidebar" class="card bg-light">
|
||||
<div class="card-header">
|
||||
<div class="character-image-container">
|
||||
<span class="character-name">{{ character.character.name }}</span>
|
||||
<div v-if="character.character.title" class="character-title">{{ character.character.title }}</div>
|
||||
<character-action-menu :character="character"></character-action-menu>
|
||||
</div>
|
||||
<span class="character-name">{{ character.character.name }}</span>
|
||||
<div v-if="character.character.title" class="character-title">{{ character.character.title }}</div>
|
||||
<character-action-menu :character="character"></character-action-menu>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="character-image-container">
|
||||
<img :src="avatarUrl(character.character.name)" class="character-avatar" style="margin-right:10px">
|
||||
</div>
|
||||
<img :src="avatarUrl(character.character.name)" class="character-avatar" style="margin-right:10px">
|
||||
<div v-if="authenticated" class="d-flex justify-content-between flex-wrap character-links-block">
|
||||
<template v-if="character.is_self">
|
||||
<a :href="editUrl" class="edit-link"><i class="fa fa-fw fa-pencil-alt"></i>Edit</a>
|
||||
|
@ -19,14 +15,17 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<span v-if="character.self_staff || character.settings.prevent_bookmarks !== true">
|
||||
<a @click="toggleBookmark" :class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}">
|
||||
<i class="fa fa-fw" :class="{'fa-minus': character.bookmarked, 'fa-plus': !character.bookmarked}"></i>Bookmark</a>
|
||||
<a @click.prevent="toggleBookmark" :class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}"
|
||||
href="#" class="btn">
|
||||
<i class="fa fa-fw" :class="{'fa-minus': character.bookmarked, 'fa-plus': !character.bookmarked}"></i>Bookmark
|
||||
</a>
|
||||
<span v-if="character.settings.prevent_bookmarks" class="prevents-bookmarks">!</span>
|
||||
</span>
|
||||
<a @click="showFriends" class="friend-link"><i class="fa fa-fw fa-user"></i>Friend</a>
|
||||
<a v-if="!oldApi" @click="showReport" class="report-link"><i class="fa fa-fw fa-exclamation-triangle"></i>Report</a>
|
||||
<a href="#" @click.prevent="showFriends" class="friend-link btn"><i class="fa fa-fw fa-user"></i>Friend</a>
|
||||
<a href="#" v-if="!oldApi" @click.prevent="showReport" class="report-link btn">
|
||||
<i class="fa fa-fw fa-exclamation-triangle"></i>Report</a>
|
||||
</template>
|
||||
<a @click="showMemo" class="memo-link"><i class="far fa-sticky-note fa-fw"></i>Memo</a>
|
||||
<a href="#" @click.prevent="showMemo" class="memo-link btn"><i class="far fa-sticky-note fa-fw"></i>Memo</a>
|
||||
</div>
|
||||
<div v-if="character.badges && character.badges.length > 0" class="badges-block">
|
||||
<div v-for="badge in character.badges" class="character-badge px-2 py-1" :class="badgeClass(badge)">
|
||||
|
|
|
@ -42,7 +42,7 @@ function rebuild(e: HTMLElement, binding: VNodeDirective): void {
|
|||
newEl.disabled = true;
|
||||
}
|
||||
} else {
|
||||
newEl = document.createElement('optgroup');
|
||||
newEl = <any>document.createElement('optgroup'); //tslint:disable-line:no-any
|
||||
newEl.label = op.label;
|
||||
buildOptions(newEl, op.options);
|
||||
}
|
||||
|
|
|
@ -33,10 +33,10 @@ export function groupObjectBy<K extends string, T extends {[k in K]: string}>(ob
|
|||
const newObject: Dictionary<T[]> = {};
|
||||
for(const objkey in obj) {
|
||||
if(!(objkey in obj)) continue;
|
||||
const realItem = obj[objkey]!;
|
||||
const realItem = <T>obj[objkey];
|
||||
const newKey = realItem[key];
|
||||
if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
|
||||
newObject[<string>newKey]!.push(realItem);
|
||||
newObject[newKey]!.push(realItem);
|
||||
}
|
||||
return newObject;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* @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 web 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 Axios from 'axios';
|
||||
import * as Raven from 'raven-js';
|
||||
import Vue from 'vue';
|
||||
import Chat from '../chat/Chat.vue';
|
||||
import {init as initCore} from '../chat/core';
|
||||
import Notifications from '../chat/notifications';
|
||||
import VueRaven from '../chat/vue-raven';
|
||||
import Socket from '../chat/WebSocket';
|
||||
import Connection from '../fchat/connection';
|
||||
import '../scss/fa.scss'; //tslint:disable-line:no-import-side-effect
|
||||
import {Logs, SettingsStore} from './logs';
|
||||
|
||||
declare const chatSettings: {account: string, theme: string, characters: ReadonlyArray<string>, defaultCharacter: string | null};
|
||||
|
||||
if(process.env.NODE_ENV === 'production') {
|
||||
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
|
||||
release: `web-${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);
|
||||
};
|
||||
}
|
||||
|
||||
const ticketProvider = async() => {
|
||||
const data = (await Axios.post<{ticket?: string, error: string}>(
|
||||
'/json/getApiTicket.php?no_friends=true&no_bookmarks=true&no_characters=true')).data;
|
||||
if(data.ticket !== undefined) return data.ticket;
|
||||
throw new Error(data.error);
|
||||
};
|
||||
|
||||
initCore(new Connection('F-Chat 3.0 (Web)', '3.0', Socket, chatSettings.account, ticketProvider), Logs, SettingsStore, Notifications);
|
||||
|
||||
require(`../scss/themes/chat/${chatSettings.theme}.scss`);
|
||||
|
||||
new Chat({ //tslint:disable-line:no-unused-expression
|
||||
el: '#app',
|
||||
propsData: {
|
||||
ownCharacters: chatSettings.characters,
|
||||
defaultCharacter: chatSettings.defaultCharacter
|
||||
}
|
||||
});
|
|
@ -0,0 +1,121 @@
|
|||
import {EventMessage, Message} from '../chat/common';
|
||||
import core from '../chat/core';
|
||||
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
|
||||
|
||||
type StoredConversation = {id: number, key: string, name: string};
|
||||
type StoredMessage = {
|
||||
id: number, conversation: number, type: Conversation.Message.Type, sender: string, text: string, time: Date, day: number
|
||||
};
|
||||
|
||||
async function promisifyRequest<T>(req: IDBRequest): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(<T>req.result);
|
||||
req.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
async function promisifyTransaction(req: IDBTransaction): Promise<Event> {
|
||||
return new Promise<Event>((resolve, reject) => {
|
||||
req.oncomplete = resolve;
|
||||
req.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
async function iterate<S, T>(request: IDBRequest, map: (stored: S) => T): Promise<ReadonlyArray<T>> {
|
||||
const array: T[] = [];
|
||||
return new Promise<ReadonlyArray<T>>((resolve, reject) => {
|
||||
request.onsuccess = function(): void {
|
||||
const c = <IDBCursorWithValue | undefined>this.result;
|
||||
if(!c) return resolve(array); //tslint:disable-line:strict-boolean-expressions
|
||||
array.push(map(<S>c.value));
|
||||
c.continue();
|
||||
};
|
||||
request.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
const dayMs = 86400000;
|
||||
|
||||
export class Logs implements Logging {
|
||||
index!: {[key: string]: StoredConversation | undefined};
|
||||
db!: IDBDatabase;
|
||||
|
||||
constructor() {
|
||||
core.connection.onEvent('connecting', async() => {
|
||||
const request = window.indexedDB.open('logs');
|
||||
request.onupgradeneeded = () => {
|
||||
const db = <IDBDatabase>request.result;
|
||||
const logsStore = db.createObjectStore('logs', {keyPath: 'id', autoIncrement: true});
|
||||
logsStore.createIndex('conversation', 'conversation');
|
||||
logsStore.createIndex('conversation-day', ['conversation', 'day']);
|
||||
db.createObjectStore('conversations', {keyPath: 'id', autoIncrement: true});
|
||||
};
|
||||
this.db = await promisifyRequest<IDBDatabase>(request);
|
||||
const trans = this.db.transaction(['conversations']);
|
||||
this.index = {};
|
||||
await iterate(trans.objectStore('conversations').openCursor(), (x: StoredConversation) => this.index[x.key] = x);
|
||||
});
|
||||
}
|
||||
|
||||
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
|
||||
const trans = this.db.transaction(['logs', 'conversations'], 'readwrite');
|
||||
let conv = this.index[conversation.key];
|
||||
if(conv === undefined) {
|
||||
const convId = await promisifyRequest<number>(trans.objectStore('conversations').add(
|
||||
{key: conversation.key, name: conversation.name}));
|
||||
this.index[conversation.key] = conv = {id: convId, key: conversation.key, name: conversation.name};
|
||||
}
|
||||
const sender = message.type === Conversation.Message.Type.Event ? undefined : message.sender.name;
|
||||
const day = Math.floor(message.time.getTime() / dayMs - message.time.getTimezoneOffset() / 1440);
|
||||
await promisifyRequest<number>(trans.objectStore('logs').put(
|
||||
{conversation: conv.id, type: message.type, sender, text: message.text, date: message.time, day}));
|
||||
await promisifyTransaction(trans);
|
||||
}
|
||||
|
||||
async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
const trans = this.db.transaction(['logs', 'conversations']);
|
||||
const conv = this.index[conversation.key];
|
||||
if(conv === undefined) return [];
|
||||
return iterate(trans.objectStore('logs').index('conversation').openCursor(conv.id, 'prev'),
|
||||
(value: StoredMessage) => value.type === Conversation.Message.Type.Event ? new EventMessage(value.text, value.time) :
|
||||
new Message(value.type, core.characters.get(value.sender), value.text, value.time));
|
||||
}
|
||||
|
||||
get conversations(): ReadonlyArray<{key: string, name: string}> {
|
||||
return Object.keys(this.index).map((k) => this.index[k]!);
|
||||
}
|
||||
|
||||
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
|
||||
const trans = this.db.transaction(['logs']);
|
||||
const id = this.index[key]!.id;
|
||||
const day = Math.floor(date.getTime() / dayMs - date.getTimezoneOffset() / 1440);
|
||||
return iterate(trans.objectStore('logs').index('conversation-day').openCursor([id, day]),
|
||||
(value: StoredMessage) => value.type === Conversation.Message.Type.Event ? new EventMessage(value.text, value.time) :
|
||||
new Message(value.type, core.characters.get(value.sender), value.text, value.time));
|
||||
}
|
||||
|
||||
async getLogDates(key: string): Promise<ReadonlyArray<Date>> {
|
||||
const trans = this.db.transaction(['logs']);
|
||||
const offset = new Date().getTimezoneOffset() * 1440;
|
||||
const id = this.index[key]!.id;
|
||||
const bound = IDBKeyRange.bound([id, 0], [id, 100000]);
|
||||
return iterate(trans.objectStore('logs').index('conversation-day').openCursor(bound, 'nextunique'),
|
||||
(value: StoredMessage) => new Date(value.day * dayMs + offset));
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsStore implements Settings.Store {
|
||||
async get<K extends keyof Settings.Keys>(key: K): Promise<Settings.Keys[K]> {
|
||||
const stored = window.localStorage.getItem(`settings.${key}`);
|
||||
return Promise.resolve(stored !== null ? JSON.parse(stored) : undefined);
|
||||
}
|
||||
|
||||
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
|
||||
window.localStorage.setItem(`settings.${key}`, JSON.stringify(value));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "net.f_list.fchat",
|
||||
"version": "0.2.15",
|
||||
"displayName": "F-Chat",
|
||||
"author": "The F-List Team",
|
||||
"description": "F-List.net Chat Client",
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "node ../webpack development",
|
||||
"build:dist": "node ../webpack production",
|
||||
"watch": "node ../webpack watch"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowJs": true,
|
||||
"outDir": "build",
|
||||
"noEmitHelpers": true,
|
||||
"importHelpers": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["chat.ts", "../**/*.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
const path = require('path');
|
||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
|
||||
|
||||
const config = {
|
||||
entry: __dirname + '/chat.ts',
|
||||
output: {
|
||||
path: __dirname + '/dist'
|
||||
},
|
||||
context: __dirname,
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
preserveWhitespace: false,
|
||||
cssSourceMap: false
|
||||
}
|
||||
},
|
||||
{test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.(woff2?)$/, loader: 'file-loader'},
|
||||
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
|
||||
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
|
||||
{test: /\.scss/, use: ['style-loader', 'css-loader', 'sass-loader']}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')})
|
||||
],
|
||||
resolve: {
|
||||
'extensions': ['.ts', '.js', '.vue', '.scss']
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = function(mode) {
|
||||
if(mode === 'production') {
|
||||
process.env.NODE_ENV = 'production';
|
||||
config.devtool = 'source-map';
|
||||
} else {
|
||||
config.devtool = 'none';
|
||||
}
|
||||
return config;
|
||||
};
|
Loading…
Reference in New Issue