0.2.18 - add webchat

This commit is contained in:
MayaWolf 2018-03-28 15:51:05 +02:00
parent 04ab2f96da
commit 4a7d97f17a
74 changed files with 1361 additions and 897 deletions

2
.gitignore vendored
View File

@ -2,4 +2,4 @@ node_modules/
/electron/app
/electron/dist
/mobile/www
*.vue.ts
/webchat/dist

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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;
}));
}
}

View File

@ -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());

View File

@ -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;

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

@ -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();
}

View File

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

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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);

View File

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

View File

@ -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;
}
}

View File

@ -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.'

View File

@ -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;
}
}

View File

@ -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' : '') +

View File

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

View File

@ -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);

View File

@ -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) : ''}`;

View File

@ -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);
}
});

View File

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

View File

@ -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 {

View File

@ -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);
}

View File

@ -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') {

View File

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

View File

@ -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"
}
}

View File

@ -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));

View File

@ -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;

50
electron/dictionaries.ts Normal file
View File

@ -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');
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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());

View File

@ -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",

View File

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

View File

@ -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 {

View File

@ -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);

View File

@ -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;

View File

@ -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':

View File

@ -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> {

View File

@ -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 {

View File

@ -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"

View File

@ -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()
}

View File

@ -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"

View File

@ -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;
}
}

View File

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

View File

@ -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";

View File

@ -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)
}
}

View File

@ -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",

View File

@ -4,10 +4,8 @@
"lib": [
"dom",
"es5",
"es2015.iterable",
"es2015.promise"
],
"allowSyntheticDefaultImports": true,
"module": "commonjs",
"sourceMap": true,
"experimentalDecorators": true,

View File

@ -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';

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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();
}

View File

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

View File

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

View File

@ -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)">

View File

@ -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);
}

View File

@ -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;
}

80
webchat/chat.ts Normal file
View File

@ -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
}
});

121
webchat/logs.ts Normal file
View File

@ -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([]);
}
}

14
webchat/package.json Normal file
View File

@ -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"
}
}

20
webchat/tsconfig.json Normal file
View File

@ -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"
]
}

53
webchat/webpack.config.js Normal file
View File

@ -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;
};