0.2.17 - Webpack 4, Bootstrap 4, remove jquery

This commit is contained in:
MayaWolf 2018-03-04 03:32:26 +01:00
parent 690ae19404
commit 04ab2f96da
182 changed files with 6956 additions and 5585 deletions

View File

@ -1,27 +1,32 @@
<template>
<div class="bbcodeEditorContainer">
<div class="bbcode-editor">
<slot></slot>
<a tabindex="0" class="btn bbcodeEditorButton bbcode-btn" role="button" @click="showToolbar = true" @blur="showToolbar = false">
<span class="fa fa-code"></span></a>
<div class="bbcode-toolbar" role="toolbar" :style="showToolbar ? 'display:block' : ''" @mousedown.stop.prevent>
<a tabindex="0" class="btn btn-secondary bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false"
style="border-bottom-left-radius:0;border-bottom-right-radius:0">
<i class="fa fa-code"></i>
</a>
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? 'display:flex' : ''" @mousedown.stop.prevent>
<div class="btn-group" style="flex-wrap:wrap">
<div class="btn btn-secondary btn-sm" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<i :class="(button.class ? button.class : 'fa ') + button.icon"></i>
</div>
<div @click="previewBBCode" class="btn btn-secondary btn-sm" :class="preview ? 'active' : ''"
:title="preview ? 'Close Preview' : 'Preview'">
<i class="fa fa-eye"></i>
</div>
</div>
<button type="button" class="close" aria-label="Close" style="margin-left:10px" @click="showToolbar = false">&times;</button>
<div class="bbcodeEditorButton btn" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<span :class="'fa ' + button.icon"></span>
</div>
<div @click="previewBBCode" class="bbcodeEditorButton btn" :class="preview ? 'active' : ''"
:title="preview ? 'Close Preview' : 'Preview'">
<span class="fa fa-eye"></span>
</div>
</div>
<div class="bbcodeEditorTextarea">
<div class="bbcode-editor-text-area">
<textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength"
:class="'bbcodeTextAreaTextArea ' + classes" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste"
:class="finalClasses" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" style="border-top-left-radius:0"
:placeholder="placeholder" @keypress="$emit('keypress', $event)" @keydown="onKeyDown"></textarea>
<div class="bbcodePreviewArea" v-show="preview">
<div class="bbcodePreviewHeader">
<ul class="bbcodePreviewWarnings" v-show="previewWarnings.length">
<div ref="sizer"></div>
<div class="bbcode-preview" v-show="preview">
<div class="bbcode-preview-warnings">
<div class="alert alert-danger" v-show="previewWarnings.length">
<li v-for="warning in previewWarnings">{{warning}}</li>
</ul>
</div>
</div>
<div class="bbcode" ref="preview-element"></div>
</div>
@ -35,6 +40,7 @@
import {Prop, Watch} from 'vue-property-decorator';
import {BBCodeElement} from '../chat/bbcode';
import {getKey} from '../chat/common';
import {Keys} from '../keys';
import {CoreBBCodeParser, urlRegex} from './core';
import {defaultButtons, EditorButton, EditorSelection} from './editor';
import {BBCodeParser} from './parser';
@ -44,7 +50,7 @@
@Prop()
readonly extras?: EditorButton[];
@Prop({default: 1000})
readonly maxlength: number;
readonly maxlength!: number;
@Prop()
readonly classes?: string;
@Prop()
@ -53,15 +59,18 @@
readonly disabled?: boolean;
@Prop()
readonly placeholder?: string;
@Prop({default: false, type: Boolean})
readonly invalid!: boolean;
preview = false;
previewWarnings: ReadonlyArray<string> = [];
previewResult = '';
text = this.value !== undefined ? this.value : '';
element: HTMLTextAreaElement;
maxHeight: number;
minHeight: number;
element!: HTMLTextAreaElement;
sizer!: HTMLElement;
maxHeight!: number;
minHeight!: number;
showToolbar = false;
protected parser: BBCodeParser;
protected parser!: BBCodeParser;
protected defaultButtons = defaultButtons;
private isShiftPressed = false;
private undoStack: string[] = [];
@ -74,16 +83,31 @@
mounted(): void {
this.element = <HTMLTextAreaElement>this.$refs['input'];
const $element = $(this.element);
this.maxHeight = parseInt($element.css('max-height'), 10);
const styles = getComputedStyle(this.element);
this.maxHeight = parseInt(styles.maxHeight! , 10);
//tslint:disable-next-line:strict-boolean-expressions
this.minHeight = parseInt($element.css('min-height'), 10) || $element.outerHeight() || 50;
this.minHeight = parseInt(styles.minHeight!, 10) || 50;
setInterval(() => {
if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) {
if(this.undoStack.length >= 30) this.undoStack.pop();
this.undoStack.unshift(this.text);
}
}, 500);
this.sizer = <HTMLElement>this.$refs['sizer'];
this.sizer.style.cssText = styles.cssText;
this.sizer.style.height = '0';
this.sizer.style.overflow = 'hidden';
this.sizer.style.position = 'absolute';
this.sizer.style.top = '0';
this.sizer.style.visibility = 'hidden';
this.resize();
}
get finalClasses(): string | undefined {
let classes = this.classes;
if(this.invalid)
classes += ' is-invalid';
return classes;
}
get buttons(): EditorButton[] {
@ -169,15 +193,15 @@
onKeyDown(e: KeyboardEvent): void {
const key = getKey(e);
if((e.metaKey || e.ctrlKey) && !e.shiftKey && key !== 'control' && key !== 'meta') {
if(key === 'z') {
if((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
if(key === Keys.KeyZ) {
e.preventDefault();
if(this.undoIndex === 0 && this.undoStack[0] !== this.text) this.undoStack.unshift(this.text);
if(this.undoStack.length > this.undoIndex + 1) {
this.text = this.undoStack[++this.undoIndex];
this.lastInput = Date.now();
}
} else if(key === 'y') {
} else if(key === Keys.KeyY) {
e.preventDefault();
if(this.undoIndex > 0) {
this.text = this.undoStack[--this.undoIndex];
@ -191,20 +215,20 @@
this.apply(button);
break;
}
} else if(key === 'shift') this.isShiftPressed = true;
} else if(e.shiftKey) this.isShiftPressed = true;
this.$emit('keydown', e);
}
onKeyUp(e: KeyboardEvent): void {
if(getKey(e) === 'shift') this.isShiftPressed = false;
if(!e.shiftKey) this.isShiftPressed = false;
this.$emit('keyup', e);
}
resize(): void {
if(this.maxHeight > 0) {
this.element.style.height = 'auto';
this.element.style.height = `${Math.max(Math.min(this.element.scrollHeight + 5, this.maxHeight), this.minHeight)}px`;
}
this.sizer.style.fontSize = this.element.style.fontSize;
this.sizer.style.lineHeight = this.element.style.lineHeight;
this.sizer.textContent = this.element.value;
this.element.style.height = `${Math.max(Math.min(this.sizer.scrollHeight, this.maxHeight), this.minHeight)}px`;
}
onPaste(e: ClipboardEvent): void {

View File

@ -54,7 +54,7 @@ export class CoreBBCodeParser extends BBCodeParser {
} else if(content.length > 0) url = content;
else {
parser.warning('url tag contains no url.');
element.textContent = ''; //Dafuq!?
element.textContent = '';
return;
}
@ -78,6 +78,7 @@ export class CoreBBCodeParser extends BBCodeParser {
const span = document.createElement('span');
span.className = 'link-domain';
span.textContent = ` [${domain(url)}]`;
(<HTMLElement & {bbcodeHide: true}>span).bbcodeHide = true;
element.appendChild(span);
}, []));
}

View File

@ -1,10 +1,11 @@
import Vue from 'vue';
import {Keys} from '../keys';
export interface EditorButton {
title: string;
tag: string;
icon: string;
key?: string;
key?: Keys;
class?: string;
startText?: string;
endText?: string;
@ -23,74 +24,75 @@ export let defaultButtons: ReadonlyArray<EditorButton> = [
title: 'Bold (Ctrl+B)\n\nMakes text appear with a bold weight.',
tag: 'b',
icon: 'fa-bold',
key: 'b'
key: Keys.KeyB
},
{
title: 'Italic (Ctrl+I)\n\nMakes text appear with an italic style.',
tag: 'i',
icon: 'fa-italic',
key: 'i'
key: Keys.KeyI
},
{
title: 'Underline (Ctrl+U)\n\nMakes text appear with an underline beneath it.',
tag: 'u',
icon: 'fa-underline',
key: 'u'
key: Keys.KeyU
},
{
title: 'Strikethrough (Ctrl+S)\n\nPlaces a horizontal line through the text. Usually used to signify a correction or redaction without omitting the text.',
tag: 's',
icon: 'fa-strikethrough',
key: 's'
key: Keys.KeyS
},
{
title: 'Color (Ctrl+D)\n\nStyles text with a color. Valid colors are: red, orange, yellow, green, cyan, blue, purple, pink, black, white, gray, primary, secondary, accent, and contrast.',
tag: 'color',
startText: '[color=]',
icon: 'fa-eyedropper',
key: 'd'
icon: 'fa-eye-dropper',
key: Keys.KeyD
},
{
title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',
tag: 'sup',
icon: 'fa-superscript',
key: 'arrowup'
key: Keys.ArrowUp
},
{
title: 'Subscript (Ctrl+↓)\n\nPushes text below the text baseline. Makes text slightly smaller. Cannot be nested.',
tag: 'sub',
icon: 'fa-subscript',
key: 'arrowdown'
key: Keys.ArrowDown
},
{
title: 'URL (Ctrl+L)\n\nCreates a clickable link to another page of your choosing.',
tag: 'url',
startText: '[url=]',
icon: 'fa-link',
key: 'l'
key: Keys.KeyL
},
{
title: 'User (Ctrl+R)\n\nLinks to a character\'s profile.',
tag: 'user',
icon: 'fa-user',
key: 'r'
key: Keys.KeyR
},
{
title: 'Icon (Ctrl+O)\n\nShows a character\'s profile image, linking to their profile.',
tag: 'icon',
icon: 'fa-user-circle',
key: 'o'
key: Keys.KeyO
},
{
title: 'EIcon (Ctrl+E)\n\nShows a previously uploaded eicon. If the icon is a gif, it will be shown as animated unless a user has turned this off.',
tag: 'eicon',
icon: 'fa-smile-o',
key: 'e'
class: 'far ',
icon: 'fa-smile',
key: Keys.KeyE
},
{
title: 'Noparse (Ctrl+N)\n\nAll BBCode placed within this tag will be ignored and treated as text. Great for sharing structure without it being rendered.',
tag: 'noparse',
icon: 'fa-ban',
key: 'n'
key: Keys.KeyN
}
];

View File

@ -26,7 +26,7 @@ export abstract class BBCodeTag {
export class BBCodeSimpleTag extends BBCodeTag {
constructor(tag: string, private elementName: keyof ElementTagNameMap, private classes?: string[], tagList?: string[]) {
constructor(tag: string, private elementName: keyof HTMLElementTagNameMap, private classes?: string[], tagList?: string[]) {
super(tag, tagList);
}
@ -81,9 +81,9 @@ class ParserTag {
export class BBCodeParser {
private _warnings: string[] = [];
private _tags: {[tag: string]: BBCodeTag | undefined} = {};
private _line: number;
private _column: number;
private _currentTag: ParserTag;
private _line = -1;
private _column = -1;
private _currentTag!: ParserTag;
private _storeWarnings = false;
parseEverything(input: string): HTMLElement {
@ -103,7 +103,7 @@ export class BBCodeParser {
return stack[0].element;
}
createElement<K extends keyof HTMLElementTagNameMap>(tag: K | keyof ElementTagNameMap): HTMLElementTagNameMap[K] {
createElement<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K] {
return document.createElement(tag);
}
@ -218,6 +218,8 @@ export class BBCodeParser {
quickReset(i);
continue;
}
(<HTMLElement & {bbcodeTag: string}>el).bbcodeTag = tagKey;
if(param.length > 0) (<HTMLElement & {bbcodeParam: string}>el).bbcodeParam = param;
if(!this._tags[tagKey]!.noClosingTag)
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
} else if(ignoreClosing[tagKey] > 0) {

View File

@ -1,4 +1,3 @@
import * as $ from 'jquery';
import {CoreBBCodeParser} from './core';
import {InlineDisplayMode} from './interfaces';
import {BBCodeCustomTag, BBCodeSimpleTag} from './parser';
@ -8,6 +7,7 @@ interface InlineImage {
hash: string
extension: string
nsfw: boolean
name?: string
}
interface StandardParserSettings {
@ -23,6 +23,18 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
allowInlines = true;
inlines: {[key: string]: InlineImage | undefined} | undefined;
createInline(inline: InlineImage): HTMLElement {
const p1 = inline.hash.substr(0, 2);
const p2 = inline.hash.substr(2, 2);
const outerEl = this.createElement('div');
const el = this.createElement('img');
el.className = 'inline-image';
el.title = el.alt = inline.name!;
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
return outerEl;
}
constructor(public settings: StandardParserSettings) {
super();
const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
@ -54,16 +66,24 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
//return null;
}
const outer = parser.createElement('div');
outer.className = 'collapseHeader';
outer.className = 'card bg-light bbcode-collapse';
const headerText = parser.createElement('div');
headerText.className = 'collapseHeaderText';
headerText.className = 'card-header bbcode-collapse-header';
const icon = parser.createElement('i');
icon.className = 'fas fa-chevron-down';
icon.style.marginRight = '10px';
headerText.appendChild(icon);
headerText.appendChild(document.createTextNode(param));
outer.appendChild(headerText);
const innerText = parser.createElement('span');
innerText.appendChild(document.createTextNode(param));
headerText.appendChild(innerText);
const body = parser.createElement('div');
body.className = 'collapseBlock';
body.className = 'card-body bbcode-collapse-body closed';
body.style.height = '0';
outer.appendChild(body);
headerText.addEventListener('click', () => {
const isCollapsed = parseInt(body.style.height!, 10) === 0;
body.style.height = isCollapsed ? `${body.scrollHeight}px` : '0';
icon.className = `fas fa-chevron-${isCollapsed ? 'up' : 'down'}`;
});
parent.appendChild(outer);
return body;
}));
@ -122,7 +142,12 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
img.className = 'character-avatar icon';
parent.replaceChild(img, element);
}, []));
this.addTag('img', new BBCodeCustomTag('img', (p, parent, param) => {
this.addTag('img', new BBCodeCustomTag('img', (parser, parent) => {
const el = parser.createElement('span');
parent.appendChild(el);
return el;
}, (p, element, parent, param) => {
const content = element.textContent!;
const parser = <StandardBBCodeParser>p;
if(!this.allowInlines) {
parser.warning('Inline images are not allowed here.');
@ -132,82 +157,38 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
parser.warning('This page does not support inline images.');
return undefined;
}
let p1: string, p2: string, inline;
const displayMode = this.settings.inlineDisplayMode;
if(!/^\d+$/.test(param)) {
parser.warning('img tag parameters must be numbers.');
return undefined;
}
if(typeof parser.inlines[param] !== 'object') {
const inline = parser.inlines[param];
if(typeof inline !== 'object') {
parser.warning(`Could not find an inline image with id ${param} It will not be visible.`);
return undefined;
}
inline = parser.inlines[param]!;
p1 = inline.hash.substr(0, 2);
p2 = inline.hash.substr(2, 2);
inline.name = content;
if(displayMode === InlineDisplayMode.DISPLAY_NONE || (displayMode === InlineDisplayMode.DISPLAY_SFW && inline.nsfw)) {
const el = parser.createElement('a');
el.className = 'unloadedInline';
el.href = '#';
el.dataset.inlineId = param;
el.onclick = () => {
$('.unloadedInline').each((_, element) => {
const inlineId = $(element).data('inline-id');
if(typeof parser.inlines![inlineId] !== 'object')
return;
const showInline = parser.inlines![inlineId]!;
const showP1 = showInline.hash.substr(0, 2);
const showP2 = showInline.hash.substr(2, 2);
//tslint:disable-next-line:max-line-length
$(element).replaceWith(`<div><img class="inline-image" src="${this.settings.staticDomain}images/charinline/${showP1}/${showP2}/${showInline.hash}.${showInline.extension}"/></div>`);
});
Array.prototype.forEach.call(document.getElementsByClassName('unloadedInline'), ((e: HTMLElement) => {
const showInline = parser.inlines![e.dataset.inlineId!];
if(typeof showInline !== 'object') return;
e.parentElement!.replaceChild(parser.createInline(showInline), e);
}));
return false;
};
const prefix = inline.nsfw ? '[NSFW Inline] ' : '[Inline] ';
el.appendChild(document.createTextNode(prefix));
parent.appendChild(el);
return el;
} else {
const outerEl = parser.createElement('div');
const el = parser.createElement('img');
el.className = 'inline-image';
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
parent.appendChild(outerEl);
return el;
}
}, (_, element, __, ___) => {
// Need to remove any appended contents, because this is a total hack job.
if(element.className !== 'inline-image')
return;
while(element.firstChild !== null)
element.removeChild(element.firstChild);
parent.replaceChild(el, element);
} else parent.replaceChild(parser.createInline(inline), element);
}, []));
}
}
export function initCollapse(): void {
$('.collapseHeader[data-bound!=true]').each((_, element) => {
const $element = $(element);
const $body = $element.children('.collapseBlock');
$element.children('.collapseHeaderText').on('click', () => {
if($element.hasClass('expandedHeader')) {
$body.css('max-height', '0');
$element.removeClass('expandedHeader');
} else {
$body.css('max-height', 'none');
const height = $body.outerHeight();
$body.css('max-height', '0');
$element.addClass('expandedHeader');
setTimeout(() => $body.css('max-height', height!), 1);
setTimeout(() => $body.css('max-height', 'none'), 250);
}
});
});
$('.collapseHeader').attr('data-bound', 'true');
}
export let standardParser: StandardBBCodeParser;
export function initParser(settings: StandardParserSettings): void {

View File

@ -1,22 +1,15 @@
<template>
<modal :buttons="false" :action="l('chat.channels')" @close="closed">
<div style="display: flex; flex-direction: column;">
<ul class="nav nav-tabs" style="flex-shrink:0">
<li role="presentation" :class="{active: !privateTabShown}">
<a href="#" @click.prevent="privateTabShown = false">{{l('channelList.public')}}</a>
</li>
<li role="presentation" :class="{active: privateTabShown}">
<a href="#" @click.prevent="privateTabShown = true">{{l('channelList.private')}}</a>
</li>
</ul>
<modal :buttons="false" :action="l('chat.channels')" @close="closed" dialog-class="w-100 channel-list">
<div style="display:flex;flex-direction:column">
<tabs style="flex-shrink:0" :tabs="[l('channelList.public'), l('channelList.private')]" v-model="tab"></tabs>
<div style="display: flex; flex-direction: column">
<div style="display:flex; padding: 10px 0; flex-shrink: 0;">
<input class="form-control" style="flex:1; margin-right:10px;" v-model="filter" :placeholder="l('filter')"/>
<a href="#" @click.prevent="sortCount = !sortCount">
<span class="fa fa-2x" :class="{'fa-sort-amount-desc': sortCount, 'fa-sort-alpha-asc': !sortCount}"></span>
<span class="fa fa-2x" :class="{'fa-sort-amount-down': sortCount, 'fa-sort-alpha-down': !sortCount}"></span>
</a>
</div>
<div style="overflow: auto;" v-show="!privateTabShown">
<div style="overflow: auto;" v-show="tab == 0">
<div v-for="channel in officialChannels" :key="channel.id">
<label :for="channel.id">
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
@ -24,7 +17,7 @@
</label>
</div>
</div>
<div style="overflow: auto;" v-show="privateTabShown">
<div style="overflow: auto;" v-show="tab == 1">
<div v-for="channel in openRooms" :key="channel.id">
<label :for="channel.id">
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
@ -46,13 +39,13 @@
import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';
import {Channel} from '../fchat';
import core from './core';
import {Channel} from './interfaces';
import l from './localize';
import ListItem = Channel.ListItem;
@Component({
components: {modal: Modal}
components: {modal: Modal, tabs: Tabs}
})
export default class ChannelList extends CustomDialog {
privateTabShown = false;
@ -60,6 +53,7 @@
sortCount = true;
filter = '';
createName = '';
tab = '0';
get openRooms(): ReadonlyArray<Channel.ListItem> {
return this.applyFilter(core.channels.openRooms);
@ -92,8 +86,15 @@
this.createName = '';
}
setJoined(channel: ListItem): void {
setJoined(channel: Channel.ListItem): void {
channel.isJoined ? core.channels.leave(channel.id) : core.channels.join(channel.id);
}
}
</script>
<style>
.channel-list .modal-body {
display: flex;
flex-direction: column;
}
</style>

View File

@ -12,9 +12,9 @@
@Component
export default class ChannelView extends Vue {
@Prop({required: true})
readonly id: string;
readonly id!: string;
@Prop({required: true})
readonly text: string;
readonly text!: string;
joinChannel(): void {
if(this.channel === undefined || !this.channel.isJoined)

View File

@ -1,5 +1,5 @@
<template>
<modal :action="l('characterSearch.action')" @submit.prevent="submit"
<modal :action="l('characterSearch.action')" @submit.prevent="submit" dialogClass="w-100"
:buttonText="results ? l('characterSearch.again') : undefined" class="character-search">
<div v-if="options && !results">
<div v-show="error" class="alert alert-danger">{{error}}</div>
@ -113,10 +113,9 @@
}
});
core.connection.onMessage('FKS', (data) => {
this.results = data.characters.filter((x) => core.state.hiddenUsers.indexOf(x) === -1)
.map((x) => core.characters.get(x)).sort(sort);
this.results = data.characters.map((x) => core.characters.get(x))
.filter((x) => core.state.hiddenUsers.indexOf(x.name) === -1 && !x.isIgnored).sort(sort);
});
(<Modal>this.$children[0]).fixDropdowns();
}
filterKink(filter: RegExp, kink: Kink): boolean {
@ -144,7 +143,7 @@
}
</script>
<style lang="less">
<style lang="scss">
.character-search {
.dropdown {
margin-bottom: 10px;

View File

@ -1,14 +1,14 @@
<template>
<div style="display:flex; flex-direction: column; height:100%; justify-content: center">
<div class="well" style="width:400px; max-width:100%; margin:0 auto;" v-if="!connected">
<div class="card bg-light" style="width:400px;max-width:100%;margin:0 auto" v-if="!connected">
<div class="alert alert-danger" v-show="error">{{error}}</div>
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<div class="card-block">
<div class="card-body">
<h4 class="card-title">{{l('login.selectCharacter')}}</h4>
<select v-model="selectedCharacter" class="form-control">
<select v-model="selectedCharacter" class="form-control custom-select">
<option v-for="character in ownCharacters" :value="character">{{character}}</option>
</select>
<div style="text-align: right; margin-top: 10px;">
<div style="text-align:right;margin-top:10px">
<button class="btn btn-primary" @click="connect" :disabled="connecting">
{{l(connecting ? 'login.connecting' : 'login.connect')}}
</button>
@ -37,14 +37,44 @@
import core from './core';
import l from './localize';
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string, bbcodeHide?: boolean};
function copyNode(str: string, node: BBCodeNode, range: Range, flags: {endFound?: true, rootFound?: true}): string {
if(node.bbcodeTag !== undefined)
str = `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]${str}[/${node.bbcodeTag}]`;
if(node.nextSibling !== null && !flags.endFound) {
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\n';
str += scanNode(node.nextSibling!, range, flags);
}
if(node.parentElement === null) flags.rootFound = true;
if(flags.rootFound && flags.endFound) return str;
return copyNode(str, node.parentNode!, range, flags);
}
function scanNode(node: BBCodeNode, range: Range, flags: {endFound?: true}): string {
if(node.bbcodeHide) return '';
if(node === range.endContainer) {
flags.endFound = true;
return node.nodeValue!.substr(0, range.endOffset);
}
let str = '';
if(node.bbcodeTag !== undefined) str += `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]`;
if(node instanceof Text) str += node.nodeValue;
if(node.firstChild !== null) str += scanNode(node.firstChild, range, flags);
if(node.bbcodeTag !== undefined) str += `[/${node.bbcodeTag}]`;
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\n';
if(node.nextSibling !== null && !flags.endFound) str += scanNode(node.nextSibling, range, flags);
return str;
}
@Component({
components: {chat: ChatView, modal: Modal}
})
export default class Chat extends Vue {
@Prop({required: true})
readonly ownCharacters: string[];
readonly ownCharacters!: string[];
@Prop({required: true})
readonly defaultCharacter: string | undefined;
readonly defaultCharacter!: string | undefined;
selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions
error = '';
connecting = false;
@ -52,6 +82,14 @@
l = l;
mounted(): void {
document.addEventListener('copy', ((e: ClipboardEvent) => {
const selection = document.getSelection();
if(selection.isCollapsed) return;
const range = selection.getRangeAt(0);
e.clipboardData.setData('text/plain', copyNode(range.startContainer.nodeValue!.substr(range.startOffset),
range.startContainer, range, {}));
e.preventDefault();
}) as EventListener);
core.register('characters', Characters(core.connection));
core.register('channels', Channels(core.connection, core.characters));
core.register('conversations', Conversations());

View File

@ -5,7 +5,7 @@
<sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars">
<img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/>
{{ownCharacter.name}}
<a href="#" @click.prevent="logOut" class="btn"><span class="fa fa-sign-out"></span>{{l('chat.logout')}}</a><br/>
<a href="#" @click.prevent="logOut" class="btn"><i class="fa fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
<div>
{{l('chat.status')}}
<a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn">
@ -35,9 +35,10 @@
<div class="name">
<span>{{conversation.character.name}}</span>
<div style="text-align:right;line-height:0">
<span class="fa"
:class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}" @mousedown.prevent
<span class="fas"
:class="{'fa-comment-alt': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="fa fa-reply" v-show="needsReply(conversation)"></span>
<span class="pin fa fa-thumbtack" :class="{'active': conversation.isPinned}" @mousedown.prevent
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="fa fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</div>
@ -49,7 +50,7 @@
<div class="list-group conversation-nav" ref="channelConversations">
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel"
:key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumb-tack"
:key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumbtack"
:class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave"
@click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
@ -66,7 +67,7 @@
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<span class="fa fa-user-circle-o conversation-icon" v-else></span>
<span class="far fa-user-circle conversation-icon" v-else></span>
<div class="name">{{conversation.character.name}}</div>
</a>
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
@ -93,6 +94,7 @@
import Sortable = require('sortablejs');
import Vue from 'vue';
import Component from 'vue-class-component';
import {Keys} from '../keys';
import ChannelList from './ChannelList.vue';
import CharacterSearch from './CharacterSearch.vue';
import {characterImage, getKey} from './common';
@ -128,7 +130,7 @@
characterImage = characterImage;
conversations = core.conversations;
getStatusIcon = getStatusIcon;
keydownListener: (e: KeyboardEvent) => void;
keydownListener!: (e: KeyboardEvent) => void;
mounted(): void {
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
@ -190,12 +192,22 @@
window.removeEventListener('keydown', this.keydownListener);
}
needsReply(conversation: Conversation): boolean {
if(!core.state.settings.showNeedsReply) return false;
for(let i = conversation.messages.length - 1; i >= 0; --i) {
const sender = conversation.messages[i].sender;
if(sender !== undefined)
return sender !== core.characters.ownCharacter;
}
return false;
}
onKeyDown(e: KeyboardEvent): void {
const selected = this.conversations.selectedConversation;
const pms = this.conversations.privateConversations;
const channels = this.conversations.channelConversations;
const console = this.conversations.consoleTab;
if(getKey(e) === 'arrowup' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(getKey(e) === Keys.ArrowUp && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(selected === console) { //tslint:disable-line:curly
if(channels.length > 0) channels[channels.length - 1].show();
else if(pms.length > 0) pms[pms.length - 1].show();
@ -210,7 +222,7 @@
else console.show();
else channels[index - 1].show();
}
else if(getKey(e) === 'arrowdown' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
else if(getKey(e) === Keys.ArrowDown && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(selected === console) { //tslint:disable-line:curly - false positive
if(pms.length > 0) pms[0].show();
else if(channels.length > 0) channels[0].show();
@ -263,8 +275,18 @@
}
</script>
<style lang="less">
@import "../less/flist_variables.less";
<style lang="scss">
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
body {
user-select: none;
}
.bbcode, .message, .profile-viewer {
user-select: initial;
}
.list-group.conversation-nav {
margin-bottom: 10px;
@ -317,8 +339,10 @@
margin: 0 45px 5px;
overflow: auto;
display: none;
align-items: stretch;
flex-direction: row;
@media (max-width: @screen-xs-max) {
@media (max-width: breakpoint-max(xs)) {
display: flex;
}
@ -363,7 +387,7 @@
.body a.btn {
padding: 2px 0;
}
@media (min-width: @screen-sm-min) {
@media (min-width: breakpoint-min(sm)) {
.sidebar {
position: static;
margin: 0;

View File

@ -78,7 +78,8 @@
name: `/${key} - ${l(`commands.${key}`)}`,
help: l(`commands.${key}.help`),
context,
permission: command.permission !== undefined ? l(`commands.help.permission${Permission[command.permission]}`) : undefined,
permission: command.permission !== undefined ?
l(`commands.help.permission${Permission[command.permission]}`) : undefined,
params,
syntax
});
@ -87,7 +88,7 @@
}
</script>
<style lang="less">
<style lang="scss">
#command-help {
h4 {
margin-bottom: 0;

View File

@ -50,14 +50,14 @@
})
export default class ConversationSettings extends CustomDialog {
@Prop({required: true})
readonly conversation: Conversation;
readonly conversation!: Conversation;
l = l;
setting = Conversation.Setting;
notify: Conversation.Setting;
highlight: Conversation.Setting;
highlightWords: string;
joinMessages: Conversation.Setting;
defaultHighlights: boolean;
notify!: Conversation.Setting;
highlight!: Conversation.Setting;
highlightWords!: string;
joinMessages!: Conversation.Setting;
defaultHighlights!: boolean;
constructor() {
super();

View File

@ -22,8 +22,8 @@
<div style="display: flex; align-items: center;">
<div style="flex: 1;">
<span v-show="conversation.channel.id.substr(0, 4) !== 'adh-'" class="fa fa-star" :title="l('channel.official')"
style="margin-right:5px;"></span>
<h4 style="margin: 0; display:inline; vertical-align: middle;">{{conversation.name}}</h4>
style="margin-right:5px;vertical-align:sub"></span>
<h5 style="margin:0;display:inline;vertical-align:middle">{{conversation.name}}</h5>
<a @click="descriptionExpanded = !descriptionExpanded" class="btn">
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
<span class="btn-text">{{l('channel.description')}}</span>
@ -37,8 +37,9 @@
<span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<ul class="nav nav-pills mode-switcher">
<li v-for="mode in modes" :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}">
<a href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a>
<li v-for="mode in modes" class="nav-item">
<a :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}"
class="nav-link" href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a>
</li>
</ul>
</div>
@ -51,9 +52,15 @@
<h4>{{l('chat.consoleTab')}}</h4>
<logs :conversation="conversation"></logs>
</div>
<div class="search" v-show="showSearch" style="position:relative">
<input v-model="searchInput" @keydown.esc="showSearch = false; searchInput = ''" @keypress="lastSearchInput = Date.now()"
:placeholder="l('chat.search')" ref="searchField" class="form-control"/>
<a class="btn btn-sm btn-light" style="position:absolute;right:5px;top:50%;transform:translateY(-50%);line-height:0"
@click="showSearch = false"><i class="fas fa-times"></i></a>
</div>
<div class="border-top messages" :class="'messages-' + conversation.mode" style="flex:1;overflow:auto;margin-top:2px"
ref="messages" @scroll="onMessagesScroll">
<template v-for="message in conversation.messages">
<template v-for="message in messages">
<message-view :message="message" :channel="conversation.channel" :key="message.id"
:classes="message == conversation.lastRead ? 'last-read' : ''">
</message-view>
@ -80,20 +87,22 @@
<span class="redText" style="flex:1;margin-left:5px">{{conversation.errorText}}</span>
</div>
<div style="position:relative; margin-top:5px;">
<div class="overlay-disable" v-show="adCountdown">{{adCountdown}}</div>
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="onInput"
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')" :disabled="adCountdown"
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')"
ref="textBox" style="position:relative" :maxlength="conversation.maxMessageLength">
<div style="float:right;text-align:right;display:flex;align-items:center">
<div v-show="conversation.maxMessageLength" style="margin-right: 5px;">
<div v-show="conversation.maxMessageLength" style="margin-right:5px">
{{getByteLength(conversation.enteredText)}} / {{conversation.maxMessageLength}}
</div>
<ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel" style="position:relative;z-index:10">
<li :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}">
<a href="#" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
<li class="nav-item">
<a href="#" :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
</li>
<li :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}">
<a href="#" @click.prevent="setSendingAds(true)">{{l('channel.mode.ads')}}</a>
<li class="nav-item">
<a href="#" :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(true)">
{{l('channel.mode.ads' + (adCountdown ? '.countdown' : ''), adCountdown)}}</a>
</li>
</ul>
</div>
@ -113,6 +122,7 @@
import {Prop, Watch} from 'vue-property-decorator';
import {EditorButton, EditorSelection} from '../bbcode/editor';
import Modal from '../components/Modal.vue';
import {Keys} from '../keys';
import {BBCodeView, Editor} from './bbcode';
import CommandHelp from './CommandHelp.vue';
import {characterImage, getByteLength, getKey} from './common';
@ -135,16 +145,29 @@
})
export default class ConversationView extends Vue {
@Prop({required: true})
readonly reportDialog: ReportDialog;
readonly reportDialog!: ReportDialog;
modes = channelModes;
descriptionExpanded = false;
l = l;
extraButtons: EditorButton[] = [];
getByteLength = getByteLength;
tabOptions: string[] | undefined;
tabOptionsIndex: number;
tabOptionSelection: EditorSelection;
tabOptionsIndex!: number;
tabOptionSelection!: EditorSelection;
showSearch = false;
searchInput = '';
search = '';
lastSearchInput = 0;
messageCount = 0;
searchTimer = 0;
windowHeight = window.innerHeight;
resizeHandler = () => {
const messageView = <HTMLElement>this.$refs['messages'];
if(this.windowHeight - window.innerHeight + messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
messageView.scrollTop = messageView.scrollHeight - messageView.offsetHeight;
this.windowHeight = window.innerHeight;
}
keydownHandler!: EventListener;
created(): void {
this.extraButtons = [{
@ -153,12 +176,34 @@
icon: 'fa-question',
handler: () => (<Modal>this.$refs['helpDialog']).show()
}];
window.addEventListener('resize', this.resizeHandler);
window.addEventListener('keydown', this.keydownHandler = ((e: KeyboardEvent) => {
if(getKey(e) === Keys.KeyF && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
this.showSearch = true;
this.$nextTick(() => (<HTMLElement>this.$refs['searchField']).focus());
}
}) as EventListener);
this.searchTimer = window.setInterval(() => {
if(Date.now() - this.lastSearchInput > 500 && this.search !== this.searchInput)
this.search = this.searchInput;
}, 500);
}
destroyed(): void {
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('keydown', this.keydownHandler);
clearInterval(this.searchTimer);
}
get conversation(): Conversation {
return core.conversations.selectedConversation;
}
get messages(): ReadonlyArray<Conversation.Message> {
return this.search !== '' ? this.conversation.messages.filter((x) => x.text.indexOf(this.search) !== -1)
: this.conversation.messages;
}
@Watch('conversation')
conversationChanged(): void {
(<Editor>this.$refs['textBox']).focus();
@ -168,14 +213,14 @@
messageAdded(newValue: Conversation.Message[]): void {
const messageView = <HTMLElement>this.$refs['messages'];
if(!this.keepScroll() && newValue.length === this.messageCount)
this.$nextTick(() => messageView.scrollTop -= (<HTMLElement>messageView.lastElementChild).clientHeight);
messageView.scrollTop -= (<HTMLElement>messageView.firstElementChild).clientHeight;
this.messageCount = newValue.length;
}
keepScroll(): boolean {
const messageView = <HTMLElement>this.$refs['messages'];
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) {
setTimeout(() => messageView.scrollTop = messageView.scrollHeight - messageView.offsetHeight, 0);
setImmediate(() => messageView.scrollTop = messageView.scrollHeight);
return true;
}
return false;
@ -197,18 +242,9 @@
if(oldValue === 'clear') this.keepScroll();
}
onInput(): void {
const messageView = <HTMLElement>this.$refs['messages'];
const oldHeight = messageView.offsetHeight;
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
setTimeout(() => {
if(oldHeight > messageView.offsetHeight) messageView.scrollTop += oldHeight - messageView.offsetHeight;
});
}
async onKeyDown(e: KeyboardEvent): Promise<void> {
const editor = <Editor>this.$refs['textBox'];
if(getKey(e) === 'tab') {
if(getKey(e) === Keys.Tab) {
e.preventDefault();
if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return;
if(this.tabOptions === undefined) {
@ -242,10 +278,10 @@
}
} else {
if(this.tabOptions !== undefined) this.tabOptions = undefined;
if(getKey(e) === 'arrowup' && this.conversation.enteredText.length === 0
if(getKey(e) === Keys.ArrowUp && this.conversation.enteredText.length === 0
&& !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey)
this.conversation.loadLastSent();
else if(getKey(e) === 'enter') {
else if(getKey(e) === Keys.Enter) {
if(e.shiftKey) return;
e.preventDefault();
await this.conversation.send();
@ -270,14 +306,10 @@
}
}
get showAdCountdown(): boolean {
return Conversation.isChannel(this.conversation) && this.conversation.adCountdown > 0 && this.conversation.isSendingAds;
}
get adCountdown(): string | undefined {
if(!this.showAdCountdown) return;
const conv = (<Conversation.ChannelConversation>this.conversation);
return l('chat.adCountdown', Math.floor(conv.adCountdown / 60).toString(), (conv.adCountdown % 60).toString());
if(!Conversation.isChannel(this.conversation) || this.conversation.adCountdown <= 0) return;
return l('chat.adCountdown',
Math.floor(this.conversation.adCountdown / 60).toString(), (this.conversation.adCountdown % 60).toString());
}
get characterImage(): string {
@ -301,11 +333,14 @@
}
</script>
<style lang="less">
@import "../less/flist_variables.less";
<style lang="scss">
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
#conversation {
.header {
@media (min-width: @screen-sm-min) {
@media (min-width: breakpoint-min(sm)) {
margin-right: 32px;
}
a.btn {
@ -317,7 +352,7 @@
padding: 3px 10px;
}
@media (max-width: @screen-xs-max) {
@media (max-width: breakpoint-max(xs)) {
.mode-switcher a {
padding: 5px 8px;
}

View File

@ -1,33 +1,35 @@
<template>
<span>
<a href="#" @click.prevent="showLogs" class="btn">
<span class="fa" :class="isPersistent ? 'fa-file-text-o' : 'fa-download'"></span>
<span :class="isPersistent ? 'fa fa-file-alt' : 'fa fa-download'"></span>
<span class="btn-text">{{l('logs.title')}}</span>
</a>
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')" dialogClass="modal-lg"
@open="onOpen" class="form-horizontal">
<div class="form-group">
<label class="col-sm-2">{{l('logs.conversation')}}</label>
<div class="col-sm-10">
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
buttonClass="form-control" :placeholder="l('filter')" @input="loadMessages">
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
</filterable-select>
</div>
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')"
dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen">
<div class="form-group row" style="flex-shrink:0">
<label class="col-2 col-form-label">{{l('logs.conversation')}}</label>
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
:placeholder="l('filter')" @input="loadMessages" class="form-control col-10">
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
</filterable-select>
</div>
<div class="form-group">
<label for="date" class="col-sm-2">{{l('logs.date')}}</label>
<div class="col-sm-10" style="display:flex">
<div class="form-group row" style="flex-shrink:0">
<label for="date" class="col-2 col-form-label">{{l('logs.date')}}</label>
<div class="col-8">
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
<option>{{l('logs.selectDate')}}</option>
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
</select>
<button @click="downloadDay" class="btn btn-default" :disabled="!selectedDate"><span class="fa fa-download"></span></button>
</div>
<div class="col-2">
<button @click="downloadDay" class="btn btn-secondary form-control" :disabled="!selectedDate"><span
class="fa fa-download"></span></button>
</div>
</div>
<div class="messages-both" style="overflow: auto">
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
</div>
<input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages"/>
<input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages" type="text"/>
</modal>
</span>
</template>
@ -59,7 +61,7 @@
export default class Logs extends Vue {
//tslint:disable:no-null-keyword
@Prop({required: true})
readonly conversation: Conversation;
readonly conversation!: Conversation;
selectedConversation: {id: string, name: string} | null = null;
selectedDate: string | null = null;
isPersistent = LogInterfaces.isPersistent(core.logs);
@ -77,7 +79,6 @@
}
mounted(): void {
(<Modal>this.$refs['dialog']).fixDropdowns();
this.conversationChanged();
}

View File

@ -3,7 +3,8 @@
<a href="#" @click.prevent="openDialog" class="btn">
<span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span>
</a>
<modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit">
<modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit"
dialogClass="w-100 modal-lg">
<div class="form-group" v-show="channel.id.substr(0, 4) === 'adh-'">
<label class="control-label" for="isPublic">
<input type="checkbox" id="isPublic" v-model="isPublic"/>
@ -27,13 +28,14 @@
<div v-if="isChannelOwner">
<h4>{{l('manageChannel.mods')}}</h4>
<div v-for="(mod, index) in opList">
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn fa fa-times"
style="padding:0;vertical-align:baseline"></a>
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn" style="padding:0;vertical-align:baseline">
<i class="fas fa-times"></i>
</a>
{{mod}}
</div>
<div style="display:flex;margin-top:5px">
<input :placeholder="l('manageChannel.modAddName')" v-model="modAddName" class="form-control"/>
<button class="btn btn-default" @click="modAdd" :disabled="!modAddName">{{l('manageChannel.modAdd')}}</button>
<button class="btn btn-secondary" @click="modAdd" :disabled="!modAddName">{{l('manageChannel.modAdd')}}</button>
</div>
</div>
</modal>
@ -56,7 +58,7 @@
})
export default class ManageChannel extends Vue {
@Prop({required: true})
readonly channel: Channel;
readonly channel!: Channel;
modes = channelModes;
isPublic = this.channelIsPublic;
mode = this.channel.mode;

View File

@ -1,5 +1,5 @@
<template>
<modal :buttons="false" :action="l('chat.recentConversations')">
<modal :buttons="false" :action="l('chat.recentConversations')" dialogClass="w-100">
<div style="display:flex; flex-direction:column; max-height:500px; flex-wrap:wrap;">
<div v-for="recent in recentConversations" style="margin: 3px;">
<user-view v-if="recent.character" :character="getCharacter(recent.character)"></user-view>

View File

@ -1,10 +1,6 @@
<template>
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings">
<ul class="nav nav-tabs" style="flex-shrink:0;margin-bottom:10px">
<li role="presentation" v-for="tab in tabs" :class="{active: tab == selectedTab}">
<a href="#" @click.prevent="selectedTab = tab">{{l('settings.tabs.' + tab)}}</a>
</li>
</ul>
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings" dialogClass="w-100">
<tabs style="flex-shrink:0;margin-bottom:10px" :tabs="tabs" v-model="selectedTab"></tabs>
<div v-show="selectedTab == 'general'">
<div class="form-group">
<label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label>
@ -96,13 +92,19 @@
{{l('settings.alwaysNotify')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="showNeedsReply">
<input type="checkbox" id="showNeedsReply" v-model="showNeedsReply"/>
{{l('settings.showNeedsReply')}}
</label>
</div>
</div>
<div v-show="selectedTab == 'import'" style="display:flex;padding-top:10px">
<select id="import" class="form-control" v-model="importCharacter" style="flex:1;">
<option value="">{{l('settings.import.selectCharacter')}}</option>
<option v-for="character in availableImports" :value="character">{{character}}</option>
</select>
<button class="btn btn-default" @click="doImport" :disabled="!importCharacter">{{l('settings.import')}}</button>
<button class="btn btn-secondary" @click="doImport" :disabled="!importCharacter">{{l('settings.import')}}</button>
</div>
</modal>
</template>
@ -111,34 +113,36 @@
import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';
import core from './core';
import {Settings as SettingsInterface} from './interfaces';
import l from './localize';
@Component(
{components: {modal: Modal}}
{components: {modal: Modal, tabs: Tabs}}
)
export default class SettingsView extends CustomDialog {
l = l;
availableImports: ReadonlyArray<string> = [];
selectedTab = 'general';
importCharacter = '';
playSound: boolean;
clickOpensMessage: boolean;
disallowedTags: string;
notifications: boolean;
highlight: boolean;
highlightWords: string;
showAvatars: boolean;
animatedEicons: boolean;
idleTimer: string;
messageSeparators: boolean;
eventMessages: boolean;
joinMessages: boolean;
alwaysNotify: boolean;
logMessages: boolean;
logAds: boolean;
fontSize: number;
playSound!: boolean;
clickOpensMessage!: boolean;
disallowedTags!: string;
notifications!: boolean;
highlight!: boolean;
highlightWords!: string;
showAvatars!: boolean;
animatedEicons!: boolean;
idleTimer!: string;
messageSeparators!: boolean;
eventMessages!: boolean;
joinMessages!: boolean;
alwaysNotify!: boolean;
logMessages!: boolean;
logAds!: boolean;
fontSize!: number;
showNeedsReply!: boolean;
constructor() {
super();
@ -168,6 +172,7 @@
this.logMessages = settings.logMessages;
this.logAds = settings.logAds;
this.fontSize = settings.fontSize;
this.showNeedsReply = settings.showNeedsReply;
};
async doImport(): Promise<void> {
@ -178,14 +183,18 @@
};
await importKey('settings');
await importKey('pinned');
await importKey('modes');
await importKey('conversationSettings');
this.init();
core.reloadSettings();
core.conversations.reloadSettings();
}
get tabs(): ReadonlyArray<string> {
return this.availableImports.length > 0 ? ['general', 'notifications', 'import'] : ['general', 'notifications'];
get tabs(): {readonly [key: string]: string} {
const tabs: {[key: string]: string} = {};
(this.availableImports.length > 0 ? ['general', 'notifications', 'import'] : ['general', 'notifications'])
.forEach((item) => tabs[item] = l(`settings.tabs.${item}`));
return tabs;
}
async submit(): Promise<void> {
@ -205,7 +214,8 @@
alwaysNotify: this.alwaysNotify,
logMessages: this.logMessages,
logAds: this.logAds,
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize,
showNeedsReply: this.showNeedsReply
};
if(this.notifications) await core.notifications.requestPermission();
}

View File

@ -1,10 +1,10 @@
<template>
<div class="sidebar-wrapper" :class="{open: expanded}">
<div :class="'sidebar sidebar-' + (right ? 'right' : 'left')">
<button @click="expanded = !expanded" class="btn btn-default btn-xs expander" :aria-label="label">
<span :class="'fa fa-rotate-270 ' + icon" style="vertical-align: middle" v-if="right"></span>
<button @click="expanded = !expanded" class="btn btn-secondary btn-xs expander" :aria-label="label">
<span :class="'fa fa-fw fa-rotate-270 ' + icon" v-if="right"></span>
<span class="fa" :class="{'fa-chevron-down': !expanded, 'fa-chevron-up': expanded}"></span>
<span :class="'fa fa-rotate-90 ' + icon" style="vertical-align: middle" v-if="!right"></span>
<span :class="'fa fa-fw fa-rotate-90 ' + icon" v-if="!right"></span>
</button>
<div class="body">
<slot></slot>
@ -26,9 +26,9 @@
@Prop()
readonly label?: string;
@Prop({required: true})
readonly icon: string;
readonly icon!: string;
@Prop({default: false})
readonly open: boolean;
readonly open!: boolean;
expanded = this.open;
@Watch('open')

View File

@ -1,19 +1,13 @@
<template>
<modal :action="l('chat.setStatus')" @submit="setStatus" @close="reset">
<modal :action="l('chat.setStatus')" @submit="setStatus" @close="reset" dialogClass="w-100 modal-lg">
<div class="form-group" id="statusSelector">
<label class="control-label">{{l('chat.setStatus.status')}}</label>
<div class="dropdown form-control" style="padding: 0;">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false" style="width:100%; text-align:left; display:flex; align-items:center">
<span style="flex: 1;"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<li><a href="#" v-for="item in statuses" @click.prevent="status = item">
<span class="fa fa-fw" :class="getStatusIcon(item)"></span>{{l('status.' + item)}}
</a></li>
</ul>
</div>
<dropdown class="dropdown form-control" style="padding:0">
<span slot="title"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span>
<a href="#" class="dropdown-item" v-for="item in statuses" @click.prevent="status = item">
<span class="fa fa-fw" :class="getStatusIcon(item)"></span>{{l('status.' + item)}}
</a>
</dropdown>
</div>
<div class="form-group">
<label class="control-label">{{l('chat.setStatus.message')}}</label>
@ -29,6 +23,7 @@
<script lang="ts">
import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog';
import Dropdown from '../components/Dropdown.vue';
import Modal from '../components/Modal.vue';
import {Editor} from './bbcode';
import {getByteLength} from './common';
@ -38,7 +33,7 @@
import {getStatusIcon} from './user_view';
@Component({
components: {modal: Modal, editor: Editor}
components: {modal: Modal, editor: Editor, dropdown: Dropdown}
})
export default class StatusSwitcher extends CustomDialog {
//tslint:disable:no-null-keyword

View File

@ -1,14 +1,7 @@
<template>
<sidebar id="user-list" :label="l('users.title')" icon="fa-users" :right="true" :open="expanded">
<ul class="nav nav-tabs" style="flex-shrink:0">
<li role="presentation" :class="{active: !channel || !memberTabShown}">
<a href="#" @click.prevent="memberTabShown = false">{{l('users.friends')}}</a>
</li>
<li role="presentation" :class="{active: memberTabShown}" v-show="channel">
<a href="#" @click.prevent="memberTabShown = true">{{l('users.members')}}</a>
</li>
</ul>
<div v-show="!channel || !memberTabShown" class="users" style="padding-left:10px">
<tabs style="flex-shrink:0" :tabs="channel ? [l('users.friends'), l('users.members')] : [l('users.friends')]" v-model="tab"></tabs>
<div class="users" style="padding-left:10px" v-show="tab == 0">
<h4>{{l('users.friends')}}</h4>
<div v-for="character in friends" :key="character.name">
<user :character="character" :showStatus="true"></user>
@ -18,7 +11,7 @@
<user :character="character" :showStatus="true"></user>
</div>
</div>
<div v-if="channel" v-show="memberTabShown" class="users" style="padding:5px">
<div v-if="channel" class="users" style="padding:5px" v-show="tab == 1">
<h4>{{l('users.memberCount', channel.sortedMembers.length)}}</h4>
<div v-for="member in channel.sortedMembers" :key="member.character.name">
<user :character="member.character" :channel="channel" :showStatus="true"></user>
@ -30,6 +23,7 @@
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import Tabs from '../components/tabs';
import core from './core';
import {Channel, Character, Conversation} from './interfaces';
import l from './localize';
@ -37,11 +31,11 @@
import UserView from './user_view';
@Component({
components: {user: UserView, sidebar: Sidebar}
components: {user: UserView, sidebar: Sidebar, tabs: Tabs}
})
export default class UserList extends Vue {
memberTabShown = false;
expanded = window.innerWidth >= 900;
tab = '0';
expanded = window.innerWidth >= 992;
l = l;
sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0));
@ -59,8 +53,11 @@
}
</script>
<style lang="less">
@import "../less/flist_variables.less";
<style lang="scss">
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
#user-list {
flex-direction: column;
h4 {
@ -77,7 +74,7 @@
border-top-left-radius: 0;
}
@media (min-width: @screen-md-min) {
@media (min-width: breakpoint-min(md)) {
.sidebar {
position: static;
margin: 0;

View File

@ -1,46 +1,37 @@
<template>
<div>
<div id="userMenu" class="dropdown-menu" v-show="showContextMenu" :style="position"
style="position:fixed;padding:10px 10px 5px;display:block;width:200px;z-index:1100" ref="menu">
<div v-if="character">
<div style="min-height: 65px;" @click.stop>
<img :src="characterImage" style="width: 60px; height:60px; margin-right: 5px; float: left;" v-if="showAvatars"/>
<h4 style="margin:0;">{{character.name}}</h4>
{{l('status.' + character.status)}}
</div>
<bbcode :text="character.statusText" @click.stop></bbcode>
<ul class="dropdown-menu border-top" role="menu"
style="display:block; position:static; border-width:1px 0 0 0; box-shadow:none; padding:0; width:100%; border-radius:0;">
<li><a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="openConversation(true)">
<span class="fa fa-fw fa-comment"></span>{{l('user.messageJump')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="openConversation(false)">
<span class="fa fa-fw fa-plus"></span>{{l('user.message')}}</a></li>
<li><a tabindex="-1" :href="profileLink" target="_blank" v-if="!showProfileFirst">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="showMemo">
<span class="fa fa-fw fa-sticky-note-o"></span>{{l('user.memo')}}</a></li>
<li><a tabindex="-1" href="#" @click.prevent="setBookmarked">
<span class="fa fa-fw fa-bookmark-o"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}
</a></li>
<li><a tabindex="-1" href="#" @click.prevent="setIgnored">
<span class="fa fa-fw fa-minus-circle"></span>{{l('user.' + (character.isIgnored ? 'unignore' : 'ignore'))}}
</a></li>
<li><a tabindex="-1" href="#" @click.prevent="setHidden">
<span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}
</a></li>
<li><a tabindex="-1" href="#" @click.prevent="report">
<span class="fa fa-fw fa-exclamation-triangle"></span>{{l('user.report')}}</a></li>
<li v-show="isChannelMod"><a tabindex="-1" href="#" @click.prevent="channelKick">
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a></li>
<li v-show="isChatOp"><a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00">
<span class="fa fa-fw fa-trash-o"></span>{{l('user.chatKick')}}</a>
</li>
</ul>
<div id="userMenu" class="list-group" v-show="showContextMenu" :style="position" v-if="character"
style="position:fixed;padding:10px 10px 5px;display:block;width:220px;z-index:1100" ref="menu">
<div style="min-height: 65px;padding:5px" class="list-group-item" @click.stop>
<img :src="characterImage" style="width:60px;height:60px;margin-right:5px;float:left" v-if="showAvatars"/>
<h5 style="margin:0;line-height:1">{{character.name}}</h5>
{{l('status.' + character.status)}}
</div>
<bbcode :text="character.statusText" v-show="character.statusText" class="list-group-item" @click.stop></bbcode>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="openConversation(true)" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-comment"></span>{{l('user.messageJump')}}</a>
<a tabindex="-1" href="#" @click.prevent="openConversation(false)" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-plus"></span>{{l('user.message')}}</a>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="!showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="showMemo" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-sticky-note"></span>{{l('user.memo')}}</a>
<a tabindex="-1" href="#" @click.prevent="setBookmarked" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-bookmark"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}</a>
<a tabindex="-1" href="#" @click.prevent="setIgnored" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-minus-circle"></span>{{l('user.' + (character.isIgnored ? 'unignore' : 'ignore'))}}</a>
<a tabindex="-1" href="#" @click.prevent="setHidden" class="list-group-item list-group-item-action" v-show="!isChatOp">
<span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}</a>
<a tabindex="-1" href="#" @click.prevent="report" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-exclamation-triangle"></span>{{l('user.report')}}</a>
<a tabindex="-1" href="#" @click.prevent="channelKick" class="list-group-item list-group-item-action" v-show="isChannelMod">
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a>
<a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00" class="list-group-item list-group-item-action"
v-show="isChatOp"><span class="far fa-fw fa-trash"></span>{{l('user.chatKick')}}</a>
</div>
<modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo">
<modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo" dialogClass="w-100">
<div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div>
<textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea>
</modal>
@ -65,17 +56,17 @@
export default class UserMenu extends Vue {
//tslint:disable:no-null-keyword
@Prop({required: true})
readonly reportDialog: ReportDialog;
readonly reportDialog!: ReportDialog;
l = l;
showContextMenu = false;
getByteLength = getByteLength;
character: Character | null = null;
position = {left: '', top: ''};
characterImage: string | null = null;
touchTimer: number | undefined;
touchedElement: HTMLElement | undefined;
channel: Channel | null = null;
memo = '';
memoId: number;
memoId = 0;
memoLoading = false;
openConversation(jump: boolean): void {
@ -159,7 +150,7 @@
handleEvent(e: MouseEvent | TouchEvent): void {
const touch = e instanceof TouchEvent ? e.changedTouches[0] : e;
let node = <HTMLElement & {character?: Character, channel?: Channel}>touch.target;
let node = <HTMLElement & {character?: Character, channel?: Channel, touched?: boolean}>touch.target;
while(node !== document.body) {
if(e.type !== 'click' && node === this.$refs['menu']) return;
if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break;
@ -170,25 +161,18 @@
if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!);
else {
this.showContextMenu = false;
this.touchedElement = undefined;
return;
}
switch(e.type) {
case 'click':
if(node.dataset['character'] === undefined) this.onClick(node.character);
if(node.dataset['character'] === undefined)
if(node === this.touchedElement) this.openMenu(touch, node.character, node.channel);
else this.onClick(node.character);
e.preventDefault();
break;
case 'touchstart':
this.touchTimer = window.setTimeout(() => {
this.openMenu(touch, node.character!, node.channel);
this.touchTimer = undefined;
}, 500);
break;
case 'touchend':
if(this.touchTimer !== undefined) {
clearTimeout(this.touchTimer);
this.touchTimer = undefined;
if(node.dataset['character'] === undefined) this.onClick(node.character);
}
this.touchedElement = node;
break;
case 'contextmenu':
this.openMenu(touch, node.character, node.channel);
@ -222,8 +206,13 @@
</script>
<style>
#userMenu li a {
padding: 3px 0;
#userMenu .list-group-item {
padding: 3px;
}
#userMenu .list-group-item-action {
border-top: 0;
z-index: -1;
}
.user-view {

View File

@ -4,7 +4,7 @@ import l from './localize';
export default class Socket implements WebSocketConnection {
static host = 'wss://chat.f-list.net:9799';
private socket: WebSocket;
private errorHandler: (error: Error) => void;
private errorHandler: ((error: Error) => void) | undefined;
private lastHandler: Promise<void> = Promise.resolve();
constructor() {

View File

@ -1,4 +1,5 @@
import {format, isToday} from 'date-fns';
import {Keys} from '../keys';
import {Character, Conversation, Settings as ISettings} from './interfaces';
export function profileLink(this: void | never, character: string): string {
@ -40,6 +41,7 @@ export class Settings implements ISettings {
logMessages = true;
logAds = false;
fontSize = 14;
showNeedsReply = false;
}
export class ConversationSettings implements Conversation.Settings {
@ -63,9 +65,8 @@ export function messageToString(this: void | never, msg: Conversation.Message, t
return `${text} ${msg.text}\r\n`;
}
export function getKey(e: KeyboardEvent): string {
/*tslint:disable-next-line:strict-boolean-expressions no-any*///because of old browsers.
return (e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier).toLowerCase();
export function getKey(e: KeyboardEvent): Keys {
return e.keyCode;
}
/*tslint:disable:no-any no-unsafe-any*///because errors can be any

View File

@ -29,10 +29,10 @@ abstract class Conversation implements Interfaces.Conversation {
lastRead: Interfaces.Message | undefined = undefined;
infoText = '';
abstract readonly maxMessageLength: number | undefined;
_settings: Interfaces.Settings;
_settings: Interfaces.Settings | undefined;
protected abstract context: CommandContext;
protected maxMessages = 100;
protected allMessages: Interfaces.Message[];
protected allMessages: Interfaces.Message[] = [];
private lastSent = '';
constructor(readonly key: string, public _isPinned: boolean) {
@ -199,7 +199,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
private chat: Interfaces.Message[] = [];
private ads: Interfaces.Message[] = [];
private both: Interfaces.Message[] = [];
private _mode: Channel.Mode;
private _mode!: Channel.Mode;
private adEnteredText = '';
private chatEnteredText = '';
private logPromise = core.logs.getBacklog(this).then((messages) => {
@ -220,7 +220,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
this.mode = value;
if(value !== 'both') this.isSendingAds = value === 'ads';
});
this.mode = this.channel.mode;
this.mode = channel.mode === 'both' && channel.id in state.modes ? state.modes[channel.id]! : channel.mode;
}
get maxMessageLength(): number {
@ -236,6 +236,10 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
this.maxMessages = 100;
this.allMessages = this[mode];
this.messages = this.allMessages.slice(-this.maxMessages);
if(mode === this.channel.mode && this.channel.id in state.modes) delete state.modes[this.channel.id];
else if(mode !== this.channel.mode && mode !== state.modes[this.channel.id]) state.modes[this.channel.id] = mode;
else return;
state.saveModes(); //tslint:disable-line:no-floating-promises
}
get enteredText(): string {
@ -272,7 +276,8 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
if(message.type !== Interfaces.Message.Type.Event) {
if(message.type === Interfaces.Message.Type.Warn) this.addModeMessage('ads', message);
if(core.state.settings.logMessages) await core.logs.logMessage(this, message);
if(this !== state.selectedConversation && this.unread === Interfaces.UnreadState.None || !state.windowFocused)
if(this.unread === Interfaces.UnreadState.None && (this !== state.selectedConversation || !state.windowFocused)
&& this.mode !== 'ads')
this.unread = Interfaces.UnreadState.Unread;
} else this.addModeMessage('ads', message);
}
@ -291,6 +296,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
protected async doSend(): Promise<void> {
const isAd = this.isSendingAds;
if(isAd && this.adCountdown > 0) return;
core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText});
await this.addMessage(
createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date()));
@ -335,12 +341,13 @@ class State implements Interfaces.State {
channelConversations: ChannelConversation[] = [];
privateMap: {[key: string]: PrivateConversation | undefined} = {};
channelMap: {[key: string]: ChannelConversation | undefined} = {};
consoleTab: ConsoleConversation;
consoleTab!: ConsoleConversation;
selectedConversation: Conversation = this.consoleTab;
recent: Interfaces.RecentConversation[] = [];
pinned: {channels: string[], private: string[]};
settings: {[key: string]: Interfaces.Settings};
windowFocused: boolean;
pinned!: {channels: string[], private: string[]};
settings!: {[key: string]: Interfaces.Settings};
modes!: {[key: string]: Channel.Mode | undefined};
windowFocused = document.hasFocus();
get hasNew(): boolean {
return this.privateConversations.some((x) => x.unread === Interfaces.UnreadState.Mention) ||
@ -369,6 +376,10 @@ class State implements Interfaces.State {
await core.settingsStore.set('pinned', this.pinned);
}
async saveModes(): Promise<void> {
await core.settingsStore.set('modes', this.modes);
}
async setSettings(key: string, value: Interfaces.Settings): Promise<void> {
this.settings[key] = value;
await core.settingsStore.set('conversationSettings', this.settings);
@ -402,6 +413,7 @@ class State implements Interfaces.State {
async reloadSettings(): Promise<void> {
//tslint:disable:strict-boolean-expressions
this.pinned = await core.settingsStore.get('pinned') || {private: [], channels: []};
this.modes = await core.settingsStore.get('modes') || {};
for(const conversation of this.channelConversations)
conversation._isPinned = this.pinned.channels.indexOf(conversation.channel.id) !== -1;
for(const conversation of this.privateConversations)
@ -433,13 +445,22 @@ function isOfInterest(this: void, character: Character): boolean {
return character.isFriend || character.isBookmarked || state.privateMap[character.name.toLowerCase()] !== undefined;
}
function isOp(conv: ChannelConversation): boolean {
const ownChar = core.characters.ownCharacter;
return ownChar.isChatOp || conv.channel.members[ownChar.name]!.rank > Channel.Rank.Member;
}
export default function(this: void): Interfaces.State {
state = new State();
window.addEventListener('focus', () => {
state.windowFocused = true;
if(state.selectedConversation !== undefined!) state.selectedConversation.unread = Interfaces.UnreadState.None;
});
window.addEventListener('blur', () => state.windowFocused = false);
window.addEventListener('blur', () => {
state.windowFocused = false;
if(state.selectedConversation !== undefined!)
state.selectedConversation.lastRead = state.selectedConversation.messages[state.selectedConversation.messages.length - 1];
});
const connection = core.connection;
connection.onEvent('connecting', async(isReconnect) => {
state.channelConversations = [];
@ -465,20 +486,23 @@ export default function(this: void): Interfaces.State {
state.channelConversations.push(conv);
await state.addRecent(conv);
} else {
const conv = state.channelMap[channel.id]!;
const conv = state.channelMap[channel.id];
if(conv === undefined) return;
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
!core.state.settings.joinMessages) return;
const text = l('events.channelJoin', `[user]${member.character.name}[/user]`);
await conv.addMessage(new EventMessage(text));
}
else if(member === undefined) {
const conv = state.channelMap[channel.id]!;
const conv = state.channelMap[channel.id];
if(conv === undefined) return;
state.channelConversations.splice(state.channelConversations.indexOf(conv), 1);
delete state.channelMap[channel.id];
await state.savePinned();
if(state.selectedConversation === conv) state.show(state.consoleTab);
} else {
const conv = state.channelMap[channel.id]!;
const conv = state.channelMap[channel.id];
if(conv === undefined) return;
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
!core.state.settings.joinMessages) return;
const text = l('events.channelLeave', `[user]${member.character.name}[/user]`);
@ -495,9 +519,9 @@ export default function(this: void): Interfaces.State {
});
connection.onMessage('MSG', async(data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored) return;
const conversation = state.channelMap[data.channel.toLowerCase()];
if(conversation === undefined) return core.channels.leave(data.channel);
if(char.isIgnored && !isOp(conversation)) return;
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
await conversation.addMessage(message);
@ -512,20 +536,21 @@ export default function(this: void): Interfaces.State {
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
message.isHighlight = true;
} else if(conversation.settings.notify === Interfaces.Setting.True)
} else if(conversation.settings.notify === Interfaces.Setting.True) {
core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
}
});
connection.onMessage('LRP', async(data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) return;
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
if((char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) && !isOp(conv)) return;
await conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
});
connection.onMessage('RLL', async(data, time) => {
const sender = core.characters.get(data.character);
if(sender.isIgnored) return;
let text: string;
if(data.type === 'bottle')
text = l('chat.bottle', `[user]${data.target}[/user]`);
@ -538,11 +563,17 @@ export default function(this: void): Interfaces.State {
const channel = (<{channel: string}>data).channel.toLowerCase();
const conversation = state.channelMap[channel];
if(conversation === undefined) return core.channels.leave(channel);
await conversation.addMessage(message);
if(data.type === 'bottle' && data.target === core.connection.character)
if(sender.isIgnored && !isOp(conversation)) return;
if(data.type === 'bottle' && data.target === core.connection.character) {
core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused)
conversation.unread = Interfaces.UnreadState.Mention;
message.isHighlight = true;
}
await conversation.addMessage(message);
} else {
if(sender.isIgnored) return;
const char = core.characters.get(
data.character === connection.character ? (<{recipient: string}>data).recipient : data.character);
if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character});
@ -590,7 +621,6 @@ export default function(this: void): Interfaces.State {
conv.infoText = text;
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('HLO', async(data, time) => addEventMessage(new EventMessage(data.message, time)));
connection.onMessage('BRO', async(data, time) => {
const text = data.character === undefined ? decodeHTML(data.message) :
l('events.broadcast', `[user]${data.character}[/user]`, decodeHTML(data.message.substr(data.character.length + 23)));
@ -703,10 +733,11 @@ export default function(this: void): Interfaces.State {
state.selectedConversation.infoText = data.message;
return addEventMessage(new EventMessage(data.message, time));
});
connection.onMessage('UPT', async(data, time) => addEventMessage(new EventMessage(l('events.uptime',
data.startstring, data.channels.toString(), data.users.toString(), data.accepted.toString(), data.maxusers.toString()), time)));
connection.onMessage('ZZZ', async(data, time) => {
state.selectedConversation.infoText = data.message;
return addEventMessage(new EventMessage(data.message, time));
});
//TODO connection.onMessage('UPT', data =>
return state;
}

View File

@ -18,6 +18,7 @@ export namespace Conversation {
readonly type: Message.Type.Event,
readonly text: string,
readonly time: Date
readonly sender?: undefined
}
export interface ChatMessage {
@ -141,7 +142,8 @@ export namespace Settings {
export type Keys = {
settings: Settings,
pinned: {channels: string[], private: string[]},
conversationSettings: {[key: string]: Conversation.Settings}
conversationSettings: {[key: string]: Conversation.Settings | undefined}
modes: {[key: string]: Channel.Mode | undefined}
recent: Conversation.RecentConversation[]
hiddenUsers: string[]
};
@ -169,6 +171,7 @@ export namespace Settings {
readonly logMessages: boolean;
readonly logAds: boolean;
readonly fontSize: number;
readonly showNeedsReply: boolean;
}
}

View File

@ -25,6 +25,9 @@ const strings: {[key: string]: string | undefined} = {
'help.report': 'How to report a user',
'help.changelog': 'Changelog',
'fs.error': 'Error writing to disk',
'spellchecker.add': 'Add to Dictionary',
'spellchecker.remove': 'Remove from Dictionary',
'spellchecker.noCorrections': 'No corrections available',
'window.newTab': 'New tab',
'title': 'F-Chat',
'version': 'Version {0}',
@ -63,7 +66,6 @@ const strings: {[key: string]: string | undefined} = {
'chat.highlight': 'mentioned {0} in {1}:\n{2}',
'chat.roll': 'rolls {0}: {1}',
'chat.bottle': 'spins the bottle: {0}',
'chat.adCountdown': 'You must wait {0}m{1}s to post another ad in this channel.',
'chat.consoleChat': 'You cannot chat here.',
'chat.typing.typing': '{0} is typing...',
'chat.typing.paused': '{0} has entered text.',
@ -72,9 +74,11 @@ const strings: {[key: string]: string | undefined} = {
'chat.disconnected': 'You were disconnected from chat.\nAttempting to reconnect.',
'chat.disconnected.title': 'Disconnected',
'chat.ignoreList': 'You are currently ignoring: {0}',
'chat.search': 'Search in messages...',
'logs.title': 'Logs',
'logs.conversation': 'Conversation',
'logs.date': 'Date',
'logs.selectDate': 'Select a date...',
'user.profile': 'Profile',
'user.message': 'Open conversation',
'user.messageJump': 'View conversation',
@ -150,7 +154,9 @@ Are you sure?`,
'settings.logMessages': 'Log messages',
'settings.logAds': 'Log ads',
'settings.fontSize': 'Font size (experimental)',
'settings.showNeedsReply': 'Show indicator on PM tabs with unanswered messages',
'settings.defaultHighlights': 'Use global highlight words',
'settings.beta': 'Opt-in to test unstable prerelease updates',
'conversationSettings.title': 'Tab Settings',
'conversationSettings.action': 'Edit settings for {0}',
'conversationSettings.default': 'Default',
@ -160,6 +166,7 @@ Are you sure?`,
'channel.mode.ads': 'Ads',
'channel.mode.chat': 'Chat',
'channel.mode.both': 'Both',
'channel.mode.ads.countdown': 'Ads ({0}m{1}s)',
'channel.official': 'Official channel',
'channel.description': 'Description',
'manageChannel.open': 'Manage',
@ -219,6 +226,7 @@ Are you sure?`,
'events.channelLeave': '{0} has left the channel.',
'events.ignore_add': 'You are now ignoring {0}\'s messages. Should they go around this by any means, please report it using the Alert Staff button.',
'events.ignore_delete': '{0} is now allowed to send you messages again.',
'events.uptime': 'Server has been running since {0}, with currently {1} channels and {2} users, a total of {3} accepted connections, and {4} maximum users.',
'commands.unknown': 'Unknown command. For a list of valid commands, please click the ? button.',
'commands.badContext': 'This command cannot be used here. Please use the Help (click the ? button) if you need further information.',
'commands.tooFewParams': 'This command requires more parameters. Please use the Help (click the ? button) if you need further information.',
@ -267,7 +275,7 @@ Are you sure?`,
'commands.makeroom.param0': 'Room name',
'commands.makeroom.param0.help': 'A name for your new private room. Must be 1-64 in length.',
'commands.ignore': 'Ignore a character',
'commands.ignore.help': 'Ignores the given character, and discards all of their messages.',
'commands.ignore.help': 'Ignores the given character, and discards all of their messages, except in channels where you are a moderator.',
'commands.unignore': 'Unignore a character',
'commands.unignore.help': 'Removes the given character from your ignore list, and allows them to send you messages again.',
'commands.ignorelist': 'Ignore list',

View File

@ -8,6 +8,7 @@ export default class Notifications implements Interface {
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
if(core.characters.ownCharacter.status === 'dnd') return;
this.playSound(sound);
if(core.state.settings.notifications) {
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive

View File

@ -1,11 +1,13 @@
import Axios from 'axios';
import Vue from 'vue';
import Editor from '../bbcode/Editor.vue';
import {InlineDisplayMode} from '../bbcode/interfaces';
import {initParser, standardParser} from '../bbcode/standard';
import CharacterLink from '../components/character_link.vue';
import CharacterSelect from '../components/character_select.vue';
import {setCharacters} from '../components/character_select/character_list';
import DateDisplay from '../components/date_display.vue';
import SimplePager from '../components/simple_pager.vue';
import {registerMethod, Store} from '../site/character_page/data_store';
import {
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterKink,
@ -115,7 +117,7 @@ async function fieldsGet(): Promise<void> {
validator: oldInfotag.list,
search_field: '',
allow_legacy: true,
infotag_group: parseInt(oldInfotag.group_id, 10)
infotag_group: oldInfotag.group_id
};
}
for(const id in fields.listitems) {
@ -175,6 +177,8 @@ export function init(characters: {[key: string]: number}): void {
Vue.component('character-select', CharacterSelect);
Vue.component('character-link', CharacterLink);
Vue.component('date-display', DateDisplay);
Vue.component('simple-pager', SimplePager);
Vue.component('bbcode-editor', Editor);
setCharacters(Object.keys(characters).map((name) => ({name, id: characters[name]})));
core.connection.onEvent('connecting', () => {
Utils.Settings.defaultCharacter = characters[core.connection.character];

View File

@ -9,21 +9,21 @@ import {Channel, Character} from './interfaces';
export function getStatusIcon(status: Character.Status): string {
switch(status) {
case 'online':
return 'fa-user-o';
return 'far fa-user';
case 'looking':
return 'fa-eye';
return 'fa fa-eye';
case 'dnd':
return 'fa-minus-circle';
return 'fa fa-minus-circle';
case 'offline':
return 'fa-ban';
return 'fa fa-ban';
case 'away':
return 'fa-circle-o';
return 'far fa-circle';
case 'busy':
return 'fa-cog';
return 'fa fa-cog';
case 'idle':
return 'fa-clock-o';
return 'far fa-clock';
case 'crown':
return 'fa-birthday-cake';
return 'fa fa-birthday-cake';
}
}
@ -35,21 +35,18 @@ const UserView = Vue.extend({
context !== undefined ? context.props : (<Vue>this).$options.propsData);
const character = props.character;
let rankIcon;
if(character.isChatOp) rankIcon = 'fa-diamond';
else if(props.channel !== undefined) {
const member = props.channel.members[character.name];
if(member !== undefined)
rankIcon = member.rank === Channel.Rank.Owner ? 'fa-asterisk' :
member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-star') : '';
else rankIcon = '';
} else rankIcon = '';
if(character.isChatOp) rankIcon = 'far fa-gem';
else if(props.channel !== undefined)
rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ?
(props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : '';
else rankIcon = '';
const html = (props.showStatus !== undefined || character.status === 'crown'
? `<span class="fa fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
(rankIcon !== '' ? `<span class="fa ${rankIcon}"></span>` : '') + character.name;
? `<span class="fa-fw ${getStatusIcon(character.status)}"></span>` : '') +
(rankIcon !== '' ? `<span class="${rankIcon}"></span>` : '') + character.name;
return createElement('span', {
attrs: {class: `user-view gender-${character.gender !== undefined ? character.gender.toLowerCase() : 'none'}`},
domProps: {character, channel: props.channel, innerHTML: html}
domProps: {character, channel: props.channel, innerHTML: html, bbcodeTag: 'user'}
});
}
});

23
components/Dropdown.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
@blur="isOpen = false" style="width:100%;text-align:left;display:flex;align-items:center">
<div style="flex:1">
<slot name="title" style="flex:1"></slot>
</div>
</button>
<div class="dropdown-menu" :style="isOpen ? 'display:block' : ''" @mousedown.stop.prevent @click="isOpen = false">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
@Component
export default class Dropdown extends Vue {
isOpen = false;
}
</script>

View File

@ -1,59 +1,50 @@
<template>
<div class="dropdown filterable-select">
<button class="btn btn-default dropdown-toggle" :class="buttonClass" data-toggle="dropdown">
<span style="flex:1">
<template v-if="multiple">{{label}}</template>
<slot v-else :option="selected">{{label}}</slot>
</span>
<span class="caret" style="align-self:center;margin-left:5px"></span>
</button>
<div class="dropdown-menu filterable-select" @click.stop>
<div style="padding:10px;">
<input v-model="filter" class="form-control" :placeholder="placeholder"/>
</div>
<ul class="dropdown-menu">
<template v-if="multiple">
<li v-for="option in filtered">
<a href="#" @click.stop="select(option)">
<input type="checkbox" :checked="selected.indexOf(option) !== -1"/>
<slot :option="option">{{option}}</slot>
</a>
</li>
</template>
<template v-else>
<li v-for="option in filtered">
<a href="#" @click="select(option)">
<slot :option="option">{{option}}</slot>
</a>
</li>
</template>
</ul>
<dropdown class="dropdown filterable-select">
<template slot="title" v-if="multiple">{{label}}</template>
<slot v-else slot="title" :option="selected">{{label}}</slot>
<div style="padding:10px;">
<input v-model="filter" class="form-control" :placeholder="placeholder"/>
</div>
</div>
<div class="dropdown-items">
<template v-if="multiple">
<a href="#" @click.stop="select(option)" v-for="option in filtered" class="dropdown-item">
<input type="checkbox" :checked="selected.indexOf(option) !== -1"/>
<slot :option="option">{{option}}</slot>
</a>
</template>
<template v-else>
<a href="#" @click="select(option)" v-for="option in filtered" class="dropdown-item">
<slot :option="option">{{option}}</slot>
</a>
</template>
</div>
</dropdown>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import Dropdown from '../components/Dropdown.vue';
@Component
@Component({
components: {dropdown: Dropdown}
})
export default class FilterableSelect extends Vue {
//tslint:disable:no-null-keyword
@Prop()
readonly placeholder?: string;
@Prop({required: true})
readonly options: object[];
readonly options!: object[];
@Prop({default: () => ((filter: RegExp, value: string) => filter.test(value))})
readonly filterFunc: (filter: RegExp, value: object) => boolean;
readonly filterFunc!: (filter: RegExp, value: object) => boolean;
@Prop()
readonly multiple?: true;
@Prop()
readonly value?: object | object[];
@Prop()
readonly title?: string;
@Prop()
readonly buttonClass?: string;
filter = '';
selected: object | object[] | null = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : null);
@ -68,10 +59,7 @@
const index = selected.indexOf(item);
if(index === -1) selected.push(item);
else selected.splice(index, 1);
} else {
this.selected = item;
$('.dropdown-toggle', this.$el).dropdown('toggle');
}
} else this.selected = item;
this.$emit('input', this.selected);
}
@ -90,17 +78,11 @@
}
</script>
<style lang="less">
<style lang="scss">
.filterable-select {
ul.dropdown-menu {
padding: 0;
.dropdown-items {
max-height: 200px;
overflow-y: auto;
position: static;
display: block;
border: 0;
box-shadow: none;
width: 100%;
}
button {
display: flex;

View File

@ -1,20 +1,19 @@
<template>
<span v-show="isShown">
<div tabindex="-1" class="modal flex-modal" @click.self="hideWithCheck"
style="align-items:flex-start;padding:30px;justify-content:center;display:flex">
<div class="modal-dialog" :class="dialogClass" style="display:flex;flex-direction:column;max-height:100%;margin:0">
<div class="modal-content" style="display:flex;flex-direction:column;flex-grow:1">
<div tabindex="-1" class="modal" @click.self="hideWithCheck" style="display:flex">
<div class="modal-dialog" :class="dialogClass" style="display:flex;align-items:center">
<div class="modal-content" style="max-height:100%">
<div class="modal-header" style="flex-shrink:0">
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">&times;</button>
<h4 class="modal-title">
<slot name="title">{{action}}</slot>
</h4>
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">&times;</button>
</div>
<div class="modal-body" style="overflow: auto; display: flex; flex-direction: column">
<div class="modal-body" style="overflow:auto">
<slot></slot>
</div>
<div class="modal-footer" v-if="buttons">
<button type="button" class="btn btn-default" @click="hideWithCheck" v-if="showCancel">Cancel</button>
<button type="button" class="btn btn-secondary" @click="hideWithCheck" v-if="showCancel">Cancel</button>
<button type="button" class="btn" :class="buttonClass" @click="submit" :disabled="disabled">
{{submitText}}
</button>
@ -31,26 +30,34 @@
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {getKey} from '../chat/common';
import {Keys} from '../keys';
const dialogStack: Modal[] = [];
window.addEventListener('keydown', (e) => {
if(getKey(e) === 'escape' && dialogStack.length > 0) dialogStack.pop()!.isShown = false;
if(getKey(e) === Keys.Escape && dialogStack.length > 0) dialogStack[dialogStack.length - 1].hideWithCheck();
});
window.addEventListener('backbutton', (e) => {
if(dialogStack.length > 0) {
e.stopPropagation();
e.preventDefault();
dialogStack.pop()!.isShown = false;
}
}, true);
@Component
export default class Modal extends Vue {
@Prop({default: ''})
readonly action: string;
readonly action!: string;
@Prop()
readonly dialogClass?: {string: boolean};
@Prop({default: true})
readonly buttons: boolean;
readonly buttons!: boolean;
@Prop({default: () => ({'btn-primary': true})})
readonly buttonClass: {string: boolean};
readonly buttonClass!: {string: boolean};
@Prop()
readonly disabled?: boolean;
@Prop({default: true})
readonly showCancel: boolean;
readonly showCancel!: boolean;
@Prop()
readonly buttonText?: string;
isShown = false;
@ -79,37 +86,11 @@
dialogStack.pop();
}
private hideWithCheck(): void {
hideWithCheck(): void {
if(this.keepOpen) return;
this.hide();
}
fixDropdowns(): void {
//tslint:disable-next-line:no-this-assignment
const vm = this;
$('.dropdown', this.$el).on('show.bs.dropdown', function(this: HTMLElement & {menu?: HTMLElement}): void {
if(this.menu !== undefined) {
this.menu.style.display = 'block';
return;
}
const $this = $(this).children('.dropdown-menu');
this.menu = $this[0];
vm.$nextTick(() => {
const offset = $this.offset();
if(offset === undefined) return;
$('body').append($this.css({
display: 'block',
left: offset.left,
position: 'absolute',
top: offset.top,
'z-index': 1100
}).detach());
});
}).on('hide.bs.dropdown', function(this: HTMLElement & {menu: HTMLElement}): void {
this.menu.style.display = 'none';
});
}
beforeDestroy(): void {
if(this.isShown) this.hide();
}

View File

@ -14,7 +14,7 @@
@Component
export default class CharacterLink extends Vue {
@Prop({required: true})
readonly character: {name: string, id: number, deleted: boolean} | string;
readonly character!: {name: string, id: number, deleted: boolean} | string;
get deleted(): boolean {
return typeof(this.character) === 'string' ? false : this.character.deleted;

View File

@ -19,7 +19,7 @@
@Component
export default class CharacterSelect extends Vue {
@Prop({required: true, type: Number})
readonly value: number;
readonly value!: number;
get characters(): SelectItem[] {
const characterList = getCharacters();

View File

@ -12,9 +12,9 @@
@Component
export default class DateDisplay extends Vue {
@Prop({required: true})
readonly time: string | null | number;
primary: string;
secondary: string;
readonly time!: string | null | number;
primary: string | undefined;
secondary: string | undefined;
constructor(options?: ComponentOptions<Vue>) {
super(options);

View File

@ -1,46 +0,0 @@
<template>
<div class="form-group" :class="allClasses">
<slot></slot>
<div :class="classes" v-if="hasErrors">
<ul>
<li v-for="error in errorList">{{ error }}</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormErrors extends Vue {
@Prop({required: true})
readonly errors: {[key: string]: string[] | undefined};
@Prop({required: true})
readonly field: string;
@Prop({default: 'col-xs-3'})
readonly classes: string;
@Prop()
readonly extraClasses?: {[key: string]: boolean};
get hasErrors(): boolean {
return typeof this.errors[this.field] !== 'undefined';
}
get errorList(): string[] {
return this.errors[this.field] !== undefined ? this.errors[this.field]! : [];
}
get allClasses(): {[key: string]: boolean} {
const classes: {[key: string]: boolean} = {'hash-error': this.hasErrors};
if(this.extraClasses === undefined) return classes;
for(const key in this.extraClasses)
classes[key] = this.extraClasses[key];
return classes;
}
}
</script>

49
components/form_group.vue Normal file
View File

@ -0,0 +1,49 @@
<template>
<div class="form-group">
<label v-if="label && id" :for="id">{{ label }}</label>
<slot :cls="{'is-invalid': hasErrors, 'is-valid': valid}" :invalid="hasErrors" :valid="valid"></slot>
<small v-if="helptext" class="form-text" :id="helpId">{{ helptext }}</small>
<div v-if="hasErrors" class="invalid-feedback">
<ul v-if="errorList.length > 1">
<li v-for="error in errorList">{{ error }}</li>
</ul>
<template v-if="errorList.length === 1"> {{ errorList[0] }}</template>
</div>
<slot v-if="valid" name="valid"></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormGroup extends Vue {
@Prop({required: true})
readonly field!: string;
@Prop({required: true})
readonly errors!: {[key: string]: ReadonlyArray<string> | undefined};
@Prop()
readonly label?: string;
@Prop()
readonly id?: string;
@Prop({default: false})
readonly valid!: boolean;
@Prop()
readonly helptext?: string;
get hasErrors(): boolean {
return typeof this.errors[this.field] !== 'undefined';
}
get errorList(): ReadonlyArray<string> {
return this.errors[this.field] || []; //tslint:disable-line:strict-boolean-expressions
}
get helpId(): string | undefined {
return this.id !== undefined ? `${this.id}Help` : undefined;
}
}
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="form-group">
<label v-if="label && id" :for="id">{{ label }}</label>
<div class="input-group">
<slot :cls="{'is-invalid': hasErrors, 'is-valid': valid}"></slot>
<slot name="button"></slot>
<small v-if="helptext" class="form-text" :id="helpId">{{ helptext }}</small>
<div v-if="hasErrors" class="invalid-feedback">
<ul v-if="errorList.length > 1">
<li v-for="error in errorList">{{ error }}</li>
</ul>
<template v-if="errorList.length === 1"> {{ errorList[0] }}</template>
</div>
<slot v-if="valid" name="valid"></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormGroupInputgroup extends Vue {
@Prop({required: true})
readonly field!: string;
@Prop({required: true})
readonly errors!: {[key: string]: ReadonlyArray<string> | undefined};
@Prop()
readonly label?: string;
@Prop()
readonly id?: string;
@Prop({default: false})
readonly valid!: boolean;
@Prop()
readonly helptext?: string;
get hasErrors(): boolean {
return typeof this.errors[this.field] !== 'undefined';
}
get errorList(): ReadonlyArray<string> {
return this.errors[this.field] || []; //tslint:disable-line:strict-boolean-expressions
}
get helpId(): string | undefined {
return this.id !== undefined ? `${this.id}Help` : undefined;
}
}
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="d-flex w-100 my-2 justify-content-between">
<div>
<slot name="previous" v-if="!routed">
<a class="btn btn-secondary" :class="{'disabled': !prev}" role="button" @click.prevent="previousPage">
<span aria-hidden="true">&larr;</span> {{prevLabel}}
</a>
</slot>
<router-link v-if="routed" :to="prevRoute" class="btn btn-secondary" :class="{'disabled': !prev}" role="button">
<span aria-hidden="true">&larr;</span> {{prevLabel}}
</router-link>
</div>
<div>
<slot name="next" v-if="!routed">
<a class="btn btn-secondary" :class="{'disabled': !next}" role="button" @click.prevent="nextPage">
{{nextLabel}} <span aria-hidden="true">&rarr;</span>
</a>
</slot>
<router-link v-if="routed" :to="nextRoute" class="btn btn-secondary" :class="{'disabled': !next}" role="button">
{{nextLabel}} <span aria-hidden="true">&rarr;</span>
</router-link>
</div>
</div>
</template>
<script lang="ts">
import cloneDeep = require('lodash/cloneDeep'); //tslint:disable-line:no-require-imports
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
type ParamDictionary = {[key: string]: number | undefined};
interface RouteParams {
name?: string
params?: ParamDictionary
}
@Component
export default class SimplePager extends Vue {
@Prop({default: 'Next Page'})
readonly nextLabel!: string;
@Prop({default: 'Previous Page'})
readonly prevLabel!: string;
@Prop({required: true})
readonly next!: boolean;
@Prop({required: true})
readonly prev!: boolean;
@Prop({default: false})
readonly routed!: boolean;
@Prop({default: () => ({})})
readonly route!: RouteParams;
@Prop({default: 'page'})
readonly paramName!: string;
nextPage(): void {
if(!this.next)
return;
this.$emit('next');
}
previousPage(): void {
if(!this.prev)
return;
this.$emit('prev');
}
get prevRoute(): RouteParams {
if(this.route.params !== undefined && this.route.params[this.paramName] !== undefined) {
const newPage = this.route.params[this.paramName]! - 1;
const clone = cloneDeep(this.route);
clone.params![this.paramName] = newPage;
return clone;
}
return {};
}
get nextRoute(): RouteParams {
if(this.route.params !== undefined && this.route.params[this.paramName] !== undefined) {
const newPage = this.route.params[this.paramName]! + 1;
const clone = cloneDeep(this.route);
clone.params![this.paramName] = newPage;
return clone;
}
return {};
}
}
</script>

27
components/tabs.ts Normal file
View File

@ -0,0 +1,27 @@
import Vue, {CreateElement, VNode} from 'vue';
//tslint:disable-next-line:variable-name
const Tabs = Vue.extend({
props: ['value', 'tabs'],
render(this: Vue & {readonly value?: string, tabs: {readonly [key: string]: string}}, createElement: CreateElement): VNode {
let children: {[key: string]: string | VNode | undefined};
if(<VNode[] | undefined>this.$slots['default'] !== undefined) {
children = {};
this.$slots['default'].forEach((child, i) => {
if(child.context !== undefined) children[child.key !== undefined ? child.key : i] = child;
});
} else children = this.tabs;
const keys = Object.keys(children);
if(this.value === undefined || children[this.value] === undefined) this.$emit('input', keys[0]);
return createElement('ul', {staticClass: 'nav nav-tabs'}, keys.map((key) => createElement('li', {staticClass: 'nav-item'},
[createElement('a', {
staticClass: 'nav-link', class: {active: this.value === key}, on: {
click: () => {
this.$emit('input', key);
}
}
}, [children[key]!])])));
}
});
export default Tabs;

View File

@ -1,34 +1,36 @@
<template>
<div @mouseover="onMouseOver" id="page" style="position:relative;padding:5px 10px 10px">
<div @mouseover="onMouseOver" id="page" style="position:relative;padding:5px 10px 10px" @auxclick.prevent>
<div v-html="styling"></div>
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
<div class="well well-lg" style="width: 400px;">
<h3 style="margin-top:0">{{l('title')}}</h3>
<div class="alert alert-danger" v-show="error">
{{error}}
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login"/>
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/>
</div>
<div class="form-group">
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
</div>
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group text-right" style="margin:0">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
<div class="card bg-light" style="width: 400px;">
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<div class="card-body">
<div class="alert alert-danger" v-show="error">
{{error}}
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login" :disabled="loggingIn"/>
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
</div>
<div class="form-group">
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
</div>
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group" style="margin:0;text-align:right">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
</div>
</div>
</div>
</div>
@ -42,7 +44,8 @@
</modal>
<modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="true" :oldApi="true" :name="profileName" :image-preview="true"></character-page>
<template slot="title">{{profileName}} <a class="btn fa fa-external-link" @click="openProfileInBrowser"></a></template>
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
</template>
</modal>
</div>
</template>
@ -50,6 +53,7 @@
<script lang="ts">
import Axios from 'axios';
import * as electron from 'electron';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import * as fs from 'fs';
import * as path from 'path';
import * as qs from 'querystring';
@ -71,15 +75,10 @@
import * as SlimcatImporter from './importer';
import Notifications from './notifications';
declare module '../chat/interfaces' {
interface State {
generalSettings?: GeneralSettings
}
}
const webContents = electron.remote.getCurrentWebContents();
const parent = electron.remote.getCurrentWindow().webContents;
log.info('About to load keytar');
/*tslint:disable:no-any*///because this is hacky
const keyStore = nativeRequire<{
getPassword(account: string): Promise<string>
@ -89,6 +88,7 @@
}>('keytar/build/Release/keytar.node');
for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
//tslint:enable
log.info('Loaded keytar.');
@Component({
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
@ -104,7 +104,7 @@
error = '';
defaultCharacter: string | null = null;
l = l;
settings: GeneralSettings;
settings!: GeneralSettings;
importProgress = 0;
profileName = '';
@ -115,7 +115,8 @@
Vue.set(core.state, 'generalSettings', this.settings);
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => core.state.generalSettings = this.settings = settings);
electron.ipcRenderer.on('settings',
(_: Event, settings: GeneralSettings) => core.state.generalSettings = this.settings = settings);
electron.ipcRenderer.on('open-profile', (_: Event, name: string) => {
const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = name;

View File

@ -3,27 +3,31 @@
<div v-html="styling"></div>
<div style="display:flex;align-items:stretch;" class="border-bottom" id="window-tabs">
<h4>F-Chat</h4>
<div :class="'fa fa-cog btn btn-' + (hasUpdate ? 'warning' : 'default')" @click="openMenu"></div>
<div class="btn" :class="'btn-' + (hasUpdate ? 'warning' : 'light')" @click="openMenu">
<i class="fa fa-cog"></i>
</div>
<ul class="nav nav-tabs" style="border-bottom:0" ref="tabs">
<li role="presentation" :class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}" v-for="tab in tabs"
:key="tab.view.id">
<a href="#" @click.prevent="show(tab)">
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item">
<a href="#" @click.prevent="show(tab)" class="nav-link"
:class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}">
<img v-if="tab.user" :src="'https://static.f-list.net/images/avatar/' + tab.user.toLowerCase() + '.png'"/>
{{tab.user || l('window.newTab')}}
<a href="#" class="fa fa-close btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit"
@click.stop="remove(tab)">
<a href="#" class="btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit"
@click.stop="remove(tab)"><i class="fa fa-times"></i>
</a>
</a>
</li>
<li role="presentation" v-show="canOpenTab" class="addTab" id="addTab">
<a href="#" @click.prevent="addTab" class="fa fa-plus"></a>
<li v-show="canOpenTab" class="addTab nav-item" id="addTab">
<a href="#" @click.prevent="addTab" class="nav-link"><i class="fa fa-plus"></i></a>
</li>
</ul>
<div style="flex:1;display:flex;justify-content:flex-end;-webkit-app-region:drag;margin-top:3px" class="btn-group"
id="windowButtons">
<span class="fa fa-window-minimize btn btn-default" @click.stop="minimize"></span>
<span :class="'fa btn btn-default fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></span>
<span class="fa fa-close fa-lg btn btn-default" @click.stop="close"></span>
<i class="far fa-window-minimize btn btn-light" @click.stop="minimize"></i>
<i class="far btn btn-light" :class="'fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></i>
<span class="btn btn-light" @click.stop="close">
<i class="fa fa-times fa-lg"></i>
</span>
</div>
</div>
</div>
@ -61,7 +65,7 @@
@Component
export default class Window extends Vue {
//tslint:disable:no-null-keyword
settings: GeneralSettings;
settings!: GeneralSettings;
tabs: Tab[] = [];
activeTab: Tab | null = null;
tabMap: {[key: number]: Tab} = {};
@ -77,6 +81,7 @@
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
electron.ipcRenderer.on('open-tab', () => this.addTab());
electron.ipcRenderer.on('update-available', () => this.hasUpdate = true);
electron.ipcRenderer.on('quit', () => this.tabs.forEach((tab) => this.remove(tab, false)));
electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => {
const tab = this.tabMap[id];
tab.user = name;
@ -87,6 +92,10 @@
});
electron.ipcRenderer.on('disconnect', (_: Event, id: number) => {
const tab = this.tabMap[id];
if(tab.hasNew) {
tab.hasNew = false;
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
}
tab.user = undefined;
tab.tray.setToolTip(l('title'));
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
@ -186,6 +195,7 @@
remove(tab: Tab, shouldConfirm: boolean = true): void {
if(shouldConfirm && tab.user !== undefined && !confirm(l('chat.confirmLeave'))) return;
this.tabs.splice(this.tabs.indexOf(tab), 1);
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
delete this.tabMap[tab.view.webContents.id];
tab.tray.destroy();
tab.view.webContents.loadURL('about:blank');
@ -210,12 +220,12 @@
}
openMenu(): void {
electron.remote.Menu.getApplicationMenu().popup();
electron.remote.Menu.getApplicationMenu()!.popup();
}
}
</script>
<style lang="less">
<style lang="scss">
#window-tabs {
user-select: none;
.btn {

View File

@ -1,6 +1,6 @@
{
"name": "fchat",
"version": "0.2.16",
"version": "0.2.17",
"author": "The F-List Team",
"description": "F-List.net Chat Client",
"main": "main.js",

View File

@ -29,11 +29,8 @@
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import 'bootstrap/js/collapse.js';
import 'bootstrap/js/dropdown.js';
import 'bootstrap/js/tab.js';
import 'bootstrap/js/transition.js';
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as qs from 'querystring';
import * as Raven from 'raven-js';
@ -41,12 +38,13 @@ import Vue from 'vue';
import {getKey} from '../chat/common';
import l from '../chat/localize';
import VueRaven from '../chat/vue-raven';
import {Keys} from '../keys';
import {GeneralSettings, nativeRequire} from './common';
import * as SlimcatImporter from './importer';
import Index from './Index.vue';
document.addEventListener('keydown', (e: KeyboardEvent) => {
if(e.ctrlKey && e.shiftKey && getKey(e) === 'i')
if(e.ctrlKey && e.shiftKey && getKey(e) === Keys.KeyI)
electron.remote.getCurrentWebContents().toggleDevTools();
});
@ -54,8 +52,10 @@ process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
const sc = nativeRequire<{
Spellchecker: {
new(): {
isMisspelled(x: string): boolean,
setDictionary(name: string | undefined, dir: string): void,
add(word: string): void
remove(word: string): void
isMisspelled(x: string): boolean
setDictionary(name: string | undefined, dir: string): void
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
}
}
@ -122,14 +122,30 @@ webContents.on('context-menu', (_, props) => {
});
if(props.misspelledWord !== '') {
const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
if(corrections.length > 0) {
menuTemplate.unshift({type: 'separator'});
menuTemplate.unshift({
label: l('spellchecker.add'),
click: () => {
if(customDictionary.indexOf(props.misspelledWord) !== -1) return;
spellchecker.add(props.misspelledWord);
customDictionary.push(props.misspelledWord);
fs.writeFile(customDictionaryPath, JSON.stringify(customDictionary), () => {/**/});
}
}, {type: 'separator'});
if(corrections.length > 0)
menuTemplate.unshift(...corrections.map((correction: string) => ({
label: correction,
click: () => webContents.replaceMisspelling(correction)
})));
}
}
else menuTemplate.unshift({enabled: false, label: l('spellchecker.noCorrections')});
} else if(customDictionary.indexOf(props.selectionText) !== -1)
menuTemplate.unshift({
label: l('spellchecker.remove'),
click: () => {
spellchecker.remove(props.selectionText);
customDictionary.splice(customDictionary.indexOf(props.selectionText), 1);
fs.writeFile(customDictionaryPath, JSON.stringify(customDictionary), () => {/**/});
}
}, {type: 'separator'});
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
});
@ -151,6 +167,10 @@ if(params['import'] !== undefined)
}
spellchecker.setDictionary(settings.spellcheckLang, dictDir);
const customDictionaryPath = path.join(settings.logDirectory, 'words');
const customDictionary = fs.existsSync(customDictionaryPath) ? <string[]>JSON.parse(fs.readFileSync(customDictionaryPath, 'utf8')) : [];
for(const word of customDictionary) spellchecker.add(word);
//tslint:disable-next-line:no-unused-expression
new Index({
el: '#app',

View File

@ -11,6 +11,7 @@ export class GeneralSettings {
spellcheckLang: string | undefined = 'en-GB';
theme = 'default';
version = electron.app.getVersion();
beta = false;
}
export function mkdir(dir: string): void {

View File

@ -6,7 +6,13 @@ import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
import l from '../chat/localize';
import {mkdir} from './common';
import {GeneralSettings, mkdir} from './common';
declare module '../chat/interfaces' {
interface State {
generalSettings?: GeneralSettings
}
}
const dayMs = 86400000;
@ -204,9 +210,12 @@ function getSettingsDir(character: string = core.connection.character): string {
export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
const file = path.join(getSettingsDir(character), key);
if(!fs.existsSync(file)) return undefined;
return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
try {
const file = path.join(getSettingsDir(character), key);
return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
} catch(e) {
return undefined;
}
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<title>F-Chat</title>
<link href="fa.css" rel="stylesheet">
</head>
<body>
<div id="app">

View File

@ -100,6 +100,7 @@ async function setDictionary(lang: string | undefined): Promise<void> {
}
const settingsDir = path.join(electron.app.getPath('userData'), 'data');
mkdir(settingsDir);
const file = path.join(settingsDir, 'settings');
const settings = new GeneralSettings();
let shouldImportSettings = false;
@ -137,7 +138,7 @@ async function addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
function setUpWebContents(webContents: Electron.WebContents): void {
const openLinkExternally = (e: Event, linkUrl: string) => {
e.preventDefault();
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)\/?#?/);
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/([^/#]+)\/?#?/);
if(profileMatch !== null && settings.profileViewer) webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
else electron.shell.openExternal(linkUrl);
};
@ -179,6 +180,7 @@ function showPatchNotes(): void {
}
function onReady(): void {
app.setAppUserModelId('net.f-list.f-chat');
app.on('open-file', createWindow);
if(settings.version !== app.getVersion()) {
@ -188,6 +190,7 @@ function onReady(): void {
}
if(process.env.NODE_ENV === 'production') {
if(settings.beta) autoUpdater.channel = 'beta';
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
const updateTimer = setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
let hasUpdate = false;
@ -195,12 +198,15 @@ function onReady(): void {
clearInterval(updateTimer);
if(hasUpdate) return;
hasUpdate = true;
const menu = electron.Menu.getApplicationMenu();
const menu = electron.Menu.getApplicationMenu()!;
menu.append(new electron.MenuItem({
label: l('action.updateAvailable'),
submenu: electron.Menu.buildFromTemplate([{
label: l('action.update'),
click: () => autoUpdater.quitAndInstall(false, true)
click: () => {
for(const w of windows) w.webContents.send('quit');
autoUpdater.quitAndInstall(false, true);
}
}, {
label: l('help.changelog'),
click: showPatchNotes
@ -288,6 +294,13 @@ function onReady(): void {
label: x,
type: <'radio'>'radio'
}))
}, {
label: l('settings.beta'), type: 'checkbox', checked: settings.beta,
click: (item: Electron.MenuItem) => {
settings.beta = item.checked;
setGeneralSettings(settings);
autoUpdater.channel = item.checked ? 'beta' : 'latest';
}
},
{type: 'separator'},
{role: 'minimize'},

View File

@ -5,21 +5,10 @@
"description": "F-List.net Chat Client",
"main": "main.js",
"license": "MIT",
"dependencies": {
"keytar": "^4.0.4",
"spellchecker": "^3.4.3"
},
"devDependencies": {
"electron": "^1.8.1",
"electron-builder": "^19.33.0",
"electron-log": "^2.2.9",
"electron-updater": "^2.8.9",
"extract-text-webpack-plugin": "^3.0.0"
},
"scripts": {
"build": "../node_modules/.bin/webpack",
"build:dist": "../node_modules/.bin/webpack --env production",
"watch": "../node_modules/.bin/webpack --watch",
"build": "node ../webpack development",
"build:dist": "node ../webpack production",
"watch": "node ../webpack watch",
"start": "electron app"
},
"build": {
@ -43,8 +32,7 @@
},
"publish": {
"provider": "generic",
"url": "https://client.f-list.net/",
"channel": "latest"
"url": "https://client.f-list.net/"
}
}
}

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"sourceMap": true,
"allowJs": true,
"outDir": "build",
"noEmitHelpers": true,
"importHelpers": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["main.ts"]
}

View File

@ -13,10 +13,5 @@
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["*.ts", "../**/*.d.ts"],
"exclude": [
"node_modules",
"dist",
"app"
]
"include": ["chat.ts", "window.ts", "../**/*.d.ts"]
}

View File

@ -1,11 +1,8 @@
const path = require('path');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const webpack = require('webpack');
const UglifyPlugin = require('uglifyjs-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const fs = require('fs');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const exportLoader = require('../export-loader');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const mainConfig = {
entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'application.json')],
@ -16,16 +13,16 @@ const mainConfig = {
context: __dirname,
target: 'electron-main',
module: {
loaders: [
rules: [
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
configFile: __dirname + '/tsconfig.json',
configFile: __dirname + '/tsconfig-main.json',
transpileOnly: true
}
},
{test: /application.json$/, loader: 'file-loader?name=package.json'},
{test: path.join(__dirname, 'application.json'), loader: 'file-loader?name=package.json', type: 'javascript/auto'},
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'}
]
},
@ -34,16 +31,15 @@ const mainConfig = {
__filename: false
},
plugins: [
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}),
exportLoader.delayTypecheck
new ForkTsCheckerWebpackPlugin({
workers: 2,
async: false,
tslint: path.join(__dirname, '../tslint.json'),
tsconfig: './tsconfig-main.json'
})
],
resolve: {
extensions: ['.ts', '.js']
},
resolveLoader: {
modules: [
'node_modules', path.join(__dirname, '../')
]
}
}, rendererConfig = {
entry: {
@ -57,12 +53,11 @@ const mainConfig = {
context: __dirname,
target: 'electron-renderer',
module: {
loaders: [
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
preLoaders: {ts: 'export-loader'},
preserveWhitespace: false
}
},
@ -71,7 +66,7 @@ const mainConfig = {
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
configFile: __dirname + '/tsconfig.json',
configFile: __dirname + '/tsconfig-renderer.json',
transpileOnly: true
}
},
@ -88,48 +83,45 @@ const mainConfig = {
__filename: false
},
plugins: [
new webpack.ProvidePlugin({
'$': 'jquery/dist/jquery.slim.js',
'jQuery': 'jquery/dist/jquery.slim.js',
'window.jQuery': 'jquery/dist/jquery.slim.js'
}),
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}),
new CommonsChunkPlugin({name: 'common', minChunks: 2}),
exportLoader.delayTypecheck
new ForkTsCheckerWebpackPlugin({
workers: 2,
async: false,
tslint: path.join(__dirname, '../tslint.json'),
tsconfig: './tsconfig-renderer.json',
vue: true
})
],
resolve: {
extensions: ['.ts', '.js', '.vue', '.css'],
alias: {qs: path.join(__dirname, 'qs.ts')}
},
resolveLoader: {
modules: [
'node_modules', path.join(__dirname, '../')
]
optimization: {
splitChunks: {chunks: 'all', minChunks: 2, name: 'common'}
}
};
module.exports = function(env) {
const dist = env === 'production';
const themesDir = path.join(__dirname, '../less/themes/chat');
module.exports = function(mode) {
const themesDir = path.join(__dirname, '../scss/themes/chat');
const themes = fs.readdirSync(themesDir);
const cssOptions = {use: [{loader: 'css-loader', options: {minimize: dist}}, 'less-loader']};
const cssOptions = {use: ['css-loader', 'sass-loader']};
for(const theme of themes) {
if(!theme.endsWith('.less')) continue;
if(!theme.endsWith('.scss')) continue;
const absPath = path.join(themesDir, theme);
rendererConfig.entry.chat.push(absPath);
const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css');
rendererConfig.plugins.push(plugin);
rendererConfig.module.loaders.push({test: absPath, use: plugin.extract(cssOptions)});
rendererConfig.module.rules.push({test: absPath, use: plugin.extract(cssOptions)});
}
if(dist) {
const faPath = path.join(themesDir, '../../fa.scss');
rendererConfig.entry.chat.push(faPath);
const faPlugin = new ExtractTextPlugin('./fa.css');
rendererConfig.plugins.push(faPlugin);
rendererConfig.module.rules.push({test: faPath, use: faPlugin.extract(cssOptions)});
if(mode === 'production') {
mainConfig.devtool = rendererConfig.devtool = 'source-map';
const plugins = [new UglifyPlugin({sourceMap: true}),
new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify('production')}),
new webpack.LoaderOptionsPlugin({minimize: true})];
mainConfig.plugins.push(...plugins);
rendererConfig.plugins.push(...plugins);
rendererConfig.plugins.push(new OptimizeCssAssetsPlugin());
} else {
//config.devtool = 'cheap-module-eval-source-map';
mainConfig.devtool = rendererConfig.devtool = 'none';
}
return [mainConfig, rendererConfig];
};

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<title>F-Chat</title>
<link href="fa.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
const fs = require('fs');
module.exports = function(source) {
fs.writeFileSync(this.resourcePath + '.ts', source);
return source;
};
module.exports.delayTypecheck = function() {
let callback;
this.plugin('fork-ts-checker-service-before-start', (c) => callback = c);
this.plugin('after-compile', (compilation, c) => {
if(compilation.compiler.parentCompilation) return c();
callback();
c();
});
};

View File

@ -33,7 +33,7 @@ function sortMember(this: void | never, array: SortableMember[], member: Sortabl
class Channel implements Interfaces.Channel {
description = '';
opList: string[];
opList: string[] = [];
owner = '';
mode: Interfaces.Mode = 'both';
members: {[key: string]: SortableMember | undefined} = {};
@ -163,16 +163,18 @@ export default function(this: void, connection: Connection, characters: Characte
const item = state.getChannelItem(data.channel);
if(data.character.identity === connection.character) {
const id = data.channel.toLowerCase();
if(state.joinedMap[id] !== undefined) return;
const channel = state.joinedMap[id] = new Channel(id, decodeHTML(data.title));
state.joinedChannels.push(channel);
if(item !== undefined) item.isJoined = true;
} else {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
if(channel.members[data.character.identity] !== undefined) return;
const member = channel.createMember(characters.get(data.character.identity));
await channel.addMember(member);
if(item !== undefined) item.memberCount++;
}
if(item !== undefined) item.memberCount++;
});
connection.onMessage('ICH', async(data) => {
const channel = state.getChannel(data.channel);
@ -214,7 +216,6 @@ export default function(this: void, connection: Connection, characters: Characte
connection.onMessage('COA', (data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.opList.push(data.character);
const member = channel.members[data.character];
if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
member.rank = Interfaces.Rank.Op;
@ -229,7 +230,6 @@ export default function(this: void, connection: Connection, characters: Characte
connection.onMessage('COR', (data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.opList.splice(channel.opList.indexOf(data.character), 1);
const member = channel.members[data.character];
if(member === undefined || member.rank === Interfaces.Rank.Owner) return;
member.rank = Interfaces.Rank.Member;

View File

@ -2,7 +2,7 @@ import {decodeHTML} from './common';
import {Character as Interfaces, Connection} from './interfaces';
class Character implements Interfaces.Character {
gender: Interfaces.Gender;
gender: Interfaces.Gender = 'None';
status: Interfaces.Status = 'offline';
statusText = '';
isFriend = false;

View File

@ -10,15 +10,15 @@ async function queryApi(this: void, endpoint: string, data: object): Promise<Axi
}
export default class Connection implements Interfaces.Connection {
character: string;
character = '';
vars: Interfaces.Vars & {[key: string]: string} = <any>{}; //tslint:disable-line:no-any
protected socket: WebSocketConnection | undefined = undefined;
private messageHandlers: {[key in keyof Interfaces.ServerCommands]?: Interfaces.CommandHandler<key>[]} = {};
private connectionHandlers: {[key in Interfaces.EventType]?: Interfaces.EventHandler[]} = {};
private errorHandlers: ((error: Error) => void)[] = [];
private ticket: string;
private ticket = '';
private cleanClose = false;
private reconnectTimer: NodeJS.Timer;
private reconnectTimer: NodeJS.Timer | undefined;
private ticketProvider: Interfaces.TicketProvider;
private reconnectDelay = 0;
private isReconnect = false;
@ -86,7 +86,7 @@ export default class Connection implements Interfaces.Connection {
}
close(): void {
clearTimeout(this.reconnectTimer);
if(this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
this.cleanClose = true;
if(this.socket !== undefined) this.socket.close();
}

112
keys.ts Normal file
View File

@ -0,0 +1,112 @@
export const enum Keys {
Backspace = 8,
Tab = 9,
Enter = 13,
Shift = 16,
Ctrl = 17,
Alt = 18,
Pause = 19,
CapsLock = 20,
Escape = 27,
Space = 32,
PageUp = 33,
PageDown = 34,
End = 35,
Home = 36,
ArrowLeft = 37,
ArrowUp = 38,
ArrowRight = 39,
ArrowDown = 40,
PrintScreen = 44,
Insert = 45,
Delete = 46,
Digit0 = 48,
Digit1 = 49,
Digit2 = 50,
Digit3 = 51,
Digit4 = 52,
Digit5 = 53,
Digit6 = 54,
Digit7 = 55,
Digit8 = 56,
Digit9 = 57,
KeyA = 65,
KeyB = 66,
KeyC = 67,
KeyD = 68,
KeyE = 69,
KeyF = 70,
KeyG = 71,
KeyH = 72,
KeyI = 73,
KeyJ = 74,
KeyK = 75,
KeyL = 76,
KeyM = 77,
KeyN = 78,
KeyO = 79,
KeyP = 80,
KeyQ = 81,
KeyR = 82,
KeyS = 83,
KeyT = 84,
KeyU = 85,
KeyV = 86,
KeyW = 87,
KeyX = 88,
KeyY = 89,
KeyZ = 90,
LeftWindowKey = 91,
RightWindowKey = 92,
SelectKey = 93,
Numpad0 = 96,
Numpad1 = 97,
Numpad2 = 98,
Numpad3 = 99,
Numpad4 = 100,
Numpad5 = 101,
Numpad6 = 102,
Numpad7 = 103,
Numpad8 = 104,
Numpad9 = 105,
NumpadMultiply = 106,
NumpadAdd = 107,
NumpadSubtract = 109,
NumpadDecimal = 110,
NumpadDivide = 111,
F1 = 112,
F2 = 113,
F3 = 114,
F4 = 115,
F5 = 116,
F6 = 117,
F7 = 118,
F8 = 119,
F9 = 120,
F10 = 121,
F11 = 122,
F12 = 123,
NumLock = 144,
ScrollLock = 145,
Semicolon = 186,
Equal = 187,
Comma = 188,
Minus = 189,
Period = 190,
ForwardSlash = 191,
Backquote = 192,
BracketLeft = 219,
BracketRight = 221,
Quote = 222
}

View File

@ -1,126 +0,0 @@
.redText {
color: @red-color;
}
.blueText {
color: @blue-color;
}
.greenText {
color: @green-color;
}
.yellowText {
color: @yellow-color;
}
.cyanText {
color: @cyan-color;
}
.purpleText {
color: @purple-color;
}
.brownText {
color: @brown-color;
}
.pinkText {
color: @pink-color;
}
.grayText {
color: @gray-color;
}
.orangeText {
color: @orange-color;
}
.whiteText {
color: @white-color;
}
.blackText {
color: @black-color;
}
/* Tweak these to be consistent with how bootstrap does sizing. */
span.bigText {
font-size: 1.4em;
}
span.smallText {
font-size: 0.8em;
}
span.leftText {
display: block;
text-align: left;
}
span.centerText {
display: block;
text-align: center;
}
span.rightText {
display: block;
text-align: right;
}
span.justifyText {
display: block;
text-align: justify;
}
div.indentText {
padding-left: 3em;
}
.character-avatar {
display: inline;
height: 100px;
width: 100px;
&.icon {
height: 50px;
width: 50px;
}
}
.collapseHeaderText {
font-weight: bold;
cursor: pointer;
width: 100%;
min-height: @line-height-computed;
}
.collapseHeader {
.well;
padding: 5px;
border-color: @collapse-border;
background-color: @collapse-header-bg;
}
.collapseBlock {
max-height: 0;
margin-left: 0.5em;
transition: max-height 0.2s;
overflow-y: hidden;
}
.styledText, .bbcode {
.force-word-wrapping();
max-width: 100%;
a {
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.link-domain {
color: @gray-light;
}

View File

@ -1,42 +0,0 @@
.bbcodeEditorButton {
.btn-default();
padding: (@padding-base-vertical/2.0) (@padding-base-horizontal/2.0);
}
.bbcodeTextAreaTextArea {
textarea& {
min-height: 150px;
}
}
.bbcodePreviewWarnings {
.alert();
.alert-danger();
}
.bbcode-toolbar {
@media (max-width: @screen-xs-max) {
background: @text-background-color;
padding: 10px;
position: absolute;
top: 0;
border-radius: 3px;
z-index: 20;
display: none;
.btn {
margin: 3px;
}
}
@media (min-width: @screen-sm-min) {
.btn-group();
.close {
display:none;
}
}
}
.bbcode-btn {
@media (min-width: @screen-sm-min) {
display: none;
}
}

View File

@ -1,261 +0,0 @@
.bg-solid-text {
background: @text-background-color
}
.link-preview {
background: @text-background-color;
border-top-right-radius: 2px;
bottom: 0;
left: 0;
max-width: 40%;
overflow-x: hidden;
padding: 0.2em 0.5em;
font-size: 12px;
position: fixed;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 100000;
&.right {
left: auto;
right: 0;
border-top-left-radius: 2px;
border-top-right-radius: 0;
}
}
.has-new {
background-color: @state-danger-bg !important;
}
.overlay-disable {
position: absolute;
opacity: 0.8;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
background: #ddd;
color: #000;
}
.sidebar-wrapper {
.modal-backdrop {
display: none;
z-index: 9;
}
&.open {
.modal-backdrop {
display: block;
}
.body {
display: block;
}
}
}
.sidebar {
position: absolute;
top: 0;
bottom: 0;
background: @body-bg;
z-index: 10;
flex-shrink: 0;
margin: -10px;
padding: 10px;
.body {
display: none;
width: 200px;
flex-direction: column;
max-height: 100%;
overflow: auto;
}
.expander {
display: block;
position: absolute;
padding: 5px 6px;
border-color: @btn-default-border;
border-top-right-radius: 0;
border-top-left-radius: 0;
@media (min-width: @screen-sm-min) {
.name {
display: none;
}
&:hover .name {
display: inline;
}
}
}
&.sidebar-left {
border-right: solid 1px @panel-default-border;
left: 0;
margin-right: 0;
padding-right: 0;
.expander {
transform: rotate(270deg) translate3d(0, 0, 0);
transform-origin: 100% 0;
-webkit-transform: rotate(270deg) translate3d(0, 0, 0);
-webkit-transform-origin: 100% 0;
right: 0;
}
}
&.sidebar-right {
border-left: solid 1px @panel-default-border;
right: 0;
margin-left: 0;
padding-left: 0;
.expander {
transform: rotate(90deg) translate3d(0, 0, 0);
transform-origin: 0 0;
-webkit-transform: rotate(90deg) translate3d(0, 0, 0);
-webkit-transform-origin: 0 0;
}
}
}
.sidebar-fixed() {
position: static;
margin: 0;
padding: 0;
height: 100%;
.body {
display: block;
}
.expander {
display: none;
}
}
.chat-text-box {
min-height: initial !important;
max-height: 250px;
resize: none;
}
.ads-text-box {
background-color: @state-info-bg;
}
.border-top {
border-top: solid 1px @panel-default-border;
}
.border-bottom {
border-bottom: solid 1px @panel-default-border;
}
.message {
word-wrap: break-word;
word-break: break-word;
padding-bottom: 1px;
}
.message-block {
padding: 1px 0;
&:not(:last-child) {
border-bottom: solid 1px @panel-default-border;
}
}
.message-warn {
background-color: @state-danger-bg;
color: @state-danger-text;
}
.messages-both {
.message-ad {
background-color: @brand-info;
padding: 0 2px 2px 2px;
box-shadow: @gray -2px -2px 2px inset;
}
}
.message-event {
color: @gray;
}
.message-highlight {
background-color: @state-success-bg;
}
.message-action .bbcode {
font-style: italic;
i, em {
font-style: normal;
}
}
.last-read {
border-bottom: solid 2px @state-success-bg !important;
}
.fa.active {
color: @brand-success;
}
.gender-shemale {
color: #CC66FF;
}
.gender-herm {
color: #9B30FF;
}
.gender-none {
color: @gray;
}
.gender-female {
color: #FF6699;
}
.gender-male {
color: #6699FF;
}
.gender-male-herm {
color: #007FFF;
}
.gender-transgender {
color: #EE8822;
}
.gender-cunt-boy {
color: #00CC66;
}
#character-page-sidebar {
margin-top: 0; // Fix up hack for merging the header on the character page, which doesn't work on chat.
}
.profile-viewer {
width: 98%;
height: 100%;
}
#window-tabs .hasNew > a {
background-color: @state-warning-bg;
border-color: @state-warning-border;
color: @state-warning-text;
&:hover {
background-color: @state-warning-border;
}
}
.btn-text {
margin-left: 3px;
@media (max-width: @screen-xs-max) {
display: none;
}
}

View File

@ -1,14 +0,0 @@
@comment-grid-columns: 50;
.comment-offset-1 {
margin-left: percentage((1 / @comment-grid-columns));
}
.comment-well {
.well();
margin-bottom: 0px;
}
.comment-well.warning {
background-color: @state-warning-bg;
border-color: @state-warning-border;
}

View File

@ -1,57 +0,0 @@
.flash-messages-fixed {
top: 0px;
left: auto;
right: auto;
width: 100%;
height: auto;
position: fixed;
z-index: 9000;
}
.flash-message {
.alert();
position: relative;
border-bottom-color: rgba(0, 0, 0, 0.3);
margin-bottom: 0;
z-index: 150;
}
.flash-message-enter-active, .flash-message-leave-active {
transition: all 0.2s;
}
.flash-message-enter, .flash-message-leave-to {
opacity: 0;
transform: translateX(100px);
}
.character-menu-item {
width: 250px;
.character-link {
display: inline-block;
padding-right: 0;
}
.character-edit-link {
margin-left: auto;
padding: 3px 20px 2px 0;
display: inline-block;
}
}
.sidebar-top-padded {
margin-top: 20px;
}
.force-word-wrapping {
overflow-wrap: break-word;
word-wrap: break-word;
//-ms-word-break: break-all;
word-break: break-word; // Non standard form used in some browsers.
//word-break: break-all;
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}

View File

@ -1,35 +0,0 @@
hr {
margin-top: 5px;
margin-bottom: 5px;
}
.modal-dialog.modal-wide {
width: 95%;
}
.panel-title {
font-weight: bold;
}
// Fix weird style where this is overwritten and cannot be styled inside a well.
.well {
// The default of 19 doesn't match any existing elements, which use either 15 or @padding-vertical/horizontal-base
padding: 15px;
blockquote {
border-color: @blockquote-border-color;
font-size: inherit;
}
}
.well-lg {
padding: 20px;
}
@select-indicator: replace("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 2'%3E%3Cpath fill='@{input-color}' d='M2 2L0 0h4z'/%3E%3C/svg%3E", "#", "%23");
select.form-control {
-webkit-appearance: none;
background: @input-bg url(@select-indicator) no-repeat right 1rem center;
background-size: 8px 10px;
padding-right: 25px;
}

View File

@ -1,53 +0,0 @@
@import "~bootstrap/less/variables.less";
// BBcode colors
@red-color: #f00;
@green-color: #0f0;
@blue-color: #00f;
@yellow-color: #ff0;
@cyan-color: #0ff;
@purple-color: #c0f;
@white-color: #fff;
@black-color: #000;
@brown-color: #8a6d3b;
@pink-color: #faa;
@gray-color: #ccc;
@orange-color: #f60;
@collapse-header-bg: @well-bg;
@collapse-border: darken(@well-border, 25%);
// Character page quick kink comparison
@quick-compare-active-border: @black-color;
@quick-compare-favorite-bg: @state-info-bg;
@quick-compare-yes-bg: @state-success-bg;
@quick-compare-maybe-bg: @state-warning-bg;
@quick-compare-no-bg: @state-danger-bg;
// character page badges
@character-badge-bg: darken(@well-bg, 10%);
@character-badge-border: darken(@well-border, 10%);
@character-badge-subscriber-bg: @alert-info-bg;
@character-badge-subscriber-border: @alert-info-border;
// Character editor
@character-list-selected-border: @brand-success;
@character-image-selected-border: @brand-success;
// Notes conversation view
@note-conversation-you-bg: @alert-info-bg;
@note-conversation-you-text: @alert-info-text;
@note-conversation-you-border: @alert-info-border;
@note-conversation-them-bg: @well-bg;
@note-conversation-them-text: @text-color;
@note-conversation-them-border: @well-border;
@nav-link-hover-color: @link-color;
// General color extensions missing from bootstrap
@text-background-color: @body-bg;
@text-background-color-disabled: @gray-lighter;
@screen-sm-min: 700px;
@screen-md-min: 900px;
@container-sm: 680px;
@container-md: 880px;

View File

@ -1,50 +0,0 @@
.tag-input-control {
display: inline-block;
margin-bottom: 0;
vertical-align: middle;
height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)
width: 100%;
padding: @padding-base-vertical @padding-base-horizontal;
font-size: @font-size-base;
line-height: @line-height-base;
color: @input-color;
background-color: @input-bg;
background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
border: 1px solid @input-border;
border-radius: @input-border-radius; // Note: This has no effect on <select>s in some browsers, due to the limited stylability of <select>s in CSS.
.box-shadow(inset 0 1px 1px rgba(0, 0, 0, .075));
.transition(~"border-color ease-in-out .15s, box-shadow ease-in-out .15s");
.tag-input {
background-color: @input-bg;
border: none;
width: auto;
&:focus {
box-shadow: none;
outline: none;
}
}
}
.form-inline .tag-input-control {
width: auto;
}
.tag-error {
border: 1px solid @state-danger-border;
background-color: @state-danger-bg;
.tag-input {
text-color: @state-danger-text;
background-color: @state-danger-bg;
}
}
.suggestion-important {
font-weight: bold !important;
}
.suggestion-description {
display: block;
font-style: italic;
font-size: @font-size-small;
}

View File

@ -1,40 +0,0 @@
@import "../variables/dark.less";
.nav-tabs > li > a:hover {
background-color: @gray-darker;
}
.modal .nav-tabs > li.active > a {
background-color: @gray-dark;
}
.message-own {
background-color: @gray-dark;
}
// Apply variables to theme.
@import "../theme_base_chat.less";
* {
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 8px @panel-default-border;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.8);
background-color: @gray-dark;
&:hover {
background-color: @gray;
}
&:active {
background-color: @gray-light;
}
}
}

View File

@ -1,40 +0,0 @@
@import "../variables/default.less";
.nav-tabs > li > a:hover {
background-color: @gray-darker;
}
.modal .nav-tabs > li.active > a {
background-color: @gray-dark;
}
.message-own {
background-color: @gray-darker;
}
// Apply variables to theme.
@import "../theme_base_chat.less";
* {
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 8px @panel-default-border;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.8);
background-color: @gray-dark;
&:hover {
background-color: @gray;
}
&:active {
background-color: @gray-light;
}
}
}

View File

@ -1,32 +0,0 @@
@import "../variables/light.less";
.message-own {
background-color: @gray-lighter;
}
// Apply variables to theme.
@import "../theme_base_chat.less";
* {
&::-webkit-scrollbar-track {
box-shadow: inset 0 0 8px @gray;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.8);
background-color: @gray-lighter;
&:hover {
background-color: @gray-light;
}
&:active {
background-color: @gray;
}
}
}

View File

@ -1,4 +0,0 @@
@import "../variables/dark.less";
// Apply variables to theme.
@import "../theme_base.less";

View File

@ -1,4 +0,0 @@
@import "../variables/default.less";
// Apply variables to theme.
@import "../theme_base.less";

View File

@ -1,4 +0,0 @@
@import "../variables/light.less";
// Apply variables to theme.
@import "../theme_base.less";

View File

@ -1,64 +0,0 @@
/*!
* Bootstrap v3.3.5 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
// Core variables and mixins
//@import "variables.less"; // This file should be drawn in through a theme file and then overwritten.
@import "~bootstrap/less/mixins.less";
// Reset and dependencies
@import "~bootstrap/less/normalize.less";
//@import "print.less";
//@import "glyphicons.less";
// Core CSS
@import "~bootstrap/less/scaffolding.less";
@import "~bootstrap/less/type.less";
//@import "code.less";
@import "~bootstrap/less/grid.less";
@import "~bootstrap/less/tables.less";
@import "~bootstrap/less/forms.less";
@import "~bootstrap/less/buttons.less";
// Components
@import "~bootstrap/less/component-animations.less";
@import "~bootstrap/less/dropdowns.less";
@import "~bootstrap/less/button-groups.less";
//@import "input-groups.less";
@import "~bootstrap/less/navs.less";
@import "~bootstrap/less/navbar.less";
//@import "breadcrumbs.less";
@import "~bootstrap/less/pagination.less";
@import "~bootstrap/less/pager.less";
@import "~bootstrap/less/labels.less";
@import "~bootstrap/less/badges.less";
//@import "jumbotron.less";
//@import "thumbnails.less";
@import "~bootstrap/less/alerts.less";
//@import "progress-bars.less";
//@import "media.less";
//@import "list-group.less";
@import "~bootstrap/less/panels.less";
//@import "responsive-embed.less";
@import "~bootstrap/less/wells.less";
@import "~bootstrap/less/close.less";
// Components w/ JavaScript
@import "~bootstrap/less/modals.less";
//@import "tooltip.less";
@import "~bootstrap/less/popovers.less";
//@import "carousel.less";
// Utility classes
@import "~bootstrap/less/utilities.less";
//@import "responsive-utilities.less";
@import "~font-awesome/less/font-awesome.less";
@import "../core.less";
@import "../character_editor.less";
@import "../character_page.less";
@import "../eicons_editor.less";
@import "../bbcode_editor.less";
@import "../bbcode.less";
@import "../comments.less";
@import "../tickets.less";
@import "../notes.less";
@import "../threads.less";
@import "../kink_editor.less";
@import "../flist_overrides.less";
@import "../tag_input.less";

View File

@ -1,61 +0,0 @@
/*!
* Bootstrap v3.3.5 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
// Core variables and mixins
//@import "variables.less"; // This file should be drawn in through a theme file and then overwritten.
@import "~bootstrap/less/mixins.less";
// Reset and dependencies
@import "~bootstrap/less/normalize.less";
//@import "print.less";
//@import "glyphicons.less";
// Core CSS
@import "~bootstrap/less/scaffolding.less";
@import "~bootstrap/less/type.less";
//@import "code.less";
@import "~bootstrap/less/grid.less";
@import "~bootstrap/less/tables.less";
@import "~bootstrap/less/forms.less";
@import "~bootstrap/less/buttons.less";
// Components
@import "~bootstrap/less/component-animations.less";
@import "~bootstrap/less/dropdowns.less";
@import "~bootstrap/less/button-groups.less";
//@import "input-groups.less";
@import "~bootstrap/less/navs.less";
//@import "~bootstrap/less/navbar.less";
//@import "breadcrumbs.less";
//@import "~bootstrap/less/pagination.less";
//@import "~bootstrap/less/pager.less";
@import "~bootstrap/less/labels.less";
//@import "~bootstrap/less/badges.less";
//@import "jumbotron.less";
//@import "thumbnails.less";
@import "~bootstrap/less/alerts.less";
@import "~bootstrap/less/progress-bars.less";
//@import "media.less";
@import "~bootstrap/less/list-group.less";
//@import "~bootstrap/less/panels.less";
//@import "responsive-embed.less";
@import "~bootstrap/less/wells.less";
@import "~bootstrap/less/close.less";
// Components w/ JavaScript
@import "~bootstrap/less/modals.less";
//@import "tooltip.less";
@import "~bootstrap/less/popovers.less";
//@import "carousel.less";
// Utility classes
@import "~bootstrap/less/utilities.less";
//@import "responsive-utilities.less";
@import "~font-awesome/less/font-awesome.less";
@import "../core.less";
@import "../character_page.less";
@import "../bbcode_editor.less";
@import "../bbcode.less";
@import "../flist_overrides.less";
@import "../chat.less";
html {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

View File

@ -1,120 +0,0 @@
//Import variable defaults first.
@import "../../flist_variables.less";
@gray-base: #000000;
@gray-darker: lighten(@gray-base, 5%);
@gray-dark: lighten(@gray-base, 25%);
@gray: lighten(@gray-base, 50%);
@gray-light: lighten(@gray-base, 65%);
@gray-lighter: lighten(@gray-base, 85%);
@body-bg: @gray-darker;
@text-color: @gray-lighter;
@text-color-disabled: @gray;
@link-color: darken(@gray-lighter, 15%);
@brand-warning: #a50;
@brand-danger: #800;
@brand-success: #080;
@brand-info: #228;
@brand-primary: @brand-info;
@blue-color: #36f;
@state-info-bg: darken(@brand-info, 15%);
@state-info-text: lighten(@brand-info, 30%);
@state-success-bg: darken(@brand-success, 15%);
@state-success-text: lighten(@brand-success, 30%);
@state-warning-bg: darken(@brand-warning, 15%);
@state-warning-text: lighten(@brand-warning, 30%);
@state-danger-bg: darken(@brand-danger, 15%);
@state-danger-text: lighten(@brand-danger, 30%);
@text-background-color: @gray-dark;
@text-background-color-disabled: @gray-darker;
@border-color: lighten(spin(@text-background-color, -10), 15%);
@border-color-active: lighten(spin(@text-background-color, -10), 25%);
@border-color-disabled: darken(spin(@text-background-color-disabled, -10), 8%);
@hover-bg: lighten(@gray-dark, 15%);
@hr-border: @text-color;
@panel-bg: @text-background-color;
@panel-default-heading-bg: @gray;
@panel-default-border: @border-color;
@input-color: @gray-lighter;
@input-bg: @text-background-color;
@input-bg-disabled: @text-background-color-disabled;
@input-border: @border-color;
@input-border-focus: @gray;
@dropdown-bg: @text-background-color;
@dropdown-color: @text-color;
@dropdown-link-color: @link-color;
@dropdown-link-hover-color: @gray-dark;
@dropdown-link-hover-bg: @gray-light;
@navbar-default-bg: @text-background-color;
@navbar-default-color: @text-color;
@navbar-default-link-color: @link-color;
@navbar-default-link-hover-color: @link-hover-color;
@nav-link-hover-bg: @gray-dark;
@nav-link-hover-color: @gray-darker;
@nav-tabs-border-color: @border-color;
@nav-tabs-link-hover-border-color: @border-color;
@nav-tabs-active-link-hover-bg: @body-bg;
@nav-tabs-active-link-hover-color: @text-color;
@nav-tabs-active-link-hover-border-color: @border-color;
@component-active-color: @gray-dark;
@component-active-bg: @gray-light;
@list-group-bg: @gray-darker;
@list-group-border: @gray-dark;
@list-group-link-color: @text-color;
@list-group-hover-bg: @gray-dark;
@btn-default-bg: @text-background-color;
@btn-default-color: @text-color;
@btn-default-border: @border-color;
@pagination-bg: @text-background-color;
@pagination-color: @text-color;
@pagination-border: @border-color;
@pagination-disabled-bg: @text-background-color-disabled;
@pagination-disabled-color: @text-color-disabled;
@pagination-disabled-border: @border-color-disabled;
@pagination-active-bg: @gray;
@pagination-active-color: @gray-lighter;
@pagination-active-border: @border-color-active;
@modal-content-bg: @text-background-color;
@modal-footer-border-color: lighten(spin(@modal-content-bg, -10), 15%);
@modal-header-border-color: @modal-footer-border-color;
@popover-bg: @body-bg;
@popover-border-color: @border-color;
@popover-title-bg: @text-background-color;
@badge-color: @gray-darker;
@close-color: saturate(@text-color, 10%);
@close-text-shadow: 0 1px 0 @text-color;
@well-bg: @text-background-color;
@well-border: @border-color;
@blockquote-border-color: @border-color-active;
@collapse-border: desaturate(@well-border, 20%);
@collapse-header-bg: desaturate(@well-bg, 20%);
@white-color: @text-color;
.blackText {
text-shadow: @gray-lighter 1px 1px 1px;
}

View File

@ -1,121 +0,0 @@
//Import variable defaults first.
@import "../../flist_variables.less";
@gray-base: #080810;
@gray-darker: lighten(@gray-base, 15%);
@gray-dark: lighten(@gray-base, 25%);
@gray: lighten(@gray-base, 60%);
@gray-light: lighten(@gray-base, 75%);
@gray-lighter: lighten(@gray-base, 95%);
// @body-bg: #262626;
@body-bg: darken(@text-background-color-disabled, 3%);
@text-color: @gray-lighter;
@text-color-disabled: @gray;
@link-color: darken(@gray-lighter, 15%);
@brand-warning: #c26c00;
@brand-danger: #930300;
@brand-success: #009900;
@brand-info: #0447af;
@brand-primary: @brand-info;
@blue-color: #36f;
@state-info-bg: darken(@brand-info, 15%);
@state-info-text: lighten(@brand-info, 30%);
@state-success-bg: darken(@brand-success, 15%);
@state-success-text: lighten(@brand-success, 30%);
@state-warning-bg: darken(@brand-warning, 15%);
@state-warning-text: lighten(@brand-warning, 30%);
@state-danger-bg: darken(@brand-danger, 15%);
@state-danger-text: lighten(@brand-danger, 30%);
@text-background-color: @gray-dark;
@text-background-color-disabled: @gray-darker;
@border-color: lighten(spin(@text-background-color, -10), 15%);
@border-color-active: lighten(spin(@text-background-color, -10), 25%);
@border-color-disabled: darken(spin(@text-background-color-disabled, -10), 8%);
@hover-bg: lighten(@gray-dark, 15%);
@hr-border: @text-color;
@panel-bg: @text-background-color;
@panel-default-heading-bg: @gray;
@panel-default-border: @border-color;
@input-color: @gray-lighter;
@input-bg: @text-background-color;
@input-bg-disabled: @text-background-color-disabled;
@input-border: @border-color;
@input-border-focus: @gray;
@dropdown-bg: @text-background-color;
@dropdown-color: @text-color;
@dropdown-link-color: @link-color;
@dropdown-link-hover-color: @gray-dark;
@dropdown-link-hover-bg: @gray-light;
@navbar-default-bg: @text-background-color;
@navbar-default-color: @text-color;
@navbar-default-link-color: @link-color;
@navbar-default-link-hover-color: @link-hover-color;
@nav-link-hover-bg: @gray-dark;
@nav-link-hover-color: @gray-darker;
@nav-tabs-border-color: @border-color;
@nav-tabs-link-hover-border-color: @border-color;
@nav-tabs-active-link-hover-bg: @body-bg;
@nav-tabs-active-link-hover-color: @text-color;
@nav-tabs-active-link-hover-border-color: @border-color;
@component-active-color: @gray-dark;
@component-active-bg: @gray-light;
@list-group-bg: @gray-darker;
@list-group-border: @gray-dark;
@list-group-link-color: @text-color;
@list-group-hover-bg: @gray-dark;
@btn-default-bg: @text-background-color;
@btn-default-color: @text-color;
@btn-default-border: @border-color;
@pagination-bg: @text-background-color;
@pagination-color: @text-color;
@pagination-border: @border-color;
@pagination-disabled-bg: @text-background-color-disabled;
@pagination-disabled-color: @text-color-disabled;
@pagination-disabled-border: @border-color-disabled;
@pagination-active-bg: @gray;
@pagination-active-color: @gray-lighter;
@pagination-active-border: @border-color-active;
@modal-content-bg: @text-background-color;
@modal-footer-border-color: lighten(spin(@modal-content-bg, -10), 15%);
@modal-header-border-color: @modal-footer-border-color;
@popover-bg: @body-bg;
@popover-border-color: @border-color;
@popover-title-bg: @text-background-color;
@badge-color: @gray-darker;
@close-color: saturate(@text-color, 10%);
@close-text-shadow: 0 1px 0 @text-color;
@well-bg: @text-background-color;
@well-border: @border-color;
@blockquote-border-color: @border-color-active;
@collapse-border: desaturate(@well-border, 20%);
@collapse-header-bg: desaturate(@well-bg, 20%);
@white-color: @text-color;
.blackText {
text-shadow: @gray-lighter 1px 1px 1px;
}

View File

@ -1,12 +0,0 @@
//Import variable defaults first.
@import "../../flist_variables.less";
// Update variables here.
// @body-bg: #00ff00;
@hr-border: @text-color;
@body-bg: #fafafa;
@brand-warning: #e09d3e;
.whiteText {
text-shadow: @gray-darker 1px 1px 1px;
}

View File

@ -1,9 +0,0 @@
.ticket-reply-well {
.well();
margin-bottom: 0px;
}
.ticket-reply-well.staff {
background-color: @state-info-bg;
border-color: @state-info-border;
}

View File

@ -1,380 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
ajv@^4.9.1:
version "4.11.8"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536"
dependencies:
co "^4.6.0"
json-stable-stringify "^1.0.1"
asap@~2.0.3:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
asn1@~0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86"
assert-plus@1.0.0, assert-plus@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
assert-plus@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
aws-sign2@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
aws4@^1.2.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
bcrypt-pbkdf@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
dependencies:
tweetnacl "^0.14.3"
boom@2.x.x:
version "2.10.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f"
dependencies:
hoek "2.x.x"
bootstrap@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-3.3.7.tgz#5a389394549f23330875a3b150656574f8a9eb71"
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
combined-stream@^1.0.5, combined-stream@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
dependencies:
delayed-stream "~1.0.0"
core-util-is@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
cryptiles@2.x.x:
version "2.0.5"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8"
dependencies:
boom "2.x.x"
dashdash@^1.12.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
dependencies:
assert-plus "^1.0.0"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
ecc-jsbn@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505"
dependencies:
jsbn "~0.1.0"
errno@^0.1.1:
version "0.1.6"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.6.tgz#c386ce8a6283f14fc09563b71560908c9bf53026"
dependencies:
prr "~1.0.1"
extend@~3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
extsprintf@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
font-awesome@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
form-data@~2.1.1:
version "2.1.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1"
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.5"
mime-types "^2.1.12"
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
dependencies:
assert-plus "^1.0.0"
graceful-fs@^4.1.2:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
har-schema@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
har-validator@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
dependencies:
ajv "^4.9.1"
har-schema "^1.0.5"
hawk@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4"
dependencies:
boom "2.x.x"
cryptiles "2.x.x"
hoek "2.x.x"
sntp "1.x.x"
hoek@2.x.x:
version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
http-signature@~1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf"
dependencies:
assert-plus "^0.2.0"
jsprim "^1.2.2"
sshpk "^1.7.0"
image-size@~0.5.0:
version "0.5.5"
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
json-stable-stringify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
dependencies:
jsonify "~0.0.0"
json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
jsonify@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
jsprim@^1.2.2:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
dependencies:
assert-plus "1.0.0"
extsprintf "1.3.0"
json-schema "0.2.3"
verror "1.10.0"
less-plugin-npm-import@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/less-plugin-npm-import/-/less-plugin-npm-import-2.1.0.tgz#823e6986c93318a98171ca858848b6bead55bf3e"
dependencies:
promise "~7.0.1"
resolve "~1.1.6"
less@^2.7.2:
version "2.7.3"
resolved "https://registry.yarnpkg.com/less/-/less-2.7.3.tgz#cc1260f51c900a9ec0d91fb6998139e02507b63b"
optionalDependencies:
errno "^0.1.1"
graceful-fs "^4.1.2"
image-size "~0.5.0"
mime "^1.2.11"
mkdirp "^0.5.0"
promise "^7.1.1"
request "2.81.0"
source-map "^0.5.3"
mime-db@~1.30.0:
version "1.30.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
mime-types@^2.1.12, mime-types@~2.1.7:
version "2.1.17"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
dependencies:
mime-db "~1.30.0"
mime@^1.2.11:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
minimist@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
mkdirp@^0.5.0:
version "0.5.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
dependencies:
minimist "0.0.8"
oauth-sign@~0.8.1:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
performance-now@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
promise@^7.1.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
dependencies:
asap "~2.0.3"
promise@~7.0.1:
version "7.0.4"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.0.4.tgz#363e84a4c36c8356b890fed62c91ce85d02ed539"
dependencies:
asap "~2.0.3"
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
qs@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
request@2.81.0:
version "2.81.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0"
dependencies:
aws-sign2 "~0.6.0"
aws4 "^1.2.1"
caseless "~0.12.0"
combined-stream "~1.0.5"
extend "~3.0.0"
forever-agent "~0.6.1"
form-data "~2.1.1"
har-validator "~4.2.1"
hawk "~3.1.3"
http-signature "~1.1.0"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.7"
oauth-sign "~0.8.1"
performance-now "^0.2.0"
qs "~6.4.0"
safe-buffer "^5.0.1"
stringstream "~0.0.4"
tough-cookie "~2.3.0"
tunnel-agent "^0.6.0"
uuid "^3.0.0"
resolve@~1.1.6:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
safe-buffer@^5.0.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
sntp@1.x.x:
version "1.0.9"
resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198"
dependencies:
hoek "2.x.x"
source-map@^0.5.3:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
sshpk@^1.7.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3"
dependencies:
asn1 "~0.2.3"
assert-plus "^1.0.0"
dashdash "^1.12.0"
getpass "^0.1.1"
optionalDependencies:
bcrypt-pbkdf "^1.0.0"
ecc-jsbn "~0.1.1"
jsbn "~0.1.0"
tweetnacl "~0.14.0"
stringstream@~0.0.4:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
tough-cookie@~2.3.0:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
dependencies:
punycode "^1.4.1"
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
dependencies:
safe-buffer "^5.0.1"
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
uuid@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
verror@1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
dependencies:
assert-plus "^1.0.0"
core-util-is "1.0.2"
extsprintf "^1.2.0"

View File

@ -2,47 +2,51 @@
<div id="page" style="position: relative; padding: 10px;" v-if="settings">
<div v-html="styling"></div>
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
<div class="well well-lg" style="width: 400px;">
<h3 style="margin-top:0">{{l('title')}}</h3>
<div class="alert alert-danger" v-show="error">
{{error}}
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login"/>
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/>
</div>
<div class="form-group">
<label class="control-label" for="theme">{{l('settings.theme')}}</label>
<select class="form-control" id="theme" v-model="settings.theme">
<option>default</option>
<option>dark</option>
<option>light</option>
</select>
</div>
<div class="form-group">
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
</div>
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group text-right">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
<div class="card bg-light" style="width: 400px;">
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<div class="card-body">
<div class="alert alert-danger" v-show="error">
{{error}}
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login" :disabled="loggingIn"/>
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
</div>
<div class="form-group">
<label class="control-label" for="theme">{{l('settings.theme')}}</label>
<select class="form-control custom-select" id="theme" v-model="settings.theme">
<option>default</option>
<option>dark</option>
<option>light</option>
</select>
</div>
<div class="form-group">
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
</div>
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group" style="text-align:right">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
</div>
</div>
</div>
</div>
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
<modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="false" :oldApi="true" :name="profileName"></character-page>
<template slot="title">{{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a>
</template>
</modal>
</div>
</template>
@ -61,7 +65,7 @@
import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection';
import CharacterPage from '../site/character_page/character_page.vue';
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import {appVersion, GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import Notifications from './notifications';
declare global {
@ -70,10 +74,15 @@
setTheme(theme: string): void
} | undefined;
}
const NativeBackground: {
start(): void
stop(): void
};
}
function confirmBack(): void {
if(confirm(l('chat.confirmLeave'))) (<Navigator & {app: {exitApp(): void}}>navigator).app.exitApp();
function confirmBack(e: Event): void {
if(!confirm(l('chat.confirmLeave'))) e.preventDefault();
}
@Component({
@ -94,25 +103,26 @@
profileName = '';
async created(): Promise<void> {
const oldOpen = window.open.bind(window);
window.open = (url?: string, target?: string, features?: string, replace?: boolean) => {
const profileMatch = url !== undefined ? url.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/) : null;
if(profileMatch !== null) {
const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = profileMatch[2];
profileViewer.show();
return null;
} else return oldOpen(url, target, features, replace);
};
document.addEventListener('open-profile', (e: Event) => {
const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = (<Event & {detail: string}>e).detail;
profileViewer.show();
});
let settings = await getGeneralSettings();
if(settings === undefined) settings = new GeneralSettings();
if(settings.version !== appVersion) {
alert('Your beta version of F-Chat 3.0 has been updated. If you are experiencing any issues after this update, please perform a full reinstall of the application. If the issue persists, please report it.');
settings.version = appVersion;
await setGeneralSettings(settings);
}
if(settings.account.length > 0) this.saveLogin = true;
this.settings = settings;
}
get styling(): string {
if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings!.theme);
return `<style>${require(`../less/themes/chat/${this.settings!.theme}.less`)}</style>`;
//tslint:disable-next-line:no-require-imports
return `<style>${require('../scss/fa.scss')}${require(`../scss/themes/chat/${this.settings!.theme}.scss`)}</style>`;
}
async login(): Promise<void> {
@ -130,16 +140,17 @@
}
if(this.saveLogin) await setGeneralSettings(this.settings!);
Socket.host = this.settings!.host;
const version = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
const connection = new Connection(`F-Chat 3.0 (Mobile)`, version, Socket,
const connection = new Connection('F-Chat 3.0 (Mobile)', appVersion, Socket,
this.settings!.account, this.settings!.password);
connection.onEvent('connected', () => {
Raven.setUserContext({username: core.connection.character});
document.addEventListener('backbutton', confirmBack);
NativeBackground.start();
});
connection.onEvent('closed', () => {
Raven.setUserContext();
document.removeEventListener('backbutton', confirmBack);
NativeBackground.stop();
});
initCore(connection, Logs, SettingsStore, Notifications);
const charNames = Object.keys(data.characters);
@ -157,6 +168,10 @@
this.loggingIn = false;
}
}
openProfileInBrowser(): void {
window.open(`profile://${this.profileName}`);
}
}
</script>
@ -164,4 +179,8 @@
html, body, #page {
height: 100%;
}
html, .modal {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
</style>

View File

@ -8,8 +8,8 @@ android {
applicationId "net.f_list.fchat"
minSdkVersion 19
targetSdkVersion 27
versionCode 4
versionName "0.1.0"
versionCode 11
versionName "0.1.4"
}
buildTypes {
release {

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
@ -13,12 +14,13 @@
android:supportsRtl="true"
android:theme="@android:style/Theme.Holo.NoActionBar">
<activity android:name=".MainActivity" android:launchMode="singleInstance"
android:configChanges="orientation|screenSize">
android:configChanges="density|fontScale|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".BackgroundService" />
</application>
</manifest>

View File

@ -0,0 +1,20 @@
package net.f_list.fchat
import android.content.Context
import android.content.Intent
import android.webkit.JavascriptInterface
class Background(private val ctx: Context) {
private val serviceIntent: Intent by lazy { Intent(ctx, BackgroundService::class.java) }
@JavascriptInterface
fun start() {
ctx.startService(serviceIntent)
}
@JavascriptInterface
fun stop() {
ctx.stopService(serviceIntent)
}
}

View File

@ -0,0 +1,35 @@
package net.f_list.fchat
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
class BackgroundService : Service() {
override fun onBind(p0: Intent?): IBinder? {
return null
}
override fun onCreate() {
super.onCreate()
val notification = Notification.Builder(this).setContentTitle(getString(R.string.app_name))
.setContentIntent(PendingIntent.getActivity(this, 1, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT))
.setSmallIcon(R.drawable.ic_notification).setAutoCancel(true).setPriority(Notification.PRIORITY_LOW)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
manager.createNotificationChannel(NotificationChannel("background", getString(R.string.channel_background), NotificationManager.IMPORTANCE_LOW));
notification.setChannelId("background");
}
startForeground(1, notification.build())
}
override fun onDestroy() {
super.onDestroy()
stopForeground(true)
}
}

View File

@ -4,48 +4,30 @@ import android.content.Context
import android.webkit.JavascriptInterface
import org.json.JSONArray
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.*
class File(private val ctx: Context) {
@JavascriptInterface
fun readFile(name: String, s: Long, l: Int): String? {
fun read(name: String): String? {
val file = File(ctx.filesDir, name)
if(!file.exists()) return null
FileInputStream(file).use { fs ->
val start = if(s != -1L) s else 0
fs.channel.position(start)
val maxLength = fs.channel.size() - start
val length = if(l != -1 && l < maxLength) l else maxLength.toInt()
val bytes = ByteArray(length)
fs.read(bytes, 0, length)
return String(bytes)
}
}
@JavascriptInterface
fun readFile(name: String): String? {
return readFile(name, -1, -1)
Scanner(file).useDelimiter("\\Z").use { return it.next() }
}
@JavascriptInterface
fun getSize(name: String) = File(ctx.filesDir, name).length()
@JavascriptInterface
fun writeFile(name: String, data: String) {
fun write(name: String, data: String) {
FileOutputStream(File(ctx.filesDir, name)).use { it.write(data.toByteArray()) }
}
@JavascriptInterface
fun append(name: String, data: String) {
FileOutputStream(File(ctx.filesDir, name), true).use { it.write(data.toByteArray()) }
}
fun listFilesN(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString()
@JavascriptInterface
fun listFiles(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString()
@JavascriptInterface
fun listDirectories(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
fun listDirectoriesN(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
@JavascriptInterface
fun ensureDirectory(name: String) {

View File

@ -0,0 +1,172 @@
package net.f_list.fchat
import android.content.Context
import android.webkit.JavascriptInterface
import org.json.JSONArray
import org.json.JSONStringer
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.CharBuffer
import java.util.*
class Logs(private val ctx: Context) {
data class IndexItem(val name: String, val index: MutableMap<Int, Long> = HashMap(), val dates: MutableList<Int> = LinkedList())
private lateinit var index: MutableMap<String, IndexItem>
private lateinit var baseDir: File
private val encoder = Charsets.UTF_8.newEncoder()
private val decoder = Charsets.UTF_8.newDecoder()
private val buffer = ByteBuffer.allocateDirect(51000).order(ByteOrder.LITTLE_ENDIAN)
@JavascriptInterface
fun initN(character: String): String {
baseDir = File(ctx.filesDir, "$character/logs")
baseDir.mkdirs()
val files = baseDir.listFiles({ _, name -> name.endsWith(".idx") })
index = HashMap(files.size)
for(file in files) {
FileInputStream(file).use { stream ->
buffer.clear()
val read = stream.channel.read(buffer)
buffer.rewind()
val nameLength = buffer.get().toInt()
buffer.limit(nameLength + 1)
val cb = CharBuffer.allocate(nameLength)
decoder.reset()
decoder.decode(buffer, cb, true)
decoder.flush(cb)
cb.flip()
val indexItem = IndexItem(cb.toString())
buffer.limit(read)
while(buffer.position() < buffer.limit()) {
val key = buffer.short.toInt()
indexItem.index[key] = buffer.int.toLong() or (buffer.get().toLong() shl 32)
indexItem.dates.add(key)
}
index[file.nameWithoutExtension] = indexItem
}
}
val json = JSONStringer().`object`()
for(item in index) json.key(item.key).`object`().key("name").value(item.value.name).key("dates").value(JSONArray(item.value.dates)).endObject()
return json.endObject().toString()
}
@JavascriptInterface
fun logMessage(key: String, conversation: String, time: Int, type: Int, sender: String, text: String) {
val day = time / 86400
val file = File(baseDir, key)
buffer.clear()
if(!index.containsKey(key)) {
index[key] = IndexItem(conversation, HashMap())
buffer.position(1)
encoder.encode(CharBuffer.wrap(conversation), buffer, true)
buffer.put(0, (buffer.position() - 1).toByte())
}
val item = index[key]!!
if(!item.index.containsKey(day)) {
buffer.putShort(day.toShort())
val size = file.length()
item.index[day] = size
item.dates.add(day)
buffer.putInt((size and 0xffffffffL).toInt())
buffer.put((size shr 32).toByte())
FileOutputStream(File(baseDir, "$key.idx"), true).use { file ->
buffer.flip()
file.channel.write(buffer)
}
}
FileOutputStream(file, true).use { file ->
buffer.clear()
buffer.putInt(time)
buffer.put(type.toByte())
buffer.position(6)
encoder.encode(CharBuffer.wrap(sender), buffer, true)
val senderLength = buffer.position() - 6
buffer.put(5, senderLength.toByte())
buffer.position(8 + senderLength)
encoder.encode(CharBuffer.wrap(text), buffer, true)
buffer.putShort(senderLength + 6, (buffer.position() - senderLength - 8).toShort())
buffer.putShort(buffer.position().toShort())
buffer.flip()
file.channel.write(buffer)
}
}
@JavascriptInterface
fun getBacklogN(key: String): String {
buffer.clear()
val file = File(baseDir, key)
if(!file.exists()) return "[]"
val list = LinkedList<JSONStringer>()
FileInputStream(file).use { stream ->
val channel = stream.channel
val lengthBuffer = ByteBuffer.allocateDirect(4).order(ByteOrder.LITTLE_ENDIAN)
channel.position(channel.size())
while(channel.position() > 0 && list.size < 20) {
lengthBuffer.rewind()
lengthBuffer.limit(2)
channel.position(channel.position() - 2)
channel.read(lengthBuffer)
lengthBuffer.clear()
val length = lengthBuffer.int
channel.position(channel.position() - length - 2)
buffer.rewind()
buffer.limit(length)
channel.read(buffer)
buffer.rewind()
val stringer = JSONStringer()
deserializeMessage(buffer, stringer)
list.addFirst(stringer)
channel.position(channel.position() - length)
}
}
val json = StringBuilder("[")
for(item in list) json.append(item).append(",")
json.setLength(json.length - 1)
return json.append("]").toString()
}
@JavascriptInterface
fun getLogsN(key: String, date: Int): String {
val offset = index[key]?.index?.get(date) ?: return "[]"
val json = JSONStringer()
json.array()
FileInputStream(File(baseDir, key)).use { stream ->
val channel = stream.channel
channel.position(offset)
while(channel.position() < channel.size()) {
buffer.clear()
val oldPosition = channel.position()
channel.read(buffer)
buffer.rewind()
deserializeMessage(buffer, json, date)
if(buffer.position() == 0) break
channel.position(oldPosition + buffer.position() + 2)
}
}
return json.endArray().toString()
}
private fun deserializeMessage(buffer: ByteBuffer, json: JSONStringer, checkDate: Int = -1) {
val date = buffer.int
if(checkDate != -1 && date / 86400 != checkDate) return
json.`object`()
json.key("time")
json.value(date)
json.key("type")
json.value(buffer.get())
json.key("sender")
val senderLength = buffer.get()
buffer.limit(6 + senderLength)
json.value(decoder.decode(buffer))
buffer.limit(buffer.capacity())
val textLength = buffer.short.toInt() and 0xffff
json.key("text")
buffer.limit(8 + senderLength + textLength)
json.value(decoder.decode(buffer))
json.endObject()
}
}

View File

@ -1,31 +1,100 @@
package net.f_list.fchat
import android.app.Activity
import android.app.AlertDialog
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.JsResult
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import java.net.URLDecoder
class MainActivity : Activity() {
private lateinit var webView: WebView
private val profileRegex = Regex("^https?://(www\\.)?f-list.net/c/(.+)/?#?")
private val backgroundPlugin = Background(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if(BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)
webView = findViewById(R.id.webview)
webView.settings.javaScriptEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.loadUrl("file:///android_asset/www/index.html")
webView.addJavascriptInterface(File(this), "NativeFile")
webView.addJavascriptInterface(Notifications(this), "NativeNotification")
webView.webChromeClient = WebChromeClient()
webView.addJavascriptInterface(backgroundPlugin, "NativeBackground")
webView.addJavascriptInterface(Logs(this), "NativeLogs")
webView.webChromeClient = object : WebChromeClient() {
override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean {
AlertDialog.Builder(this@MainActivity).setTitle(R.string.app_name).setMessage(message).setPositiveButton(R.string.ok, { _, _ -> result.confirm() }).show()
return true
}
override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean {
var ok = false
AlertDialog.Builder(this@MainActivity).setTitle(R.string.app_name).setMessage(message).setOnDismissListener({ if(ok) result.confirm() else result.cancel() })
.setPositiveButton(R.string.ok, { _, _ -> ok = true}).setNegativeButton(R.string.cancel, null).show()
return true
}
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
val match = profileRegex.find(url)
if(match != null) {
val char = URLDecoder.decode(match.groupValues[2], "UTF-8")
webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('open-profile',{detail:'$char'}))", null)
} else {
var uri = Uri.parse(url)
if(uri.scheme == "profile") uri = Uri.parse("https://www.f-list.net/c/${uri.authority}")
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
return true
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
webView.evaluateJavascript("(function(n){n.listFiles=function(p){return JSON.parse(n.listFilesN(p))};n.listDirectories=function(p){return JSON.parse(n.listDirectoriesN(p))}})(NativeFile)", null)
webView.evaluateJavascript("(function(n){n.init=function(c){return JSON.parse(n.initN(c))};n.getBacklog=function(k){return JSON.parse(n.getBacklogN(k))};n.getLogs=function(k,d){return JSON.parse(n.getLogsN(k,d))}})(NativeLogs)", null)
}
}
}
override fun onBackPressed() {
webView.evaluateJavascript("var e=new Event('backbutton',{cancelable:true});document.dispatchEvent(e);e.defaultPrevented", {
if(it != "true") super.onBackPressed()
});
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if(intent.action == "notification") {
val data = intent.extras.getString("data")
webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('notification-clicked',{detail:{data:'$data'}}))", {}) //TODO
webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('notification-clicked',{detail:{data:'$data'}}))", null)
}
}
override fun onResume() {
super.onResume()
webView.requestFocus()
}
override fun onPause() {
super.onPause()
webView.clearFocus()
}
override fun onDestroy() {
super.onDestroy()
findViewById<ViewGroup>(R.id.content).removeAllViews()
webView.removeAllViews()
webView.destroy()
backgroundPlugin.stop()
}
}

View File

@ -1,6 +1,7 @@
package net.f_list.fchat
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
@ -9,21 +10,30 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.AsyncTask
import android.os.Build
import android.os.Vibrator
import android.webkit.JavascriptInterface
import java.net.URL
class Notifications(private val ctx: Context) {
init {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
manager.createNotificationChannel(NotificationChannel("messages", ctx.getString(R.string.channel_messages), NotificationManager.IMPORTANCE_LOW))
}
}
@JavascriptInterface
fun notify(notify: Boolean, title: String, text: String, icon: String, sound: String?, data: String?): Int {
val soundUri = if(sound != null) Uri.parse("file://android_asset/www/sounds/$sound.mp3") else null
if(!notify) {
(ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator).vibrate(400)
val vibrator = (ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
vibrator.vibrate(400, Notification.AUDIO_ATTRIBUTES_DEFAULT)
else vibrator.vibrate(400)
return 0
}
if(soundUri != null) {
if(sound != null) {
val player = MediaPlayer()
val asset = ctx.assets.openFd("www/sounds/$sound.mp3")
player.setDataSource(asset.fileDescriptor, asset.startOffset, asset.length)
@ -34,8 +44,9 @@ class Notifications(private val ctx: Context) {
val intent = Intent(ctx, MainActivity::class.java)
intent.action = "notification"
intent.putExtra("data", data)
val notification = Notification.Builder(ctx).setContentTitle(title).setContentText(text).setSmallIcon(R.drawable.ic_notification).setDefaults(Notification.DEFAULT_VIBRATE)
.setContentIntent(PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)).setAutoCancel(true)
val notification = Notification.Builder(ctx).setContentTitle(title).setContentText(text).setSmallIcon(R.drawable.ic_notification).setAutoCancel(true)
.setContentIntent(PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)).setDefaults(Notification.DEFAULT_VIBRATE or Notification.DEFAULT_LIGHTS)
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) notification.setChannelId("messages")
object : AsyncTask<String, Void, Bitmap>() {
override fun doInBackground(vararg args: String): Bitmap {
val connection = URL(args[0]).openConnection()
@ -44,10 +55,10 @@ class Notifications(private val ctx: Context) {
override fun onPostExecute(result: Bitmap?) {
notification.setLargeIcon(result)
(ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(1, notification.build())
(ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(2, notification.build())
}
}.execute(icon)
return 1
return 2
}
@JavascriptInterface

View File

@ -4,6 +4,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/content"
android:focusable="true"
android:focusableInTouchMode="true"
tools:context="net.f_list.fchat.MainActivity">
<WebView

View File

@ -1,3 +1,7 @@
<resources>
<string name="app_name">F-Chat</string>
<string name="channel_background">Running in Background</string>
<string name="channel_messages">Messages</string>
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
</resources>

View File

@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.2.10'
ext.kotlin_version = '1.2.21'
repositories {
jcenter()
}

View File

@ -29,9 +29,6 @@
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import 'bootstrap/js/dropdown.js';
import 'bootstrap/js/modal.js';
import 'bootstrap/js/tab.js';
import * as Raven from 'raven-js';
import Vue from 'vue';
import VueRaven from '../chat/vue-raven';

View File

@ -1,150 +1,72 @@
import {getByteLength, Message as MessageImpl} from '../chat/common';
import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
declare global {
const NativeFile: {
readFile(name: string): Promise<string | undefined>
readFile(name: string, start: number, length: number): Promise<string | undefined>
writeFile(name: string, data: string): Promise<void>
listDirectories(name: string): Promise<string>
listFiles(name: string): Promise<string>
read(name: string): Promise<string | undefined>
write(name: string, data: string): Promise<void>
listDirectories(name: string): Promise<string[]>
listFiles(name: string): Promise<string[]>
getSize(name: string): Promise<number>
append(name: string, data: string): Promise<void>
ensureDirectory(name: string): Promise<void>
};
type NativeMessage = {time: number, type: number, sender: string, text: string};
const NativeLogs: {
init(character: string): Promise<Index>
logMessage(key: string, conversation: string, time: number, type: Conversation.Message.Type, sender: string,
message: string): Promise<void>;
getBacklog(key: string): Promise<ReadonlyArray<NativeMessage>>;
getLogs(key: string, date: number): Promise<ReadonlyArray<NativeMessage>>
};
}
const dayMs = 86400000;
export const appVersion = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
export class GeneralSettings {
account = '';
password = '';
host = 'wss://chat.f-list.net:9799';
theme = 'default';
version = appVersion;
}
type Index = {[key: string]: {name: string, index: {[key: number]: number | undefined}} | undefined};
function serializeMessage(message: Conversation.Message): string {
const time = message.time.getTime() / 1000;
let str = String.fromCharCode((time >> 24) % 256) + String.fromCharCode((time >> 16) % 256)
+ String.fromCharCode((time >> 8) % 256) + String.fromCharCode(time % 256);
str += String.fromCharCode(message.type);
if(message.type !== Conversation.Message.Type.Event) {
str += String.fromCharCode(message.sender.name.length);
str += message.sender.name;
} else str += '\0';
const textLength = message.text.length;
str += String.fromCharCode((textLength >> 8) % 256) + String.fromCharCode(textLength % 256);
str += message.text;
const length = getByteLength(str);
str += String.fromCharCode((length >> 8) % 256) + String.fromCharCode(length % 256);
return str;
}
function deserializeMessage(str: string): {message: Conversation.Message, end: number} {
let index = 0;
const time = str.charCodeAt(index++) << 24 | str.charCodeAt(index++) << 16 | str.charCodeAt(index++) << 8 | str.charCodeAt(index++);
const type = str.charCodeAt(index++);
const senderLength = str.charCodeAt(index++);
const sender = str.substring(index, index += senderLength);
const messageLength = str.charCodeAt(index++) << 8 | str.charCodeAt(index++);
const text = str.substring(index, index += messageLength);
const end = str.charCodeAt(index++) << 8 | str.charCodeAt(index);
return {message: new MessageImpl(type, core.characters.get(sender), text, new Date(time * 1000)), end: end + 2};
}
type Index = {[key: string]: {name: string, dates: number[]} | undefined};
export class Logs implements Logging.Persistent {
private index: Index = {};
private logDir: string;
constructor() {
core.connection.onEvent('connecting', async() => {
this.index = {};
this.logDir = `${core.connection.character}/logs`;
await NativeFile.ensureDirectory(this.logDir);
const entries = <string[]>JSON.parse(await NativeFile.listFiles(this.logDir));
for(const entry of entries)
if(entry.substr(-4) === '.idx') {
const str = (await NativeFile.readFile(`${this.logDir}/${entry}`))!;
let i = str.charCodeAt(0);
const name = str.substr(1, i++);
const index: {[key: number]: number} = {};
while(i < str.length) {
const key = str.charCodeAt(i++) << 8 | str.charCodeAt(i++);
index[key] = str.charCodeAt(i++) << 32 | str.charCodeAt(i++) << 24 | str.charCodeAt(i++) << 16 |
str.charCodeAt(i++) << 8 | str.charCodeAt(i++);
}
this.index[entry.slice(0, -4).toLowerCase()] = {name, index};
}
this.index = await NativeLogs.init(core.connection.character);
});
}
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
const file = `${this.logDir}/${conversation.key}`;
const serialized = serializeMessage(message);
const date = Math.floor(message.time.getTime() / dayMs);
let indexBuffer: string | undefined;
const time = message.time.getTime();
const date = Math.floor(time / dayMs);
let index = this.index[conversation.key];
if(index !== undefined) {
if(index.index[date] === undefined) indexBuffer = '';
} else {
index = this.index[conversation.key] = {name: conversation.name, index: {}};
const nameLength = getByteLength(conversation.name);
indexBuffer = String.fromCharCode(nameLength) + conversation.name;
}
if(indexBuffer !== undefined) {
const size = await NativeFile.getSize(file);
index.index[date] = size;
indexBuffer += String.fromCharCode((date >> 8) % 256) + String.fromCharCode(date % 256) +
String.fromCharCode((size >> 32) % 256) + String.fromCharCode((size >> 24) % 256) +
String.fromCharCode((size >> 16) % 256) + String.fromCharCode((size >> 8) % 256) + String.fromCharCode(size % 256);
await NativeFile.append(`${file}.idx`, indexBuffer);
}
await NativeFile.append(file, serialized);
if(index === undefined) index = this.index[conversation.key] = {name: conversation.name, dates: []};
if(index.dates[index.dates.length - 1] !== date) index.dates.push(date);
return NativeLogs.logMessage(conversation.key, conversation.name, time / 1000, message.type,
message.type === Conversation.Message.Type.Event ? '' : message.sender.name, message.text);
}
async getBacklog(conversation: Conversation): Promise<Conversation.Message[]> {
const file = `${this.logDir}/${conversation.key}`;
let count = 20;
let messages = new Array<Conversation.Message>(count);
let pos = await NativeFile.getSize(file);
while(pos > 0 && count > 0) {
const l = (await NativeFile.readFile(file, pos - 2, pos))!;
const length = (l.charCodeAt(0) << 8 | l.charCodeAt(1));
pos = pos - length - 2;
messages[--count] = deserializeMessage((await NativeFile.readFile(file, pos, length))!).message;
}
if(count !== 0) messages = messages.slice(count);
return messages;
async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
return (await NativeLogs.getBacklog(conversation.key))
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
}
async getLogs(key: string, date: Date): Promise<Conversation.Message[]> {
const file = `${this.logDir}/${key}`;
const messages: Conversation.Message[] = [];
const day = date.getTime() / dayMs;
const index = this.index[key];
if(index === undefined) return [];
let pos = index.index[date.getTime() / dayMs];
if(pos === undefined) return [];
const size = await NativeFile.getSize(file);
while(pos < size) {
const deserialized = deserializeMessage((await NativeFile.readFile(file, pos, 51000))!);
if(Math.floor(deserialized.message.time.getTime() / dayMs) !== day) break;
messages.push(deserialized.message);
pos += deserialized.end;
}
return messages;
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
return (await NativeLogs.getLogs(key, date.getTime() / dayMs))
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
}
getLogDates(key: string): ReadonlyArray<Date> {
const entry = this.index[key];
if(entry === undefined) return [];
const dates = [];
for(const date in entry.index)
dates.push(new Date(parseInt(date, 10) * dayMs));
return dates;
return entry.dates.map((x) => new Date(x * dayMs));
}
get conversations(): ReadonlyArray<{id: string, name: string}> {
@ -156,27 +78,27 @@ export class Logs implements Logging.Persistent {
}
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
const file = await NativeFile.readFile('!settings');
const file = await NativeFile.read('!settings');
if(file === undefined) return undefined;
return <GeneralSettings>JSON.parse(file);
}
export async function setGeneralSettings(value: GeneralSettings): Promise<void> {
return NativeFile.writeFile('!settings', JSON.stringify(value));
return NativeFile.write('!settings', JSON.stringify(value));
}
export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character: string = core.connection.character): Promise<Settings.Keys[K] | undefined> {
const file = await NativeFile.readFile(`${character}/${key}`);
const file = await NativeFile.read(`${character}/${key}`);
if(file === undefined) return undefined;
return <Settings.Keys[K]>JSON.parse(file);
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
return NativeFile.writeFile(`${core.connection.character}/${key}`, JSON.stringify(value));
return NativeFile.write(`${core.connection.character}/${key}`, JSON.stringify(value));
}
async getAvailableCharacters(): Promise<string[]> {
return <string[]>JSON.parse(await NativeFile.listDirectories('/'));
return NativeFile.listDirectories('/');
}
}

View File

@ -8,7 +8,8 @@
/* Begin PBXBuildFile section */
6C28207F1FF5839A00AB9E78 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6C2820811FF5839A00AB9E78 /* Localizable.strings */; };
6C5C1C591FF14432006A3BA1 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5C1C581FF14432006A3BA1 /* View.swift */; };
6C4C230D201E7DF1009B3460 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4C230C201E7DF1009B3460 /* Background.swift */; };
6C8ED6192024A820007685DA /* Logs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8ED6182024A820007685DA /* Logs.swift */; };
6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */; };
6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CA94BAD1FEFEE7800183A1A /* ViewController.swift */; };
6CA94BB11FEFEE7800183A1A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CA94BAF1FEFEE7800183A1A /* Main.storyboard */; };
@ -22,7 +23,8 @@
/* Begin PBXFileReference section */
6C2820801FF5839A00AB9E78 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6C5C1C581FF14432006A3BA1 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
6C4C230C201E7DF1009B3460 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = "<group>"; };
6C8ED6182024A820007685DA /* Logs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logs.swift; sourceTree = "<group>"; };
6CA94BA81FEFEE7800183A1A /* F-Chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "F-Chat.app"; sourceTree = BUILT_PRODUCTS_DIR; };
6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
6CA94BAD1FEFEE7800183A1A /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
@ -66,18 +68,19 @@
6CA94BAA1FEFEE7800183A1A /* F-Chat */ = {
isa = PBXGroup;
children = (
6C2820811FF5839A00AB9E78 /* Localizable.strings */,
6CA94BBD1FEFF2C200183A1A /* www */,
6CA94BBF1FEFFC2F00183A1A /* native.js */,
6CA94BB71FEFEE7800183A1A /* Info.plist */,
6CA94BAB1FEFEE7800183A1A /* AppDelegate.swift */,
6C4C230C201E7DF1009B3460 /* Background.swift */,
6CA94BC11FF009B000183A1A /* File.swift */,
6C8ED6182024A820007685DA /* Logs.swift */,
6CA94BC31FF070C800183A1A /* Notification.swift */,
6CA94BAD1FEFEE7800183A1A /* ViewController.swift */,
6CA94BAF1FEFEE7800183A1A /* Main.storyboard */,
6CA94BB21FEFEE7800183A1A /* Assets.xcassets */,
6CA94BB41FEFEE7800183A1A /* LaunchScreen.storyboard */,
6CA94BB71FEFEE7800183A1A /* Info.plist */,
6CA94BBF1FEFFC2F00183A1A /* native.js */,
6CA94BC11FF009B000183A1A /* File.swift */,
6CA94BC31FF070C800183A1A /* Notification.swift */,
6C5C1C581FF14432006A3BA1 /* View.swift */,
6C2820811FF5839A00AB9E78 /* Localizable.strings */,
6CA94BAF1FEFEE7800183A1A /* Main.storyboard */,
);
path = "F-Chat";
sourceTree = "<group>";
@ -164,9 +167,10 @@
files = (
6CA94BC41FF070C800183A1A /* Notification.swift in Sources */,
6CA94BAE1FEFEE7800183A1A /* ViewController.swift in Sources */,
6C5C1C591FF14432006A3BA1 /* View.swift in Sources */,
6CA94BC21FF009B000183A1A /* File.swift in Sources */,
6CA94BAC1FEFEE7800183A1A /* AppDelegate.swift in Sources */,
6C8ED6192024A820007685DA /* Logs.swift in Sources */,
6C4C230D201E7DF1009B3460 /* Background.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -101,7 +101,7 @@
{
"idiom" : "ios-marketing",
"size" : "1024x1024",
"filename" : "icon-1024.png",
"filename" : "icon-1024.jpg",
"scale" : "1x"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Some files were not shown because too many files have changed in this diff Show More