0.2.7 - Profile viewer and many many bug fixes

This commit is contained in:
MayaWolf 2017-12-05 02:47:27 +01:00
parent cf015bd4b7
commit ebf7cb43c5
81 changed files with 3926 additions and 337 deletions

View File

@ -34,6 +34,7 @@
import {getKey} from '../chat/common';
import {CoreBBCodeParser, urlRegex} from './core';
import {defaultButtons, EditorButton, EditorSelection} from './editor';
import {BBCodeParser} from './parser';
@Component
export default class Editor extends Vue {
@ -56,10 +57,14 @@
element: HTMLTextAreaElement;
maxHeight: number;
minHeight: number;
protected parser = new CoreBBCodeParser();
protected parser: BBCodeParser;
protected defaultButtons = defaultButtons;
private isShiftPressed = false;
created(): void {
this.parser = new CoreBBCodeParser();
}
mounted(): void {
this.element = <HTMLTextAreaElement>this.$refs['input'];
const $element = $(this.element);

View File

@ -44,7 +44,7 @@ export class CoreBBCodeParser extends BBCodeParser {
parent.appendChild(el);
return el;
}, (parser, element, _, param) => {
const content = element.innerText.trim();
const content = element.textContent!.trim();
while(element.firstChild !== null) element.removeChild(element.firstChild);
let url: string, display: string = content;
@ -54,23 +54,26 @@ export class CoreBBCodeParser extends BBCodeParser {
} else if(content.length > 0) url = content;
else {
parser.warning('url tag contains no url.');
element.innerText = ''; //Dafuq!?
element.textContent = ''; //Dafuq!?
return;
}
// This fixes problems where content based urls are marked as invalid if they contain spaces.
url = fixURL(url);
if(!urlRegex.test(url)) {
element.innerText = `[BAD URL] ${url}`;
element.textContent = `[BAD URL] ${url}`;
return;
}
const fa = parser.createElement('i');
fa.className = 'fa fa-link';
element.appendChild(fa);
const a = parser.createElement('a');
a.href = url;
a.rel = 'nofollow noreferrer noopener';
a.target = '_blank';
a.className = 'link-graphic';
a.className = 'user-link';
a.title = url;
a.innerText = display;
a.textContent = display;
element.appendChild(a);
const span = document.createElement('span');
span.className = 'link-domain';

View File

@ -48,7 +48,7 @@ export let defaultButtons: ReadonlyArray<EditorButton> = [
tag: 'color',
startText: '[color=]',
icon: 'fa-eyedropper',
key: 'q'
key: 'd'
},
{
title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',

1
bbcode/interfaces.ts Normal file
View File

@ -0,0 +1 @@
export const enum InlineDisplayMode {DISPLAY_ALL, DISPLAY_SFW, DISPLAY_NONE}

View File

@ -18,10 +18,10 @@ export abstract class BBCodeTag {
}
//tslint:disable-next-line:no-empty
afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement, ____?: string): void {
afterClose(_: BBCodeParser, __: HTMLElement, ___: HTMLElement | undefined, ____?: string): void {
}
abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement;
abstract createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined;
}
export class BBCodeSimpleTag extends BBCodeTag {
@ -33,8 +33,8 @@ export class BBCodeSimpleTag extends BBCodeTag {
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement {
if(param.length > 0)
parser.warning('Unexpected parameter');
const el = parser.createElement(this.elementName);
if(this.classes !== undefined)
const el = <HTMLElement>parser.createElement(this.elementName);
if(this.classes !== undefined && this.classes.length > 0)
el.className = this.classes.join(' ');
parent.appendChild(el);
/*tslint:disable-next-line:no-unsafe-any*/// false positive
@ -42,7 +42,7 @@ export class BBCodeSimpleTag extends BBCodeTag {
}
}
export type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement;
export type CustomElementCreator = (parser: BBCodeParser, parent: HTMLElement, param: string) => HTMLElement | undefined;
export type CustomCloser = (parser: BBCodeParser, current: HTMLElement, parent: HTMLElement, param: string) => void;
export class BBCodeCustomTag extends BBCodeTag {
@ -50,7 +50,7 @@ export class BBCodeCustomTag extends BBCodeTag {
super(tag, tagList);
}
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement {
createElement(parser: BBCodeParser, parent: HTMLElement, param: string): HTMLElement | undefined {
return this.customCreator(parser, parent, param);
}
@ -63,7 +63,7 @@ export class BBCodeCustomTag extends BBCodeTag {
enum BufferType { Raw, Tag }
class ParserTag {
constructor(public tag: string, public param: string, public element: HTMLElement, public parent: HTMLElement,
constructor(public tag: string, public param: string, public element: HTMLElement, public parent: HTMLElement | undefined,
public line: number, public column: number) {
}
@ -155,8 +155,7 @@ export class BBCodeParser {
let curType: BufferType = BufferType.Raw;
// Root tag collects output.
const root = this.createElement('span');
const rootTag = new ParserTag('<root>', '', root, root, 1, 1);
const rootTag = new ParserTag('<root>', '', this.createElement('span'), undefined, 1, 1);
stack.push(rootTag);
this._currentTag = rootTag;
let paramStart = -1;
@ -207,13 +206,18 @@ export class BBCodeParser {
if(!allowed)
break;
}
const tag = this._tags[tagKey]!;
if(!allowed) {
ignoreNextClosingTag(tagKey);
quickReset(i);
continue;
}
const parent = stackTop().element;
const el = this._tags[tagKey]!.createElement(this, parent, param);
const el: HTMLElement | undefined = tag.createElement(this, parent, param);
if(el === undefined) {
quickReset(i);
continue;
}
if(!this._tags[tagKey]!.noClosingTag)
stack.push(new ParserTag(tagKey, param, el, parent, this._line, this._column));
} else if(ignoreClosing[tagKey] > 0) {

215
bbcode/standard.ts Normal file
View File

@ -0,0 +1,215 @@
import * as $ from 'jquery';
import {CoreBBCodeParser} from './core';
import {InlineDisplayMode} from './interfaces';
import {BBCodeCustomTag, BBCodeSimpleTag} from './parser';
interface InlineImage {
id: number
hash: string
extension: string
nsfw: boolean
}
interface StandardParserSettings {
siteDomain: string
staticDomain: string
animatedIcons: boolean
inlineDisplayMode: InlineDisplayMode
}
const usernameRegex = /^[a-zA-Z0-9_\-\s]+$/;
export class StandardBBCodeParser extends CoreBBCodeParser {
allowInlines = true;
inlines: {[key: string]: InlineImage | undefined} | undefined;
constructor(public settings: StandardParserSettings) {
super();
const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
hrTag.noClosingTag = true;
this.addTag('hr', hrTag);
this.addTag('quote', new BBCodeCustomTag('quote', (parser, parent, param) => {
if(param !== '')
parser.warning('Unexpected paramter on quote tag.');
const element = parser.createElement('blockquote');
const innerElement = parser.createElement('div');
innerElement.className = 'quoteHeader';
innerElement.appendChild(document.createTextNode('Quote:'));
element.appendChild(innerElement);
parent.appendChild(element);
return element;
}));
this.addTag('left', new BBCodeSimpleTag('left', 'span', ['leftText']));
this.addTag('right', new BBCodeSimpleTag('right', 'span', ['rightText']));
this.addTag('center', new BBCodeSimpleTag('center', 'span', ['centerText']));
this.addTag('justify', new BBCodeSimpleTag('justify', 'span', ['justifyText']));
this.addTag('big', new BBCodeSimpleTag('big', 'span', ['bigText'], ['url', 'i', 'u', 'b', 'color', 's']));
this.addTag('small', new BBCodeSimpleTag('small', 'span', ['smallText'], ['url', 'i', 'u', 'b', 'color', 's']));
this.addTag('indent', new BBCodeSimpleTag('indent', 'div', ['indentText']));
this.addTag('heading', new BBCodeSimpleTag('heading', 'h2', [], ['url', 'i', 'u', 'b', 'color', 's']));
this.addTag('collapse', new BBCodeCustomTag('collapse', (parser, parent, param) => {
if(param === '') { //tslint:disable-line:curly
parser.warning('title parameter is required.');
// HACK: Compatability fix with old site. Titles are not trimmed on old site, so empty collapse titles need to be allowed.
//return null;
}
const outer = parser.createElement('div');
outer.className = 'collapseHeader';
const headerText = parser.createElement('div');
headerText.className = 'collapseHeaderText';
outer.appendChild(headerText);
const innerText = parser.createElement('span');
innerText.appendChild(document.createTextNode(param));
headerText.appendChild(innerText);
const body = parser.createElement('div');
body.className = 'collapseBlock';
outer.appendChild(body);
parent.appendChild(outer);
return body;
}));
this.addTag('user', new BBCodeCustomTag('user', (parser, parent, _) => {
const el = parser.createElement('span');
parent.appendChild(el);
return el;
}, (parser, element, parent, param) => {
if(param !== '')
parser.warning('Unexpected parameter on user tag.');
const content = element.innerText;
if(!usernameRegex.test(content))
return;
const a = parser.createElement('a');
a.href = `${this.settings.siteDomain}c/${content}`;
a.target = '_blank';
a.className = 'character-link';
a.appendChild(document.createTextNode(content));
parent.replaceChild(a, element);
}, []));
this.addTag('icon', new BBCodeCustomTag('icon', (parser, parent, _) => {
const el = parser.createElement('span');
parent.appendChild(el);
return el;
}, (parser, element, parent, param) => {
if(param !== '')
parser.warning('Unexpected parameter on icon tag.');
const content = element.innerText;
if(!usernameRegex.test(content))
return;
const a = parser.createElement('a');
a.href = `${this.settings.siteDomain}c/${content}`;
a.target = '_blank';
const img = parser.createElement('img');
img.src = `${this.settings.staticDomain}images/avatar/${content.toLowerCase()}.png`;
img.className = 'character-avatar icon';
a.appendChild(img);
parent.replaceChild(a, element);
}, []));
this.addTag('eicon', new BBCodeCustomTag('eicon', (parser, parent, _) => {
const el = parser.createElement('span');
parent.appendChild(el);
return el;
}, (parser, element, parent, param) => {
if(param !== '')
parser.warning('Unexpected parameter on eicon tag.');
const content = element.innerText;
if(!usernameRegex.test(content))
return;
let extension = '.gif';
if(!this.settings.animatedIcons)
extension = '.png';
const img = parser.createElement('img');
img.src = `${this.settings.staticDomain}images/eicon/${content.toLowerCase()}${extension}`;
img.className = 'character-avatar icon';
parent.replaceChild(img, element);
}, []));
this.addTag('img', new BBCodeCustomTag('img', (p, parent, param) => {
const parser = <StandardBBCodeParser>p;
if(!this.allowInlines) {
parser.warning('Inline images are not allowed here.');
return undefined;
}
if(typeof parser.inlines === 'undefined') {
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') {
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);
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="imageBlock" src="${this.settings.staticDomain}images/charinline/${showP1}/${showP2}/${showInline.hash}.${showInline.extension}"/></div>`);
});
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 = 'imageBlock';
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 !== 'imageBlock')
return;
while(element.firstChild !== null)
element.removeChild(element.firstChild);
}, []));
}
}
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 {
standardParser = new StandardBBCodeParser(settings);
}

View File

@ -1,5 +1,5 @@
<template>
<modal :buttons="false" :action="l('chat.channels')">
<modal :buttons="false" :action="l('chat.channels')" @close="closed">
<div style="display: flex; flex-direction: column;">
<ul class="nav nav-tabs">
<li role="presentation" :class="{active: !privateTabShown}">
@ -72,7 +72,7 @@
applyFilter(list: {[key: string]: Channel.ListItem | undefined}): ReadonlyArray<Channel.ListItem> {
const channels: Channel.ListItem[] = [];
if(this.filter.length > 0) {
const search = new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
const search = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
//tslint:disable-next-line:forin
for(const key in list) {
const item = list[key]!;
@ -89,6 +89,10 @@
this.hide();
}
closed(): void {
this.createName = '';
}
setJoined(channel: ListItem): void {
channel.isJoined ? core.channels.leave(channel.id) : core.channels.join(channel.id);
}

View File

@ -1,5 +1,5 @@
<template>
<modal :action="l('characterSearch.action')" @submit.prevent="submit" :disabled="!data.kinks.length"
<modal :action="l('characterSearch.action')" @submit.prevent="submit"
:buttonText="results ? l('characterSearch.again') : undefined" class="character-search">
<div v-if="options && !results">
<div v-show="error" class="alert alert-danger">{{error}}</div>
@ -10,7 +10,6 @@
<filterable-select v-for="item in ['genders', 'orientations', 'languages', 'furryprefs', 'roles', 'positions']" :multiple="true"
v-model="data[item]" :placeholder="l('filter')" :title="l('characterSearch.' + item)" :options="options[item]" :key="item">
</filterable-select>
<div v-show="!data.kinks.length" class="alert alert-warning">{{l('characterSearch.kinkNotice')}}</div>
</div>
<div v-else-if="results" class="results">
<h4>{{l('characterSearch.results')}}</h4>

View File

@ -74,6 +74,7 @@
core.connection.onError((e) => {
this.error = errorToString(e);
this.connecting = false;
this.connected = false;
});
}
@ -85,7 +86,7 @@
connect(): void {
this.connecting = true;
core.connection.connect(this.selectedCharacter).catch((e) => {
if(e.request !== undefined) this.error = l('login.connectError'); //catch axios network errors
if((<Error & {request?: object}>e).request !== undefined) this.error = l('login.connectError'); //catch axios network errors
else throw e;
});
}

View File

@ -1,5 +1,5 @@
<template>
<div style="height:100%; display: flex; position: relative;" @click="$refs['userMenu'].handleEvent($event)"
<div style="height:100%; display: flex; position: relative;" id="chatView" @click="$refs['userMenu'].handleEvent($event)"
@contextmenu="$refs['userMenu'].handleEvent($event)" @touchstart="$refs['userMenu'].handleEvent($event)"
@touchend="$refs['userMenu'].handleEvent($event)">
<div class="sidebar sidebar-left" id="sidebar">
@ -38,17 +38,18 @@
{{l('chat.pms')}}
<div class="list-group conversation-nav" ref="privateConversations">
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)"
:class="getClasses(conversation)" :data-character="conversation.character.name"
class="list-group-item list-group-item-action item-private" :key="conversation.key">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<div class="name">
<span>{{conversation.character.name}}</span>
<div style="text-align:right;line-height:0">
<span class="fa"
:class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}"
@click.stop.prevent="conversation.isPinned = !conversation.isPinned" @mousedown.stop.prevent
></span><span class="fa fa-times leave" @click.stop="conversation.close()"></span>
<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
@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>
</div>
</a>
@ -61,8 +62,9 @@
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel"
:key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumb-tack"
:class="{'active': conversation.isPinned}" @click.stop.prevent="conversation.isPinned = !conversation.isPinned"
@mousedown.stop.prevent></span><span class="fa fa-times leave" @click.stop="conversation.close()"></span></span>
: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>
</a>
</div>
</div>
@ -102,7 +104,7 @@
import Component from 'vue-class-component';
import ChannelList from './ChannelList.vue';
import CharacterSearch from './CharacterSearch.vue';
import {characterImage} from './common';
import {characterImage, getKey} from './common';
import ConversationView from './ConversationView.vue';
import core from './core';
import {Character, Connection, Conversation} from './interfaces';
@ -134,8 +136,12 @@
characterImage = characterImage;
conversations = core.conversations;
getStatusIcon = getStatusIcon;
keydownListener: (e: KeyboardEvent) => void;
mounted(): void {
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
document.addEventListener('keydown', this.keydownListener);
this.setFontSize(core.state.settings.fontSize);
Sortable.create(this.$refs['privateConversations'], {
animation: 50,
onEnd: (e: {oldIndex: number, newIndex: number}) => core.conversations.privateConversations[e.oldIndex].sort(e.newIndex)
@ -175,6 +181,67 @@
idleTimer = undefined;
}
});
core.watch<number>(function(): number {
return this.state.settings.fontSize;
}, (value) => {
this.setFontSize(value);
});
}
destroyed(): void {
document.removeEventListener('keydown', this.keydownListener);
}
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(selected === console) return;
if(Conversation.isPrivate(selected)) {
const index = pms.indexOf(selected);
if(index === 0) console.show();
else pms[index - 1].show();
} else {
const index = channels.indexOf(<Conversation.ChannelConversation>selected);
if(index === 0)
if(pms.length > 0) pms[pms.length - 1].show();
else console.show();
else channels[index - 1].show();
}
} else if(getKey(e) === '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();
} else if(Conversation.isPrivate(selected)) {
const index = pms.indexOf(selected);
if(index === pms.length - 1) {
if(channels.length > 0) channels[0].show();
} else pms[index + 1].show();
} else {
const index = channels.indexOf(<Conversation.ChannelConversation>selected);
if(index !== channels.length - 1) channels[index + 1].show();
}
}
setFontSize(fontSize: number): void {
let overrideEl = <HTMLStyleElement | null>document.getElementById('overrideFontSize');
if(overrideEl !== null)
document.body.removeChild(overrideEl);
overrideEl = document.createElement('style');
overrideEl.id = 'overrideFontSize';
document.body.appendChild(overrideEl);
const sheet = <CSSStyleSheet>overrideEl.sheet;
const selectorList = ['#chatView', '.btn', '.form-control'];
for(const selector of selectorList)
sheet.insertRule(`${selector} { font-size: ${fontSize}px; }`, sheet.cssRules.length);
const lineHeightBase = 1.428571429;
const lineHeight = Math.floor(fontSize * 1.428571429);
const formHeight = (lineHeight + (6 * 2) + 2);
sheet.insertRule(`.form-control { line-height: ${lineHeightBase}; height: ${formHeight}px; }`, sheet.cssRules.length);
sheet.insertRule(`select.form-control { line-height: ${lineHeightBase}; height: ${formHeight}px; }`, sheet.cssRules.length);
}
logOut(): void {

View File

@ -43,7 +43,7 @@
get filteredCommands(): ReadonlyArray<CommandItem> {
if(this.filter.length === 0) return this.commands;
const filter = new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
return this.commands.filter((x) => filter.test(x.name));
}
@ -52,7 +52,8 @@
//tslint:disable-next-line:forin
for(const key in commands) {
const command = commands[key]!;
if(command.documented !== undefined || command.permission !== undefined && (command.permission & permissions) === 0) continue;
if(command.documented !== undefined ||
command.permission !== undefined && command.permission > 0 && (command.permission & permissions) === 0) continue;
const params = [];
let syntax = `/${key} `;
if(command.params !== undefined)

View File

@ -81,7 +81,7 @@
</div>
<div style="position:relative; margin-top:5px;">
<div class="overlay-disable" v-show="adCountdown">{{adCountdown}}</div>
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" @keypress="onKeyPress" :extras="extraButtons"
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="onInput"
classes="form-control chat-text-box" :disabled="adCountdown" ref="textBox" style="position:relative;"
:maxlength="conversation.maxMessageLength">
<div style="float:right;text-align:right;display:flex;align-items:center">
@ -197,15 +197,13 @@
if(oldValue === 'clear') this.keepScroll();
}
onKeyPress(e: KeyboardEvent): void {
onInput(): void {
const messageView = <HTMLElement>this.$refs['messages'];
const oldHeight = messageView.offsetHeight;
setTimeout(() => messageView.scrollTop += oldHeight - messageView.offsetHeight);
if(getKey(e) === 'Enter') {
if(e.shiftKey) return;
e.preventDefault();
this.conversation.send();
}
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
setTimeout(() => {
if(oldHeight > messageView.offsetHeight) messageView.scrollTop += oldHeight - messageView.offsetHeight;
});
}
onKeyDown(e: KeyboardEvent): void {
@ -222,7 +220,7 @@
selection.text = editor.text.substring(selection.start, selection.end);
if(selection.text.length === 0) return;
}
const search = new RegExp(`^${selection.text.replace(/[^\w]/, '\\$&')}`, 'i');
const search = new RegExp(`^${selection.text.replace(/[^\w]/gi, '\\$&')}`, 'i');
const c = (<Conversation.PrivateConversation>this.conversation);
let options: ReadonlyArray<{character: Character}>;
options = Conversation.isChannel(this.conversation) ? this.conversation.channel.sortedMembers :
@ -246,6 +244,11 @@
if(this.tabOptions !== undefined) this.tabOptions = undefined;
if(getKey(e) === 'ArrowUp' && this.conversation.enteredText.length === 0)
this.conversation.loadLastSent();
else if(getKey(e) === 'Enter') {
if(e.shiftKey) return;
e.preventDefault();
this.conversation.send();
}
}
}

View File

@ -70,7 +70,7 @@
get filteredMessages(): ReadonlyArray<Conversation.Message> {
if(this.filter.length === 0) return this.messages;
const filter = new RegExp(this.filter, 'i');
const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
return this.messages.filter(
(x) => filter.test(x.text) || x.type !== Conversation.Message.Type.Event && filter.test(x.sender.name));
}

View File

@ -1,6 +1,6 @@
<template>
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings">
<ul class="nav nav-tabs">
<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>
@ -50,6 +50,10 @@
{{l('settings.logAds')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="fontSize">{{l('settings.fontSize')}}</label>
<input id="fontSize" type="number" min="10" max="24" number class="form-control" v-model="fontSize"/>
</div>
</div>
<div v-show="selectedTab == 'notifications'">
<div class="form-group">
@ -135,6 +139,7 @@
alwaysNotify: boolean;
logMessages: boolean;
logAds: boolean;
fontSize: number;
constructor() {
super();
@ -163,6 +168,7 @@
this.alwaysNotify = settings.alwaysNotify;
this.logMessages = settings.logMessages;
this.logAds = settings.logAds;
this.fontSize = settings.fontSize;
};
async doImport(): Promise<void> {
@ -199,7 +205,8 @@
joinMessages: this.joinMessages,
alwaysNotify: this.alwaysNotify,
logMessages: this.logMessages,
logAds: this.logAds
logAds: this.logAds,
fontSize: this.fontSize
};
if(this.notifications) await requestNotificationsPermission();
}

View File

@ -25,7 +25,7 @@
</div>
<div v-if="channel" v-show="memberTabShown" class="users" style="padding: 5px;">
<div v-for="member in channel.sortedMembers" :key="member.character.name">
<user :character="member.character" :channel="channel"></user>
<user :character="member.character" :channel="channel" :showStatus="true"></user>
</div>
</div>
</div>

View File

@ -146,19 +146,21 @@
handleEvent(e: MouseEvent | TouchEvent): void {
const touch = e instanceof TouchEvent ? e.changedTouches[0] : e;
let node = <Node & {character?: Character, channel?: Channel}>touch.target;
let node = <HTMLElement & {character?: Character, channel?: Channel}>touch.target;
while(node !== document.body) {
if(e.type === 'touchstart' && node === this.$refs['menu']) return;
if(node.character !== undefined || node.parentNode === null) break;
node = node.parentNode;
}
if(node.character === undefined) {
this.showContextMenu = false;
return;
if(e.type !== 'click' && node === this.$refs['menu']) return;
if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break;
node = node.parentElement!;
}
if(node.character === undefined)
if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!);
else {
this.showContextMenu = false;
return;
}
switch(e.type) {
case 'click':
this.onClick(node.character);
if(node.dataset['character'] === undefined) this.onClick(node.character);
break;
case 'touchstart':
this.touchTimer = window.setTimeout(() => {
@ -170,7 +172,7 @@
if(this.touchTimer !== undefined) {
clearTimeout(this.touchTimer);
this.touchTimer = undefined;
this.onClick(node.character);
if(node.dataset['character'] === undefined) this.onClick(node.character);
}
break;
case 'contextmenu':

View File

@ -72,7 +72,7 @@ export default class BBCodeParser extends CoreBBCodeParser {
const img = parser.createElement('img');
img.src = characterImage(content);
img.style.cursor = 'pointer';
img.className = 'characterAvatarIcon';
img.className = 'character-avatar icon';
img.title = img.alt = content;
(<HTMLImageElement & {character: Character}>img).character = core.characters.get(content);
parent.replaceChild(img, element);
@ -92,7 +92,7 @@ export default class BBCodeParser extends CoreBBCodeParser {
const img = parser.createElement('img');
img.src = `https://static.f-list.net/images/eicon/${content.toLowerCase()}.${extension}`;
img.title = img.alt = content;
img.className = 'characterAvatarIcon';
img.className = 'character-avatar icon';
parent.replaceChild(img, element);
}, []));
this.addTag('session', new BBCodeCustomTag('session', (parser, parent) => {

View File

@ -39,6 +39,7 @@ export class Settings implements ISettings {
alwaysNotify = false;
logMessages = true;
logAds = false;
fontSize = 14;
}
export class ConversationSettings implements Conversation.Settings {
@ -64,7 +65,7 @@ export function messageToString(this: void | never, msg: Conversation.Message, t
export function getKey(e: KeyboardEvent): string {
/*tslint:disable-next-line:strict-boolean-expressions no-any*///because of old browsers.
return e.key || (<any>e).keyIdentifier;
return e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier;
}
/*tslint:disable:no-any no-unsafe-any*///because errors can be any

View File

@ -148,7 +148,7 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
this.safeAddMessage(message);
if(message.type !== Interfaces.Message.Type.Event) {
if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
if(this.settings.notify !== Interfaces.Setting.False)
if(this.settings.notify !== Interfaces.Setting.False && message.sender !== core.characters.ownCharacter)
core.notifications.notify(this, message.sender.name, message.text, characterImage(message.sender.name), 'attention');
if(this !== state.selectedConversation)
this.unread = Interfaces.UnreadState.Mention;
@ -214,7 +214,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
core.watch<Channel.Mode | undefined>(function(): Channel.Mode | undefined {
const c = this.channels.getChannel(channel.id);
return c !== undefined ? c.mode : undefined;
}, (value) => {
}, (value: Channel.Mode | undefined) => {
if(value === undefined) return;
this.mode = value;
if(value !== 'both') this.isSendingAds = value === 'ads';
@ -256,9 +256,11 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
}
addMessage(message: Interfaces.Message): void {
if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null
&& (this.channel.members[message.sender.name]!.rank > Channel.Rank.Member || message.sender.isChatOp))
message = new Message(MessageType.Warn, message.sender, message.text.substr(6), message.time);
if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null) {
const member = this.channel.members[message.sender.name];
if(member !== undefined && member.rank > Channel.Rank.Member || message.sender.isChatOp)
message = new Message(MessageType.Warn, message.sender, message.text.substr(6), message.time);
}
if(message.type === MessageType.Ad) {
this.addModeMessage('ads', message);
@ -365,19 +367,18 @@ class State implements Interfaces.State {
}
addRecent(conversation: Conversation): void {
/*tslint:disable-next-line:no-any*///TS isn't smart enough for this
const remove = (predicate: (item: any) => boolean) => {
const remove = <T extends Interfaces.RecentConversation>(predicate: (item: T) => boolean) => {
for(let i = 0; i < this.recent.length; ++i)
if(predicate(this.recent[i])) {
if(predicate(<T>this.recent[i])) {
this.recent.splice(i, 1);
break;
}
};
if(Interfaces.isChannel(conversation)) {
remove((c) => c.channel === conversation.channel.id);
remove<Interfaces.RecentChannelConversation>((c) => c.channel === conversation.channel.id);
this.recent.unshift({channel: conversation.channel.id, name: conversation.channel.name});
} else {
remove((c) => c.character === conversation.name);
remove<Interfaces.RecentPrivateConversation>((c) => c.character === conversation.name);
state.recent.unshift({character: conversation.name});
}
if(this.recent.length >= 50) this.recent.pop();
@ -430,7 +431,11 @@ export default function(this: void): Interfaces.State {
connection.onEvent('connecting', async(isReconnect) => {
state.channelConversations = [];
state.channelMap = {};
if(!isReconnect) state.consoleTab = new ConsoleConversation();
if(!isReconnect) {
state.consoleTab = new ConsoleConversation();
state.privateConversations = [];
state.privateMap = {};
} else state.consoleTab.unread = Interfaces.UnreadState.None;
state.selectedConversation = state.consoleTab;
await state.reloadSettings();
});
@ -440,11 +445,10 @@ export default function(this: void): Interfaces.State {
queuedJoin(state.pinned.channels.slice());
});
core.channels.onEvent((type, channel, member) => {
const key = channel.id.toLowerCase();
if(type === 'join')
if(member === undefined) {
const conv = new ChannelConversation(channel);
state.channelMap[key] = conv;
state.channelMap[channel.id] = conv;
state.channelConversations.push(conv);
state.addRecent(conv);
} else {
@ -455,9 +459,9 @@ export default function(this: void): Interfaces.State {
conv.addMessage(new EventMessage(text));
}
else if(member === undefined) {
const conv = state.channelMap[key]!;
const conv = state.channelMap[channel.id]!;
state.channelConversations.splice(state.channelConversations.indexOf(conv), 1);
delete state.channelMap[key];
delete state.channelMap[channel.id];
state.savePinned();
if(state.selectedConversation === conv) state.show(state.consoleTab);
} else {
@ -479,7 +483,8 @@ export default function(this: void): Interfaces.State {
connection.onMessage('MSG', (data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored) return;
const conversation = state.channelMap[data.channel.toLowerCase()]!;
const conversation = state.channelMap[data.channel.toLowerCase()];
if(conversation === undefined) return core.channels.leave(data.channel);
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
conversation.addMessage(message);
@ -501,7 +506,8 @@ export default function(this: void): Interfaces.State {
connection.onMessage('LRP', (data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored) return;
const conv = state.channelMap[data.channel.toLowerCase()]!;
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
});
connection.onMessage('RLL', (data, time) => {
@ -516,7 +522,9 @@ export default function(this: void): Interfaces.State {
}
const message = new Message(MessageType.Roll, sender, text, time);
if('channel' in data) {
const conversation = state.channelMap[(<{channel: string}>data).channel.toLowerCase()]!;
const channel = (<{channel: string}>data).channel.toLowerCase();
const conversation = state.channelMap[channel];
if(conversation === undefined) return core.channels.leave(channel);
conversation.addMessage(message);
if(data.type === 'bottle' && data.target === core.connection.character)
core.notifications.notify(conversation, conversation.name, messageToString(message),
@ -549,17 +557,23 @@ export default function(this: void): Interfaces.State {
});
connection.onMessage('CBU', (data, time) => {
const text = l('events.ban', data.channel, data.character, data.operator);
state.channelMap[data.channel.toLowerCase()]!.infoText = text;
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.infoText = text;
addEventMessage(new EventMessage(text, time));
});
connection.onMessage('CKU', (data, time) => {
const text = l('events.kick', data.channel, data.character, data.operator);
state.channelMap[data.channel.toLowerCase()]!.infoText = text;
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.infoText = text;
addEventMessage(new EventMessage(text, time));
});
connection.onMessage('CTU', (data, time) => {
const text = l('events.timeout', data.channel, data.character, data.operator, data.length.toString());
state.channelMap[data.channel.toLowerCase()]!.infoText = text;
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.infoText = text;
addEventMessage(new EventMessage(text, time));
});
connection.onMessage('HLO', (data, time) => addEventMessage(new EventMessage(data.message, time)));

View File

@ -41,7 +41,9 @@ export namespace Conversation {
}
}
export type RecentConversation = {readonly channel: string, readonly name: string} | {readonly character: string};
export type RecentChannelConversation = {readonly channel: string, readonly name: string};
export type RecentPrivateConversation = {readonly character: string};
export type RecentConversation = RecentChannelConversation | RecentPrivateConversation;
export type TypingStatus = 'typing' | 'paused' | 'clear';
@ -164,6 +166,7 @@ export namespace Settings {
readonly alwaysNotify: boolean;
readonly logMessages: boolean;
readonly logAds: boolean;
readonly fontSize: number;
}
}

View File

@ -14,7 +14,9 @@ const strings: {[key: string]: string | undefined} = {
'action.cancel': 'Cancel',
'consoleWarning.head': 'THIS IS THE DANGER ZONE.',
'consoleWarning.body': `ANYTHING YOU WRITE OR PASTE IN HERE COULD BE USED TO STEAL YOUR PASSWORDS OR TAKE OVER YOUR ENTIRE COMPUTER. This is where happiness goes to die. If you aren't a developer or a special kind of daredevil, please get out of here!`,
'help': 'Help',
'help.fchat': 'FChat 3.0 Help and Changelog',
'help.feedback': 'Report a Bug / Suggest Something',
'help.rules': 'F-List Rules',
'help.faq': 'F-List FAQ',
'help.report': 'How to report a user',
@ -47,6 +49,8 @@ const strings: {[key: string]: string | undefined} = {
'chat.channels': 'Channels',
'chat.pms': 'PMs',
'chat.consoleTab': 'Console',
'chat.pinTab': 'Pin this tab',
'chat.closeTab': 'Close this tab',
'chat.confirmLeave': 'You are still connected to chat. Would you like to disconnect?',
'chat.highlight': 'mentioned {0} in {1}:\n{2}',
'chat.roll': 'rolls {0}: {1}',
@ -122,17 +126,19 @@ Are you sure?`,
'settings.animatedEicons': 'Animate [eicon]s',
'settings.idleTimer': 'Idle timer (minutes, clear to disable)',
'settings.messageSeparators': 'Display separators between messages',
'settings.eventMessages': 'Also display console messages in current tab',
'settings.eventMessages': 'Also display console messages (like login/logout, status changes) in current tab',
'settings.joinMessages': 'Display join/leave messages in channels',
'settings.alwaysNotify': 'Always notify for PMs/highlights, even when looking at the tab',
'settings.closeToTray': 'Close to tray',
'settings.spellcheck': 'Spellcheck',
'settings.spellcheck.disabled': 'Disabled',
'settings.theme': 'Theme',
'settings.profileViewer': 'Use profile viewer',
'settings.logMessages': 'Log messages',
'settings.logAds': 'Log ads',
'settings.fontSize': 'Font size (experimental)',
'settings.defaultHighlights': 'Use global highlight words',
'conversationSettings.title': 'Settings',
'conversationSettings.title': 'Tab Settings',
'conversationSettings.action': 'Edit settings for {0}',
'conversationSettings.default': 'Default',
'conversationSettings.true': 'Yes',
@ -157,7 +163,6 @@ Are you sure?`,
'characterSearch.again': 'Start another search',
'characterSearch.results': 'Results',
'characterSearch.kinks': 'Kinks',
'characterSearch.kinkNotice': 'Must select at least one kink.',
'characterSearch.genders': 'Genders',
'characterSearch.orientations': 'Orientations',
'characterSearch.languages': 'Languages',
@ -350,7 +355,8 @@ Are you sure?`,
'importer.importGeneral': 'slimCat data has been detected on your computer.\nWould you like to import general settings?',
'importer.importCharacter': 'slimCat data for this character has been detected on your computer.\nWould you like to import settings and logs?\nThis may take a while.\nAny existing FChat 3.0 data for this character will be overwritten.',
'importer.importing': 'Importing data',
'importer.importingNote': 'Importing logs. This may take a couple of minutes. Please do not close the application, even if it appears to hang for a while, as you may end up with incomplete logs.'
'importer.importingNote': 'Importing logs. This may take a couple of minutes. Please do not close the application, even if it appears to hang for a while, as you may end up with incomplete logs.',
'importer.error': 'There was an error importing your settings. The defaults will be used.'
};
export default function l(key: string, ...args: string[]): string {

172
chat/profile_api.ts Normal file
View File

@ -0,0 +1,172 @@
import Axios from 'axios';
import Vue from 'vue';
import {InlineDisplayMode} from '../bbcode/interfaces';
import {initParser, standardParser} from '../bbcode/standard';
import {registerMethod, Store} from '../site/character_page/data_store';
import {
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterSettings,
GuestbookState, KinkChoiceFull, SharedKinks
} from '../site/character_page/interfaces';
import '../site/directives/vue-select'; //tslint:disable-line:no-import-side-effect
import * as Utils from '../site/utils';
import core from './core';
async function characterData(name: string | undefined): Promise<Character> {
const data = await core.connection.queryApi('character-data.php', {name}) as CharacterInfo & {
badges: string[]
customs_first: boolean
custom_kinks: {[key: number]: {choice: 'fave' | 'yes' | 'maybe' | 'no', name: string, description: string, children: number[]}}
custom_title: string
infotags: {[key: string]: string}
settings: CharacterSettings
};
const newKinks: {[key: string]: KinkChoiceFull} = {};
for(const key in data.kinks)
newKinks[key] = <KinkChoiceFull>(data.kinks[key] === 'fave' ? 'favorite' : data.kinks[key]);
const newCustoms: CharacterCustom[] = [];
for(const key in data.custom_kinks) {
const custom = data.custom_kinks[key];
newCustoms.push({
id: parseInt(key, 10),
choice: custom.choice === 'fave' ? 'favorite' : custom.choice,
name: custom.name,
description: custom.description
});
for(const childId of custom.children)
if(data.kinks[childId] !== undefined)
newKinks[childId] = parseInt(key, 10);
}
const newInfotags: {[key: string]: CharacterInfotag} = {};
for(const key in data.infotags) {
const characterInfotag = data.infotags[key];
const infotag = Store.kinks.infotags[key];
if(infotag === undefined) continue;
newInfotags[key] = infotag.type === 'list' ? {list: parseInt(characterInfotag, 10)} : {string: characterInfotag};
}
return {
is_self: false,
character: {
id: data.id,
name: data.name,
title: data.custom_title,
description: data.description,
created_at: data.created_at,
updated_at: data.updated_at,
views: data.views,
image_count: data.images!.length,
inlines: data.inlines,
kinks: newKinks,
customs: newCustoms,
infotags: newInfotags,
online_chat: false
},
badges: data.badges,
settings: data.settings,
bookmarked: false,
self_staff: false
};
}
function contactMethodIconUrl(name: string): string {
return `${Utils.staticDomain}images/social/${name}.png`;
}
async function fieldsGet(): Promise<void> {
if(Store.kinks !== undefined) return; //tslint:disable-line:strict-type-predicates
try {
const fields = (await(Axios.get(`${Utils.siteDomain}json/api/mapping-list.php`))).data as SharedKinks & {
kinks: {[key: string]: {group_id: number}}
infotags: {[key: string]: {list: string, group_id: string}}
};
const kinks: SharedKinks = {kinks: {}, kink_groups: {}, infotags: {}, infotag_groups: {}, listitems: {}};
for(const id in fields.kinks) {
const oldKink = fields.kinks[id];
kinks.kinks[oldKink.id] = {
id: oldKink.id,
name: oldKink.name,
description: oldKink.description,
kink_group: oldKink.group_id
};
}
for(const id in fields.kink_groups) {
const oldGroup = fields.kink_groups[id]!;
kinks.kink_groups[oldGroup.id] = {
id: oldGroup.id,
name: oldGroup.name,
description: '',
sort_order: oldGroup.id
};
}
for(const id in fields.infotags) {
const oldInfotag = fields.infotags[id];
kinks.infotags[oldInfotag.id] = {
id: oldInfotag.id,
name: oldInfotag.name,
type: oldInfotag.type,
validator: oldInfotag.list,
search_field: '',
allow_legacy: true,
infotag_group: parseInt(oldInfotag.group_id, 10)
};
}
for(const id in fields.listitems) {
const oldListItem = fields.listitems[id]!;
kinks.listitems[oldListItem.id] = {
id: oldListItem.id,
name: oldListItem.name,
value: oldListItem.value,
sort_order: oldListItem.id
};
}
for(const id in fields.infotag_groups) {
const oldGroup = fields.infotag_groups[id]!;
kinks.infotag_groups[oldGroup.id] = {
id: oldGroup.id,
name: oldGroup.name,
description: oldGroup.description,
sort_order: oldGroup.id
};
}
Store.kinks = kinks;
} catch(e) {
Utils.ajaxError(e, 'Error loading character fields');
throw e;
}
}
async function friendsGet(id: number): Promise<CharacterFriend[]> {
return (await core.connection.queryApi<{friends: CharacterFriend[]}>('character-friends.php', {id})).friends;
}
async function imagesGet(id: number): Promise<CharacterImage[]> {
return (await core.connection.queryApi<{images: CharacterImage[]}>('character-images.php', {id})).images;
}
async function guestbookGet(id: number, page: number): Promise<GuestbookState> {
return core.connection.queryApi<GuestbookState>('character-guestbook.php', {id, page: page - 1});
}
export function init(): void {
Utils.setDomains('https://www.f-list.net/', 'https://static.f-list.net/');
initParser({
siteDomain: Utils.siteDomain,
staticDomain: Utils.staticDomain,
animatedIcons: false,
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
});
Vue.directive('bbcode', (el, binding) => {
while(el.firstChild !== null)
el.removeChild(el.firstChild);
el.appendChild(standardParser.parseEverything(<string>binding.value));
});
registerMethod('characterData', characterData);
registerMethod('contactMethodIconUrl', contactMethodIconUrl);
registerMethod('fieldsGet', fieldsGet);
registerMethod('friendsGet', friendsGet);
registerMethod('imagesGet', imagesGet);
registerMethod('guestbookPageGet', guestbookGet);
registerMethod('imageUrl', (image: CharacterImageOld) => image.url);
registerMethod('imageThumbUrl', (image: CharacterImage) => `${Utils.staticDomain}images/charthumb/${image.id}.${image.extension}`);
}

View File

@ -21,7 +21,7 @@ export function getStatusIcon(status: Character.Status): string {
case 'busy':
return 'fa-cog';
case 'idle':
return 'fa-hourglass';
return 'fa-clock-o';
case 'crown':
return 'fa-birthday-cake';
}
@ -40,7 +40,7 @@ const UserView = Vue.extend({
const member = props.channel.members[character.name];
if(member !== undefined)
rankIcon = member.rank === Channel.Rank.Owner ? 'fa-asterisk' :
member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-play') : '';
member.rank === Channel.Rank.Op ? (props.channel.id.substr(0, 4) === 'adh-' ? 'fa-at' : 'fa-star') : '';
else rankIcon = '';
} else rankIcon = '';

View File

@ -3,6 +3,7 @@ import Vue from 'vue';
/*tslint:disable:no-unsafe-any no-any*///hack
function formatComponentName(vm: any): string {
if(vm === undefined) return 'undefined';
if(vm.$root === vm) return '<root instance>';
const name = vm._isVue
? vm.$options.name || vm.$options._componentTag

View File

@ -85,7 +85,7 @@
}
get filterRegex(): RegExp {
return new RegExp(this.filter.replace(/[^\w]/, '\\$&'), 'i');
return new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
}
}
</script>

View File

@ -7,7 +7,7 @@
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
<h4 class="modal-title">{{action}}</h4>
</div>
<div class="modal-body form-horizontal" style="overflow: auto; display: flex; flex-direction: column">
<div class="modal-body" style="overflow: auto; display: flex; flex-direction: column">
<slot></slot>
</div>
<div class="modal-footer" v-if="buttons">

View File

@ -0,0 +1,35 @@
<template>
<span :class="linkClasses" v-if="character">
<slot v-if="deleted">[Deleted] {{ name }}</slot>
<a :href="characterUrl" class="characterLinkLink" v-else><slot>{{ name }}</slot></a>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../site/utils';
@Component
export default class CharacterLink extends Vue {
@Prop({required: true})
readonly character: {name: string, id: number, deleted: boolean} | string;
get deleted(): boolean {
return typeof(this.character) === 'string' ? false : this.character.deleted;
}
get linkClasses(): string {
return this.deleted ? 'characterLinkDeleted' : 'characterLink';
}
get characterUrl(): string {
return Utils.characterURL(this.name);
}
get name(): string {
return typeof(this.character) === 'string' ? this.character : this.character.name;
}
}
</script>

View File

@ -0,0 +1,35 @@
<template>
<span class="localizable-date" :title="secondary">{{primary}}</span>
</template>
<script lang="ts">
import {distanceInWordsToNow, format} from 'date-fns';
import Vue, {ComponentOptions} from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Settings} from '../site/utils';
@Component
export default class DateDisplay extends Vue {
@Prop({required: true})
readonly time: string | null | number;
primary: string;
secondary: string;
constructor(options?: ComponentOptions<Vue>) {
super(options);
if(this.time === null || this.time === 0)
return;
const date = isNaN(+this.time) ? new Date(`${this.time}+00:00`) : new Date(+this.time * 1000);
const absolute = format(date, 'YYYY-MM-DD HH:mm');
const relative = distanceInWordsToNow(date, {addSuffix: true});
if(Settings.fuzzyDates) {
this.primary = relative;
this.secondary = absolute;
} else {
this.primary = absolute;
this.secondary = relative;
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<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>

View File

@ -41,6 +41,9 @@
</div>
</div>
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
<modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="false" :hideGroups="true" :name="profileName"></character-page>
</modal>
</div>
</template>
@ -53,9 +56,11 @@
import Chat from '../chat/Chat.vue';
import core, {init as initCore} from '../chat/core';
import l from '../chat/localize';
import {init as profileApiInit} from '../chat/profile_api';
import Socket from '../chat/WebSocket';
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 Notifications from './notifications';
@ -63,8 +68,10 @@
if(confirm(l('chat.confirmLeave'))) (<Navigator & {app: {exitApp(): void}}>navigator).app.exitApp();
}
profileApiInit();
@Component({
components: {chat: Chat, modal: Modal}
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
})
export default class Index extends Vue {
//tslint:disable:no-null-keyword
@ -78,8 +85,19 @@
l = l;
settings: GeneralSettings | null = null;
importProgress = 0;
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);
};
let settings = await getGeneralSettings();
if(settings === undefined) settings = new GeneralSettings();
if(settings.account.length > 0) this.saveLogin = true;

View File

@ -4,7 +4,6 @@
"lib": [
"dom",
"es5",
"scripthost",
"es2015.iterable",
"es2015.promise"
],
@ -18,7 +17,6 @@
"importHelpers": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},

View File

@ -25,7 +25,7 @@
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group">
<div class="form-group" style="margin:0">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
@ -40,6 +40,9 @@
<div class="progress-bar" :style="{width: importProgress * 100 + '%'}"></div>
</div>
</modal>
<modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="false" :hideGroups="true" :name="profileName"></character-page>
</modal>
</div>
</template>
@ -57,9 +60,11 @@
import {Settings} from '../chat/common';
import core, {init as initCore} from '../chat/core';
import l from '../chat/localize';
import {init as profileApiInit} from '../chat/profile_api';
import Socket from '../chat/WebSocket';
import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection';
import CharacterPage from '../site/character_page/character_page.vue';
import {nativeRequire} from './common';
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import * as SlimcatImporter from './importer';
@ -68,8 +73,8 @@
import * as spellchecker from './spellchecker';
const webContents = electron.remote.getCurrentWebContents();
webContents.on('context-menu', (_, props: Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}) => {
const menuTemplate = createContextMenu(props);
webContents.on('context-menu', (_, props) => {
const menuTemplate = createContextMenu(<Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}>props);
if(props.misspelledWord !== '') {
const corrections = spellchecker.getCorrections(props.misspelledWord);
if(corrections.length > 0) {
@ -99,7 +104,7 @@
let trayMenu = electron.remote.Menu.buildFromTemplate(defaultTrayMenu);
let isClosing = false;
let mainWindow: Electron.BrowserWindow | undefined = electron.remote.getCurrentWindow(); //TODO
let mainWindow: Electron.BrowserWindow | undefined = electron.remote.getCurrentWindow();
//tslint:disable-next-line:no-require-imports
const tray = new electron.remote.Tray(path.join(__dirname, <string>require('./build/tray.png')));
tray.setToolTip(l('title'));
@ -116,8 +121,10 @@
for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
//tslint:enable
profileApiInit();
@Component({
components: {chat: Chat, modal: Modal}
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
})
export default class Index extends Vue {
//tslint:disable:no-null-keyword
@ -135,13 +142,18 @@
currentSettings: GeneralSettings;
isConnected = false;
importProgress = 0;
profileName = '';
constructor(options?: ComponentOptions<Index>) {
super(options);
let settings = getGeneralSettings();
if(settings === undefined) {
if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral')))
settings = SlimcatImporter.importGeneral();
try {
if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral')))
settings = SlimcatImporter.importGeneral();
} catch {
alert(l('importer.error'));
}
settings = settings !== undefined ? settings : new GeneralSettings();
}
this.account = settings.account;
@ -187,6 +199,12 @@
this.currentSettings.closeToTray = item.checked;
setGeneralSettings(this.currentSettings);
}
}, {
label: l('settings.profileViewer'), type: 'checkbox', checked: this.currentSettings.profileViewer,
click: (item: Electron.MenuItem) => {
this.currentSettings.profileViewer = item.checked;
setGeneralSettings(this.currentSettings);
}
},
{label: l('settings.spellcheck'), submenu: spellcheckerMenu},
{
@ -200,7 +218,8 @@
},
{type: 'separator'},
{role: 'minimize'},
process.platform === 'darwin' ? {role: 'quit'} : {
{
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : undefined,
label: l('action.quit'),
click(): void {
isClosing = true;
@ -232,6 +251,13 @@
}));
electron.remote.Menu.setApplicationMenu(menu);
});
electron.ipcRenderer.on('open-profile', (_: Event, name: string) => {
if(this.currentSettings.profileViewer) {
const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = name;
profileViewer.show();
} else electron.remote.shell.openExternal(`https://www.f-list.net/c/${name}`);
});
}
async addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
@ -293,6 +319,7 @@
Raven.setUserContext({username: core.connection.character});
trayMenu.insert(0, new electron.remote.MenuItem({label: core.connection.character, enabled: false}));
trayMenu.insert(1, new electron.remote.MenuItem({type: 'separator'}));
tray.setContextMenu(trayMenu);
});
connection.onEvent('closed', () => {
this.isConnected = false;
@ -332,7 +359,7 @@
try {
return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.currentSettings.theme}.css`))}</style>`;
} catch(e) {
if(e.code === 'ENOENT' && this.currentSettings.theme !== 'default') {
if((<Error & {code: string}>e).code === 'ENOENT' && this.currentSettings.theme !== 'default') {
this.currentSettings.theme = 'default';
return this.styling;
}

View File

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

View File

@ -29,8 +29,11 @@
* @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/modal.js';
import 'bootstrap/js/tab.js';
import 'bootstrap/js/transition.js';
import * as electron from 'electron';
import * as Raven from 'raven-js';
import Vue from 'vue';

View File

@ -1,7 +1,7 @@
import {addMinutes} from 'date-fns';
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import {promisify} from 'util';
import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
@ -10,18 +10,13 @@ import {mkdir} from './common';
const dayMs = 86400000;
const baseDir = path.join(electron.remote.app.getPath('userData'), 'data');
mkdir(baseDir);
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const readdir = promisify(fs.readdir);
const open = promisify(fs.open);
const fstat = promisify(fs.fstat);
const read = promisify(fs.read);
const noAssert = process.env.NODE_ENV === 'production';
export class GeneralSettings {
account = '';
closeToTray = true;
profileViewer = true;
host = 'wss://chat.f-list.net:9799';
spellcheckLang: string | undefined = 'en-GB';
theme = 'default';
@ -127,7 +122,7 @@ export class Logs implements Logging.Persistent {
for(; offset < content.length; offset += 7) {
const key = content.readUInt16LE(offset);
item.index[key] = item.offsets.length;
item.offsets.push(content.readUIntLE(offset + 2, 5));
item.offsets.push(content.readUIntLE(offset + 2, 5, noAssert));
}
this.index[file.slice(0, -4).toLowerCase()] = item;
}
@ -139,14 +134,14 @@ export class Logs implements Logging.Persistent {
if(!fs.existsSync(file)) return [];
let count = 20;
let messages = new Array<Conversation.Message>(count);
const fd = await open(file, 'r');
let pos = (await fstat(fd)).size;
const fd = fs.openSync(file, 'r');
let pos = fs.fstatSync(fd).size;
const buffer = Buffer.allocUnsafe(65536);
while(pos > 0 && count > 0) {
await read(fd, buffer, 0, 2, pos - 2);
fs.readSync(fd, buffer, 0, 2, pos - 2);
const length = buffer.readUInt16LE(0);
pos = pos - length - 2;
await read(fd, buffer, 0, length, pos);
fs.readSync(fd, buffer, 0, length, pos);
messages[--count] = deserializeMessage(buffer).message;
}
if(count !== 0) messages = messages.slice(count);
@ -156,9 +151,11 @@ export class Logs implements Logging.Persistent {
getLogDates(key: string): ReadonlyArray<Date> {
const entry = this.index[key];
if(entry === undefined) return [];
const dayOffset = new Date().getTimezoneOffset() * 60000;
const dates = [];
for(const date in entry.index) dates.push(new Date(parseInt(date, 10) * dayMs + dayOffset));
for(const item in entry.index) { //tslint:disable:forin
const date = new Date(parseInt(item, 10) * dayMs);
dates.push(addMinutes(date, date.getTimezoneOffset()));
}
return dates;
}
@ -170,11 +167,11 @@ export class Logs implements Logging.Persistent {
const buffer = Buffer.allocUnsafe(50100);
const messages: Conversation.Message[] = [];
const file = getLogFile(key);
const fd = await open(file, 'r');
const fd = fs.openSync(file, 'r');
let pos = index.offsets[dateOffset];
const size = dateOffset + 1 < index.offsets.length ? index.offsets[dateOffset + 1] : (await fstat(fd)).size;
const size = dateOffset + 1 < index.offsets.length ? index.offsets[dateOffset + 1] : (fs.fstatSync(fd)).size;
while(pos < size) {
await read(fd, buffer, 0, 50100, pos);
fs.readSync(fd, buffer, 0, 50100, pos);
const deserialized = deserializeMessage(buffer);
messages.push(deserialized.message);
pos += deserialized.end;
@ -220,14 +217,14 @@ export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
const file = path.join(getSettingsDir(character), key);
if(!fs.existsSync(file)) return undefined;
return <Settings.Keys[K]>JSON.parse(await readFile(file, 'utf8'));
return <Settings.Keys[K]>JSON.parse(fs.readFileSync(file, 'utf8'));
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
return (await readdir(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
return (fs.readdirSync(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
await writeFile(path.join(getSettingsDir(), key), JSON.stringify(value));
fs.writeFileSync(path.join(getSettingsDir(), key), JSON.stringify(value));
}
}

View File

@ -125,7 +125,9 @@ function createMessage(line: string, ownCharacter: string, name: string, isChann
async function importSettings(dir: string): Promise<void> {
const settings = new Settings();
const settingsStore = new SettingsStore();
const buffer = fs.readFileSync(path.join(dir, 'Global', '!settings.xml'));
const settingsFile = path.join(dir, 'Global', '!settings.xml');
if(!fs.existsSync(settingsFile)) return;
const buffer = fs.readFileSync(settingsFile);
const content = buffer.toString('utf8', (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) ? 3 : 0);
const config = new DOMParser().parseFromString(content, 'application/xml').firstElementChild;
if(config === null) return;

View File

@ -30,7 +30,7 @@
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import * as electron from 'electron';
import log from 'electron-log';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import {autoUpdater} from 'electron-updater';
import * as path from 'path';
import * as url from 'url';
@ -44,7 +44,7 @@ if(datadir.length > 0) app.setPath('userData', datadir[0].substr('--datadir='.le
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow: Electron.BrowserWindow | undefined;
const windows: Electron.BrowserWindow[] = [];
const baseDir = app.getPath('userData');
mkdir(baseDir);
@ -57,7 +57,7 @@ log.info('Starting application.');
function sendUpdaterStatusToWindow(status: string, progress?: object): void {
log.info(status);
mainWindow!.webContents.send('updater-status', status, progress);
for(const window of windows) window.webContents.send('updater-status', status, progress);
}
const updaterEvents = ['checking-for-update', 'update-available', 'update-not-available', 'error', 'update-downloaded'];
@ -71,27 +71,25 @@ autoUpdater.on('download-progress', (_, progress: object) => {
});
function runUpdater(): void {
//tslint:disable-next-line:no-floating-promises
autoUpdater.checkForUpdates();
//tslint:disable-next-line:no-floating-promises
setInterval(() => { autoUpdater.checkForUpdates(); }, 3600000);
electron.ipcMain.on('install-update', () => {
autoUpdater.quitAndInstall(false, true);
});
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
electron.ipcMain.on('install-update', () => autoUpdater.quitAndInstall(false, true));
}
function bindWindowEvents(window: Electron.BrowserWindow): void {
// Prevent page navigation by opening links in an external browser.
const openLinkExternally = (e: Event, linkUrl: string) => {
e.preventDefault();
electron.shell.openExternal(linkUrl);
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/);
if(profileMatch !== null) window.webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
else electron.shell.openExternal(linkUrl);
};
window.webContents.on('will-navigate', openLinkExternally);
window.webContents.on('new-window', openLinkExternally);
// Fix focus events not properly propagating down to the document.
window.on('focus', () => mainWindow!.webContents.send('focus', true));
window.on('blur', () => mainWindow!.webContents.send('focus', false));
window.on('focus', () => window.webContents.send('focus', true));
window.on('blur', () => window.webContents.send('focus', false));
// Save window state when it is being closed.
window.on('close', () => windowState.setSavedWindowState(window));
@ -100,51 +98,25 @@ function bindWindowEvents(window: Electron.BrowserWindow): void {
function createWindow(): void {
const lastState = windowState.getSavedWindowState();
const windowProperties = {...lastState, center: lastState.x === undefined};
// Create the browser window.
mainWindow = new electron.BrowserWindow(windowProperties);
if(lastState.maximized)
mainWindow.maximize();
const window = new electron.BrowserWindow(windowProperties);
windows.push(window);
if(lastState.maximized) window.maximize();
// and load the index.html of the app.
mainWindow.loadURL(url.format({
window.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true
}));
bindWindowEvents(mainWindow);
bindWindowEvents(window);
// Open the DevTools.
// mainWindow.webContents.openDevTools()
// Emitted when the window is closed.
mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
mainWindow = undefined;
});
window.on('closed', () => windows.splice(windows.indexOf(window), 1));
if(process.env.NODE_ENV === 'production') runUpdater();
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if(process.platform !== 'darwin') app.quit();
app.makeSingleInstance(() => {
if(windows.length < 3) createWindow();
});
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if(mainWindow === undefined) createWindow();
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
app.on('window-all-closed', () => app.quit());

View File

@ -42,7 +42,7 @@ export function createContextMenu(props: Electron.ContextMenuParams & {editFlags
export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
const viewItem = {
label: l('action.view'),
label: `&${l('action.view')}`,
submenu: [
{role: 'resetzoom'},
{role: 'zoomin'},
@ -53,9 +53,9 @@ export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
};
const menu: Electron.MenuItemConstructorOptions[] = [
{
label: l('title')
label: `&${l('title')}`
}, {
label: l('action.edit'),
label: `&${l('action.edit')}`,
submenu: [
{role: 'undo'},
{role: 'redo'},
@ -66,12 +66,16 @@ export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
{role: 'selectall'}
]
}, viewItem, {
role: 'help',
label: `&${l('help')}`,
submenu: [
{
label: l('help.fchat'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0')
},
{
label: l('help.feedback'),
click: () => electron.shell.openExternal('https://goo.gl/forms/WnLt3Qm3TPt64jQt2')
},
{
label: l('help.rules'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/Rules')

View File

@ -1,4 +1,4 @@
{
{
"name": "fchat",
"version": "3.0.0",
"author": "The F-List Team",
@ -34,6 +34,10 @@
"node_modules/**/*.node"
],
"asar": false,
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"category": "Network"
},

View File

@ -1,7 +1,6 @@
{
"compilerOptions": {
"target": "es6",
"allowSyntheticDefaultImports": true,
"module": "commonjs",
"sourceMap": true,
"experimentalDecorators": true,
@ -11,7 +10,6 @@
"importHelpers": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},

View File

@ -1,5 +1,5 @@
import {app, screen} from 'electron';
import log from 'electron-log';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import * as fs from 'fs';
import * as path from 'path';

View File

@ -113,7 +113,7 @@ export default function(this: void, connection: Connection, characters: Characte
let getChannelTimer: NodeJS.Timer | undefined;
let rejoin: string[] | undefined;
connection.onEvent('connecting', (isReconnect) => {
if(isReconnect) rejoin = Object.keys(state.joinedMap);
if(isReconnect && rejoin === undefined) rejoin = Object.keys(state.joinedMap);
state.joinedChannels = [];
state.joinedMap = {};
});
@ -162,14 +162,16 @@ export default function(this: void, connection: Connection, characters: Characte
state.joinedChannels.push(channel);
if(item !== undefined) item.isJoined = true;
} else {
const channel = state.getChannel(data.channel)!;
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
const member = channel.createMember(characters.get(data.character.identity));
channel.addMember(member);
if(item !== undefined) item.memberCount++;
}
});
connection.onMessage('ICH', (data) => {
const channel = state.getChannel(data.channel)!;
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.mode = data.mode;
const members: {[key: string]: Interfaces.Member} = {};
const sorted: Interfaces.Member[] = [];
@ -185,7 +187,11 @@ export default function(this: void, connection: Connection, characters: Characte
if(item !== undefined) item.memberCount = data.users.length;
for(const handler of state.handlers) handler('join', channel);
});
connection.onMessage('CDS', (data) => state.getChannel(data.channel)!.description = decodeHTML(data.description));
connection.onMessage('CDS', (data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.description = decodeHTML(data.description);
});
connection.onMessage('LCH', (data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return;
@ -201,7 +207,8 @@ export default function(this: void, connection: Connection, characters: Characte
}
});
connection.onMessage('COA', (data) => {
const channel = state.getChannel(data.channel)!;
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;
@ -209,12 +216,14 @@ export default function(this: void, connection: Connection, characters: Characte
channel.reSortMember(member);
});
connection.onMessage('COL', (data) => {
const channel = state.getChannel(data.channel)!;
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.owner = data.oplist[0];
channel.opList = data.oplist.slice(1);
});
connection.onMessage('COR', (data) => {
const channel = state.getChannel(data.channel)!;
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;
@ -222,7 +231,8 @@ export default function(this: void, connection: Connection, characters: Characte
channel.reSortMember(member);
});
connection.onMessage('CSO', (data) => {
const channel = state.getChannel(data.channel)!;
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
const oldOwner = channel.members[channel.owner];
if(oldOwner !== undefined) {
oldOwner.rank = Interfaces.Rank.Member;
@ -235,7 +245,11 @@ export default function(this: void, connection: Connection, characters: Characte
channel.reSortMember(newOwner);
}
});
connection.onMessage('RMO', (data) => state.getChannel(data.channel)!.mode = data.mode);
connection.onMessage('RMO', (data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.mode = data.mode;
});
connection.onMessage('FLN', (data) => {
for(const key in state.joinedMap)
state.joinedMap[key]!.removeMember(data.character);

View File

@ -10,7 +10,7 @@ class Character implements Interfaces.Character {
isChatOp = false;
isIgnored = false;
constructor(readonly name: string) {
constructor(public name: string) {
}
}
@ -111,6 +111,7 @@ export default function(this: void, connection: Connection): Interfaces.State {
connection.onMessage('NLN', (data) => {
const character = state.get(data.identity);
if(data.identity === connection.character) state.ownCharacter = character;
character.name = data.identity;
character.gender = data.gender;
state.setStatus(character, data.status, '');
});

View File

@ -31,7 +31,12 @@ export default class Connection implements Interfaces.Connection {
this.cleanClose = false;
const isReconnect = this.character === character;
this.character = character;
this.ticket = await this.ticketProvider();
try {
this.ticket = await this.ticketProvider();
} catch(e) {
for(const handler of this.errorHandlers) handler(<Error>e);
return;
}
await this.invokeHandlers('connecting', isReconnect);
const socket = this.socket = new this.socketProvider();
socket.onOpen(() => {
@ -75,14 +80,14 @@ export default class Connection implements Interfaces.Connection {
if(this.socket !== undefined) this.socket.close();
}
async queryApi(endpoint: string, data?: {account?: string, ticket?: string}): Promise<object> {
async queryApi<T = object>(endpoint: string, data?: {account?: string, ticket?: string}): Promise<T> {
if(data === undefined) data = {};
data.account = this.account;
data.ticket = this.ticket;
let res = <{error: string}>(await queryApi(endpoint, data)).data;
let res = <T & {error: string}>(await queryApi(endpoint, data)).data;
if(res.error === 'Invalid ticket.' || res.error === 'Your login ticket has expired (five minutes) or no ticket requested.') {
data.ticket = this.ticket = await this.ticketProvider();
res = <{error: string}>(await queryApi(endpoint, data)).data;
res = <T & {error: string}>(await queryApi(endpoint, data)).data;
}
if(res.error !== '') {
const error = new Error(res.error);
@ -109,13 +114,13 @@ export default class Connection implements Interfaces.Connection {
}
onMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
let handlers: (Interfaces.CommandHandler<K>[] | undefined) = this.messageHandlers[type];
let handlers = <Interfaces.CommandHandler<K>[] | undefined>this.messageHandlers[type];
if(handlers === undefined) handlers = this.messageHandlers[type] = [];
handlers.push(handler);
}
offMessage<K extends keyof Interfaces.ServerCommands>(type: K, handler: Interfaces.CommandHandler<K>): void {
const handlers: (Interfaces.CommandHandler<K>[] | undefined) = this.messageHandlers[type];
const handlers = <Interfaces.CommandHandler<K>[] | undefined>this.messageHandlers[type];
if(handlers === undefined) return;
handlers.splice(handlers.indexOf(handler), 1);
}
@ -149,7 +154,7 @@ export default class Connection implements Interfaces.Connection {
}
}
const time = new Date();
const handlers: Interfaces.CommandHandler<T>[] | undefined = this.messageHandlers[type];
const handlers = <Interfaces.CommandHandler<T>[] | undefined>this.messageHandlers[type];
if(handlers !== undefined)
for(const handler of handlers) handler(data, time);
}

View File

@ -143,7 +143,7 @@ export namespace Connection {
onError(handler: (error: Error) => void): void
send(type: 'CHA' | 'FRL' | 'ORS' | 'PCR' | 'PIN' | 'UPT'): void
send<K extends keyof ClientCommands>(type: K, data: ClientCommands[K]): void
queryApi(endpoint: string, data?: object): Promise<object>
queryApi<T = object>(endpoint: string, data?: object): Promise<T>
}
}
export type Connection = Connection.Connection;

View File

@ -75,14 +75,18 @@ span.justifyText {
text-align: justify;
}
span.indentText {
div.indentText {
padding-left: 3em;
}
.characterAvatarIcon {
.character-avatar {
display: inline;
height: 50px;
width: 50px;
height: 100px;
width: 100px;
&.icon {
height: 50px;
width: 50px;
}
}
.collapseHeaderText {
@ -107,8 +111,8 @@ span.indentText {
}
.styledText, .bbcode {
.force-word-wrapping();
max-width: 100%;
word-wrap: break-word;
a {
text-decoration: underline;
&:hover {

View File

@ -13,7 +13,7 @@
.characterList.characterListSelected {
border-width: 2px;
border-color: @characterListSelectedColor;
border-color: @character-list-selected-border;
}
// Character image editor.
@ -28,7 +28,7 @@
}
.characterImage.characterImageSelected {
border-color: @characterListSelectedColor;
border-color: @character-image-selected-border;
}
.characterImagePreview {

View File

@ -1,97 +1,198 @@
// Kinkes
.subkinkList.closed {
display: none;
}
.subkink {
cursor: pointer;
}
.characterPageAvatar {
.character-page-avatar {
height: 100px;
width: 100px;
}
// Inline images
.imageBlock {
.inline-image {
max-width: 100%;
height: auto;
}
// Quick Compare
.stockKink.quickCompareActive {
border: 1px solid @quickCompareActiveColor;
}
.stockKink.quickCompareFave {
background-color: @quickCompareFaveColor;
}
.stockKink.quickCompareYes {
background-color: @quickCompareYesColor;
}
.stockKink.quickCompareMaybe {
background-color: @quickCompareMaybeColor;
}
.stockKink.quickCompareNo {
background-color: @quickCompareNoColor;
.character-page {
.character-name {
font-size: @font-size-h3;
font-weight: bold;
}
.character-title {
font-size: @font-size-small;
font-style: italic;
}
.edit-link {
margin-left: 5px;
margin-top: @line-height-base;
}
.character-links-block {
a {
cursor: pointer;
}
}
.badges-block,.contact-block,.quick-info-block,.character-list-block {
margin-top: 15px;
}
}
// Kink Group Highlighting
.highlightedKink {
font-weight: bolder;
.badges-block {
.character-badge {
background-color: @character-badge-bg;
border: 1px solid @character-badge-border;
border-radius: @border-radius-base;
.box-shadow(inset 0 1px 1px rgba(0,0,0,.05));
&.character-badge-subscription-lifetime {
background-color: @character-badge-subscriber-bg;
border: 2px dashed @character-badge-subscriber-border;
font-weight: bold;
}
}
}
.infotags {
> .infotag-group {
.infotag-title {
font-size: @font-size-h4;
}
}
}
.infotag {
.infotag-label {
font-weight: bold;
}
.infotag-value {
.force-word-wrapping();
}
}
.contact-method {
.contact-value {
.force-word-wrapping();
margin-left: 5px;
}
}
.quick-info-block {
.quick-info-label {
font-weight: bold;
}
}
.character-kinks {
margin-top: 15px;
> .col-xs-3 {
// Fix up padding on columns so they look distinct without being miles apart.
padding: 0 5px 0 0;
}
.kinks-column {
padding: 15px;
border: 1px solid @well-border;
border-radius: @border-radius-base;
> .kinks-header {
font-size: @font-size-h4;
}
}
}
.character-kink {
.subkink-list {
.well();
margin-bottom: 0;
padding: 5px 15px;
cursor: default;
}
.subkink-list.closed {
display: none;
}
&.subkink {
cursor: pointer;
}
.comparison-active {
border: 1px solid @quick-compare-active-border;
}
&.comparison-favorite {
.comparison-active();
background-color: @quick-compare-favorite-bg;
}
&.comparison-yes {
.comparison-active();
background-color: @quick-compare-yes-bg;
}
&.comparison-maybe {
.comparison-active();
background-color: @quick-compare-maybe-bg;
}
&.comparison-no {
.comparison-active();
background-color: @quick-compare-no-bg;
}
&.highlighted {
font-weight: bolder;
}
}
#character-page-sidebar {
background-color: @well-bg;
height: 100%;
margin-top: -20px;
}
// Character Images
.character-images {
.character-image {
.col-xs-2();
.img-thumbnail();
vertical-align: middle;
border: none;
display: inline-block;
background: transparent;
img {
.center-block();
}
}
}
// Guestbook
.guestbookPager {
display: inline-block;
width: 50%;
}
.characterSubTitle {
font-size: @font-size-small;
font-style: italic;
}
.characterPageName {
font-size: @font-size-h3;
font-weight: bold;
}
.characterImages {
.container-fluid();
}
.characterPageImage {
.col-xs-2();
.img-thumbnail();
border: none;
display: inline-block;
img {
.center-block();
.guestbook {
.guestbook-pager {
display: inline-block;
width: 50%;
}
}
.guestbook-post {
.row();
}
.guestbook-avatar {
float: left;
}
.guestbook-avatar {
width: 50px;
float: left;
}
.guestbook-contents {
.well();
}
.guestbook-contents.deleted {
.alert-warning();
}
.guestbook-reply {
.guestbook-body {
:before {
content: "Reply: ";
.guestbook-contents {
.well();
&.deleted {
.alert-warning();
}
}
.guestbook-reply {
&:before {
content: "Reply ";
font-weight: bold;
}
.reply-message {
.well();
.alert-info();
}
}
.well();
.alert-info();
}
#character-friends {
.character-friend {
display: inline-block;
margin: 5px;
}
}

View File

@ -190,4 +190,12 @@
.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%;
}

View File

@ -5,13 +5,15 @@
width: 100%;
height: auto;
position: fixed;
z-index: 900000;
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 {
@ -38,4 +40,18 @@
.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

@ -13,7 +13,13 @@ hr {
// 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;
}
}
.well-lg {
padding: 20px;
}

View File

@ -1,3 +1,4 @@
// BBcode colors
@red-color: #f00;
@green-color: #0f0;
@blue-color: #00f;
@ -10,16 +11,28 @@
@pink-color: #faa;
@gray-color: #cccc;
@orange-color: #f60;
@collapse-header-bg: @well-bg;
@collapse-border: darken(@well-border, 25%);
@quickCompareActiveColor: @black-color;
@quickCompareFaveColor: @brand-success;
@quickCompareYesColor: @brand-info;
@quickCompareMaybeColor: @brand-warning;
@quickCompareNoColor: @brand-danger;
@characterListSelectedColor: @brand-success;
// Character page quick kink comparison
@quick-compare-active-border: @black-color;
@quick-compare-favorite-bg: @brand-success;
@quick-compare-yes-bg: @brand-info;
@quick-compare-maybe-bg: @brand-warning;
@quick-compare-no-bg: @brand-danger;
// 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;
@ -29,7 +42,6 @@
@nav-link-hover-color: @link-color;
@collapse-header-bg: @well-bg;
// General color extensions missing from bootstrap
@text-background-color: @body-bg;
@text-background-color-disabled: @gray-lighter;
@text-background-color-disabled: @gray-lighter;

View File

@ -50,6 +50,7 @@
//@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";

View File

@ -0,0 +1,167 @@
<template>
<div class="row character-page" id="pageBody">
<div class="alert alert-info" v-show="loading">Loading character information.</div>
<div class="alert alert-danger" v-show="error">{{error}}</div>
<div class="col-xs-2" v-if="!loading">
<sidebar :character="character" @memo="memo" @bookmarked="bookmarked"></sidebar>
</div>
<div class="col-xs-10" v-if="!loading">
<div id="characterView" class="row">
<div>
<div v-if="character.ban_reason" id="headerBanReason" class="alert alert-warning">
This character has been banned and is not visible to the public. Reason:
<br/> {{ character.ban_reason }}
<template v-if="character.timeout"><br/>Timeout expires:
<date :time="character.timeout"></date>
</template>
</div>
<div v-if="character.block_reason" id="headerBlocked" class="alert alert-warning">
This character has been blocked and is not visible to the public. Reason:
<br/> {{ character.block_reason }}
</div>
<div v-if="character.memo" id="headerCharacterMemo" class="alert alert-info">Memo: {{ character.memo.memo }}</div>
<ul class="nav nav-tabs" role="tablist" style="margin-bottom:5px">
<li role="presentation" class="active"><a href="#overview" aria-controls="overview" role="tab" data-toggle="tab">Overview</a>
</li>
<li role="presentation"><a href="#infotags" aria-controls="infotags" role="tab" data-toggle="tab">Info</a></li>
<li role="presentation" v-if="!hideGroups"><a href="#groups" aria-controls="groups" role="tab" data-toggle="tab">Groups</a></li>
<li role="presentation"><a href="#images" aria-controls="images" role="tab"
data-toggle="tab">Images ({{ character.character.image_count }})</a></li>
<li v-if="character.settings.guestbook" role="presentation"><a href="#guestbook" aria-controls="guestbook"
role="tab" data-toggle="tab">Guestbook</a></li>
<li v-if="character.is_self || character.settings.show_friends" role="presentation"><a href="#friends"
aria-controls="friends" role="tab" data-toggle="tab">Friends</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="overview" aria-labeledby="overview-tab">
<div v-bbcode="character.character.description" class="well"></div>
<character-kinks :character="character"></character-kinks>
</div>
<div role="tabpanel" class="tab-pane" id="infotags" aria-labeledby="infotags-tab">
<character-infotags :character="character"></character-infotags>
</div>
<div role="tabpanel" class="tab-pane" id="groups" aria-labeledby="groups-tab" v-if="!hideGroups">
<character-groups :character="character" ref="groups"></character-groups>
</div>
<div role="tabpanel" class="tab-pane" id="images" aria-labeledby="images-tab">
<character-images :character="character" ref="images"></character-images>
</div>
<div v-if="character.settings.guestbook" role="tabpanel" class="tab-pane" id="guestbook"
aria-labeledby="guestbook-tab">
<character-guestbook :character="character" ref="guestbook"></character-guestbook>
</div>
<div v-if="character.is_self || character.settings.show_friends" role="tabpanel" class="tab-pane" id="friends"
aria-labeledby="friends-tab">
<character-friends :character="character" ref="friends"></character-friends>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {initCollapse, standardParser} from '../../bbcode/standard';
import * as Utils from '../utils';
import {methods, Store} from './data_store';
import {Character, SharedStore} from './interfaces';
import DateDisplay from '../../components/date_display.vue';
import FriendsView from './friends.vue';
import GroupsView from './groups.vue';
import GuestbookView from './guestbook.vue';
import ImagesView from './images.vue';
import InfotagsView from './infotags.vue';
import CharacterKinksView from './kinks.vue';
import Sidebar from './sidebar.vue';
interface ShowableVueTab extends Vue {
show?(target: Element): void
}
@Component({
components: {
sidebar: Sidebar,
date: DateDisplay,
'character-friends': FriendsView,
'character-guestbook': GuestbookView,
'character-groups': GroupsView,
'character-infotags': InfotagsView,
'character-images': ImagesView,
'character-kinks': CharacterKinksView
}
})
export default class CharacterPage extends Vue {
//tslint:disable:no-null-keyword
@Prop()
private readonly name?: string;
@Prop()
private readonly characterid?: number;
@Prop({required: true})
private readonly authenticated: boolean;
@Prop()
readonly hideGroups?: true;
private shared: SharedStore = Store;
private character: Character | null = null;
loading = true;
error = '';
beforeMount(): void {
this.shared.authenticated = this.authenticated;
}
mounted(): void {
if(this.character === null) this._getCharacter().then(); //tslint:disable-line:no-floating-promises
}
beforeDestroy(): void {
$('a[data-toggle="tab"]').off('shown.bs.tab', (e) => this.switchTabHook(e));
}
switchTabHook(evt: JQuery.Event): void {
const targetId = (<HTMLElement>evt.target).getAttribute('aria-controls')!;
//tslint:disable-next-line:strict-type-predicates no-unbound-method
if(typeof this.$refs[targetId] !== 'undefined' && typeof (<ShowableVueTab>this.$refs[targetId]).show === 'function')
(<ShowableVueTab>this.$refs[targetId]).show!(<Element>evt.target);
}
@Watch('name')
onCharacterSet(): void {
this._getCharacter().then(); //tslint:disable-line:no-floating-promises
}
memo(memo: {id: number, memo: string}): void {
Vue.set(this.character!, 'memo', memo);
}
bookmarked(state: boolean): void {
Vue.set(this.character!, 'bookmarked', state);
}
private async _getCharacter(): Promise<void> {
if(this.name === undefined || this.name.length === 0)
return;
try {
this.loading = true;
await methods.fieldsGet();
this.character = await methods.characterData(this.name, this.characterid);
standardParser.allowInlines = true;
standardParser.inlines = this.character.character.inlines;
this.loading = false;
this.$nextTick(() => {
$('a[data-toggle="tab"]').on('shown.bs.tab', (e) => this.switchTabHook(e));
initCollapse();
});
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Failed to load character information.');
}
}
}
</script>

View File

@ -0,0 +1,53 @@
<template>
<div class="contact-method" :title="altText">
<span v-if="contactLink" class="contact-link">
<a :href="contactLink" target="_blank" rel="nofollow noreferrer noopener">
<img :src="iconUrl"><span class="contact-value">{{value}}</span>
</a>
</span>
<span v-else>
<img :src="iconUrl"><span class="contact-value">{{value}}</span>
</span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {formatContactLink, formatContactValue} from './contact_utils';
import {methods, Store} from './data_store';
interface DisplayContactMethod {
id: number
value: string
}
@Component
export default class ContactMethodView extends Vue {
@Prop({required: true})
private readonly method: DisplayContactMethod;
get iconUrl(): string {
const infotag = Store.kinks.infotags[this.method.id];
if(typeof infotag === 'undefined')
return 'Unknown Infotag';
return methods.contactMethodIconUrl(infotag.name);
}
get value(): string {
return formatContactValue(this.method.id, this.method.value);
}
get altText(): string {
const infotag = Store.kinks.infotags[this.method.id];
if(typeof infotag === 'undefined')
return '';
return infotag.name;
}
get contactLink(): string | undefined {
return formatContactLink(this.method.id, this.method.value);
}
}
</script>

View File

@ -0,0 +1,109 @@
import {urlRegex as websitePattern} from '../../bbcode/core';
import {Store} from './data_store';
const daUsernamePattern = /^([a-z0-9_\-]+)$/i;
const daSitePattern = /^https?:\/\/([a-z0-9_\-]+)\.deviantart\.com\//i;
const emailPattern = /^((?:[a-z0-9])+(?:[a-z0-9\._-])*@(?:[a-z0-9_-])+(?:[a-z0-9\._-]+)+)$/i;
const faUsernamePattern = /^([a-z0-9_\-~.]+)$/i;
const faSitePattern = /^https?:\/\/(?:www\.)?furaffinity\.net\/user\/([a-z0-9_\-~,]+)\/?$/i;
const inkbunnyUsernamePattern = /^([a-z0-9]+)$/i;
const inkbunnySitePattern = /^https?:\/\/inkbunny\.net\/([a-z0-9]+)\/?$/i;
const skypeUsernamePattern = /^([a-z][a-z0-9.,\-_]*)/i;
const twitterUsernamePattern = /^([a-z0-9_]+)$/i;
const twitterSitePattern = /^https?:\/\/(?:www\.)?twitter\.com\/([a-z0-9_]+)\/?$/i;
const yimUsernamePattern = /^([a-z0-9_\-]+)$/i;
const daNormalize = normalizeSiteUsernamePair(daSitePattern, daUsernamePattern);
const faNormalize = normalizeSiteUsernamePair(faSitePattern, faUsernamePattern);
const inkbunnyNormalize = normalizeSiteUsernamePair(inkbunnySitePattern, inkbunnyUsernamePattern);
const twitterNormalize = normalizeSiteUsernamePair(twitterSitePattern, twitterUsernamePattern);
function normalizeSiteUsernamePair(site: RegExp, username: RegExp): (value: string) => string | undefined {
return (value: string): string | undefined => {
let matches = value.match(site);
if(matches !== null && matches.length === 2)
return matches[1];
matches = value.match(username);
if(matches !== null && matches.length === 2)
return matches[1];
return;
};
}
export function formatContactValue(id: number, value: string): string {
const infotag = Store.kinks.infotags[id];
if(typeof infotag === 'undefined')
return value;
const methodName = infotag.name.toLowerCase();
const formatters: {[key: string]: (() => string | undefined) | undefined} = {
deviantart(): string | undefined {
return daNormalize(value);
},
furaffinity(): string | undefined {
return faNormalize(value);
},
inkbunny(): string | undefined {
return inkbunnyNormalize(value);
},
twitter(): string | undefined {
return twitterNormalize(value);
}
};
if(typeof formatters[methodName] === 'function') {
const formatted = formatters[methodName]!();
return formatted !== undefined ? formatted : value;
}
return value;
}
export function formatContactLink(id: number, value: string): string | undefined {
const infotag = Store.kinks.infotags[id];
if(typeof infotag === 'undefined')
return;
const methodName = infotag.name.toLowerCase();
const formatters: {[key: string]: (() => string | undefined) | undefined} = {
deviantart(): string | undefined {
const username = daNormalize(value);
if(username !== undefined)
return `https://${username}.deviantart.com/`;
},
'e-mail'(): string | undefined {
const matches = value.match(emailPattern);
if(matches !== null && matches.length === 2)
return `mailto:${value}`;
},
furaffinity(): string | undefined {
const username = faNormalize(value);
if(username !== undefined)
return `https://www.furaffinity.net/user/${username}`;
},
inkbunny(): string | undefined {
const username = inkbunnyNormalize(value);
if(username !== undefined)
return `https://inkbunny.net/${username}`;
},
skype(): string | undefined {
const matches = value.match(skypeUsernamePattern);
if(matches !== null && matches.length === 2)
return `skype:${value}?chat`;
},
twitter(): string | undefined {
const username = twitterNormalize(value);
if(username !== undefined)
return `https://twitter.com/${username}`;
},
website(): string | undefined {
const matches = value.match(websitePattern);
if(matches !== null && matches.length === 2)
return value;
},
yim(): string | undefined {
const matches = value.match(yimUsernamePattern);
if(matches !== null && matches.length === 2)
return `ymsg:sendIM?${value}`;
}
};
if(typeof formatters[methodName] === 'function')
return formatters[methodName]!();
return;
}

View File

@ -0,0 +1,91 @@
import Vue from 'vue';
export default abstract class ContextMenu extends Vue {
//tslint:disable:no-null-keyword
abstract propName: string;
showMenu = false;
private position = {left: 0, top: 0};
private selectedItem: HTMLElement | null;
private touchTimer: number;
abstract itemSelected(element: HTMLElement): void;
shouldShowMenu(_: HTMLElement): boolean {
return true;
}
hideMenu(): void {
this.showMenu = false;
this.selectedItem = null;
}
bindOffclick(): void {
document.body.addEventListener('click', () => this.hideMenu());
}
private fixPosition(e: MouseEvent | Touch): void {
const getMenuPosition = (input: number, direction: string): number => {
const win = (<Window & {[key: string]: number}>window)[`inner${direction}`];
const menu = (<HTMLElement & {[key: string]: number}>this.$refs['menu'])[`offset${direction}`];
let position = input;
if(input + menu > win)
position = win - menu - 5;
return position;
};
const left = getMenuPosition(e.clientX, 'Width');
const top = getMenuPosition(e.clientY, 'Height');
this.position = {left, top};
}
protected innerClick(): void {
this.itemSelected(this.selectedItem!);
this.hideMenu();
}
outerClick(event: MouseEvent | TouchEvent): void {
// Provide an opt-out
if(event.ctrlKey) return;
if(event.type === 'touchend') window.clearTimeout(this.touchTimer);
const targetingEvent = event instanceof TouchEvent ? event.touches[0] : event;
const findTarget = (): HTMLElement | undefined => {
let element = <HTMLElement>targetingEvent.target;
while(element !== document.body) {
if(typeof element.dataset[this.propName] !== 'undefined' || element.parentElement === null) break;
element = element.parentElement;
}
return typeof element.dataset[this.propName] === 'undefined' ? undefined : element;
};
const target = findTarget();
if(target === undefined) {
this.hideMenu();
return;
}
switch(event.type) {
case 'click':
case 'contextmenu':
this.openMenu(targetingEvent, target);
break;
case 'touchstart':
this.touchTimer = window.setTimeout(() => this.openMenu(targetingEvent, target), 500);
}
event.preventDefault();
}
private openMenu(event: MouseEvent | Touch, element: HTMLElement): void {
if(!this.shouldShowMenu(element))
return;
this.showMenu = true;
this.selectedItem = element;
this.$nextTick(() => {
this.fixPosition(event);
});
}
get positionText(): string {
return `left: ${this.position.left}px; top: ${this.position.top}px;`;
}
}

View File

@ -0,0 +1,100 @@
<template>
<div id="copyCustomDialog" tabindex="-1" class="modal" ref="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
<h4 class="modal-title">Copy Custom Kink</h4>
</div>
<div class="modal-body form-horizontal">
<form-errors :errors="errors" field="name">
<label class="col-xs-3 control-label">Name:</label>
<div class="col-xs-9">
<input type="text" class="form-control" maxlength="60" v-model="name"/>
</div>
</form-errors>
<form-errors :errors="errors" field="description">
<label class="col-xs-3 control-label">Description:</label>
<div class="col-xs-9">
<input type="text" class="form-control" maxlength="60" v-model="description"/>
</div>
</form-errors>
<form-errors :errors="errors" field="choice">
<label class="col-xs-3 control-label">Choice:</label>
<div class="col-xs-9">
<select v-model="choice" class="form-control">
<option value="favorite">Favorite</option>
<option value="yes">Yes</option>
<option value="maybe">Maybe</option>
<option value="no">No</option>
</select>
</div>
</form-errors>
<form-errors :errors="errors" field="target">
<label class="col-xs-3 control-label">Target Character:</label>
<div class="col-xs-9">
<character-select v-model="target"></character-select>
</div>
</form-errors>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" @click="copyCustom"
:disabled="!valid || submitting">
Copy Custom Kink
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import FormErrors from '../../components/form_errors.vue';
import * as Utils from '../utils';
import {methods} from './data_store';
import {KinkChoice} from './interfaces';
@Component({
components: {
'form-errors': FormErrors
}
})
export default class CopyCustomDialog extends Vue {
private name = '';
private description = '';
private choice: KinkChoice = 'favorite';
private target = Utils.Settings.defaultCharacter;
errors = {};
submitting = false;
show(name: string, description: string): void {
this.name = name;
this.description = description;
$(this.$refs['dialog']).modal('show');
}
async copyCustom(): Promise<void> {
try {
this.errors = {};
this.submitting = true;
await methods.characterCustomKinkAdd(this.target, this.name, this.description, this.choice);
this.submitting = false;
} catch(e) {
this.submitting = false;
Utils.ajaxError(e, 'Unable to copy custom kink');
if(Utils.isJSONError(e))
this.errors = e.response.data;
}
}
get valid(): boolean {
return this.name.length > 0 && this.description.length > 0;
}
}
</script>

View File

@ -0,0 +1,47 @@
<template>
<div>
<ul class="dropdown-menu" role="menu" @click="innerClick($event)" @touchstart="innerClick($event)" @touchend="innerClick($event)"
style="position: fixed; display: block;" :style="positionText" ref="menu" v-show="showMenu">
<li><a href="#">Copy Custom</a></li>
</ul>
<copy-dialog ref="copy-dialog"></copy-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import ContextMenu from './context_menu';
import CopyCustomDialog from './copy_custom_dialog.vue';
interface ShowableCustomVueDialog extends Vue {
show(name: string, description: string): void
}
@Component({
components: {
'copy-dialog': CopyCustomDialog
}
})
export default class CopyCustomMenu extends ContextMenu {
@Prop({required: true})
readonly propName: string;
itemSelected(element: HTMLElement): void {
const getName = (children: ReadonlyArray<HTMLElement>): string => {
for(const child of children)
if(child.className === 'kink-name')
return child.textContent!;
return 'Unknown';
};
const name = getName(<any>element.children); //tslint:disable-line:no-any
const description = element.title;
(<ShowableCustomVueDialog>this.$refs['copy-dialog']).show(name, description);
}
mounted(): void {
this.bindOffclick();
}
}
</script>

View File

@ -0,0 +1,19 @@
import {Component} from 'vue';
import {SharedStore, StoreMethods} from './interfaces';
export let Store: SharedStore = {
kinks: <any>undefined, //tslint:disable-line:no-any
authenticated: false
};
export const registeredComponents: {[key: string]: Component | undefined} = {};
export function registerComponent(name: string, component: Component): void {
registeredComponents[name] = component;
}
export function registerMethod<K extends keyof StoreMethods>(name: K, func: StoreMethods[K]): void {
methods[name] = func;
}
export const methods: StoreMethods = <StoreMethods>{}; //tslint:disable-line:no-object-literal-type-assertion

View File

@ -0,0 +1,57 @@
<template>
<div id="deleteDialog" tabindex="-1" class="modal" ref="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
<h4 class="modal-title">Delete character {{name}}</h4>
</div>
<div class="modal-body">
Are you sure you want to permanently delete {{ name }}?<br/>
Character deletion cannot be undone for any reason.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" @click="deleteCharacter" :disabled="deleting">Delete Character</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character} from './interfaces';
@Component
export default class DeleteDialog extends Vue {
@Prop({required: true})
private readonly character: Character;
deleting = false;
get name(): string {
return this.character.character.name;
}
show(): void {
$(this.$refs['dialog']).modal('show');
}
async deleteCharacter(): Promise<void> {
try {
this.deleting = true;
await methods.characterDelete(this.character.character.id);
$(this.$refs['dialog']).modal('hide');
window.location.assign('/');
} catch(e) {
Utils.ajaxError(e, 'Unable to delete character');
}
this.deleting = false;
}
}
</script>

View File

@ -0,0 +1,108 @@
<template>
<div id="duplicateDialog" tabindex="-1" class="modal" ref="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
<h4 class="modal-title">Duplicate character {{name}}</h4>
</div>
<div class="modal-body form-horizontal">
<p>This will duplicate the character, kinks, infotags, customs, subkinks and images. Guestbook
entries, friends, groups, and bookmarks are not duplicated.</p>
<div class="form-group">
<label class="col-xs-1 control-label">Name:</label>
<div class="col-xs-5">
<input type="text" class="form-control" maxlength="60" v-model="newName"
:class="{'has-error': error}"/>
</div>
<div class="col-xs-2">
<button type="button" class="btn btn-default" @click="checkName"
:disabled="newName.length < 2 || checking">
Check Name
</button>
</div>
<div class="col-xs-3">
<ul>
<li v-show="valid" class="text-success">Name available and valid.</li>
</ul>
<ul>
<li v-show="error" class="text-danger">{{ error }}</li>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" @click="duplicate"
:disabled="duplicating || checking">
Duplicate Character
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character} from './interfaces';
@Component
export default class DuplicateDialog extends Vue {
@Prop({required: true})
private readonly character: Character;
error = '';
private newName = '';
valid = false;
checking = false;
duplicating = false;
get name(): string {
return this.character.character.name;
}
show(): void {
$(this.$refs['dialog']).modal('show');
}
async checkName(): Promise<boolean> {
try {
this.checking = true;
const result = await methods.characterNameCheck(this.newName);
this.valid = result.valid;
this.error = '';
return true;
} catch(e) {
this.valid = false;
this.error = '';
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
return false;
} finally {
this.checking = false;
}
}
async duplicate(): Promise<void> {
try {
this.duplicating = true;
const result = await methods.characterDuplicate(this.character.character.id, this.newName);
$(this.$refs['dialog']).modal('hide');
window.location.assign(result.next);
} catch(e) {
Utils.ajaxError(e, 'Unable to duplicate character');
this.valid = false;
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
}
this.duplicating = false;
}
}
</script>

View File

@ -0,0 +1,189 @@
<template>
<div id="friendDialog" tabindex="-1" class="modal" ref="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"
aria-label="Close">&times;
</button>
<h4 class="modal-title">Friends for {{name}}</h4>
</div>
<div class="modal-body">
<div v-show="loading" class="alert alert-info">Loading friend information.</div>
<div v-show="error" class="alert alert-danger">{{error}}</div>
<template v-if="!loading">
<div v-if="existing.length" class="well">
<h4>Existing Friendships</h4>
<hr>
<div v-for="friend in existing">
<character-link :character="request.source"><img class="character-avatar icon"
:src="avatarUrl(request.source.name)"/>
{{request.source.name}}
</character-link>
Since:
<date-display :time="friend.createdAt"></date-display>
<button type="button" class="btn btn-danger"
@click="dissolve(friend)">
Remove
</button>
</div>
</div>
<div v-if="pending.length" class="well">
<h4>Pending Requests To Character</h4>
<hr>
<div v-for="request in pending">
<character-link :character="request.source"><img class="character-avatar icon"
:src="avatarUrl(request.source.name)"/>
{{request.source.name}}
</character-link>
Sent:
<date-display :time="request.createdAt"></date-display>
<button type="button" class="btn btn-danger"
@click="cancel(request)">
Cancel
</button>
</div>
</div>
<div v-if="incoming.length" class="well">
<h4>Pending Requests From Character</h4>
<hr>
<div v-for="request in incoming">
<character-link :character="request.target"><img class="character-avatar icon"
:src="avatarUrl(request.target.name)"/>
{{request.target.name}}
</character-link>
Sent:
<date-display :time="request.createdAt"></date-display>
<button type="button" class="btn btn-success acceptFriend"
@click="accept(request)">
Accept
</button>
<button type="button" class="btn btn-danger ignoreFriend"
@click="ignore(request)">
Ignore
</button>
</div>
</div>
<div class="well">
<h4>Request Friendship</h4>
<hr>
<div class="form-inline">
<label class="control-label"
for="friendRequestCharacter">Character: </label>
<character-select id="friendRequestCharacter" v-model="ourCharacter"></character-select>
<button @click="request" class="btn btn-default" :disable="requesting || !ourCharacter">Request</button>
</div>
</div>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, Friend, FriendRequest} from './interfaces';
@Component
export default class FriendDialog extends Vue {
@Prop({required: true})
private readonly character: Character;
private ourCharacter = Utils.Settings.defaultCharacter;
private incoming: FriendRequest[] = [];
private pending: FriendRequest[] = [];
private existing: Friend[] = [];
requesting = false;
loading = true;
error = '';
avatarUrl = Utils.avatarURL;
get name(): string {
return this.character.character.name;
}
async request(): Promise<void> {
try {
this.requesting = true;
const newRequest = await methods.friendRequest(this.character.character.id, this.ourCharacter);
this.pending.push(newRequest);
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to send friend request');
}
this.requesting = false;
}
async dissolve(friendship: Friend): Promise<void> {
try {
await methods.friendDissolve(friendship.id);
this.existing = Utils.filterOut(this.existing, 'id', friendship.id);
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to dissolve friendship');
}
}
async accept(request: FriendRequest): Promise<void> {
try {
const friend = await methods.friendRequestAccept(request.id);
this.existing.push(friend);
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to accept friend request');
}
}
async cancel(request: FriendRequest): Promise<void> {
try {
await methods.friendRequestCancel(request.id);
this.pending = Utils.filterOut(this.pending, 'id', request.id);
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to cancel friend request');
}
}
async ignore(request: FriendRequest): Promise<void> {
try {
await methods.friendRequestIgnore(request.id);
this.incoming = Utils.filterOut(this.incoming, 'id', request.id);
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to ignore friend request');
}
}
async show(): Promise<void> {
$(this.$refs['dialog']).modal('show');
try {
this.loading = true;
const friendData = await methods.characterFriends(this.character.character.id);
this.incoming = friendData.incoming;
this.pending = friendData.pending;
this.existing = friendData.existing;
} catch(e) {
Utils.ajaxError(e, 'Unable to load character friendship information');
}
this.loading = false;
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<div id="character-friends">
<div v-show="loading" class="alert alert-info">Loading friends.</div>
<template v-if="!loading">
<div class="character-friend" v-for="friend in friends" :key="friend.id">
<a :href="characterUrl(friend.name)"><img class="character-avatar" :src="avatarUrl(friend.name)" :title="friend.name"></a>
</div>
</template>
<div v-if="!loading && !friends.length" class="alert alert-info">No friends to display.</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, CharacterFriend} from './interfaces';
@Component
export default class FriendsView extends Vue {
@Prop({required: true})
private readonly character: Character;
private shown = false;
friends: CharacterFriend[] = [];
loading = true;
error = '';
avatarUrl = Utils.avatarURL;
characterUrl = Utils.characterURL;
async show(): Promise<void> {
if(this.shown) return;
try {
this.error = '';
this.shown = true;
this.loading = true;
this.friends = await methods.friendsGet(this.character.character.id);
} catch(e) {
this.shown = false;
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to load friends.');
}
this.loading = false;
}
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<div class="character-groups">
<div v-show="loading" class="alert alert-info">Loading groups.</div>
<template v-if="!loading">
<div class="character-group" v-for="group in groups" :key="group.id">
<a :href="groupUrl(group)">{{group.title}}: {{group.threadCount}}</a>
</div>
</template>
<div v-if="!loading && !groups.length" class="alert alert-info">No groups.</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, CharacterGroup} from './interfaces';
@Component
export default class GroupsView extends Vue {
@Prop({required: true})
private readonly character: Character;
private shown = false;
groups: CharacterGroup[] = [];
loading = true;
error = '';
groupUrl(group: CharacterGroup): string {
return `${Utils.staticDomain}threads/group/${group.id}`;
}
async show(): Promise<void> {
if(this.shown) return;
try {
this.error = '';
this.shown = true;
this.loading = true;
this.groups = await methods.groupsGet(this.character.character.id);
} catch(e) {
this.shown = false;
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to load groups.');
}
this.loading = false;
}
}
</script>

View File

@ -0,0 +1,143 @@
<template>
<div class="guestbook">
<div v-show="loading" class="alert alert-info">Loading guestbook.</div>
<div class="guestbook-controls">
<label v-show="canEdit" class="control-label">Unapproved only:
<input type="checkbox" v-model="unapprovedOnly"/>
</label>
<nav>
<ul class="pager">
<li class="previous" v-show="page > 1">
<a @click="previousPage">
<span aria-hidden="true">&larr;</span>Previous Page
</a>
</li>
<li class="next" v-show="hasNextPage">
<a @click="nextPage">
Next Page<span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</nav>
</div>
<template v-if="!loading">
<div class="alert alert-info" v-show="posts.length === 0">No guestbook posts.</div>
<guestbook-post :post="post" :can-edit="canEdit" v-for="post in posts" :key="post.id" @reload="getPage"></guestbook-post>
<div v-if="authenticated" class="form-horizontal">
<bbcode-editor v-model="newPost.message" :maxlength="5000" classes="form-control"></bbcode-editor>
<label class="control-label"
for="guestbookPostPrivate">Private(only visible to owner): </label>
<input type="checkbox"
class="form-control"
id="guestbookPostPrivate"
v-model="newPost.privatePost"/>
<label class="control-label"
for="guestbook-post-character">Character: </label>
<character-select id="guestbook-post-character" v-model="newPost.character"></character-select>
<button @click="makePost" class="btn btn-success" :disabled="newPost.posting">Post</button>
</div>
</template>
<div class="guestbook-controls">
<nav>
<ul class="pager">
<li class="previous" v-show="page > 1">
<a @click="previousPage">
<span aria-hidden="true">&larr;</span>Previous Page
</a>
</li>
<li class="next" v-show="hasNextPage">
<a @click="nextPage">
Next Page<span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods, Store} from './data_store';
import {Character, GuestbookPost} from './interfaces';
import GuestbookPostView from './guestbook_post.vue';
@Component({
components: {
'guestbook-post': GuestbookPostView
}
})
export default class GuestbookView extends Vue {
@Prop({required: true})
private readonly character: Character;
loading = true;
error = '';
authenticated = Store.authenticated;
posts: GuestbookPost[] = [];
private unapprovedOnly = false;
private page = 1;
hasNextPage = false;
canEdit = false;
private newPost = {
posting: false,
privatePost: false,
character: Utils.Settings.defaultCharacter,
message: ''
};
async nextPage(): Promise<void> {
this.page += 1;
return this.getPage();
}
async previousPage(): Promise<void> {
this.page -= 1;
return this.getPage();
}
@Watch('unapprovedOnly')
async getPage(): Promise<void> {
try {
this.loading = true;
const guestbookState = await methods.guestbookPageGet(this.character.character.id, this.page, this.unapprovedOnly);
this.posts = guestbookState.posts;
this.hasNextPage = guestbookState.nextPage;
this.canEdit = guestbookState.canEdit;
} catch(e) {
this.posts = [];
this.hasNextPage = false;
this.canEdit = false;
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to load guestbook posts.');
} finally {
this.loading = false;
}
}
async makePost(): Promise<void> {
try {
this.newPost.posting = true;
await methods.guestbookPostPost(this.character.character.id, this.newPost.character, this.newPost.message,
this.newPost.privatePost);
this.page = 1;
await this.getPage();
} catch(e) {
Utils.ajaxError(e, 'Unable to post new guestbook post.');
} finally {
this.newPost.posting = false;
}
}
async show(): Promise<void> {
return this.getPage();
}
}
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="guestbook-post" :id="'guestbook-post-' + post.id">
<div class="guestbook-contents" :class="{deleted: post.deleted}">
<div class="row">
<div class="col-xs-1 guestbook-avatar">
<character-link :character="post.character">
<img :src="avatarUrl" class="character-avatar icon"/>
</character-link>
</div>
<div class="col-xs-10">
<span v-show="post.private" class="post-private">*</span>
<span v-show="!post.approved" class="post-unapproved"> (unapproved)</span>
<span class="guestbook-timestamp">
<character-link :character="post.character"></character-link>, posted <date-display
:time="post.postedAt"></date-display>
</span>
<button class="btn btn-default" v-show="canEdit" @click="approve" :disabled="approving">
{{ (post.approved) ? 'Unapprove' : 'Approve' }}
</button>
</div>
<div class="col-xs-1 text-right">
<button class="btn btn-danger" v-show="!post.deleted && (canEdit || post.canEdit)"
@click="deletePost" :disabled="deleting">Delete
</button>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="bbcode guestbook-message" v-bbcode="post.message"></div>
<div v-if="post.reply && !replyBox" class="guestbook-reply">
<date-display v-if="post.repliedAt" :time="post.repliedAt"></date-display>
<div class="reply-message" v-bbcode="post.reply"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<a v-show="canEdit && !replyBox" class="reply-link" @click="replyBox = !replyBox">
{{ post.reply ? 'Edit Reply' : 'Reply' }}
</a>
<template v-if="replyBox">
<bbcode-editor v-model="replyMessage" :maxlength="5000" classes="form-control"></bbcode-editor>
<button class="btn btn-success" @click="postReply" :disabled="replying">Reply</button>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import CharacterLink from '../../components/character_link.vue';
import DateDisplay from '../../components/date_display.vue';
import * as Utils from '../utils';
import {methods} from './data_store';
import {GuestbookPost} from './interfaces';
@Component({
components: {'date-display': DateDisplay, 'character-link': CharacterLink}
})
export default class GuestbookPostView extends Vue {
@Prop({required: true})
private readonly post: GuestbookPost;
@Prop({required: true})
readonly canEdit: boolean;
replying = false;
replyBox = false;
private replyMessage = this.post.reply;
approving = false;
deleting = false;
get avatarUrl(): string {
return Utils.avatarURL(this.post.character.name);
}
async deletePost(): Promise<void> {
try {
this.deleting = true;
await methods.guestbookPostDelete(this.post.id);
Vue.set(this.post, 'deleted', true);
this.$emit('reload');
} catch(e) {
Utils.ajaxError(e, 'Unable to delete guestbook post.');
} finally {
this.deleting = false;
}
}
async approve(): Promise<void> {
try {
this.approving = true;
await methods.guestbookPostApprove(this.post.id, !this.post.approved);
this.post.approved = !this.post.approved;
} catch(e) {
Utils.ajaxError(e, 'Unable to change post approval.');
} finally {
this.approving = false;
}
}
async postReply(): Promise<void> {
try {
this.replying = true;
const replyData = await methods.guestbookPostReply(this.post.id, this.replyMessage);
this.post.reply = replyData.reply;
this.post.repliedAt = replyData.repliedAt;
this.replyBox = false;
} catch(e) {
Utils.ajaxError(e, 'Unable to post guestbook reply.');
} finally {
this.replying = false;
}
}
}
</script>

View File

@ -0,0 +1,51 @@
<template>
<div class="character-images">
<div v-show="loading" class="alert alert-info">Loading images.</div>
<template v-if="!loading">
<div class="character-image" v-for="image in images" :key="image.id">
<a :href="imageUrl(image)" target="_blank">
<img :src="thumbUrl(image)" :title="image.description">
</a>
</div>
</template>
<div v-if="!loading && !images.length" class="alert alert-info">No images.</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, CharacterImage} from './interfaces';
@Component
export default class ImagesView extends Vue {
@Prop({required: true})
private readonly character: Character;
private shown = false;
images: CharacterImage[] = [];
loading = true;
error = '';
imageUrl = (image: CharacterImage) => methods.imageUrl(image);
thumbUrl = (image: CharacterImage) => methods.imageThumbUrl(image);
async show(): Promise<void> {
if(this.shown) return;
try {
this.error = '';
this.shown = true;
this.loading = true;
this.images = await methods.imagesGet(this.character.character.id);
} catch(e) {
this.shown = false;
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;
Utils.ajaxError(e, 'Unable to load images.');
}
this.loading = false;
}
}
</script>

View File

@ -0,0 +1,54 @@
<template>
<div class="infotag">
<span class="infotag-label">{{label}}: </span>
<span v-if="!contactLink" class="infotag-value">{{value}}</span>
<span v-if="contactLink" class="infotag-value"><a :href="contactLink">{{value}}</a></span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {formatContactLink, formatContactValue} from './contact_utils';
import {Store} from './data_store';
import {DisplayInfotag} from './interfaces';
@Component
export default class InfotagView extends Vue {
@Prop({required: true})
private readonly infotag: DisplayInfotag;
get label(): string {
const infotag = Store.kinks.infotags[this.infotag.id];
if(typeof infotag === 'undefined')
return 'Unknown Infotag';
return infotag.name;
}
get contactLink(): string | undefined {
if(this.infotag.isContact)
return formatContactLink(this.infotag.id, this.infotag.string!);
}
get value(): string {
const infotag = Store.kinks.infotags[this.infotag.id];
if(typeof infotag === 'undefined')
return '';
if(this.infotag.isContact)
return formatContactValue(this.infotag.id, this.infotag.string!);
switch(infotag.type) {
case 'text':
return this.infotag.string!;
case 'number':
if(infotag.allow_legacy && this.infotag.number === null)
return this.infotag.string !== undefined ? this.infotag.string : '';
return this.infotag.number!.toPrecision();
}
const listitem = Store.kinks.listitems[this.infotag.list!];
if(typeof listitem === 'undefined')
return '';
return listitem.value;
}
}
</script>

View File

@ -0,0 +1,79 @@
<template>
<div class="infotags">
<div class="infotag-group" v-for="group in groupedInfotags" :key="group.id">
<div class="col-xs-2">
<div class="infotag-title">{{group.name}}</div>
<hr>
<infotag :infotag="infotag" v-for="infotag in group.infotags" :key="infotag.id"></infotag>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {Store} from './data_store';
import {Character, CONTACT_GROUP_ID, DisplayInfotag} from './interfaces';
import InfotagView from './infotag.vue';
interface DisplayInfotagGroup {
name: string
sortOrder: number
infotags: DisplayInfotag[]
}
@Component({
components: {
infotag: InfotagView
}
})
export default class InfotagsView extends Vue {
@Prop({required: true})
private readonly character: Character;
get groupedInfotags(): DisplayInfotagGroup[] {
const groups = Store.kinks.infotag_groups;
const infotags = Store.kinks.infotags;
const characterTags = this.character.character.infotags;
const outputGroups: DisplayInfotagGroup[] = [];
const groupedTags = Utils.groupObjectBy(infotags, 'infotag_group');
for(const groupId in groups) {
const group = groups[groupId]!;
const groupedInfotags = groupedTags[groupId];
if(groupedInfotags === undefined) continue;
const collectedTags: DisplayInfotag[] = [];
for(const infotag of groupedInfotags) {
const characterInfotag = characterTags[infotag.id];
if(typeof characterInfotag === 'undefined')
continue;
const newInfotag: DisplayInfotag = {
id: infotag.id,
isContact: infotag.infotag_group === CONTACT_GROUP_ID,
string: characterInfotag.string,
number: characterInfotag.number,
list: characterInfotag.list
};
collectedTags.push(newInfotag);
}
collectedTags.sort((a, b): number => {
const infotagA = infotags[a.id]!;
const infotagB = infotags[b.id]!;
return infotagA.name < infotagB.name ? -1 : 1;
});
outputGroups.push({
name: group.name,
sortOrder: group.sort_order,
infotags: collectedTags
});
}
outputGroups.sort((a, b) => a.sortOrder < b.sortOrder ? -1 : 1);
return outputGroups.filter((a) => a.infotags.length > 0);
}
}
</script>

View File

@ -0,0 +1,327 @@
export interface CharacterMenuItem {
label: string
permission: string
link(character: Character): string
handleClick?(evt?: MouseEvent): void
}
export interface SelectItem {
text: string
value: string | number
}
export interface SharedStore {
kinks: SharedKinks
authenticated: boolean
}
export interface StoreMethods {
bookmarkUpdate(id: number, state: boolean): Promise<boolean>
characterBlock?(id: number, block: boolean, reason?: string): Promise<void>
characterCustomKinkAdd(id: number, name: string, description: string, choice: KinkChoice): Promise<void>
characterData(name: string | undefined, id: number | undefined): Promise<Character>
characterDelete(id: number): Promise<void>
characterDuplicate(id: number, name: string): Promise<DuplicateResult>
characterFriends(id: number): Promise<FriendsByCharacter>
characterNameCheck(name: string): Promise<CharacterNameCheckResult>
characterRename?(id: number, name: string, renamedFor?: string): Promise<RenameResult>
characterReport(reportData: CharacterReportData): Promise<void>
contactMethodIconUrl(name: string): string
fieldsGet(): Promise<void>
friendDissolve(id: number): Promise<void>
friendRequest(target: number, source: number): Promise<FriendRequest>
friendRequestAccept(id: number): Promise<Friend>
friendRequestIgnore(id: number): Promise<void>
friendRequestCancel(id: number): Promise<void>
friendsGet(id: number): Promise<CharacterFriend[]>
groupsGet(id: number): Promise<CharacterGroup[]>
guestbookPageGet(id: number, page: number, unapproved: boolean): Promise<GuestbookState>
guestbookPostApprove(id: number, approval: boolean): Promise<void>
guestbookPostDelete(id: number): Promise<void>
guestbookPostPost(target: number, source: number, message: string, privatePost: boolean): Promise<void>
guestbookPostReply(id: number, reply: string | null): Promise<GuestbookReply>
hasPermission?(permission: string): boolean
imagesGet(id: number): Promise<CharacterImage[]>
imageUrl(image: CharacterImage): string
imageThumbUrl(image: CharacterImage): string
kinksGet(id: number): Promise<CharacterKink[]>
memoUpdate(id: number, memo: string): Promise<MemoReply>
}
export interface SharedKinks {
kinks: {[key: string]: Kink | undefined}
kink_groups: {[key: string]: KinkGroup | undefined}
infotags: {[key: string]: Infotag | undefined}
infotag_groups: {[key: string]: InfotagGroup | undefined}
listitems: {[key: string]: ListItem | undefined}
}
export type SiteDate = number | string | null;
export type KinkChoice = 'favorite' | 'yes' | 'maybe' | 'no';
export type KinkChoiceFull = KinkChoice | number;
export const CONTACT_GROUP_ID = 1;
export interface DisplayKink {
id: number
name: string
description: string
choice?: KinkChoice
group: number
isCustom: boolean
hasSubkinks: boolean
subkinks: DisplayKink[]
ignore: boolean
}
export interface DisplayInfotag {
id: number
isContact: boolean
string?: string
number?: number | null
list?: number
}
export interface Kink {
id: number
name: string
description: string
kink_group: number
}
export interface KinkGroup {
id: number
name: string
description: string
sort_order: number
}
export interface Infotag {
id: number
name: string
type: 'number' | 'text' | 'list'
search_field: string
validator: string
allow_legacy: boolean
infotag_group: number
}
export interface InfotagGroup {
id: number
name: string
description: string
sort_order: number
}
export interface ListItem {
id: number
name: string
value: string
sort_order: number
}
export interface CharacterFriend {
id: number
name: string
}
export interface CharacterKink {
id: number
choice: KinkChoice
}
export interface CharacterInfotag {
list?: number
string?: string
number?: number
}
export interface CharacterCustom {
id: number
choice: KinkChoice
name: string
description: string
}
export interface CharacterInline {
id: number
hash: string
extension: string
nsfw: boolean
}
export type CharacterImage = CharacterImageOld | CharacterImageNew;
export interface CharacterImageNew {
id: number
extension: string
description: string
hash: string
sort_order: number | null
}
export interface CharacterImageOld {
id: number
extension: string
height: number
width: number
description: string
sort_order: number | null
url: string
}
export type CharacterName = string | CharacterNameDetails;
export interface CharacterNameDetails {
id: number
name: string
deleted: boolean
}
export type ThreadOrderMode = 'post' | 'explicit';
export interface GroupPermissions {
view: boolean
edit: boolean
threads: boolean
permissions: boolean
}
export interface CharacterGroup {
id: number
title: string
public: boolean
description: string
threadCount: number
orderMode: ThreadOrderMode
createdAt: SiteDate
myPermissions: GroupPermissions
character: CharacterName
owner: boolean
}
export interface CharacterInfo {
readonly id: number
readonly name: string
readonly description: string
readonly title?: string
readonly created_at: SiteDate
readonly updated_at: SiteDate
readonly views: number
readonly last_online_at?: SiteDate
readonly timezone?: number
readonly image_count?: number
readonly inlines: {[key: string]: CharacterInline | undefined}
images?: CharacterImage[]
readonly kinks: {[key: string]: KinkChoiceFull | undefined}
readonly customs: CharacterCustom[]
readonly infotags: {[key: string]: CharacterInfotag | undefined}
readonly online_chat?: boolean
}
export interface CharacterSettings {
readonly customs_first: boolean
readonly show_friends: boolean
readonly badges: boolean
readonly guestbook: boolean
readonly prevent_bookmarks: boolean
readonly public: boolean
}
export interface Character {
readonly is_self: boolean
character: CharacterInfo
readonly settings: CharacterSettings
readonly badges?: string[]
memo?: {
id: number
memo: string
}
readonly character_list?: {
id: number
name: string
}[]
bookmarked?: boolean
readonly self_staff: boolean
readonly ban?: string
readonly ban_reason?: string
readonly timeout?: number
readonly block_reason?: string
}
export interface GuestbookPost {
readonly id: number
readonly character: CharacterNameDetails
approved: boolean
readonly private: boolean
postedAt: SiteDate
message: string
reply: string | null
repliedAt: SiteDate
canEdit: boolean
deleted?: boolean
}
export interface GuestbookReply {
readonly reply: string
readonly postId: number
readonly repliedAt: SiteDate
}
export interface GuestbookState {
posts: GuestbookPost[]
readonly nextPage: boolean
readonly canEdit: boolean
}
export interface MemoReply {
readonly id: number
readonly memo: string
readonly updated_at: SiteDate
}
export interface DuplicateResult {
// Url to redirect user to when duplication is complete.
readonly next: string
}
export type RenameResult = DuplicateResult;
export interface CharacterNameCheckResult {
valid: boolean
}
export interface CharacterReportData {
subject: string
message: string
character: number | null
type: string
url: string
reported_character: number
}
export interface Friend {
id: number
source: CharacterNameDetails
target: CharacterNameDetails
createdAt: SiteDate
}
export type FriendRequest = Friend;
export interface FriendsByCharacter {
existing: Friend[]
pending: FriendRequest[]
incoming: FriendRequest[]
name: string
}

View File

@ -0,0 +1,61 @@
<template>
<div class="character-kink" :class="kinkClasses" :id="kinkId" :title="kink.description" @click="toggleSubkinks" :data-custom="customId">
<i v-show="kink.hasSubkinks" class="fa" :class="{'fa-minus': !listClosed, 'fa-plus': listClosed}"></i>
<i v-show="!kink.hasSubkinks && kink.isCustom" class="fa fa-dot-circle-o custom-kink-icon"></i>
<span class="kink-name">{{ kink.name }}</span>
<template v-if="kink.hasSubkinks">
<div class="subkink-list" :class="{closed: this.listClosed}">
<kink v-for="subkink in kink.subkinks" :kink="subkink" :key="kink.id" :comparisons="comparisons"
:highlights="highlights"></kink>
</div>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {DisplayKink} from './interfaces';
@Component({
name: 'kink'
})
export default class KinkView extends Vue {
@Prop({required: true})
readonly kink: DisplayKink;
@Prop({required: true})
readonly highlights: {[key: number]: boolean};
@Prop({required: true})
readonly comparisons: {[key: number]: string | undefined};
listClosed = true;
toggleSubkinks(): void {
if(!this.kink.hasSubkinks)
return;
this.listClosed = !this.listClosed;
}
get kinkId(): number {
return this.kink.isCustom ? -this.kink.id : this.kink.id;
}
get kinkClasses(): {[key: string]: boolean} {
const classes: {[key: string]: boolean} = {
'stock-kink': !this.kink.isCustom,
'custom-kink': this.kink.isCustom,
highlighted: !this.kink.isCustom && this.highlights[this.kink.id],
subkink: this.kink.hasSubkinks
};
classes[`kink-id-${this.kinkId}`] = true;
classes[`kink-group-${this.kink.group}`] = true;
if(!this.kink.isCustom && typeof this.comparisons[this.kink.id] !== 'undefined')
classes[`comparison-${this.comparisons[this.kink.id]}`] = true;
return classes;
}
get customId(): number | undefined {
return this.kink.isCustom ? this.kink.id : undefined;
}
}
</script>

View File

@ -0,0 +1,209 @@
<template>
<div class="character-kinks-block" @contextmenu="contextMenu" @touchstart="contextMenu" @touchend="contextMenu">
<div class="compare-highlight-block clearfix">
<div v-if="shared.authenticated" class="quick-compare-block pull-left form-inline">
<character-select v-model="characterToCompare"></character-select>
<button class="btn btn-primary" @click="compareKinks" :disabled="loading || !characterToCompare">
{{ compareButtonText }}
</button>
</div>
<div class="pull-right form-inline">
<select v-model="highlightGroup" class="form-control">
<option :value="null">None</option>
<option v-for="group in kinkGroups" :value="group.id" :key="group.id">{{group.name}}</option>
</select>
</div>
</div>
<div class="character-kinks clearfix">
<div class="col-xs-3 kinks-favorite">
<div class="kinks-column">
<div class="kinks-header">
Favorite
</div>
<hr>
<kink v-for="kink in groupedKinks['favorite']" :kink="kink" :key="kink.id" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
<div class="col-xs-3 kinks-yes">
<div class="kinks-column">
<div class="kinks-header">
Yes
</div>
<hr>
<kink v-for="kink in groupedKinks['yes']" :kink="kink" :key="kink.id" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
<div class="col-xs-3 kinks-maybe">
<div class="kinks-column">
<div class="kinks-header">
Maybe
</div>
<hr>
<kink v-for="kink in groupedKinks['maybe']" :kink="kink" :key="kink.id" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
<div class="col-xs-3 kinks-no">
<div class="kinks-column">
<div class="kinks-header">
No
</div>
<hr>
<kink v-for="kink in groupedKinks['no']" :kink="kink" :key="kink.id" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
</div>
<context-menu v-if="shared.authenticated" prop-name="custom" ref="context-menu"></context-menu>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import * as Utils from '../utils';
import CopyCustomMenu from './copy_custom_menu.vue';
import {methods, Store} from './data_store';
import {Character, DisplayKink, Kink, KinkChoice, KinkGroup} from './interfaces';
import KinkView from './kink.vue';
@Component({
components: {
'context-menu': CopyCustomMenu,
kink: KinkView
}
})
export default class CharacterKinksView extends Vue {
//tslint:disable:no-null-keyword
@Prop({required: true})
private readonly character: Character;
private shared = Store;
characterToCompare = Utils.Settings.defaultCharacter;
highlightGroup: number | null = null;
private loading = false;
private comparing = false;
highlighting: {[key: string]: boolean} = {};
comparison: {[key: string]: KinkChoice} = {};
async compareKinks(): Promise<void> {
if(this.comparing) {
this.comparison = {};
this.comparing = false;
this.loading = false;
return;
}
try {
this.loading = true;
this.comparing = true;
const kinks = await methods.kinksGet(this.character.character.id);
const toAssign: {[key: number]: KinkChoice} = {};
for(const kink of kinks)
toAssign[kink.id] = kink.choice;
this.comparison = toAssign;
} catch(e) {
this.comparing = false;
this.comparison = {};
Utils.ajaxError(e, 'Unable to get kinks for comparison.');
}
this.loading = false;
}
@Watch('highlightGroup')
highlightKinks(group: number | null): void {
this.highlighting = {};
if(group === null) return;
const toAssign: {[key: string]: boolean} = {};
for(const kinkId in this.shared.kinks.kinks) {
const kink = this.shared.kinks.kinks[kinkId]!;
if(kink.kink_group === group)
toAssign[kinkId] = true;
}
this.highlighting = toAssign;
}
get kinkGroups(): {[key: string]: KinkGroup | undefined} {
return this.shared.kinks.kink_groups;
}
get compareButtonText(): string {
if(this.loading)
return 'Loading...';
return this.comparing ? 'Clear' : 'Compare';
}
get groupedKinks(): {[key in KinkChoice]: DisplayKink[]} | undefined {
const kinks = this.shared.kinks.kinks;
const characterKinks = this.character.character.kinks;
const characterCustoms = this.character.character.customs;
const displayCustoms: {[key: string]: DisplayKink | undefined} = {};
const outputKinks: {[key: string]: DisplayKink[]} = {favorite: [], yes: [], maybe: [], no: []};
const makeKink = (kink: Kink): DisplayKink => ({
id: kink.id,
name: kink.name,
description: kink.description,
group: kink.kink_group,
isCustom: false,
hasSubkinks: false,
ignore: false,
subkinks: []
});
const kinkSorter = (a: DisplayKink, b: DisplayKink) => {
if(this.character.settings.customs_first && a.isCustom !== b.isCustom)
return a.isCustom < b.isCustom ? 1 : -1;
if(a.name === b.name)
return 0;
return a.name < b.name ? -1 : 1;
};
for(const custom of characterCustoms)
displayCustoms[custom.id] = {
id: custom.id,
name: custom.name,
description: custom.description,
choice: custom.choice,
group: -1,
isCustom: true,
hasSubkinks: false,
ignore: false,
subkinks: []
};
for(const kinkId in characterKinks) {
const kinkChoice = characterKinks[kinkId]!;
const kink = kinks[kinkId];
if(kink === undefined) return;
const newKink = makeKink(kink);
if(typeof kinkChoice === 'number' && typeof displayCustoms[kinkChoice] !== 'undefined') {
const custom = displayCustoms[kinkChoice]!;
newKink.ignore = true;
custom.hasSubkinks = true;
custom.subkinks.push(newKink);
}
if(!newKink.ignore)
outputKinks[kinkChoice].push(newKink);
}
for(const customId in displayCustoms) {
const custom = displayCustoms[customId]!;
if(custom.hasSubkinks)
custom.subkinks.sort(kinkSorter);
outputKinks[<string>custom.choice].push(custom);
}
for(const choice in outputKinks)
outputKinks[choice].sort(kinkSorter);
return <{[key in KinkChoice]: DisplayKink[]}>outputKinks;
}
contextMenu(event: TouchEvent): void {
(<CopyCustomMenu>this.$refs['context-menu']).outerClick(event);
}
}
</script>

View File

@ -0,0 +1,71 @@
<template>
<div id="memoDialog" tabindex="-1" class="modal" ref="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
<h4 class="modal-title">Memo for {{name}}</h4>
</div>
<div class="modal-body">
<div class="form-group" v-if="editing">
<textarea v-model="message" maxlength="1000" class="form-control"></textarea>
</div>
<div v-if="!editing">
<p>{{message}}</p>
<p><a href="#" @click="editing=true">Edit</a></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
<button v-if="editing" class="btn btn-primary" @click="save" :disabled="saving">Save and Close</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {methods} from './data_store';
import {Character} from './interfaces';
@Component
export default class MemoDialog extends Vue {
@Prop({required: true})
private readonly character: Character;
private message = '';
editing = false;
saving = false;
get name(): string {
return this.character.character.name;
}
show(): void {
if(this.character.memo !== undefined)
this.message = this.character.memo.memo;
$(this.$refs['dialog']).modal('show');
}
async save(): Promise<void> {
try {
this.saving = true;
const memoReply = await methods.memoUpdate(this.character.character.id, this.message);
if(this.message === '')
this.$emit('memo', undefined);
else
this.$emit('memo', memoReply);
this.saving = false;
$(this.$refs['dialog']).modal('hide');
} catch(e) {
this.saving = false;
}
}
}
</script>

View File

@ -0,0 +1,141 @@
<template>
<div id="reportDialog" tabindex="-1" class="modal" ref="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"
aria-label="Close">&times;
</button>
<h4 class="modal-title">Report Character {{ name }}</h4>
</div>
<div class="modal-body form-horizontal">
<div class="form-group">
<label class="col-xs-4 control-label">Type:</label>
<div class="col-xs-8">
<select v-select="validTypes" v-model="type" class="form-control">
</select>
</div>
</div>
<div v-if="type !== 'takedown'">
<div class="form-group" v-if="type === 'profile'">
<label class="col-xs-4 control-label">Violation Type:</label>
<div class="col-xs-8">
<select v-select="violationTypes" v-model="violation" class="form-control">
</select>
</div>
</div>
<div class="form-group">
<label class="col-xs-4 control-label">Your Character:</label>
<div class="col-xs-8">
<character-select v-model="ourCharacter"></character-select>
</div>
</div>
<div class="form-group">
<label class="col-xs-4 control-label">Reason/Message:</label>
<div class="col-xs-8">
<bbcode-editor v-model="message" :maxlength="45000"
:classes="'form-control'"></bbcode-editor>
</div>
</div>
</div>
<div v-show="type === 'takedown'" class="alert alert-info">
Please file art takedowns from the <a :href="ticketUrl">tickets page.</a>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
<button :disabled="!dataValid || submitting" class="btn btn-primary" @click="submitReport">
Report Character
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, SelectItem} from './interfaces';
@Component
export default class ReportDialog extends Vue {
@Prop({required: true})
private readonly character: Character;
private ourCharacter = Utils.Settings.defaultCharacter;
private type = '';
private violation = '';
private message = '';
submitting = false;
ticketUrl = `${Utils.siteDomain}tickets/new`;
validTypes: ReadonlyArray<SelectItem> = [
{text: 'None', value: ''},
{text: 'Profile Violation', value: 'profile'},
{text: 'Name Request', value: 'name_request'},
{text: 'Art Takedown', value: 'takedown'},
{text: 'Other', value: 'other'}
];
violationTypes: ReadonlyArray<string> = [
'Real life images on underage character',
'Real life animal images on sexual character',
'Amateur/farmed real life images',
'Defamation',
'OOC Kinks',
'Real life contact information',
'Solicitation for real life contact',
'Other'
];
get name(): string {
return this.character.character.name;
}
get dataValid(): boolean {
if(this.type === '' || this.type === 'takedown')
return false;
if(this.message === '')
return false;
if(this.type === 'profile' && this.violation === '')
return false;
return true;
}
show(): void {
$(this.$refs['dialog']).modal('show');
}
async submitReport(): Promise<void> {
try {
this.submitting = true;
const message = (this.type === 'profile' ? `Reporting character for violation: ${this.violation}\n\n` : '') + this.message;
await methods.characterReport({
subject: (this.type === 'name_request' ? 'Requesting name: ' : 'Reporting character: ') + this.name,
message,
character: this.ourCharacter,
type: this.type,
url: Utils.characterURL(this.name),
reported_character: this.character.character.id
});
this.submitting = false;
$(this.$refs['dialog']).modal('hide');
Utils.flashSuccess('Character reported.');
} catch(e) {
this.submitting = false;
Utils.ajaxError(e, 'Unable to report character');
}
}
}
</script>

View File

@ -0,0 +1,252 @@
<template>
<div id="character-page-sidebar">
<span class="character-name">{{ character.character.name }}</span>
<div v-if="character.character.title" class="character-title">{{ character.character.title }}</div>
<character-action-menu :character="character"></character-action-menu>
<div>
<img :src="avatarUrl(character.character.name)" class="character-avatar">
</div>
<div v-if="authenticated" class="character-links-block">
<template v-if="character.is_self">
<a :href="editUrl" class="edit-link"><i class="fa fa-pencil"></i>Edit</a>
<a @click="showDelete" class="delete-link"><i class="fa fa-trash"></i>Delete</a>
<a @click="showDuplicate" class="duplicate-link"><i class="fa fa-copy"></i>Duplicate</a>
</template>
<template v-else>
<span v-if="character.self_staff || character.settings.prevent_bookmarks !== true">
<a @click="toggleBookmark" :class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}">
{{ character.bookmarked ? '-' : '+' }}Bookmark
</a>
<span v-if="character.settings.prevent_bookmarks" class="prevents-bookmarks">!</span>
</span>
<a @click="showFriends" class="friend-link"><i class="fa fa-user"></i>Friend</a>
<a @click="showReport" class="report-link"><i class="fa fa-exclamation-triangle"></i>Report</a>
</template>
<a @click="showMemo" class="memo-link"><i class="fa fa-sticky-note-o"></i>Memo</a>
</div>
<div v-if="character.badges && character.badges.length > 0" class="badges-block">
<div v-for="badge in character.badges" class="character-badge" :class="badgeClass(badge)">
<i class="fa fa-fw" :class="badgeIconClass(badge)"></i> {{ badgeTitle(badge) }}
</div>
</div>
<a v-if="authenticated && !character.is_self" :href="noteUrl" class="character-page-note-link">Send Note</a>
<div v-if="character.character.online_chat" @click="showInChat" class="character-page-online-chat">Online In Chat</div>
<div class="contact-block">
<contact-method v-for="method in contactMethods" :method="method" :key="method.id"></contact-method>
</div>
<div class="quick-info-block">
<infotag-item v-for="infotag in quickInfoItems" :infotag="infotag" :key="infotag.id"></infotag-item>
<div class="quick-info">
<span class="quick-info-label">Created: </span>
<span class="quick-info-value"><date :time="character.character.created_at"></date></span>
</div>
<div class="quick-info">
<span class="quick-info-label">Last updated: </span>
<span class="quick-info-value"><date :time="character.character.updated_at"></date></span>
</div>
<div class="quick-info" v-if="character.character.last_online_at">
<span class="quick-info-label">Last online:</span>
<span class="quick-info-value"><date :time="character.character.last_online_at"></date></span>
</div>
<div class="quick-info">
<span class="quick-info-label">Views: </span>
<span class="quick-info-value">{{character.character.views}}</span>
</div>
<div class="quick-info" v-if="character.character.timezone != null">
<span class="quick-info-label">Timezone:</span>
<span class="quick-info-value">
UTC{{character.character.timezone > 0 ? '+' : ''}}{{character.character.timezone != 0 ? character.character.timezone : ''}}
</span>
</div>
</div>
<div class="character-list-block">
<div v-for="listCharacter in character.character_list">
<img :src="avatarUrl(listCharacter.name)" class="character-avatar icon">
<character-link :character="listCharacter.name"></character-link>
</div>
</div>
<template>
<memo-dialog :character="character" ref="memo-dialog" @memo="memo"></memo-dialog>
<delete-dialog :character="character" ref="delete-dialog"></delete-dialog>
<rename-dialog :character="character" ref="rename-dialog"></rename-dialog>
<duplicate-dialog :character="character" ref="duplicate-dialog"></duplicate-dialog>
<report-dialog v-if="authenticated && !character.is_self" :character="character" ref="report-dialog"></report-dialog>
<friend-dialog :character="character" ref="friend-dialog"></friend-dialog>
<block-dialog :character="character" ref="block-dialog"></block-dialog>
</template>
</div>
</template>
<script lang="ts">
import Vue, {Component as VueComponent, ComponentOptions, CreateElement, VNode} from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../utils';
import {methods, registeredComponents, Store} from './data_store';
import {Character, CONTACT_GROUP_ID, Infotag, SharedStore} from './interfaces';
import DateDisplay from '../../components/date_display.vue';
import InfotagView from './infotag.vue';
import ContactMethodView from './contact_method.vue';
import DeleteDialog from './delete_dialog.vue';
import DuplicateDialog from './duplicate_dialog.vue';
import FriendDialog from './friend_dialog.vue';
import MemoDialog from './memo_dialog.vue';
import ReportDialog from './report_dialog.vue';
interface ShowableVueDialog extends Vue {
show(): void
}
function resolveComponent(name: string): () => Promise<VueComponent | ComponentOptions<Vue>> {
return async(): Promise<VueComponent | ComponentOptions<Vue>> => {
if(typeof registeredComponents[name] === 'undefined')
return {
render(createElement: CreateElement): VNode {
return createElement('span');
},
name
};
return registeredComponents[name]!;
};
}
Vue.component('block-dialog', resolveComponent('block-dialog'));
Vue.component('rename-dialog', resolveComponent('rename-dialog'));
Vue.component('character-action-menu', resolveComponent('character-action-menu'));
@Component({
components: {
'contact-method': ContactMethodView,
date: DateDisplay,
'delete-dialog': DeleteDialog,
'duplicate-dialog': DuplicateDialog,
'friend-dialog': FriendDialog,
'infotag-item': InfotagView,
'memo-dialog': MemoDialog,
'report-dialog': ReportDialog
}
})
export default class Sidebar extends Vue {
@Prop({required: true})
readonly character: Character;
readonly shared: SharedStore = Store;
readonly quickInfoIds: ReadonlyArray<number> = [1, 3, 2, 49, 9, 29, 15, 41, 25]; // Do not sort these.
readonly avatarUrl = Utils.avatarURL;
badgeClass(badgeName: string): string {
return `character-badge-${badgeName.replace('.', '-')}`;
}
badgeIconClass(badgeName: string): string {
const classMap: {[key: string]: string} = {
admin: 'fa-star',
global: 'fa-star-o',
chatop: 'fa-commenting',
chanop: 'fa-commenting-o',
helpdesk: 'fa-user',
developer: 'fa-terminal',
'subscription.lifetime': 'fa-certificate'
};
return badgeName in classMap ? classMap[badgeName] : '';
}
badgeTitle(badgeName: string): string {
const badgeMap: {[key: string]: string} = {
admin: 'Administrator',
global: 'Global Moderator',
chatop: 'Chat Moderator',
chanop: 'Channel Moderator',
helpdesk: 'Helpdesk',
developer: 'Developer',
'subscription.lifetime': 'Lifetime Subscriber',
'subscription.other': 'Subscriber'
};
return badgeName in badgeMap ? badgeMap[badgeName] : badgeName;
}
showDelete(): void {
(<ShowableVueDialog>this.$refs['delete-dialog']).show();
}
showDuplicate(): void {
(<ShowableVueDialog>this.$refs['duplicate-dialog']).show();
}
showMemo(): void {
(<ShowableVueDialog>this.$refs['memo-dialog']).show();
}
showReport(): void {
(<ShowableVueDialog>this.$refs['report-dialog']).show();
}
showFriends(): void {
(<ShowableVueDialog>this.$refs['friend-dialog']).show();
}
async toggleBookmark(): Promise<void> {
const previousState = this.character.bookmarked;
try {
const state = !this.character.bookmarked;
this.$emit('bookmarked', state);
const actualState = await methods.bookmarkUpdate(this.character.character.id, state);
this.$emit('bookmarked', actualState);
} catch(e) {
this.$emit('bookmarked', previousState);
Utils.ajaxError(e, 'Unable to change bookmark state.');
}
}
get editUrl(): string {
return `${Utils.siteDomain}character/${this.character.character.id}/`;
}
get noteUrl(): string {
return `${Utils.siteDomain}notes/folder/1/0?target=${this.character.character.name}`;
}
get contactMethods(): object[] {
const contactInfotags = Utils.groupObjectBy(Store.kinks.infotags, 'infotag_group');
contactInfotags[CONTACT_GROUP_ID]!.sort((a: Infotag, b: Infotag) => a.name < b.name ? -1 : 1);
const contactMethods = [];
for(const infotag of contactInfotags[CONTACT_GROUP_ID]!) {
const charTag = this.character.character.infotags[infotag.id];
if(charTag === undefined) continue;
contactMethods.push({
id: infotag.id,
value: charTag.string
});
}
return contactMethods;
}
get quickInfoItems(): object[] {
const quickItems = [];
for(const id of this.quickInfoIds) {
const infotag = this.character.character.infotags[id];
if(infotag === undefined) continue;
quickItems.push({
id,
string: infotag.string,
list: infotag.list,
number: infotag.number
});
}
return quickItems;
}
get authenticated(): boolean {
return Store.authenticated;
}
memo(memo: object): void {
this.$emit('memo', memo);
}
}
</script>

View File

@ -0,0 +1,60 @@
import Vue, {VNodeDirective} from 'vue';
//tslint:disable:strict-boolean-expressions
type Option = { value: string | null, disabled: boolean, text: string, label: string, options: Option[]} | string | number;
function rebuild(e: HTMLElement, binding: VNodeDirective): void {
const el = <HTMLSelectElement>e;
if(binding.oldValue === binding.value) return;
if(!binding.value) console.error('Must provide a value');
const value = <Option[]>binding.value;
function _isObject(val: any): val is object { //tslint:disable-line:no-any
return val !== null && typeof val === 'object';
}
function clearOptions(): void {
let i = el.options.length;
while(i--) {
const opt = el.options[i];
const parent = opt.parentNode!;
if(parent === el) parent.removeChild(opt);
else {
el.removeChild(parent);
i = el.options.length;
}
}
}
function buildOptions(parent: HTMLElement, options: Option[]): void {
let newEl: (HTMLOptionElement & {'_value'?: string | null});
for(let i = 0, l = options.length; i < l; i++) {
const op = options[i];
if(!_isObject(op) || !op.options) {
newEl = document.createElement('option');
if(typeof op === 'string' || typeof op === 'number')
newEl.text = newEl.value = op as string;
else {
if(op.value !== null && !_isObject(op.value))
newEl.value = op.value;
newEl['_value'] = op.value;
newEl.text = op.text || '';
if(op.disabled)
newEl.disabled = true;
}
} else {
newEl = document.createElement('optgroup');
newEl.label = op.label;
buildOptions(newEl, op.options);
}
parent.appendChild(newEl);
}
}
clearOptions();
buildOptions(el, value);
}
export default Vue.directive('select', {
inserted: rebuild,
update: rebuild
});

93
site/flash_display.ts Normal file
View File

@ -0,0 +1,93 @@
import Vue from 'vue';
export type flashMessageType = 'info' | 'success' | 'warning' | 'danger';
let boundHandler;
interface FlashComponent extends Vue {
lastId: number
floating: boolean
messages: {
id: number
message: string
classes: string
}[]
removeMessage(id: number)
}
export function addFlashMessage(type: flashMessageType, message: string): void {
instance.addMessage(type, message);
}
function bindEventHandler(vm): void {
boundHandler = eventHandler.bind(vm);
document.addEventListener('scroll', boundHandler);
document.addEventListener('resize', boundHandler);
}
function removeHandlers(): void {
document.removeEventListener('scroll', boundHandler);
document.removeEventListener('resize', boundHandler);
boundHandler = undefined;
}
function eventHandler(this: FlashComponent): void {
const isElementVisible = (el: Element): boolean => {
const rect = el.getBoundingClientRect();
const vHeight = window.innerWidth || document.documentElement.clientHeight;
const vWidth = window.innerWidth || document.documentElement.clientWidth;
const efp = (x, y) => document.elementFromPoint(x, y);
if(rect.top > vHeight || rect.bottom < 0 || rect.left > vWidth || rect.right < 0)
return false;
return true;
//return (el.contains(efp(rect.left, rect.top)) || el.contains(efp(rect.right, rect.top)));
};
this.floating = !isElementVisible(this.$refs['detector'] as Element);
}
function addMessage(this: FlashComponent, type: flashMessageType, message: string): void {
if(!boundHandler) {
bindEventHandler(this);
boundHandler();
}
const newId = this.lastId++;
this.messages.push({id: newId, message, classes: `flash-message alert-${type}`});
setTimeout(() => {
this.removeMessage(newId);
}, 15000);
}
function removeMessage(id: number): void {
this.messages = this.messages.filter(function(item) {
return item['id'] !== id;
});
if(this.messages.length === 0)
removeHandlers();
}
interface FlashMessageManager {
addMessage(type: flashMessageType, message: string): void
removeMessage(id: number): void
}
const instance: Vue & FlashMessageManager = new Vue({
template: '#flashMessagesTemplate',
el: '#flashMessages',
data() {
return {
lastId: 1,
messages: [],
floating: false
};
},
computed: {
containerClasses(this: FlashComponent): string {
return this.floating ? 'flash-messages-fixed' : 'flash-messages';
}
},
methods: {
addMessage,
removeMessage
}
}) as Vue & FlashMessageManager;

111
site/utils.ts Normal file
View File

@ -0,0 +1,111 @@
import Axios, {AxiosError, AxiosResponse} from 'axios';
//import {addFlashMessage, flashMessageType} from './flash_display';
import {InlineDisplayMode} from '../bbcode/interfaces';
export function avatarURL(name: string): string {
const uregex = /^[a-zA-Z0-9_\-\s]+$/;
if(!uregex.test(name)) return '#';
return `${staticDomain}images/avatar/${name.toLowerCase()}.png`;
}
export function characterURL(name: string): string {
const uregex = /^[a-zA-Z0-9_\-\s]+$/;
if(!uregex.test(name)) return '#';
return `${siteDomain}c/${name}`;
}
interface Dictionary<T> {
[key: string]: T | undefined;
}
export function groupObjectBy<K extends string, T extends {[k in K]: string | number}>(obj: Dictionary<T>, key: K): Dictionary<T[]> {
const newObject: Dictionary<T[]> = {};
for(const objkey in obj) {
if(!(objkey in obj)) continue;
const realItem = obj[objkey]!;
const newKey = realItem[key];
if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
newObject[newKey]!.push(realItem);
}
return newObject;
}
export function groupArrayBy<K extends string, T extends {[k in K]: string | number}>(arr: T[], key: K): Dictionary<T[]> {
const newObject: Dictionary<T[]> = {};
arr.map((item) => {
const realItem = item;
const newKey = realItem[key];
if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
newObject[newKey]!.push(realItem);
});
return newObject;
}
export function filterOut<K extends string, V, T extends {[key in K]: V}>(arr: ReadonlyArray<T>, field: K, value: V): T[] {
return arr.filter((item) => item[field] !== value);
}
//tslint:disable-next-line:no-any
export function isJSONError(error: any): error is Error & {response: AxiosResponse<{[key: string]: object | string | number}>} {
return (<AxiosError>error).response !== undefined && typeof (<AxiosError>error).response!.data === 'object';
}
export function ajaxError(error: any, prefix: string, showFlashMessage: boolean = true): void { //tslint:disable-line:no-any
let message: string | undefined;
if(error instanceof Error) {
if(Axios.isCancel(error)) return;
if(isJSONError(error)) {
const data = <{error?: string | string[]}>error.response.data;
if(typeof (data.error) === 'string')
message = data.error;
else if(typeof (data.error) === 'object' && data.error.length > 0)
message = data.error[0];
}
if(message === undefined)
message = (<Error & {response?: AxiosResponse}>error).response !== undefined ?
(<Error & {response: AxiosResponse}>error).response.statusText : error.name;
} else message = <string>error;
if(showFlashMessage) flashError(`[ERROR] ${prefix}: ${message}`);
}
export function flashError(message: string): void {
flashMessage('danger', message);
}
export function flashSuccess(message: string): void {
flashMessage('success', message);
}
export function flashMessage(type: string, message: string): void {
console.log(`${type}: ${message}`); //TODO addFlashMessage(type, message);
}
export let siteDomain = '';
export let staticDomain = '';
interface Settings {
animatedIcons: boolean
inlineDisplayMode: InlineDisplayMode
defaultCharacter: number
fuzzyDates: boolean
}
export let Settings: Settings = {
animatedIcons: false,
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL,
defaultCharacter: -1,
fuzzyDates: true
};
export function setDomains(site: string, stat: string): void {
siteDomain = site;
staticDomain = stat;
}
export function copySettings(settings: Settings): void {
Settings.animatedIcons = settings.animatedIcons;
Settings.inlineDisplayMode = settings.inlineDisplayMode;
Settings.defaultCharacter = settings.defaultCharacter;
Settings.fuzzyDates = settings.fuzzyDates;
}

View File

@ -54,6 +54,7 @@
true,
"array"
],
"await-promise": [true, "AxiosPromise"],
"comment-format": false,
"completed-docs": false,
"curly": [
@ -62,6 +63,7 @@
],
"cyclomatic-complexity": false,
"eofline": false,
"forin": false,
"interface-name": false,
"interface-over-type-literal": false,
"linebreak-style": false,
@ -74,12 +76,7 @@
true,
"no-public"
],
"member-ordering": [
true,
{
"order": "fields-first"
}
],
"member-ordering": false,
"newline-before-return": false,
"no-angle-bracket-type-assertion": false,
"no-bitwise": false,
@ -89,6 +86,7 @@
"no-console": false,
"no-default-export": false,
"no-floating-promises": [true, "AxiosPromise"],
"no-implicit-dependencies": false,
"no-import-side-effect": [
true,
{
@ -100,7 +98,6 @@
"no-non-null-assertion": false,
"no-parameter-properties": false,
"no-parameter-reassignment": false,
//covered by --noImplicitAny
"no-string-literal": false,
"no-submodule-imports": [true, "vue", "bootstrap"],
"no-unused-variable": false,
@ -137,6 +134,7 @@
true,
"never"
],
"strict-boolean-expressions": [true, "allow-boolean-or-undefined"],
"switch-default": false,
"trailing-comma": [
true,
@ -168,8 +166,7 @@
"check-type-operator",
"check-rest-spread"
],
"vue-props": true,
"no-return-await": true
"vue-props": true
},
"rulesDirectory": ["./tslint"]
}

View File

@ -1,36 +0,0 @@
"use strict";
exports.__esModule = true;
var tslib_1 = require("tslib");
var Lint = require("tslint");
var ts = require("typescript");
var Rule = /** @class */ (function (_super) {
tslib_1.__extends(Rule, _super);
function Rule() {
return _super !== null && _super.apply(this, arguments) || this;
}
Rule.prototype.apply = function (sourceFile) {
return this.applyWithFunction(sourceFile, walk, undefined);
};
return Rule;
}(Lint.Rules.AbstractRule));
exports.Rule = Rule;
function walk(ctx) {
if (ctx.sourceFile.isDeclarationFile)
return;
return ts.forEachChild(ctx.sourceFile, cb);
function cb(node) {
if (node.kind !== ts.SyntaxKind.ReturnStatement || node.expression === undefined)
return ts.forEachChild(node, cb);
var curNode = node.expression;
while (true) {
switch (curNode.kind) {
case ts.SyntaxKind.ParenthesizedExpression:
curNode = curNode.expression;
continue;
case ts.SyntaxKind.AwaitExpression:
ctx.addFailureAtNode(node, 'return await is redundant');
}
break;
}
}
}