This commit is contained in:
Maya 2019-01-03 18:38:17 +01:00
parent 8810b29552
commit a5e57cd52c
104 changed files with 1797 additions and 1653 deletions

View File

@ -5,7 +5,7 @@
style="border-bottom-left-radius:0;border-bottom-right-radius:0" v-if="hasToolbar">
<i class="fa fa-code"></i>
</a>
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? 'display:flex' : ''" @mousedown.stop.prevent
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? {display: 'flex'} : undefined" @mousedown.stop.prevent
v-if="hasToolbar" style="flex:1 51%">
<div class="btn-group" style="flex-wrap:wrap">
<div class="btn btn-light btn-sm" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
@ -21,7 +21,7 @@
<div class="bbcode-editor-text-area" style="order:100;width:100%;">
<textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength" :placeholder="placeholder"
:class="finalClasses" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" @keypress="$emit('keypress', $event)"
:style="hasToolbar ? 'border-top-left-radius:0' : ''"@keydown="onKeyDown"></textarea>
:style="hasToolbar ? {'border-top-left-radius': 0} : undefined" @keydown="onKeyDown"></textarea>
<textarea ref="sizer"></textarea>
<div class="bbcode-preview" v-show="preview">
<div class="bbcode-preview-warnings">
@ -36,9 +36,8 @@
</template>
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {BBCodeElement} from '../chat/bbcode';
import {getKey} from '../chat/common';
import {Keys} from '../keys';
@ -82,6 +81,7 @@
//tslint:disable:strict-boolean-expressions
private resizeListener!: () => void;
@Hook('created')
created(): void {
this.parser = new CoreBBCodeParser();
this.resizeListener = () => {
@ -91,6 +91,7 @@
};
}
@Hook('mounted')
mounted(): void {
this.element = <HTMLTextAreaElement>this.$refs['input'];
const styles = getComputedStyle(this.element);
@ -113,8 +114,10 @@
this.resize();
window.addEventListener('resize', this.resizeListener);
}
//tslint:enable
@Hook('destroyed')
destroyed(): void {
window.removeEventListener('resize', this.resizeListener);
}
@ -189,7 +192,7 @@
// Allow emitted variations for custom buttons.
this.$once('insert', (startText: string, endText: string) => this.applyText(startText, endText));
if(button.handler !== undefined)
return <void>button.handler.call(this, this);
return button.handler.call(this, this);
if(button.startText === undefined)
button.startText = `[${button.tag}]`;
if(button.endText === undefined)

View File

@ -72,9 +72,8 @@ export class CoreBBCodeParser extends BBCodeParser {
a.textContent = display;
element.appendChild(a);
const span = document.createElement('span');
span.className = 'link-domain';
span.className = 'link-domain bbcode-pseudo';
span.textContent = ` [${domain(url)}]`;
(<HTMLElement & {bbcodeHide: true}>span).bbcodeHide = true;
element.appendChild(span);
return element;
}));

View File

@ -166,10 +166,14 @@ export class BBCodeParser {
if(tag instanceof BBCodeTextTag) {
i = this.parse(input, i + 1, tag, undefined, isAllowed);
element = tag.createElement(this, parent, param, input.substring(mark, input.lastIndexOf('[', i)));
if(element === undefined) parent.appendChild(document.createTextNode(input.substring(tagStart, i + 1)));
} else {
element = tag.createElement(this, parent, param, '');
if(element === undefined) parent.appendChild(document.createTextNode(input.substring(tagStart, i + 1)));
if(!tag.noClosingTag)
i = this.parse(input, i + 1, tag, element !== undefined ? element : parent, isAllowed);
if(element === undefined)
parent.appendChild(document.createTextNode(input.substring(input.lastIndexOf('[', i), i + 1)));
}
mark = i + 1;
this._currentTag = currentTag;
@ -182,7 +186,7 @@ export class BBCodeParser {
parent.appendChild(document.createTextNode(input.substring(mark, selfAllowed ? tagStart : i + 1)));
return i;
} else if(!selfAllowed)
return tagStart - 1;
return mark - 1;
else if(isAllowed(tagKey))
this.warning(`Unexpected closing ${tagKey} tag. Needed ${self} tag instead.`);
} else if(isAllowed(tagKey)) this.warning(`Found closing ${tagKey} tag that was never opened.`);

View File

@ -1,15 +1,8 @@
import {InlineImage} from '../interfaces';
import {CoreBBCodeParser} from './core';
import {InlineDisplayMode} from './interfaces';
import {BBCodeCustomTag, BBCodeSimpleTag, BBCodeTextTag} from './parser';
interface InlineImage {
id: number
hash: string
extension: string
nsfw: boolean
name?: string
}
interface StandardParserSettings {
siteDomain: string
staticDomain: string
@ -29,7 +22,7 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
const outerEl = this.createElement('div');
const el = this.createElement('img');
el.className = 'inline-image';
el.title = el.alt = inline.name!;
el.title = el.alt = inline.name;
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
return outerEl;

View File

@ -12,7 +12,7 @@
<span class="fa fa-2x" :class="{'fa-sort-amount-down': sortCount, 'fa-sort-alpha-down': !sortCount}"></span>
</a>
</div>
<div style="overflow: auto;" v-show="tab == 0">
<div style="overflow: auto;" v-show="tab === '0'">
<div v-for="channel in officialChannels" :key="channel.id">
<label :for="channel.id">
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
@ -20,7 +20,7 @@
</label>
</div>
</div>
<div style="overflow: auto;" v-show="tab == 1">
<div style="overflow: auto;" v-show="tab === '1'">
<div v-for="channel in openRooms" :key="channel.id">
<label :for="channel.id">
<input type="checkbox" :checked="channel.isJoined" :id="channel.id" @click.prevent="setJoined(channel)"/>
@ -42,7 +42,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';

View File

@ -1,11 +1,14 @@
<template>
<a href="#" @click.prevent="joinChannel" :disabled="channel && channel.isJoined"><span class="fa fa-hashtag"></span>{{displayText}}</a>
<a href="#" @click.prevent="joinChannel()" :disabled="channel && channel.isJoined">
<span class="fa fa-hashtag"></span>
<template v-if="channel">{{channel.name}}<span class="bbcode-pseudo"> ({{channel.memberCount}})</span></template>
<template v-else>{{text}}</template>
</a>
</template>
<script lang="ts">
import {Component, Hook, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import core from './core';
import {Channel} from './interfaces';
@ -16,6 +19,7 @@
@Prop({required: true})
readonly text!: string;
@Hook('mounted')
mounted(): void {
core.channels.requestChannelsIfNeeded(300000);
}
@ -23,10 +27,8 @@
joinChannel(): void {
if(this.channel === undefined || !this.channel.isJoined)
core.channels.join(this.id);
}
get displayText(): string {
return this.channel !== undefined ? `${this.channel.name} (${this.channel.memberCount})` : this.text;
const channel = core.conversations.byKey(`#${this.id}`);
if(channel !== undefined) channel.show();
}
get channel(): Channel.ListItem | undefined {

View File

@ -1,5 +1,5 @@
<template>
<modal :action="l('characterSearch.action')" @submit.prevent="submit" dialogClass="w-100"
<modal :action="l('characterSearch.action')" @submit.prevent="submit()" dialogClass="w-100"
:buttonText="results ? l('characterSearch.again') : undefined" class="character-search">
<div v-if="options && !results">
<div v-show="error" class="alert alert-danger">{{error}}</div>
@ -7,7 +7,7 @@
:title="l('characterSearch.kinks')" :filterFunc="filterKink" :options="options.kinks">
<template slot-scope="s">{{s.option.name}}</template>
</filterable-select>
<filterable-select v-for="item in ['genders', 'orientations', 'languages', 'furryprefs', 'roles', 'positions']" :multiple="true"
<filterable-select v-for="item in listItems" :multiple="true"
v-model="data[item]" :placeholder="l('filter')" :title="l('characterSearch.' + item)" :options="options[item]" :key="item">
</filterable-select>
</div>
@ -26,8 +26,8 @@
</template>
<script lang="ts">
import {Component, Hook} from '@f-list/vue-ts';
import Axios from 'axios';
import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog';
import FilterableSelect from '../components/FilterableSelect.vue';
import Modal from '../components/Modal.vue';
@ -39,7 +39,7 @@
import UserView from './user_view';
type Options = {
kinks: {id: number, name: string, description: string}[],
kinks: Kink[],
listitems: {id: string, name: string, value: string}[]
};
@ -55,35 +55,30 @@
return 0;
}
interface Data {
kinks: Kink[]
genders: string[]
orientations: string[]
languages: string[]
furryprefs: string[]
roles: string[]
positions: string[]
}
@Component({
components: {modal: Modal, user: UserView, 'filterable-select': FilterableSelect, bbcode: BBCodeView}
})
export default class CharacterSearch extends CustomDialog {
//tslint:disable:no-null-keyword
l = l;
kinksFilter = '';
error = '';
results: Character[] | null = null;
results: Character[] | undefined;
characterImage = characterImage;
options: {
kinks: Kink[]
genders: string[]
orientations: string[]
languages: string[]
furryprefs: string[]
roles: string[]
positions: string[]
} | null = null;
data: {[key: string]: (string | Kink)[]} = {
kinks: <Kink[]>[],
genders: <string[]>[],
orientations: <string[]>[],
languages: <string[]>[],
furryprefs: <string[]>[],
roles: <string[]>[],
positions: <string[]>[]
};
options!: Data;
data: Data = {kinks: [], genders: [], orientations: [], languages: [], furryprefs: [], roles: [], positions: []};
listItems: ReadonlyArray<keyof Data> = ['genders', 'orientations', 'languages', 'furryprefs', 'roles', 'positions'];
@Hook('created')
async created(): Promise<void> {
if(options === undefined)
options = <Options | undefined>(await Axios.get('https://www.f-list.net/json/api/mapping-list.php')).data;
@ -99,6 +94,7 @@
});
}
@Hook('mounted')
mounted(): void {
core.connection.onMessage('ERR', (data) => {
switch(data.number) {
@ -129,15 +125,17 @@
}
submit(): void {
if(this.results !== null) {
this.results = null;
if(this.results !== undefined) {
this.results = undefined;
return;
}
this.error = '';
const data: Connection.ClientCommands['FKS'] & {[key: string]: (string | number)[]} = {kinks: []};
for(const key in this.data)
if(this.data[key].length > 0)
data[key] = key === 'kinks' ? (<Kink[]>this.data[key]).map((x) => x.id) : (<string[]>this.data[key]);
for(const key in this.data) {
const item = this.data[<keyof Data>key];
if(item.length > 0)
data[key] = key === 'kinks' ? (<Kink[]>item).map((x) => x.id) : (<string[]>item);
}
core.connection.send('FKS', data);
}
}

View File

@ -4,7 +4,7 @@
<div class="alert alert-danger" v-show="error">{{error}}</div>
<h3 class="card-header" style="margin-top:0;display:flex">
{{l('title')}}
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn" style="flex:1;text-align:right">
<a href="#" @click.prevent="showLogs()" class="btn" style="flex:1;text-align:right">
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
</h3>
@ -32,9 +32,8 @@
</template>
<script lang="ts">
import {Component, Hook, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import Modal from '../components/Modal.vue';
import Channels from '../fchat/channels';
import Characters from '../fchat/characters';
@ -46,7 +45,7 @@
import l from './localize';
import Logs from './Logs.vue';
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string, bbcodeHide?: boolean};
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string};
function copyNode(str: string, node: BBCodeNode, end: Node, range: Range, flags: {endFound?: true}): string {
if(node === end) flags.endFound = true;
@ -54,7 +53,7 @@
str = `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]${str}[/${node.bbcodeTag}]`;
if(node.nextSibling !== null && !flags.endFound) {
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\r\n';
str += scanNode(node.nextSibling!, end, range, flags);
str += scanNode(node.nextSibling, end, range, flags);
}
if(node.parentElement === null) return str;
return copyNode(str, node.parentNode!, end, range, flags);
@ -62,7 +61,7 @@
function scanNode(node: BBCodeNode, end: Node, range: Range, flags: {endFound?: true}, hide?: boolean): string {
let str = '';
hide = hide || node.bbcodeHide;
hide = hide || node instanceof HTMLElement && node.classList.contains('bbcode-pseudo');
if(node === end) flags.endFound = true;
if(node.bbcodeTag !== undefined) str += `[${node.bbcodeTag}${node.bbcodeParam !== undefined ? `=${node.bbcodeParam}` : ''}]`;
if(node instanceof Text) str += node === range.endContainer ? node.nodeValue!.substr(0, range.endOffset) : node.nodeValue;
@ -91,6 +90,7 @@
l = l;
copyPlain = false;
@Hook('mounted')
mounted(): void {
document.title = l('title', core.connection.character);
document.addEventListener('copy', ((e: ClipboardEvent) => {
@ -102,10 +102,11 @@
if(selection === null || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
let start = range.startContainer, end = range.endContainer;
let startValue: string;
let startValue = '';
if(start instanceof HTMLElement) {
start = start.childNodes[range.startOffset];
startValue = start instanceof HTMLImageElement ? start.alt : scanNode(start.firstChild!, end, range, {});
if(<Node | undefined>start === undefined) start = range.startContainer;
else startValue = start instanceof HTMLImageElement ? start.alt : scanNode(start.firstChild!, end, range, {});
} else
startValue = start.nodeValue!.substring(range.startOffset, start === range.endContainer ? range.endOffset : undefined);
if(end instanceof HTMLElement && range.endOffset > 0) end = end.childNodes[range.endOffset - 1];
@ -157,6 +158,10 @@
(<Modal>this.$refs['reconnecting']).hide();
}
showLogs(): void {
(<Logs>this.$refs['logsDialog']).show();
}
async connect(): Promise<void> {
this.connecting = true;
await core.notifications.initSounds(['attention', 'login', 'logout', 'modalert', 'newnote']);

View File

@ -1,24 +1,23 @@
<template>
<div style="height:100%; display: flex; position: relative;" id="chatView" @click="$refs['userMenu'].handleEvent($event)"
@contextmenu="$refs['userMenu'].handleEvent($event)" @touchstart.passive="$refs['userMenu'].handleEvent($event)"
@touchend="$refs['userMenu'].handleEvent($event)">
<div style="height:100%; display: flex; position: relative;" id="chatView" @click="userMenuHandle" @contextmenu="userMenuHandle" @touchstart.passive="userMenuHandle"
@touchend="userMenuHandle">
<sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars">
<img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/>
<a target="_blank" :href="ownCharacterLink" class="btn" style="margin-right:5px">{{ownCharacter.name}}</a>
<a href="#" @click.prevent="logOut" class="btn"><i class="fas fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
<a href="#" @click.prevent="logOut()" class="btn"><i class="fas fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
<div>
{{l('chat.status')}}
<a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn">
<a href="#" @click.prevent="showStatus()" class="btn">
<span class="fas fa-fw" :class="getStatusIcon(ownCharacter.status)"></span>{{l('status.' + ownCharacter.status)}}
</a>
</div>
<div style="clear:both">
<a href="#" @click.prevent="$refs['searchDialog'].show()" class="btn"><span class="fas fa-search"></span>
<a href="#" @click.prevent="showSearch()" class="btn"><span class="fas fa-search"></span>
{{l('characterSearch.open')}}</a>
</div>
<div><a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn"><span class="fas fa-cog"></span>
<div><a href="#" @click.prevent="showSettings()" class="btn"><span class="fas fa-cog"></span>
{{l('settings.open')}}</a></div>
<div><a href="#" @click.prevent="$refs['recentDialog'].show()" class="btn"><span class="fas fa-history"></span>
<div><a href="#" @click.prevent="showRecent()" class="btn"><span class="fas fa-history"></span>
{{l('chat.recentConversations')}}</a></div>
<div class="list-group conversation-nav">
<a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()"
@ -47,7 +46,7 @@
</div>
</a>
</div>
<a href="#" @click.prevent="$refs['channelsDialog'].show()" class="btn"><span class="fas fa-list"></span>
<a href="#" @click.prevent="showChannels()" class="btn"><span class="fas fa-list"></span>
{{l('chat.channels')}}</a>
<div class="list-group conversation-nav" ref="channelConversations">
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
@ -62,7 +61,7 @@
</a>
</div>
</sidebar>
<div style="width: 100%; display:flex; flex-direction:column;">
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
<div id="quick-switcher" class="list-group">
<a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()"
class="list-group-item list-group-item-action">
@ -95,10 +94,10 @@
</template>
<script lang="ts">
import {Component, Hook} from '@f-list/vue-ts';
//tslint:disable-next-line:no-require-imports
import Sortable = require('sortablejs');
import Vue from 'vue';
import Component from 'vue-class-component';
import {Keys} from '../keys';
import ChannelList from './ChannelList.vue';
import CharacterSearch from './CharacterSearch.vue';
@ -139,22 +138,23 @@
focusListener!: () => void;
blurListener!: () => void;
@Hook('mounted')
mounted(): void {
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
window.addEventListener('keydown', this.keydownListener);
this.setFontSize(core.state.settings.fontSize);
Sortable.create(this.$refs['privateConversations'], {
Sortable.create(<HTMLElement>this.$refs['privateConversations'], {
animation: 50,
onEnd: async(e: {oldIndex: number, newIndex: number}) => {
onEnd: async(e) => {
if(e.oldIndex === e.newIndex) return;
return core.conversations.privateConversations[e.oldIndex].sort(e.newIndex);
return core.conversations.privateConversations[e.oldIndex!].sort(e.newIndex!);
}
});
Sortable.create(this.$refs['channelConversations'], {
Sortable.create(<HTMLElement>this.$refs['channelConversations'], {
animation: 50,
onEnd: async(e: {oldIndex: number, newIndex: number}) => {
onEnd: async(e) => {
if(e.oldIndex === e.newIndex) return;
return core.conversations.channelConversations[e.oldIndex].sort(e.newIndex);
return core.conversations.channelConversations[e.oldIndex!].sort(e.newIndex!);
}
});
const ownCharacter = core.characters.ownCharacter;
@ -175,7 +175,7 @@
window.addEventListener('blur', this.blurListener = () => {
core.notifications.isInBackground = true;
if(idleTimer !== undefined) clearTimeout(idleTimer);
if(core.state.settings.idleTimer > 0)
if(core.state.settings.idleTimer > 0 && core.characters.ownCharacter.status !== 'dnd')
idleTimer = window.setTimeout(() => {
lastUpdate = Date.now();
idleStatus = {status: ownCharacter.status, statusmsg: ownCharacter.statusText};
@ -195,6 +195,7 @@
});
}
@Hook('destroyed')
destroyed(): void {
window.removeEventListener('keydown', this.keydownListener);
window.removeEventListener('focus', this.focusListener);
@ -204,7 +205,7 @@
needsReply(conversation: Conversation): boolean {
if(!core.state.settings.showNeedsReply) return false;
for(let i = conversation.messages.length - 1; i >= 0; --i) {
const sender = conversation.messages[i].sender;
const sender = (<Partial<Conversation.ChatMessage>>conversation.messages[i]).sender;
if(sender !== undefined)
return sender !== core.characters.ownCharacter;
}
@ -268,6 +269,30 @@
if(confirm(l('chat.confirmLeave'))) core.connection.close();
}
showSettings(): void {
(<SettingsView>this.$refs['settingsDialog']).show();
}
showSearch(): void {
(<CharacterSearch>this.$refs['searchDialog']).show();
}
showRecent(): void {
(<RecentConversations>this.$refs['recentDialog']).show();
}
showChannels(): void {
(<ChannelList>this.$refs['channelsDialog']).show();
}
showStatus(): void {
(<StatusSwitcher>this.$refs['statusDialog']).show();
}
userMenuHandle(e: MouseEvent | TouchEvent): void {
(<UserMenu>this.$refs['userMenu']).handleEvent(e);
}
get showAvatars(): boolean {
return core.state.settings.showAvatars;
}

View File

@ -25,7 +25,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component, Hook} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import core from './core';
@ -55,6 +55,7 @@
return this.commands.filter((x) => filter.test(x.name));
}
@Hook('mounted')
mounted(): void {
const permissions = core.connection.vars.permissions;
for(const key in commands) {

View File

@ -1,5 +1,5 @@
<template>
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @close="init()" dialogClass="w-100"
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @open="load()" dialogClass="w-100"
:buttonText="l('conversationSettings.save')">
<div class="form-group">
<label class="control-label" :for="'notify' + conversation.key">{{l('conversationSettings.notify')}}</label>
@ -39,8 +39,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {Component, Prop} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import {Conversation} from './interfaces';
@ -60,23 +59,13 @@
joinMessages!: Conversation.Setting;
defaultHighlights!: boolean;
constructor() {
super();
this.init();
}
init = function(this: ConversationSettings): void {
load(): void {
const settings = this.conversation.settings;
this.notify = settings.notify;
this.highlight = settings.highlight;
this.highlightWords = settings.highlightWords.join(',');
this.joinMessages = settings.joinMessages;
this.defaultHighlights = settings.defaultHighlights;
};
@Watch('conversation')
conversationChanged(): void {
this.init();
}
submit(): void {

View File

@ -1,17 +1,17 @@
<template>
<div style="height:100%;display:flex;flex-direction:column;flex:1;margin:0 5px;position:relative" id="conversation">
<div style="display:flex" v-if="conversation.character" class="header">
<div style="display:flex" v-if="isPrivate(conversation)" class="header">
<img :src="characterImage" style="height:60px;width:60px;margin-right:10px" v-if="settings.showAvatars"/>
<div style="flex:1;position:relative;display:flex;flex-direction:column">
<div>
<user :character="conversation.character"></user>
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn">
<a href="#" @click.prevent="showLogs()" class="btn">
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
<a href="#" @click.prevent="showSettings()" class="btn">
<span class="fa fa-cog"></span> <span class="btn-text">{{l('conversationSettings.title')}}</span>
</a>
<a href="#" @click.prevent="reportDialog.report();" class="btn">
<a href="#" @click.prevent="reportDialog.report()" class="btn">
<span class="fa fa-exclamation-triangle"></span><span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<div style="overflow:auto;max-height:50px">
@ -20,7 +20,7 @@
</div>
</div>
</div>
<div v-else-if="conversation.channel" class="header">
<div v-else-if="isChannel(conversation)" class="header">
<div style="display: flex; align-items: center;">
<div style="flex: 1;">
<span v-show="conversation.channel.id.substr(0, 4) !== 'adh-'" class="fa fa-star" :title="l('channel.official')"
@ -30,33 +30,33 @@
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
<span class="btn-text">{{l('channel.description')}}</span>
</a>
<a href="#" @click.prevent="$refs['manageDialog'].show()" v-show="isChannelMod" class="btn">
<a href="#" @click.prevent="showManage()" v-show="isChannelMod" class="btn">
<span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span>
</a>
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn">
<a href="#" @click.prevent="showLogs()" class="btn">
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
<a href="#" @click.prevent="showSettings()" class="btn">
<span class="fa fa-cog"></span> <span class="btn-text">{{l('conversationSettings.title')}}</span>
</a>
<a href="#" @click.prevent="reportDialog.report();" class="btn">
<a href="#" @click.prevent="reportDialog.report()" class="btn">
<span class="fa fa-exclamation-triangle"></span><span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<ul class="nav nav-pills mode-switcher">
<li v-for="mode in modes" class="nav-item">
<a :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}"
<a :class="isChannel(conversation) ? {active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'} : undefined"
class="nav-link" href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a>
</li>
</ul>
</div>
<div style="z-index:5;position:absolute;left:0;right:0;max-height:60%;overflow:auto"
:style="'display:' + (descriptionExpanded ? 'block' : 'none')" class="bg-solid-text border-bottom">
:style="{display: descriptionExpanded ? 'block' : 'none'}" class="bg-solid-text border-bottom">
<bbcode :text="conversation.channel.description"></bbcode>
</div>
</div>
<div v-else class="header" style="display:flex;align-items:center">
<h4>{{l('chat.consoleTab')}}</h4>
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn">
<a href="#" @click.prevent="showLogs()" class="btn">
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
</div>
@ -64,31 +64,32 @@
<div class="input-group-prepend">
<div class="input-group-text"><span class="fas fa-search"></span></div>
</div>
<input v-model="searchInput" @keydown.esc="hideSearch" @keypress="lastSearchInput = Date.now()"
<input v-model="searchInput" @keydown.esc="hideSearch()" @keypress="lastSearchInput = Date.now()"
:placeholder="l('chat.search')" ref="searchField" class="form-control"/>
<a class="btn btn-sm btn-light" style="position:absolute;right:5px;top:50%;transform:translateY(-50%);line-height:0;z-index:10"
@click="hideSearch"><i class="fas fa-times"></i></a>
</div>
<div class="border-top messages" :class="'messages-' + conversation.mode" ref="messages" @scroll="onMessagesScroll"
style="flex:1;overflow:auto;margin-top:2px;position:relative">
<div class="border-top messages" :class="isChannel(conversation) ? 'messages-' + conversation.mode : undefined" ref="messages"
@scroll="onMessagesScroll" style="flex:1;overflow:auto;margin-top:2px">
<template v-for="message in messages">
<message-view :message="message" :channel="conversation.channel" :key="message.id"
<message-view :message="message" :channel="isChannel(conversation) ? conversation.channel : undefined" :key="message.id"
:classes="message == conversation.lastRead ? 'last-read' : ''">
</message-view>
<span v-if="message.sfc && message.sfc.action == 'report'" :key="'r' + message.id">
<span v-if="hasSFC(message) && message.sfc.action === 'report'" :key="'r' + message.id">
<a :href="'https://www.f-list.net/fchat/getLog.php?log=' + message.sfc.logid"
v-if="message.sfc.logid" target="_blank">{{l('events.report.viewLog')}}</a>
<span v-else>{{l('events.report.noLog')}}</span>
<span v-show="!message.sfc.confirmed">
| <a href="#" @click.prevent="acceptReport(message.sfc)">{{l('events.report.confirm')}}</a>
| <a href="#" @click.prevent="message.sfc.action === 'report' && acceptReport(message.sfc)">{{l('events.report.confirm')}}</a>
</span>
</span>
</template>
</div>
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')" :hasToolbar="settings.bbCodeBar"
ref="textBox" style="position:relative;margin-top:5px" :maxlength="conversation.maxMessageLength">
<span v-if="conversation.typingStatus && conversation.typingStatus !== 'clear'" class="chat-info-text">
:classes="'form-control chat-text-box' + (isChannel(conversation) && conversation.isSendingAds ? ' ads-text-box' : '')"
:hasToolbar="settings.bbCodeBar" ref="textBox" style="position:relative;margin-top:5px"
:maxlength="isChannel(conversation) || isPrivate(conversation) ? conversation.maxMessageLength : undefined">
<span v-if="isPrivate(conversation) && conversation.typingStatus !== 'clear'" class="chat-info-text">
{{l('chat.typing.' + conversation.typingStatus, conversation.name)}}
</span>
<div v-show="conversation.infoText" class="chat-info-text">
@ -100,10 +101,10 @@
<span class="redText" style="flex:1;margin-left:5px">{{conversation.errorText}}</span>
</div>
<div class="bbcode-editor-controls">
<div v-show="conversation.maxMessageLength" style="margin-right:5px">
<div v-if="isChannel(conversation) || isPrivate(conversation)" style="margin-right:5px">
{{getByteLength(conversation.enteredText)}} / {{conversation.maxMessageLength}}
</div>
<ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel"
<ul class="nav nav-pills send-ads-switcher" v-if="isChannel(conversation)"
style="position:relative;z-index:10;margin-right:5px">
<li class="nav-item">
<a href="#" :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
@ -120,14 +121,13 @@
<command-help ref="helpDialog"></command-help>
<settings ref="settingsDialog" :conversation="conversation"></settings>
<logs ref="logsDialog" :conversation="conversation"></logs>
<manage-channel ref="manageDialog" :channel="conversation.channel" v-if="conversation.channel"></manage-channel>
<manage-channel ref="manageDialog" v-if="isChannel(conversation)" :channel="conversation.channel"></manage-channel>
</div>
</template>
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {EditorButton, EditorSelection} from '../bbcode/editor';
import {isShowing as anyDialogsShown} from '../components/Modal.vue';
import {Keys} from '../keys';
@ -177,7 +177,10 @@
ignoreScroll = false;
adCountdown = 0;
adsMode = l('channel.mode.ads');
isChannel = Conversation.isChannel;
isPrivate = Conversation.isPrivate;
@Hook('mounted')
mounted(): void {
this.extraButtons = [{
title: 'Help\n\nClick this button for a quick overview of slash commands.',
@ -218,6 +221,7 @@
});
}
@Hook('destroyed')
destroyed(): void {
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('keydown', this.keydownHandler);
@ -234,7 +238,7 @@
return core.conversations.selectedConversation;
}
get messages(): ReadonlyArray<Conversation.Message> {
get messages(): ReadonlyArray<Conversation.Message | Conversation.SFCMessage> {
if(this.search === '') return this.conversation.messages;
const filter = new RegExp(this.search.replace(/[^\w]/gi, '\\$&'), 'i');
return this.conversation.messages.filter((x) => filter.test(x.text));
@ -361,6 +365,22 @@
}
}
showLogs(): void {
(<Logs>this.$refs['logsDialog']).show();
}
showSettings(): void {
(<ConversationSettings>this.$refs['settingsDialog']).show();
}
showManage(): void {
(<ManageChannel>this.$refs['manageDialog']).show();
}
hasSFC(message: Conversation.Message): message is Conversation.SFCMessage {
return (<Partial<Conversation.SFCMessage>>message).sfc !== undefined;
}
get characterImage(): string {
return characterImage(this.conversation.name);
}

View File

@ -38,7 +38,7 @@
<label for="date" class="col-sm-2 col-form-label">{{l('logs.date')}}</label>
<div class="col-sm-8 col-10 col-xl-9">
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
<option :value="null">{{l('logs.allDates')}}</option>
<option :value="undefined">{{l('logs.allDates')}}</option>
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
</select>
</div>
@ -47,8 +47,8 @@
class="fa fa-download"></span></button>
</div>
</div>
<div class="messages-both" style="overflow:auto" ref="messages" tabindex="-1" @scroll="onMessagesScroll">
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
<div class="messages messages-both" style="overflow:auto;overscroll-behavior:none;" ref="messages" tabindex="-1" @scroll="onMessagesScroll">
<message-view v-for="message in displayedMessages" :message="message" :key="message.id" :logs="true"></message-view>
</div>
<div class="input-group" style="flex-shrink:0">
<div class="input-group-prepend">
@ -60,9 +60,8 @@
</template>
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import {format} from 'date-fns';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import CustomDialog from '../components/custom_dialog';
import FilterableSelect from '../components/FilterableSelect.vue';
import Modal from '../components/Modal.vue';
@ -86,13 +85,12 @@
components: {modal: Modal, 'message-view': MessageView, 'filterable-select': FilterableSelect}
})
export default class Logs extends CustomDialog {
//tslint:disable:no-null-keyword
@Prop()
readonly conversation?: Conversation;
selectedConversation: LogInterface.Conversation | null = null;
dates: ReadonlyArray<Date> = [];
selectedDate: string | null = null;
conversations: LogInterface.Conversation[] = [];
selectedConversation: LogInterface.Conversation | undefined;
dates: ReadonlyArray<Date> = [];
selectedDate: string | undefined;
l = l;
filter = '';
messages: ReadonlyArray<Conversation.Message> = [];
@ -103,6 +101,14 @@
showFilters = true;
canZip = core.logs.canZip;
dateOffset = -1;
windowStart = 0;
windowEnd = 0;
resizeListener = async() => this.onMessagesScroll();
get displayedMessages(): ReadonlyArray<Conversation.Message> {
if(this.selectedDate !== undefined) return this.filteredMessages;
return this.filteredMessages.slice(this.windowStart, this.windowEnd);
}
get filteredMessages(): ReadonlyArray<Conversation.Message> {
if(this.filter.length === 0) return this.messages;
@ -111,35 +117,42 @@
(x) => filter.test(x.text) || x.type !== Conversation.Message.Type.Event && filter.test(x.sender.name));
}
@Hook('mounted')
async mounted(): Promise<void> {
this.characters = await core.logs.getAvailableCharacters();
await this.loadCharacter();
return this.conversationChanged();
window.addEventListener('resize', this.resizeListener);
}
@Hook('beforeDestroy')
beforeDestroy(): void {
window.removeEventListener('resize', this.resizeListener);
}
async loadCharacter(): Promise<void> {
this.selectedConversation = undefined;
return this.loadConversations();
}
async loadConversations(): Promise<void> {
if(this.selectedCharacter === '') return;
this.conversations = (await core.logs.getConversations(this.selectedCharacter)).slice();
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
this.selectedConversation = null;
}
filterConversation(filter: RegExp, conversation: {key: string, name: string}): boolean {
async loadDates(): Promise<void> {
this.dates = this.selectedConversation === undefined ? [] :
(await core.logs.getLogDates(this.selectedCharacter, this.selectedConversation.key)).slice().reverse();
}
filterConversation(filter: RegExp, conversation: LogInterface.Conversation): boolean {
return filter.test(conversation.name);
}
@Watch('conversation')
async conversationChanged(): Promise<void> {
if(this.conversation === undefined) return;
//tslint:disable-next-line:strict-boolean-expressions
this.selectedConversation = this.conversations.filter((x) => x.key === this.conversation!.key)[0] || null;
}
@Watch('selectedConversation')
async conversationSelected(): Promise<void> {
this.dates = this.selectedConversation === null ? [] :
(await core.logs.getLogDates(this.selectedCharacter, this.selectedConversation.key)).slice().reverse();
this.selectedDate = null;
async conversationSelected(oldValue: Conversation | undefined, newValue: Conversation | undefined): Promise<void> {
if(oldValue !== undefined && newValue !== undefined && oldValue.key === newValue.key) return;
await this.loadDates();
this.selectedDate = undefined;
this.dateOffset = -1;
this.filter = '';
await this.loadMessages();
@ -147,9 +160,18 @@
@Watch('filter')
onFilterChanged(): void {
if(this.selectedDate === undefined) {
this.windowEnd = this.filteredMessages.length;
this.windowStart = this.windowEnd - 50;
}
this.$nextTick(async() => this.onMessagesScroll());
}
@Watch('showFilters')
async onFilterToggle(): Promise<void> {
return this.onMessagesScroll();
}
download(file: string, logs: string): void {
const a = document.createElement('a');
a.href = logs;
@ -164,13 +186,13 @@
}
downloadDay(): void {
if(this.selectedConversation === null || this.selectedDate === null || this.messages.length === 0) return;
if(this.selectedConversation === undefined || this.selectedDate === undefined || this.messages.length === 0) return;
const name = `${this.selectedConversation.name}-${formatDate(new Date(this.selectedDate))}.txt`;
this.download(name, `data:${encodeURIComponent(name)},${encodeURIComponent(getLogs(this.messages))}`);
}
async downloadConversation(): Promise<void> {
if(this.selectedConversation === null) return;
if(this.selectedConversation === undefined) return;
const zip = new Zip();
for(const date of this.dates) {
const messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key, date);
@ -195,14 +217,17 @@
async onOpen(): Promise<void> {
if(this.selectedCharacter !== '') {
this.conversations = (await core.logs.getConversations(this.selectedCharacter)).slice();
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
this.dates = this.selectedConversation === null ? [] :
(await core.logs.getLogDates(this.selectedCharacter, this.selectedConversation.key)).slice().reverse();
await this.loadMessages();
await this.loadConversations();
if(this.conversation !== undefined)
this.selectedConversation = this.conversations.filter((x) => x.key === this.conversation!.key)[0];
else {
await this.loadDates();
await this.loadMessages();
}
}
this.keyDownListener = (e) => {
if(getKey(e) === Keys.KeyA && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
if((<HTMLElement>e.target).tagName.toLowerCase() === 'input') return;
e.preventDefault();
const selection = document.getSelection();
if(selection === null) return;
@ -223,36 +248,69 @@
window.removeEventListener('keydown', this.keyDownListener!);
}
async loadMessages(): Promise<ReadonlyArray<Conversation.Message>> {
if(this.selectedConversation === null)
return this.messages = [];
if(this.selectedDate !== null) {
async loadMessages(): Promise<void> {
if(this.selectedConversation === undefined) this.messages = [];
else if(this.selectedDate !== undefined) {
this.dateOffset = -1;
return this.messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
new Date(this.selectedDate));
}
if(this.dateOffset === -1) {
this.messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key, new Date(this.selectedDate));
} else if(this.dateOffset === -1) {
this.messages = [];
this.dateOffset = 0;
}
this.$nextTick(async() => this.onMessagesScroll());
return this.messages;
this.windowStart = 0;
this.windowEnd = 0;
this.lastScroll = -1;
this.lockScroll = false;
this.$nextTick(async() => this.onMessagesScroll());
} else return this.onMessagesScroll();
}
async onMessagesScroll(): Promise<void> {
lockScroll = false;
lastScroll = -1;
async onMessagesScroll(ev?: Event): Promise<void> {
const list = <HTMLElement | undefined>this.$refs['messages'];
if(this.selectedConversation === null || this.selectedDate !== null || list === undefined || list.scrollTop > 15
|| !this.dialog.isShown || this.dateOffset >= this.dates.length) return;
const messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
this.dates[this.dateOffset++]);
this.messages = messages.concat(this.messages);
const noOverflow = list.offsetHeight === list.scrollHeight;
const firstMessage = <HTMLElement>list.firstElementChild!;
this.$nextTick(() => {
if(list.offsetHeight === list.scrollHeight) return this.onMessagesScroll();
else if(noOverflow) setTimeout(() => list.scrollTop = list.scrollHeight, 0);
else setTimeout(() => list.scrollTop = firstMessage.offsetTop, 0);
});
if(this.lockScroll) return;
if(list === undefined || ev !== undefined && Math.abs(list.scrollTop - this.lastScroll) < 50) return;
this.lockScroll = true;
function getTop(index: number): number {
return (<HTMLElement>list!.children[index]).offsetTop;
}
while(this.selectedConversation !== undefined && this.selectedDate === undefined && this.dialog.isShown) {
const oldHeight = list.scrollHeight, oldTop = list.scrollTop;
const oldFirst = this.displayedMessages[0];
const oldEnd = this.windowEnd;
const length = this.displayedMessages.length;
const oldTotal = this.filteredMessages.length;
let loaded = false;
if(length <= 20 || getTop(20) > list.scrollTop)
this.windowStart -= 50;
else if(length > 100 && getTop(100) < list.scrollTop)
this.windowStart += 50;
else if(length >= 100 && getTop(length - 100) > list.scrollTop + list.offsetHeight)
this.windowEnd -= 50;
else if(getTop(length - 20) < list.scrollTop + list.offsetHeight)
this.windowEnd += 50;
if(this.windowStart <= 0 && this.dateOffset < this.dates.length) {
const messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
this.dates[this.dateOffset++]);
this.messages = messages.concat(this.messages);
const addedTotal = this.filteredMessages.length - oldTotal;
this.windowStart += addedTotal;
this.windowEnd += addedTotal;
loaded = true;
}
this.windowStart = Math.max(this.windowStart, 0);
this.windowEnd = Math.min(this.windowEnd, this.filteredMessages.length);
if(this.displayedMessages[0] !== oldFirst) {
list.style.overflow = 'hidden';
await this.$nextTick();
list.scrollTop = oldTop + list.scrollHeight - oldHeight;
list.style.overflow = 'auto';
} else if(this.windowEnd === oldEnd && !loaded) break;
else await this.$nextTick();
}
this.lastScroll = list.scrollTop;
this.lockScroll = false;
}
}
</script>

View File

@ -38,8 +38,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Prop} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import {Editor} from './bbcode';

View File

@ -1,18 +1,25 @@
<template>
<modal :buttons="false" :action="l('chat.recentConversations')" dialogClass="w-100 modal-lg">
<div style="display:flex; flex-direction:column; max-height:500px; flex-wrap:wrap;">
<div v-for="recent in recentConversations" style="margin: 3px;">
<user-view v-if="recent.character" :character="getCharacter(recent.character)"></user-view>
<channel-view v-else :id="recent.channel" :text="recent.name"></channel-view>
<tabs style="flex-shrink:0;margin-bottom:10px" v-model="selectedTab"
:tabs="[l('chat.pms'), l('chat.channels')]"></tabs>
<div>
<div v-show="selectedTab === '0'" class="recent-conversations">
<user-view v-for="recent in recentPrivate" v-if="recent.character"
:key="recent.character" :character="getCharacter(recent.character)"></user-view>
</div>
<div v-show="selectedTab === '1'" class="recent-conversations">
<channel-view v-for="recent in recentChannels" :key="recent.channel" :id="recent.channel"
:text="recent.name"></channel-view>
</div>
</div>
</modal>
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';
import ChannelView from './ChannelTagView.vue';
import core from './core';
import {Character, Conversation} from './interfaces';
@ -20,17 +27,34 @@
import UserView from './user_view';
@Component({
components: {'user-view': UserView, 'channel-view': ChannelView, modal: Modal}
components: {'user-view': UserView, 'channel-view': ChannelView, modal: Modal, tabs: Tabs}
})
export default class RecentConversations extends CustomDialog {
l = l;
selectedTab = '0';
get recentConversations(): ReadonlyArray<Conversation.RecentConversation> {
get recentPrivate(): ReadonlyArray<Conversation.RecentPrivateConversation> {
return core.conversations.recent;
}
get recentChannels(): ReadonlyArray<Conversation.RecentChannelConversation> {
return core.conversations.recentChannels;
}
getCharacter(name: string): Character {
return core.characters.get(name);
}
}
</script>
<style lang="scss">
.recent-conversations {
display: flex;
flex-direction: column;
max-height: 500px;
flex-wrap: wrap;
& > * {
margin: 3px;
}
}
</style>

View File

@ -1,19 +1,21 @@
<template>
<modal :action="l('chat.report')" @submit.prevent="submit" :disabled="submitting">
<modal :action="l('chat.report')" @submit.prevent="submit()" :disabled="submitting" dialogClass="modal-lg">
<div class="alert alert-danger" v-show="error">{{error}}</div>
<h4>{{reporting}}</h4>
<span v-show="!character">{{l('chat.report.channel.description')}}</span>
<div ref="caption"></div>
<br/>
<div class="form-group">
<label>{{l('chat.report.text')}}</label>
<h6>{{l('chat.report.conversation')}}</h6>
<p>{{conversation}}</p>
<h6>{{l('chat.report.reporting')}}</h6>
<p>{{character ? character.name : l('chat.report.general')}}</p>
<h6>{{l('chat.report.text')}}</h6>
<textarea class="form-control" v-model="text"></textarea>
</div>
</modal>
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component, Hook} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import BBCodeParser, {BBCodeElement} from './bbcode';
@ -26,35 +28,31 @@
components: {modal: Modal}
})
export default class ReportDialog extends CustomDialog {
//tslint:disable:no-null-keyword
character: Character | null = null;
character: Character | undefined;
text = '';
l = l;
error = '';
submitting = false;
@Hook('mounted')
mounted(): void {
(<Element>this.$refs['caption']).appendChild(new BBCodeParser().parseEverything(l('chat.report.description')));
}
@Hook('beforeDestroy')
beforeDestroy(): void {
(<BBCodeElement>(<Element>this.$refs['caption']).firstChild).cleanup!();
}
get reporting(): string {
const conversation = core.conversations.selectedConversation;
const isChannel = !Conversation.isPrivate(conversation);
if(isChannel && this.character === null) return l('chat.report.channel', conversation.name);
if(this.character === null) return '';
const key = `chat.report.${(isChannel ? 'channel.user' : 'private')}`;
return l(key, this.character.name, conversation.name);
get conversation(): string {
return core.conversations.selectedConversation.name;
}
report(character?: Character): void {
this.error = '';
this.text = '';
const current = core.conversations.selectedConversation;
this.character = character !== undefined ? character : Conversation.isPrivate(current) ? current.character : null;
this.character = character !== undefined ? character : Conversation.isPrivate(current) ? current.character : undefined;
this.show();
}
@ -64,7 +62,7 @@
const log = conversation.reportMessages.map((x) => messageToString(x));
const tab = (Conversation.isChannel(conversation) ? `${conversation.name} (${conversation.channel.id})`
: Conversation.isPrivate(conversation) ? `Conversation with ${conversation.name}` : 'Console');
const text = (this.character !== null ? `Reporting user: [user]${this.character.name}[/user] | ` : '') + this.text;
const text = (this.character !== undefined ? `Reporting user: [user]${this.character.name}[/user] | ` : '') + this.text;
const data = {
character: core.connection.character,
reportText: this.text,
@ -73,10 +71,10 @@
text: true,
reportUser: <string | undefined>undefined
};
if(this.character !== null) data.reportUser = this.character.name;
if(this.character !== undefined) data.reportUser = this.character.name;
try {
this.submitting = true;
const report = <{log_id?: number}>(await core.connection.queryApi('report-submit.php', data));
const report = (await core.connection.queryApi<{log_id?: number}>('report-submit.php', data));
//tslint:disable-next-line:strict-boolean-expressions
if(!report.log_id) return;
core.connection.send('SFC', {action: 'report', logid: report.log_id, report: text, tab: conversation.name});

View File

@ -1,8 +1,8 @@
<template>
<modal :action="l('settings.action')" @submit="submit" @close="init()" id="settings" dialogClass="w-100">
<modal :action="l('settings.action')" @submit="submit" @open="load()" id="settings" dialogClass="w-100">
<tabs style="flex-shrink:0;margin-bottom:10px" v-model="selectedTab"
:tabs="[l('settings.tabs.general'), l('settings.tabs.notifications'), l('settings.tabs.import')]"></tabs>
<div v-show="selectedTab == 0">
:tabs="[l('settings.tabs.general'), l('settings.tabs.notifications'), l('settings.tabs.hideAds'), l('settings.tabs.import')]"></tabs>
<div v-show="selectedTab === '0'">
<div class="form-group">
<label class="control-label" for="disallowedTags">{{l('settings.disallowedTags')}}</label>
<input id="disallowedTags" class="form-control" v-model="disallowedTags"/>
@ -70,7 +70,7 @@
<input id="fontSize" type="number" min="10" max="24" class="form-control" v-model="fontSize"/>
</div>
</div>
<div v-show="selectedTab == 1">
<div v-show="selectedTab === '1'">
<div class="form-group">
<label class="control-label" for="playSound">
<input type="checkbox" id="playSound" v-model="playSound"/>
@ -118,7 +118,16 @@
</label>
</div>
</div>
<div v-show="selectedTab == 2" style="display:flex;padding-top:10px">
<div v-show="selectedTab === '2'">
<template v-if="hidden.length">
<div v-for="(user, i) in hidden">
<span class="fa fa-times" style="cursor:pointer" @click.stop="hidden.splice(i, 1)"></span>
{{user}}
</div>
</template>
<template v-else>{{l('settings.hideAds.empty')}}</template>
</div>
<div v-show="selectedTab === '3'" style="display:flex;padding-top:10px">
<select id="import" class="form-control" v-model="importCharacter" style="flex:1;margin-right:10px">
<option value="">{{l('settings.import.selectCharacter')}}</option>
<option v-for="character in availableImports" :value="character">{{character}}</option>
@ -129,7 +138,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import Tabs from '../components/tabs';
@ -166,16 +175,7 @@
colorBookmarks!: boolean;
bbCodeBar!: boolean;
constructor() {
super();
this.init();
}
async created(): Promise<void> {
this.availableImports = (await core.settingsStore.getAvailableCharacters()).filter((x) => x !== core.connection.character);
}
init = function(this: SettingsView): void {
async load(): Promise<void> {
const settings = core.state.settings;
this.playSound = settings.playSound;
this.clickOpensMessage = settings.clickOpensMessage;
@ -197,7 +197,8 @@
this.enterSend = settings.enterSend;
this.colorBookmarks = settings.colorBookmarks;
this.bbCodeBar = settings.bbCodeBar;
};
this.availableImports = (await core.settingsStore.getAvailableCharacters()).filter((x) => x !== core.connection.character);
}
async doImport(): Promise<void> {
if(!confirm(l('settings.import.confirm', this.importCharacter, core.connection.character))) return;
@ -209,9 +210,11 @@
await importKey('pinned');
await importKey('modes');
await importKey('conversationSettings');
this.init();
core.reloadSettings();
core.conversations.reloadSettings();
core.connection.close(false);
}
get hidden(): string[] {
return core.state.hiddenUsers;
}
async submit(): Promise<void> {

View File

@ -15,9 +15,8 @@
</template>
<script lang="ts">
import {Component, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
@Component
export default class Sidebar extends Vue {

View File

@ -21,7 +21,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component} from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Dropdown from '../components/Dropdown.vue';
import Modal from '../components/Modal.vue';
@ -36,16 +36,15 @@
components: {modal: Modal, editor: Editor, dropdown: Dropdown}
})
export default class StatusSwitcher extends CustomDialog {
//tslint:disable:no-null-keyword
selectedStatus: Character.Status | null = null;
enteredText: string | null = null;
selectedStatus: Character.Status | undefined;
enteredText: string | undefined;
statuses = userStatuses;
l = l;
getByteLength = getByteLength;
getStatusIcon = getStatusIcon;
get status(): Character.Status {
return this.selectedStatus !== null ? this.selectedStatus : this.character.status;
return this.selectedStatus !== undefined ? this.selectedStatus : this.character.status;
}
set status(status: Character.Status) {
@ -53,7 +52,7 @@
}
get text(): string {
return this.enteredText !== null ? this.enteredText : this.character.statusText;
return this.enteredText !== undefined ? this.enteredText : this.character.statusText;
}
set text(text: string) {
@ -69,8 +68,8 @@
}
reset(): void {
this.selectedStatus = null;
this.enteredText = null;
this.selectedStatus = undefined;
this.enteredText = undefined;
}
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<sidebar id="user-list" :label="l('users.title')" icon="fa-users" :right="true" :open="expanded">
<tabs style="flex-shrink:0" :tabs="channel ? [l('users.friends'), l('users.members')] : [l('users.friends')]" v-model="tab"></tabs>
<div class="users" style="padding-left:10px" v-show="tab == 0">
<div class="users" style="padding-left:10px" v-show="tab === '0'">
<h4>{{l('users.friends')}}</h4>
<div v-for="character in friends" :key="character.name">
<user :character="character" :showStatus="true" :bookmark="false"></user>
@ -11,7 +11,7 @@
<user :character="character" :showStatus="true" :bookmark="false"></user>
</div>
</div>
<div v-if="channel" style="padding-left:5px;flex:1;display:flex;flex-direction:column" v-show="tab == 1">
<div v-if="channel" style="padding-left:5px;flex:1;display:flex;flex-direction:column" v-show="tab === '1'">
<div class="users" style="flex:1;padding-left:5px">
<h4>{{l('users.memberCount', channel.sortedMembers.length)}}</h4>
<div v-for="member in filteredMembers" :key="member.character.name">
@ -29,8 +29,8 @@
</template>
<script lang="ts">
import {Component} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import Tabs from '../components/tabs';
import core from './core';
import {Channel, Character, Conversation} from './interfaces';

View File

@ -8,7 +8,7 @@
{{l('status.' + character.status)}}
</div>
<bbcode id="userMenuStatus" :text="character.statusText" v-show="character.statusText" class="list-group-item"
style="max-height:200px;overflow:auto;clear:both"></bbcode>
style="max-height:200px;overflow:auto;clear:both"></bbcode>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="openConversation(true)" class="list-group-item list-group-item-action">
@ -17,19 +17,19 @@
<span class="fa fa-fw fa-plus"></span>{{l('user.message')}}</a>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="!showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="showMemo" class="list-group-item list-group-item-action">
<a tabindex="-1" href="#" @click.prevent="showMemo()" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-sticky-note"></span>{{l('user.memo')}}</a>
<a tabindex="-1" href="#" @click.prevent="setBookmarked" class="list-group-item list-group-item-action">
<a tabindex="-1" href="#" @click.prevent="setBookmarked()" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-bookmark"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}</a>
<a tabindex="-1" href="#" @click.prevent="setIgnored" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-minus-circle"></span>{{l('user.' + (character.isIgnored ? 'unignore' : 'ignore'))}}</a>
<a tabindex="-1" href="#" @click.prevent="setHidden" class="list-group-item list-group-item-action" v-show="!isChatOp">
<a tabindex="-1" href="#" @click.prevent="setHidden()" class="list-group-item list-group-item-action" v-show="!isChatOp">
<span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}</a>
<a tabindex="-1" href="#" @click.prevent="report" class="list-group-item list-group-item-action">
<a tabindex="-1" href="#" @click.prevent="report()" class="list-group-item list-group-item-action" style="border-top-width:1px">
<span class="fa fa-fw fa-exclamation-triangle"></span>{{l('user.report')}}</a>
<a tabindex="-1" href="#" @click.prevent="channelKick" class="list-group-item list-group-item-action" v-show="isChannelMod">
<a tabindex="-1" href="#" @click.prevent="setIgnored()" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-minus-circle"></span>{{l('user.' + (character.isIgnored ? 'unignore' : 'ignore'))}}</a>
<a tabindex="-1" href="#" @click.prevent="channelKick()" class="list-group-item list-group-item-action" v-show="isChannelMod">
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a>
<a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00" class="list-group-item list-group-item-action"
<a tabindex="-1" href="#" @click.prevent="chatKick()" style="color:#f00" class="list-group-item list-group-item-action"
v-show="isChatOp"><span class="fas fa-fw fa-trash"></span>{{l('user.chatKick')}}</a>
</div>
<modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo" dialogClass="w-100">
@ -40,9 +40,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import Modal from '../components/Modal.vue';
import {BBCodeView} from './bbcode';
import {characterImage, errorToString, getByteLength, profileLink} from './common';
@ -55,17 +54,16 @@
components: {bbcode: BBCodeView, modal: Modal}
})
export default class UserMenu extends Vue {
//tslint:disable:no-null-keyword
@Prop({required: true})
readonly reportDialog!: ReportDialog;
l = l;
showContextMenu = false;
getByteLength = getByteLength;
character: Character | null = null;
character: Character | undefined;
position = {left: '', top: ''};
characterImage: string | null = null;
characterImage: string | undefined;
touchedElement: HTMLElement | undefined;
channel: Channel | null = null;
channel: Channel | undefined;
memo = '';
memoId = 0;
memoLoading = false;
@ -107,7 +105,7 @@
this.memo = '';
(<Modal>this.$refs['memo']).show();
try {
const memo = <{note: string | null, id: number}>await core.connection.queryApi('character-memo-get2.php',
const memo = await core.connection.queryApi<{note: string | null, id: number}>('character-memo-get2.php',
{target: this.character!.name});
this.memoId = memo.id;
this.memo = memo.note !== null ? memo.note : '';
@ -123,7 +121,7 @@
}
get isChannelMod(): boolean {
if(this.channel === null) return false;
if(this.channel === undefined) return false;
if(core.characters.ownCharacter.isChatOp) return true;
const member = this.channel.members[core.connection.character];
return member !== undefined && member.rank > Channel.Rank.Member;
@ -189,9 +187,9 @@
}
private openMenu(touch: MouseEvent | Touch, character: Character, channel: Channel | undefined): void {
this.channel = channel !== undefined ? channel : null;
this.channel = channel;
this.character = character;
this.characterImage = null;
this.characterImage = undefined;
this.showContextMenu = true;
this.position = {left: `${touch.clientX}px`, top: `${touch.clientY}px`};
this.$nextTick(() => {
@ -212,7 +210,7 @@
}
#userMenu .list-group-item-action {
border-top: 0;
border-top-width: 0;
z-index: -1;
}
</style>

View File

@ -9,6 +9,10 @@ export default class Socket implements WebSocketConnection {
this.socket = new WebSocket(Socket.host);
}
get readyState(): WebSocketConnection.ReadyState {
return this.socket.readyState;
}
close(): void {
this.socket.close();
}

View File

@ -24,7 +24,6 @@ export const BBCodeView: Component = {
if(element.cleanup !== undefined) element.cleanup();
}
};
context.data.staticClass = `bbcode${context.data.staticClass !== undefined ? ` ${context.data.staticClass}` : ''}`;
const vnode = createElement('span', context.data);
vnode.key = context.props.text;
return vnode;
@ -84,18 +83,22 @@ export default class BBCodeParser extends CoreBBCodeParser {
return img;
}));
this.addTag(new BBCodeTextTag('session', (parser, parent, param, content) => {
const root = parser.createElement('span');
const el = parser.createElement('span');
parent.appendChild(el);
parent.appendChild(root);
root.appendChild(el);
const view = new ChannelView({el, propsData: {id: content, text: param}});
this.cleanup.push(view);
return el;
return root;
}));
this.addTag(new BBCodeTextTag('channel', (parser, parent, _, content) => {
const root = parser.createElement('span');
const el = parser.createElement('span');
parent.appendChild(el);
parent.appendChild(root);
root.appendChild(el);
const view = new ChannelView({el, propsData: {id: content, text: content}});
this.cleanup.push(view);
return el;
return root;
}));
}

View File

@ -2,7 +2,7 @@ import {queuedJoin} from '../fchat/channels';
import {decodeHTML} from '../fchat/common';
import {characterImage, ConversationSettings, EventMessage, Message, messageToString} from './common';
import core from './core';
import {Channel, Character, Connection, Conversation as Interfaces} from './interfaces';
import {Channel, Character, Conversation as Interfaces} from './interfaces';
import l from './localize';
import {CommandContext, isAction, isCommand, isWarn, parse as parseCommand} from './slash_commands';
import MessageType = Interfaces.Message.Type;
@ -353,7 +353,8 @@ class State implements Interfaces.State {
channelMap: {[key: string]: ChannelConversation | undefined} = {};
consoleTab!: ConsoleConversation;
selectedConversation: Conversation = this.consoleTab;
recent: Interfaces.RecentConversation[] = [];
recent: Interfaces.RecentPrivateConversation[] = [];
recentChannels: Interfaces.RecentChannelConversation[] = [];
pinned!: {channels: string[], private: string[]};
settings!: {[key: string]: Interfaces.Settings};
modes!: {[key: string]: Channel.Mode | undefined};
@ -371,13 +372,18 @@ class State implements Interfaces.State {
conv = new PrivateConversation(character);
this.privateConversations.push(conv);
this.privateMap[key] = conv;
state.addRecent(conv); //tslint:disable-line:no-floating-promises
const index = this.recent.findIndex((c) => c.character === conv!.name);
if(index !== -1) this.recent.splice(index, 1);
if(this.recent.length >= 50) this.recent.pop();
this.recent.unshift({character: conv.name});
core.settingsStore.set('recent', this.recent); //tslint:disable-line:no-floating-promises
return conv;
}
byKey(key: string): Conversation | undefined {
if(key === '_') return this.consoleTab;
return (key[0] === '#' ? this.channelMap : this.privateMap)[key];
key = key.toLowerCase();
return key[0] === '#' ? this.channelMap[key.substr(1)] : this.privateMap[key];
}
async savePinned(): Promise<void> {
@ -395,25 +401,6 @@ class State implements Interfaces.State {
await core.settingsStore.set('conversationSettings', this.settings);
}
async addRecent(conversation: Conversation): Promise<void> {
const remove = <T extends Interfaces.RecentConversation>(predicate: (item: T) => boolean) => {
for(let i = 0; i < this.recent.length; ++i)
if(predicate(<T>this.recent[i])) {
this.recent.splice(i, 1);
break;
}
};
if(Interfaces.isChannel(conversation)) {
remove<Interfaces.RecentChannelConversation>((c) => c.channel === conversation.channel.id);
this.recent.unshift({channel: conversation.channel.id, name: conversation.channel.name});
} else {
remove<Interfaces.RecentPrivateConversation>((c) => c.character === conversation.name);
state.recent.unshift({character: conversation.name});
}
if(this.recent.length >= 50) this.recent.pop();
await core.settingsStore.set('recent', this.recent);
}
show(conversation: Conversation): void {
this.selectedConversation.onHide();
conversation.unread = Interfaces.UnreadState.None;
@ -429,13 +416,14 @@ class State implements Interfaces.State {
for(const conversation of this.privateConversations)
conversation._isPinned = this.pinned.private.indexOf(conversation.name) !== -1;
this.recent = await core.settingsStore.get('recent') || [];
this.recentChannels = await core.settingsStore.get('recentChannels') || [];
const settings = <{[key: string]: ConversationSettings}> await core.settingsStore.get('conversationSettings') || {};
for(const key in settings) {
const settingsItem = new ConversationSettings();
for(const itemKey in settings[key])
settingsItem[<keyof ConversationSettings>itemKey] = settings[key][<keyof ConversationSettings>itemKey];
settings[key] = settingsItem;
const conv = (key[0] === '#' ? this.channelMap : this.privateMap)[key];
const conv = this.byKey(key);
if(conv !== undefined) conv._settings = settingsItem;
}
this.settings = settings;
@ -494,7 +482,11 @@ export default function(this: void): Interfaces.State {
const conv = new ChannelConversation(channel);
state.channelMap[channel.id] = conv;
state.channelConversations.push(conv);
await state.addRecent(conv);
const index = state.recentChannels.findIndex((c) => c.channel === channel.id);
if(index !== -1) state.recentChannels.splice(index, 1);
if(state.recentChannels.length >= 50) state.recentChannels.pop();
state.recentChannels.unshift({channel: channel.id, name: conv.channel.name});
core.settingsStore.set('recentChannels', state.recentChannels); //tslint:disable-line:no-floating-promises
} else {
const conv = state.channelMap[channel.id];
if(conv === undefined) return;
@ -548,6 +540,8 @@ export default function(this: void): Interfaces.State {
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
message.isHighlight = true;
await state.consoleTab.addMessage(new EventMessage(l('events.highlight', `[user]${data.character}[/user]`, results[0],
`[session=${conversation.name}]${data.channel}[/session]`), time));
} else if(conversation.settings.notify === Interfaces.Setting.True) {
await core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
@ -655,7 +649,9 @@ export default function(this: void): Interfaces.State {
connection.onMessage('IGN', async(data, time) => {
if(data.action !== 'add' && data.action !== 'delete') return;
return addEventMessage(new EventMessage(l(`events.ignore_${data.action}`, data.character), time));
const text = l(`events.ignore_${data.action}`, data.character);
state.selectedConversation.infoText = text;
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('RTB', async(data, time) => {
let url = 'https://www.f-list.net/';
@ -711,8 +707,7 @@ export default function(this: void): Interfaces.State {
if(data.type === 'note')
await core.notifications.notify(state.consoleTab, character, text, characterImage(character), 'newnote');
});
type SFCMessage = (Interfaces.Message & {sfc: Connection.ServerCommands['SFC'] & {confirmed?: true}});
const sfcList: SFCMessage[] = [];
const sfcList: Interfaces.SFCMessage[] = [];
connection.onMessage('SFC', async(data, time) => {
let text: string, message: Interfaces.Message;
if(data.action === 'report') {
@ -721,7 +716,7 @@ export default function(this: void): Interfaces.State {
await core.notifications.notify(state.consoleTab, data.character, text, characterImage(data.character), 'modalert');
message = new EventMessage(text, time);
safeAddMessage(sfcList, message, 500);
(<SFCMessage>message).sfc = data;
(<Interfaces.SFCMessage>message).sfc = data;
} else {
text = l('events.report.confirmed', `[user]${data.moderator}[/user]`, `[user]${data.character}[/user]`);
for(const item of sfcList)

View File

@ -104,7 +104,6 @@ export interface Core {
register(module: 'conversations', state: Conversation.State): void
register(module: 'channels', state: Channel.State): void
register(module: 'characters', state: Character.State): void
reloadSettings(): void
watch<T>(getter: (this: VueState) => T, callback: WatchHandler<T>): void
}

View File

@ -1,36 +1,34 @@
//tslint:disable:no-shadowed-variable
declare global {
interface Function {
//tslint:disable-next-line:ban-types no-any
bind<T extends Function>(this: T, thisArg: any): T;
//tslint:disable-next-line:ban-types no-any
bind<T, TReturn>(this: (t: T) => TReturn, thisArg: any, arg: T): () => TReturn;
}
}
import {Connection} from '../fchat';
import {Channel, Character} from '../fchat/interfaces';
export {Connection, Channel, Character} from '../fchat/interfaces';
export const userStatuses = ['online', 'looking', 'away', 'busy', 'dnd'];
export const channelModes = ['chat', 'ads', 'both'];
export const userStatuses: ReadonlyArray<Character.Status> = ['online', 'looking', 'away', 'busy', 'dnd'];
export const channelModes: ReadonlyArray<Channel.Mode> = ['chat', 'ads', 'both'];
export namespace Conversation {
export interface EventMessage {
readonly type: Message.Type.Event,
readonly text: string,
interface BaseMessage {
readonly id: number
readonly type: Message.Type
readonly text: string
readonly time: Date
readonly sender?: undefined
}
export interface ChatMessage {
readonly type: Message.Type,
readonly sender: Character,
readonly text: string,
readonly time: Date
export interface EventMessage extends BaseMessage {
readonly type: Message.Type.Event
}
export interface ChatMessage extends BaseMessage {
readonly isHighlight: boolean
readonly sender: Character
}
export type Message = EventMessage | ChatMessage;
export interface SFCMessage extends EventMessage {
sfc: Connection.ServerCommands['SFC'] & {confirmed?: true}
}
export namespace Message {
export enum Type {
Message,
@ -44,7 +42,6 @@ export namespace Conversation {
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';
@ -79,12 +76,12 @@ export namespace Conversation {
readonly privateConversations: ReadonlyArray<PrivateConversation>
readonly channelConversations: ReadonlyArray<ChannelConversation>
readonly consoleTab: Conversation
readonly recent: ReadonlyArray<RecentConversation>
readonly recent: ReadonlyArray<RecentPrivateConversation>
readonly recentChannels: ReadonlyArray<RecentChannelConversation>
readonly selectedConversation: Conversation
readonly hasNew: boolean;
byKey(key: string): Conversation | undefined
getPrivate(character: Character): PrivateConversation
reloadSettings(): void
}
export enum Setting {
@ -142,7 +139,8 @@ export namespace Settings {
pinned: {channels: string[], private: string[]},
conversationSettings: {[key: string]: Conversation.Settings | undefined}
modes: {[key: string]: Channel.Mode | undefined}
recent: Conversation.RecentConversation[]
recent: Conversation.RecentPrivateConversation[]
recentChannels: Conversation.RecentChannelConversation[]
hiddenUsers: string[]
};

View File

@ -91,6 +91,7 @@ const strings: {[key: string]: string | undefined} = {
'logs.corruption.mobile': 'Log corruption has been detected. This is usually caused by a crash/force close or power loss mid-write. Will now attempt to fix corrupted logs.',
'logs.corruption.mobile.success': 'Your logs have been fixed.',
'logs.corruption.mobile.error': 'Unable to fix corrupted logs. Please clear the application data or reinstall the app.',
'logs.corruption.web': 'Error reading logs from browser storage. If this issue persists, please clear your stored browser data for F-Chat.',
'user.profile': 'Profile',
'user.message': 'Open conversation',
'user.messageJump': 'View conversation',
@ -111,10 +112,9 @@ const strings: {[key: string]: string | undefined} = {
'users.members': 'Members',
'users.memberCount': '{0} Members',
'chat.report': 'Alert Staff',
'chat.report.description': `
[color=red]Before you alert the moderators, PLEASE READ:[/color]
'chat.report.description': `[color=red]Before you alert the moderators, PLEASE READ:[/color]
If you're just having personal trouble with someone, right-click their name and ignore them.
Please make sure what you're reporting is a violation of the site's [url=https://wiki.f-list.net/Code_of_Conduct]Code of Conduct[/url] otherwise nothing will be done.
Please make sure what you're reporting is a violation of the site's [url=https://wiki.f-list.net/Code_of_Conduct]Code of Conduct[/url], otherwise nothing will be done.
This tool is intended for chat moderation. If you have a question, please visit our [url=https://wiki.f-list.net/Frequently_Asked_Questions]FAQ[/url] first, and if that doesn't help, join [session=Helpdesk]Helpdesk[/session] and ask your question there.
@ -123,25 +123,26 @@ If your problem lies anywhere outside of the chat, please send in a Ticket inste
For a more comprehensive guide as how and when to report another user, please [url=https://wiki.f-list.net/How_to_Report_a_User]consult this page.[/url]
Please provide a brief summary of your problem and the rules that have been violated.
[color=red]DO NOT PASTE LOGS INTO THIS FIELD.
[color=red]DO NOT PASTE LOGS INTO THE "REPORT TEXT" FIELD.
SELECT THE TAB YOU WISH TO REPORT, LOGS ARE AUTOMATICALLY ATTACHED[/color]`,
'chat.report.channel.user': 'Reporting user {0} in channel {1}',
'chat.report.channel': 'General report for channel {0}',
'chat.report.channel.description': 'If you wish to report a specific user, please right-click them and select "Report".',
'chat.report.private': 'Reporting private conversation with user {0}',
'chat.report.conversation': 'Reporting tab',
'chat.report.reporting': 'Reporting user',
'chat.report.general': 'No one in particular. If you wish to report a specific user, please right-click them and select "Report".',
'chat.report.text': 'Report text',
'chat.recentConversations': 'Recent conversations',
'settings.tabs.general': 'General',
'settings.tabs.notifications': 'Notifications',
'settings.tabs.hideAds': 'Hidden users',
'settings.tabs.import': 'Import',
'settings.open': 'Settings',
'settings.action': 'Change settings',
'settings.hideAds.empty': `You aren't currently hiding the ads of any users.`,
'settings.import': 'Import settings',
'settings.import.selectCharacter': 'Select a character',
'settings.import.confirm': `You are importing settings from your character {0}.
This will overwrite any and all settings, pinned conversations and conversation settings of character {1}.
Logs and recent conversations will not be touched.
You may need to log out and back in for some settings to take effect.
You will be logged out. Once you log back in, the settings will have been imported.
Are you sure?`,
'settings.playSound': 'Play notification sounds',
'settings.notifications': 'Show desktop/push notifications',
@ -258,6 +259,7 @@ Once this process has started, do not interrupt it or your logs will get corrupt
'events.ignore_add': 'You are now ignoring {0}\'s messages. Should they go around this by any means, please report it using the Alert Staff button.',
'events.ignore_delete': '{0} is now allowed to send you messages again.',
'events.uptime': 'Server has been running since {0}, with currently {1} channels and {2} users, a total of {3} accepted connections, and {4} maximum users.',
'events.highlight': '{0} said "{1}" in {2}',
'commands.unknown': 'Unknown command. For a list of valid commands, please click the ? button.',
'commands.badContext': 'This command cannot be used here. Please use the Help (click the ? button) if you need further information.',
'commands.tooFewParams': 'This command requires more parameters. Please use the Help (click the ? button) if you need further information.',

View File

@ -1,58 +1,57 @@
import {Component, CreateElement, RenderContext, VNode, VNodeChildrenArrayContents} from 'vue';
import {Component, Prop} from '@f-list/vue-ts';
import {CreateElement, default as Vue, VNode, VNodeChildrenArrayContents} from 'vue';
import {Channel} from '../fchat';
import {BBCodeView} from './bbcode';
import {formatTime} from './common';
import core from './core';
import {Conversation} from './interfaces';
import UserView from './user_view';
// TODO convert this to single-file once Vue supports it for functional components.
// template:
// <span>[{{formatTime(message.time)}}]</span>
// <span v-show="message.type == MessageTypes.Action">*</span>
// <span><user :character="message.sender" :reportDialog="$refs['reportDialog']"></user></span>
// <span v-show="message.type == MessageTypes.Message">:</span>
// <bbcode :text="message.text"></bbcode>
const userPostfix: {[key: number]: string | undefined} = {
[Conversation.Message.Type.Message]: ': ',
[Conversation.Message.Type.Ad]: ': ',
[Conversation.Message.Type.Action]: ''
};
//tslint:disable-next-line:variable-name
const MessageView: Component = {
functional: true,
render(createElement: CreateElement,
context: RenderContext<{message: Conversation.Message, classes?: string, channel?: Channel}>): VNode {
const message = context.props.message;
@Component({
render(this: MessageView, createElement: CreateElement): VNode {
const message = this.message;
const children: VNodeChildrenArrayContents =
[createElement('span', {staticClass: 'message-time'}, `[${formatTime(message.time)}] `)];
const separators = core.connection.isOpen ? core.state.settings.messageSeparators : false;
/*tslint:disable-next-line:prefer-template*///unreasonable here
let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` + (separators ? ' message-block' : '') +
(message.type !== Conversation.Message.Type.Event && message.sender.name === core.connection.character ? ' message-own' : '') +
((context.props.classes !== undefined) ? ` ${context.props.classes}` : '');
((this.classes !== undefined) ? ` ${this.classes}` : '');
if(message.type !== Conversation.Message.Type.Event) {
children.push((message.type === Conversation.Message.Type.Action) ? '*' : '',
createElement(UserView, {props: {character: message.sender, channel: context.props.channel}}),
createElement(UserView, {props: {character: message.sender, channel: this.channel}}),
userPostfix[message.type] !== undefined ? userPostfix[message.type]! : ' ');
if(message.isHighlight) classes += ' message-highlight';
}
children.push(createElement(BBCodeView,
{props: {unsafeText: message.text, afterInsert: message.type === Conversation.Message.Type.Ad ? (elm: HTMLElement) => {
setImmediate(() => {
elm = elm.parentElement!;
if(elm.scrollHeight > elm.offsetHeight) {
const expand = document.createElement('div');
expand.className = 'expand fas fa-caret-down';
expand.addEventListener('click', function(): void { this.parentElement!.className += ' expanded'; });
elm.appendChild(expand);
}
});
} : undefined}}));
const isAd = message.type === Conversation.Message.Type.Ad && !this.logs;
children.push(createElement(BBCodeView, {props: {unsafeText: message.text, afterInsert: isAd ? (elm: HTMLElement) => {
setImmediate(() => {
elm = elm.parentElement!;
if(elm.scrollHeight > elm.offsetHeight) {
const expand = document.createElement('div');
expand.className = 'expand fas fa-caret-down';
expand.addEventListener('click', function(): void { this.parentElement!.className += ' expanded'; });
elm.appendChild(expand);
}
});
} : undefined}}));
const node = createElement('div', {attrs: {class: classes}}, children);
node.key = context.data.key;
node.key = message.id;
return node;
}
};
export default MessageView;
})
export default class MessageView extends Vue {
@Prop({required: true})
readonly message!: Conversation.Message;
@Prop
readonly classes?: string;
@Prop
readonly channel?: Channel;
@Prop
readonly logs?: true;
}

View File

@ -8,10 +8,13 @@ import CharacterSelect from '../components/character_select.vue';
import {setCharacters} from '../components/character_select/character_list';
import DateDisplay from '../components/date_display.vue';
import SimplePager from '../components/simple_pager.vue';
import {
Character as CharacterInfo, CharacterImage, CharacterImageOld, CharacterInfotag, CharacterSettings, KinkChoice
} from '../interfaces';
import {registerMethod, Store} from '../site/character_page/data_store';
import {
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterKink,
CharacterSettings, Friend, FriendRequest, FriendsByCharacter, GuestbookState, KinkChoice, KinkChoiceFull, SharedKinks
Character, CharacterFriend, CharacterKink, Friend, FriendRequest, FriendsByCharacter, 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';
@ -25,40 +28,39 @@ const parserSettings = {
};
async function characterData(name: string | undefined): Promise<Character> {
const data = await core.connection.queryApi('character-data.php', {name}) as CharacterInfo & {
const data = await core.connection.queryApi<CharacterInfo & {
badges: string[]
customs_first: boolean
character_list: {id: number, name: string}[]
current_user: {inline_mode: number, animated_icons: boolean}
custom_kinks: {[key: number]: {choice: 'fave' | 'yes' | 'maybe' | 'no', name: string, description: string, children: number[]}}
custom_kinks: {
[key: number]:
{id: number, choice: 'favorite' | 'yes' | 'maybe' | 'no', name: string, description: string, children: number[]}
}
custom_title: string
images: CharacterImage[]
kinks: {[key: string]: string}
infotags: {[key: string]: string}
memo?: {id: number, memo: string}
settings: CharacterSettings,
timezone: number
};
}>('character-data.php', {name});
const newKinks: {[key: string]: KinkChoiceFull} = {};
for(const key in data.kinks)
newKinks[key] = <KinkChoiceFull>(<string>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
});
if((<'fave'>custom.choice) === 'fave') custom.choice = 'favorite';
custom.id = parseInt(key, 10);
for(const childId of custom.children)
newKinks[childId] = parseInt(key, 10);
newKinks[childId] = custom.id;
}
(<any>data.settings).block_bookmarks = (<any>data.settings).prevent_bookmarks; //tslint:disable-line:no-any
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};
}
parserSettings.inlineDisplayMode = data.current_user.inline_mode;
@ -73,13 +75,14 @@ async function characterData(name: string | undefined): Promise<Character> {
created_at: data.created_at,
updated_at: data.updated_at,
views: data.views,
image_count: data.images!.length,
image_count: data.images.length,
inlines: data.inlines,
kinks: newKinks,
customs: newCustoms,
customs: data.custom_kinks,
infotags: newInfotags,
online_chat: false,
timezone: data.timezone
timezone: data.timezone,
deleted: false
},
memo: data.memo,
character_list: data.character_list,
@ -97,7 +100,7 @@ function contactMethodIconUrl(name: string): string {
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 & {
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}}
};
@ -221,7 +224,7 @@ export function init(characters: {[key: string]: number}): void {
core.connection.queryApi<void>('friend-remove.php', {source_id: friend.source.id, dest_id: friend.target.id}));
registerMethod('friendRequestAccept', async(req: FriendRequest) => {
await core.connection.queryApi('request-accept.php', {request_id: req.id});
return { id: undefined!, source: req.target, target: req.source, createdAt: Date.now() / 1000 };
return {id: undefined!, source: req.target, target: req.source, createdAt: Date.now() / 1000};
});
registerMethod('friendRequestCancel', async(req: FriendRequest) =>
core.connection.queryApi<void>('request-cancel.php', {request_id: req.id}));

View File

@ -25,7 +25,7 @@ function VueRaven(this: void, raven: Raven.RavenStatic): Raven.RavenStatic {
}
});
if(typeof oldOnError === 'function') oldOnError.call(this, error, vm);
if(typeof oldOnError === 'function') oldOnError.call(this, error, vm, info);
else console.log(error);
};

View File

@ -6,7 +6,7 @@
<slot name="title" style="flex:1"></slot>
</div>
</a>
<div class="dropdown-menu" :style="open ? 'display:block' : ''" @mousedown.stop.prevent @click="isOpen = false"
<div class="dropdown-menu" :style="open ? {display: 'block'} : undefined" @mousedown.stop.prevent @click="isOpen = false"
ref="menu">
<slot></slot>
</div>
@ -14,9 +14,8 @@
</template>
<script lang="ts">
import {Component, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
@Component
export default class Dropdown extends Vue {
@ -35,7 +34,7 @@
menu.style.cssText = '';
return;
}
let element: HTMLElement | null = this.$el;
let element = <HTMLElement | null>this.$el;
while(element !== null) {
if(getComputedStyle(element).position === 'fixed') {
menu.style.display = 'block';

View File

@ -4,12 +4,13 @@
<slot v-else slot="title" :option="selected">{{label}}</slot>
<div style="padding:10px;">
<input v-model="filter" class="form-control" :placeholder="placeholder" @mousedown.stop @focus="keepOpen = true" @blur="keepOpen = false"/>
<input v-model="filter" class="form-control" :placeholder="placeholder" @mousedown.stop @focus="keepOpen = true"
@blur="keepOpen = false"/>
</div>
<div class="dropdown-items">
<template v-if="multiple">
<a href="#" @click.stop="select(option)" v-for="option in filtered" class="dropdown-item">
<input type="checkbox" :checked="selected.indexOf(option) !== -1"/>
<input type="checkbox" :checked="isSelected(option)"/>
<slot :option="option">{{option}}</slot>
</a>
</template>
@ -23,16 +24,14 @@
</template>
<script lang="ts">
import {Component, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import Dropdown from '../components/Dropdown.vue';
@Component({
components: {dropdown: Dropdown}
})
export default class FilterableSelect extends Vue {
//tslint:disable:no-null-keyword
@Prop()
readonly placeholder?: string;
@Prop({required: true})
@ -46,11 +45,11 @@
@Prop()
readonly title?: string;
filter = '';
selected: object | object[] | null = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : null);
selected: object | object[] | undefined = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : undefined);
keepOpen = false;
@Watch('value')
watchValue(newValue: object | object[] | null): void {
watchValue(newValue: object | object[] | undefined): void {
this.selected = newValue;
}
@ -67,13 +66,17 @@
this.$emit('input', this.selected);
}
isSelected(option: object): boolean {
return (<object[]>this.selected).indexOf(option) !== -1;
}
get filtered(): object[] {
return this.options.filter((x) => this.filterFunc(this.filterRegex, x));
}
get label(): string | undefined {
return this.multiple !== undefined ? `${this.title} - ${(<object[]>this.selected).length}` :
(this.selected !== null ? this.selected.toString() : this.title);
(this.selected !== undefined ? this.selected.toString() : this.title);
}
get filterRegex(): RegExp {

View File

@ -1,7 +1,7 @@
<template>
<span v-show="isShown">
<div class="modal" @click.self="hideWithCheck" style="display:flex">
<div class="modal-dialog" :class="dialogClass" style="display:flex;align-items:center">
<div class="modal" @click.self="hideWithCheck()" style="display:flex;justify-content:center">
<div class="modal-dialog" :class="dialogClass" style="display:flex;align-items:center;margin-left:0;margin-right:0">
<div class="modal-content" style="max-height:100%">
<div class="modal-header" style="flex-shrink:0">
<h4 class="modal-title">
@ -26,9 +26,8 @@
</template>
<script lang="ts">
import {Component, Hook, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {getKey} from '../chat/common';
import {Keys} from '../keys';
@ -95,6 +94,7 @@
this.hide();
}
@Hook('beforeDestroy')
beforeDestroy(): void {
if(this.isShown) this.hide();
}

View File

@ -6,9 +6,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import * as Utils from '../site/utils';
@Component

View File

@ -6,9 +6,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {getCharacters} from './character_select/character_list';
interface SelectItem {

View File

@ -1,6 +1,8 @@
import {Component} from '@f-list/vue-ts';
import Vue from 'vue';
import Modal from './Modal.vue';
@Component
export default class CustomDialog extends Vue {
protected get dialog(): Modal {
return <Modal>this.$children[0];

View File

@ -3,10 +3,9 @@
</template>
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-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 Vue from 'vue';
import {Settings} from '../site/utils';
@Component
@ -16,8 +15,9 @@
primary: string | undefined;
secondary: string | undefined;
constructor(options?: ComponentOptions<Vue>) {
super(options);
@Hook('mounted')
@Watch('time')
update(): void {
if(this.time === null || this.time === 0)
return;
const date = isNaN(+this.time) ? new Date(`${this.time}+00:00`) : new Date(+this.time * 1000);

View File

@ -14,9 +14,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormGroup extends Vue {

View File

@ -17,9 +17,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
@Component
export default class FormGroupInputgroup extends Vue {

View File

@ -2,21 +2,21 @@
<div class="d-flex w-100 my-2 justify-content-between">
<div>
<slot name="previous" v-if="!routed">
<a class="btn btn-secondary" :class="{'disabled': !prev}" role="button" @click.prevent="previousPage">
<a class="btn btn-secondary" :class="{'disabled': !prev}" role="button" @click.prevent="previousPage()">
<span aria-hidden="true">&larr;</span> {{prevLabel}}
</a>
</slot>
<router-link v-if="routed" :to="prevRoute" class="btn btn-secondary" :class="{'disabled': !prev}" role="button">
<router-link v-else :to="prevRoute" class="btn btn-secondary" :class="{'disabled': !prev}" role="button">
<span aria-hidden="true">&larr;</span> {{prevLabel}}
</router-link>
</div>
<div>
<slot name="next" v-if="!routed">
<a class="btn btn-secondary" :class="{'disabled': !next}" role="button" @click.prevent="nextPage">
<a class="btn btn-secondary" :class="{'disabled': !next}" role="button" @click.prevent="nextPage()">
{{nextLabel}} <span aria-hidden="true">&rarr;</span>
</a>
</slot>
<router-link v-if="routed" :to="nextRoute" class="btn btn-secondary" :class="{'disabled': !next}" role="button">
<router-link v-else :to="nextRoute" class="btn btn-secondary" :class="{'disabled': !next}" role="button">
{{nextLabel}} <span aria-hidden="true">&rarr;</span>
</router-link>
</div>
@ -24,10 +24,9 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import cloneDeep = require('lodash/cloneDeep'); //tslint:disable-line:no-require-imports
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
type ParamDictionary = {[key: string]: number | undefined};
interface RouteParams {

View File

@ -6,9 +6,9 @@ const Tabs = Vue.extend({
render(this: Vue & {readonly value?: string, _v?: string, selected?: string, tabs: {readonly [key: string]: string}},
createElement: CreateElement): VNode {
let children: {[key: string]: string | VNode | undefined};
if(<VNode[] | undefined>this.$slots['default'] !== undefined) {
if(this.$slots['default'] !== undefined) {
children = {};
this.$slots['default'].forEach((child, i) => {
this.$slots['default']!.forEach((child, i) => {
if(child.context !== undefined) children[child.key !== undefined ? child.key : i] = child;
});
} else children = this.tabs;
@ -19,14 +19,11 @@ const Tabs = Vue.extend({
this.$emit('input', this._v = keys[0]);
if(this.selected !== this._v && children[this.selected!] !== undefined)
this.$emit('input', this._v = this.selected);
return createElement('ul', {staticClass: 'nav nav-tabs'}, keys.map((key) => createElement('li', {staticClass: 'nav-item'},
[createElement('a', {
staticClass: 'nav-link', class: {active: this._v === key}, on: {
click: () => {
this.$emit('input', key);
}
}
}, [children[key]!])])));
return createElement('div', {staticClass: 'nav-tabs-scroll'},
[createElement('ul', {staticClass: 'nav nav-tabs'}, keys.map((key) => createElement('li', {staticClass: 'nav-item'},
[createElement('a', {
staticClass: 'nav-link', class: {active: this._v === key}, on: { click: () => this.$emit('input', key) }
}, [children[key]!])])))]);
}
});

View File

@ -10,18 +10,18 @@
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login()" :disabled="loggingIn"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login" :disabled="loggingIn"/>
<input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login()" :disabled="loggingIn"/>
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<div class="input-group">
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login()" :disabled="loggingIn"/>
<div class="input-group-append">
<button class="btn btn-outline-secondary" @click="resetHost"><span class="fas fa-undo-alt"></span></button>
<button class="btn btn-outline-secondary" @click="resetHost()"><span class="fas fa-undo-alt"></span></button>
</div>
</div>
</div>
@ -65,6 +65,7 @@
</template>
<script lang="ts">
import {Component, Hook} from '@f-list/vue-ts';
import Axios from 'axios';
import * as electron from 'electron';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
@ -74,7 +75,6 @@
import * as Raven from 'raven-js';
import {promisify} from 'util';
import Vue from 'vue';
import Component from 'vue-class-component';
import Chat from '../chat/Chat.vue';
import {getKey, Settings} from '../chat/common';
import core, {init as initCore} from '../chat/core';
@ -109,15 +109,14 @@
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
})
export default class Index extends Vue {
//tslint:disable:no-null-keyword
showAdvanced = false;
saveLogin = false;
loggingIn = false;
password = '';
character: string | undefined;
characters: string[] | null = null;
characters: string[] | undefined;
error = '';
defaultCharacter: string | null = null;
defaultCharacter: string | undefined;
l = l;
settings!: GeneralSettings;
importProgress = 0;
@ -125,6 +124,7 @@
fixCharacters: ReadonlyArray<string> = [];
fixCharacter = '';
@Hook('created')
async created(): Promise<void> {
if(this.settings.account.length > 0) this.saveLogin = true;
keyStore.getPassword(this.settings.account)
@ -192,8 +192,8 @@
});
connection.onEvent('closed', () => {
if(this.character === undefined) return;
electron.ipcRenderer.send('disconnect', this.character);
this.character = undefined;
electron.ipcRenderer.send('disconnect', connection.character);
parent.send('disconnect', webContents.id);
Raven.setUserContext();
});

View File

@ -18,14 +18,14 @@
</a>
</li>
<li v-show="canOpenTab" class="addTab nav-item" id="addTab">
<a href="#" @click.prevent="addTab" class="nav-link"><i class="fa fa-plus"></i></a>
<a href="#" @click.prevent="addTab()" class="nav-link"><i class="fa fa-plus"></i></a>
</li>
</ul>
<div style="flex:1;display:flex;justify-content:flex-end;-webkit-app-region:drag" class="btn-group"
id="windowButtons">
<i class="far fa-window-minimize btn btn-light" @click.stop="minimize"></i>
<i class="far btn btn-light" :class="'fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></i>
<span class="btn btn-light" @click.stop="close">
<i class="far fa-window-minimize btn btn-light" @click.stop="minimize()"></i>
<i class="far btn btn-light" :class="'fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize()"></i>
<span class="btn btn-light" @click.stop="close()">
<i class="fa fa-times fa-lg"></i>
</span>
</div>
@ -36,12 +36,12 @@
<script lang="ts">
import Sortable = require('sortablejs'); //tslint:disable-line:no-require-imports
import {Component, Hook} from '@f-list/vue-ts';
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
import Vue from 'vue';
import Component from 'vue-class-component';
import l from '../chat/localize';
import {GeneralSettings} from './common';
@ -71,10 +71,9 @@
@Component
export default class Window extends Vue {
//tslint:disable:no-null-keyword
settings!: GeneralSettings;
tabs: Tab[] = [];
activeTab: Tab | null = null;
activeTab: Tab | undefined;
tabMap: {[key: number]: Tab} = {};
isMaximized = browserWindow.isMaximized();
canOpenTab = true;
@ -83,6 +82,7 @@
platform = process.platform;
lockTab = false;
@Hook('mounted')
mounted(): void {
this.addTab();
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => this.settings = settings);
@ -105,7 +105,6 @@
tab.hasNew = false;
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
}
electron.ipcRenderer.send('disconnect', tab.user);
tab.user = undefined;
tab.tray.setToolTip(l('title'));
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
@ -115,14 +114,8 @@
tab.hasNew = hasNew;
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
});
browserWindow.on('maximize', () => {
this.isMaximized = true;
this.activeTab!.view.setBounds(getWindowBounds());
});
browserWindow.on('unmaximize', () => {
this.isMaximized = false;
this.activeTab!.view.setBounds(getWindowBounds());
});
browserWindow.on('maximize', () => this.isMaximized = true);
browserWindow.on('unmaximize', () => this.isMaximized = false);
electron.ipcRenderer.on('switch-tab', (_: Event) => {
const index = this.tabs.indexOf(this.activeTab!);
this.show(this.tabs[index + 1 === this.tabs.length ? 0 : index + 1]);
@ -133,12 +126,12 @@
document.addEventListener('click', () => this.activeTab!.view.webContents.focus());
window.addEventListener('focus', () => this.activeTab!.view.webContents.focus());
Sortable.create(this.$refs['tabs'], {
Sortable.create(<HTMLElement>this.$refs['tabs'], {
animation: 50,
onEnd: (e: {oldIndex: number, newIndex: number}) => {
onEnd: (e) => {
if(e.oldIndex === e.newIndex) return;
const tab = this.tabs.splice(e.oldIndex, 1)[0];
this.tabs.splice(e.newIndex, 0, tab);
const tab = this.tabs.splice(e.oldIndex!, 1)[0];
this.tabs.splice(e.newIndex!, 0, tab);
},
onMove: (e: {related: HTMLElement}) => e.related.id !== 'addTab',
filter: '.addTab'
@ -163,7 +156,7 @@
}
destroyAllTabs(): void {
browserWindow.setBrowserView(null!);
browserWindow.setBrowserView(null!); //tslint:disable-line:no-null-keyword
this.tabs.forEach(destroyTab);
this.tabs = [];
}
@ -230,7 +223,7 @@
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
delete this.tabMap[tab.view.webContents.id];
if(this.tabs.length === 0) {
browserWindow.setBrowserView(null!);
browserWindow.setBrowserView(null!); //tslint:disable-line:no-null-keyword
if(process.env.NODE_ENV === 'production') browserWindow.close();
} else if(this.activeTab === tab) this.show(this.tabs[0]);
destroyTab(tab);

View File

@ -49,19 +49,17 @@ document.addEventListener('keydown', (e: KeyboardEvent) => {
process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
const sc = nativeRequire<{
Spellchecker: {
new(): {
add(word: string): void
remove(word: string): void
isMisspelled(x: string): boolean
setDictionary(name: string | undefined, dir: string): void
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
}
Spellchecker: new() => {
add(word: string): void
remove(word: string): void
isMisspelled(x: string): boolean
setDictionary(name: string | undefined, dir: string): void
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
}
}>('spellchecker/build/Release/spellchecker.node');
const spellchecker = new sc.Spellchecker();
Axios.defaults.params = { __fchat: `desktop/${electron.remote.app.getVersion()}` };
Axios.defaults.params = {__fchat: `desktop/${electron.remote.app.getVersion()}`};
if(process.env.NODE_ENV === 'production') {
setupRaven('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', electron.remote.app.getVersion());
@ -72,30 +70,23 @@ if(process.env.NODE_ENV === 'production') {
});
}
let browser: string | undefined;
function openIncognito(url: string): void {
if(browser === undefined)
try { //tslint:disable-next-line:max-line-length
browser = execSync(`FOR /F "skip=2 tokens=3" %A IN ('REG QUERY HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice /v ProgId') DO @(echo %A)`)
.toString().trim();
.toString().trim().toLowerCase();
} catch(e) {
console.error(e);
}
switch(browser) {
case 'FirefoxURL':
exec(`start firefox.exe -private-window ${url}`);
break;
case 'ChromeHTML':
exec(`start chrome.exe -incognito ${url}`);
break;
case 'VivaldiHTM':
exec(`start vivaldi.exe -incognito ${url}`);
break;
case 'OperaStable':
exec(`start opera.exe -private ${url}`);
break;
default:
exec(`start iexplore.exe -private ${url}`);
}
const commands = {
chrome: 'chrome.exe -incognito', firefox: 'firefox.exe -private-window', vivaldi: 'vivaldi.exe -incognito',
opera: 'opera.exe -private'
};
let start = 'iexplore.exe -private';
for(const key in commands)
if(browser!.indexOf(key) !== -1) start = commands[<keyof typeof commands>key];
exec(`start ${start} ${url}`);
}
const webContents = electron.remote.getCurrentWebContents();
@ -172,14 +163,16 @@ webContents.on('context-menu', (_, props) => {
});
let dictDir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');
if(process.platform === 'win32')
exec(`for /d %I in ("${dictDir}") do @echo %~sI`, (_, stdout) => { dictDir = stdout.trim(); });
if(process.platform === 'win32') //get the path in DOS (8-character) format as special characters cause problems otherwise
exec(`for /d %I in ("${dictDir}") do @echo %~sI`, (_, stdout) => dictDir = stdout.trim());
electron.webFrame.setSpellCheckProvider('', false, {spellCheck: (text) => !spellchecker.isMisspelled(text)});
function onSettings(s: GeneralSettings): void {
settings = s;
spellchecker.setDictionary(s.spellcheckLang, dictDir);
for(const word of s.customDictionary) spellchecker.add(word);
}
electron.ipcRenderer.on('settings', (_: Event, s: GeneralSettings) => onSettings(s));
const params = <{[key: string]: string | undefined}>qs.parse(window.location.search.substr(1));

View File

@ -290,7 +290,7 @@ export class Logs implements Logging {
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const baseDir = core.state.generalSettings!.logDirectory;
mkdir(baseDir);
return (fs.readdirSync(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
return (fs.readdirSync(baseDir)).filter((x) => fs.statSync(path.join(baseDir, x)).isDirectory());
}
}
@ -312,7 +312,7 @@ export class SettingsStore implements Settings.Store {
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const baseDir = core.state.generalSettings!.logDirectory;
return (fs.readdirSync(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
return (fs.readdirSync(baseDir)).filter((x) => fs.statSync(path.join(baseDir, x)).isDirectory());
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {

View File

@ -184,7 +184,7 @@ export async function importCharacter(ownCharacter: string, progress: (progress:
progress(i / subdirs.length);
const subdir = subdirs[i];
const subdirPath = path.join(dir, subdir);
if(subdir === '!Notifications' || subdir === 'Global' || !fs.lstatSync(subdirPath).isDirectory()) continue;
if(subdir === '!Notifications' || subdir === 'Global' || !fs.statSync(subdirPath).isDirectory()) continue;
const channelMarker = subdir.indexOf('(');
let key: string, name: string;

View File

@ -381,7 +381,10 @@ function onReady(): void {
settings.customDictionary.splice(settings.customDictionary.indexOf(word), 1);
setGeneralSettings(settings);
});
electron.ipcMain.on('disconnect', (_: Event, character: string) => characters.splice(characters.indexOf(character), 1));
electron.ipcMain.on('disconnect', (_: Event, character: string) => {
const index = characters.indexOf(character);
if(index !== -1) characters.splice(index, 1);
});
const emptyBadge = electron.nativeImage.createEmpty();
//tslint:disable-next-line:no-require-imports
const badge = electron.nativeImage.createFromPath(path.join(__dirname, <string>require('./build/badge.png')));

View File

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

View File

@ -1,10 +1,9 @@
{
"compilerOptions": {
"target": "es6",
"target": "es2017",
"module": "commonjs",
"sourceMap": true,
"allowJs": true,
"outDir": "build",
"noEmitHelpers": true,
"importHelpers": true,
"forceConsistentCasingInFileNames": true,

View File

@ -1,11 +1,10 @@
{
"compilerOptions": {
"target": "es6",
"target": "es2017",
"module": "commonjs",
"sourceMap": true,
"experimentalDecorators": true,
"allowJs": true,
"outDir": "build",
"noEmitHelpers": true,
"importHelpers": true,
"forceConsistentCasingInFileNames": true,

View File

@ -1,8 +1,9 @@
const path = require('path');
const fs = require('fs');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('@f-list/fork-ts-checker-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const vueTransformer = require('@f-list/vue-ts/transform').default;
const mainConfig = {
entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'package.json')],
@ -69,7 +70,8 @@ const mainConfig = {
options: {
appendTsSuffixTo: [/\.vue$/],
configFile: __dirname + '/tsconfig-renderer.json',
transpileOnly: true
transpileOnly: true,
getCustomTransformers: () => ({before: [vueTransformer]})
}
},
{test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},

View File

@ -59,9 +59,9 @@ export default function(this: void, connection: Connection): Interfaces.State {
connection.onEvent('connecting', async(isReconnect) => {
state.friends = [];
state.bookmarks = [];
state.bookmarkList = (<{characters: string[]}>await connection.queryApi('bookmark-list.php')).characters;
state.friendList = ((<{friends: {source: string, dest: string, last_online: number}[]}>await connection.queryApi('friend-list.php'))
.friends).map((x) => x.dest);
state.bookmarkList = (await connection.queryApi<{characters: string[]}>('bookmark-list.php')).characters;
state.friendList = (await connection.queryApi<{friends: {source: string, dest: string, last_online: number}[]}>('friend-list.php'))
.friends.map((x) => x.dest);
if(isReconnect && (<Character | undefined>state.ownCharacter) !== undefined)
reconnectStatus = {status: state.ownCharacter.status, statusmsg: state.ownCharacter.statusText};
for(const key in state.characters) {

View File

@ -86,11 +86,12 @@ export default class Connection implements Interfaces.Connection {
this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000;
}
close(): void {
close(keepState: boolean = true): void {
if(this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
this.cleanClose = true;
if(this.socket !== undefined) this.socket.close();
if(!keepState) this.character = '';
}
get isOpen(): boolean {
@ -143,7 +144,7 @@ export default class Connection implements Interfaces.Connection {
}
send<K extends keyof Interfaces.ClientCommands>(command: K, data?: Interfaces.ClientCommands[K]): void {
if(this.socket !== undefined)
if(this.socket !== undefined && this.socket.readyState === WebSocketConnection.ReadyState.OPEN)
this.socket.send(<string>command + (data !== undefined ? ` ${JSON.stringify(data)}` : ''));
}

View File

@ -138,7 +138,7 @@ export namespace Connection {
readonly vars: Vars
readonly isOpen: boolean
connect(character: string): void
close(): void
close(keepState?: boolean): void
onMessage<K extends keyof ServerCommands>(type: K, handler: CommandHandler<K>): void
offMessage<K extends keyof ServerCommands>(type: K, handler: CommandHandler<K>): void
onEvent(type: EventType, handler: EventHandler): void
@ -232,6 +232,10 @@ export namespace Channel {
export type Channel = Channel.Channel;
export namespace WebSocketConnection {
export enum ReadyState { CONNECTING, OPEN, CLOSING, CLOSED }
}
export interface WebSocketConnection {
close(): void
onMessage(handler: (message: string) => Promise<void>): void
@ -239,4 +243,5 @@ export interface WebSocketConnection {
onClose(handler: () => void): void
onError(handler: (error: Error) => void): void
send(message: string): void
readyState: WebSocketConnection.ReadyState
}

99
interfaces.ts Normal file
View File

@ -0,0 +1,99 @@
export interface SimpleCharacter {
id: number
name: string
deleted: boolean
}
export interface InlineImage {
id: number
name: string
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
hash: string
height: number
width: number
description: string
sort_order: number | null
url: string
}
export type InfotagType = 'number' | 'text' | 'list';
export interface CharacterInfotag {
list?: number
string?: string
number?: number
}
export interface Infotag {
id: number
name: string
type: InfotagType
search_field: string
validator: string
allow_legacy: boolean
infotag_group: string
list?: number
}
export interface Character extends SimpleCharacter {
id: number
name: string
title: string
description: string
kinks: {[key: string]: KinkChoice | number | undefined}
inlines: {[key: string]: InlineImage}
customs: {[key: string]: CustomKink | undefined}
infotags: {[key: number]: CharacterInfotag | undefined}
created_at: number
updated_at: number
views: number
last_online_at?: number
timezone?: number
image_count?: number
online_chat?: boolean
}
export type KinkChoice = 'favorite' | 'yes' | 'maybe' | 'no';
export interface CharacterSettings {
readonly customs_first: boolean
readonly show_friends: boolean
readonly show_badges: boolean
readonly guestbook: boolean
readonly block_bookmarks: boolean
readonly public: boolean
readonly moderate_guestbook: boolean
readonly hide_timezone: boolean
readonly hide_contact_details: boolean
}
export interface Kink {
id: number
name: string
description: string
kink_group: number
}
export interface CustomKink {
id: number
name: string
choice: KinkChoice
description: string
}

View File

@ -10,16 +10,16 @@
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login" :disabled="loggingIn"/>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login()" :disabled="loggingIn"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login" :disabled="loggingIn"/>
<input class="form-control" type="password" id="password" v-model="settings.password" @keypress.enter="login()" :disabled="loggingIn"/>
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<div class="input-group">
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login()" :disabled="loggingIn"/>
<div class="input-group-append">
<button class="btn btn-outline-secondary" @click="resetHost"><span class="fas fa-undo-alt"></span></button>
</div>
@ -40,7 +40,7 @@
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group" style="text-align:right">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
<button class="btn btn-primary" @click="login()" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
</div>
@ -57,11 +57,11 @@
</template>
<script lang="ts">
import {Component, Hook} from '@f-list/vue-ts';
import Axios from 'axios';
import * as qs from 'qs';
import * as Raven from 'raven-js';
import Vue from 'vue';
import Component from 'vue-class-component';
import Chat from '../chat/Chat.vue';
import core, {init as initCore} from '../chat/core';
import l from '../chat/localize';
@ -94,18 +94,18 @@
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
})
export default class Index extends Vue {
//tslint:disable:no-null-keyword
showAdvanced = false;
saveLogin = false;
loggingIn = false;
characters: ReadonlyArray<string> | null = null;
characters?: ReadonlyArray<string>;
error = '';
defaultCharacter: string | null = null;
defaultCharacter?: string;
settingsStore = new SettingsStore();
l = l;
settings: GeneralSettings | null = null;
settings!: GeneralSettings;
profileName = '';
@Hook('created')
async created(): Promise<void> {
document.addEventListener('open-profile', (e: Event) => {
const profileViewer = <Modal>this.$refs['profileViewer'];
@ -123,13 +123,13 @@
}
resetHost(): void {
this.settings!.host = new GeneralSettings().host;
this.settings.host = new GeneralSettings().host;
}
get styling(): string {
if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings!.theme);
if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings.theme);
//tslint:disable-next-line:no-require-imports
return `<style>${require('../scss/fa.scss')}${require(`../scss/themes/chat/${this.settings!.theme}.scss`)}</style>`;
return `<style>${require('../scss/fa.scss')}${require(`../scss/themes/chat/${this.settings.theme}.scss`)}</style>`;
}
async login(): Promise<void> {
@ -138,17 +138,17 @@
try {
const data = <{ticket?: string, error: string, characters: {[key: string]: number}, default_character: number}>
(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify({
account: this.settings!.account, password: this.settings!.password, no_friends: true, no_bookmarks: true,
account: this.settings.account, password: this.settings.password, no_friends: true, no_bookmarks: true,
new_character_list: true
}))).data;
if(data.error !== '') {
this.error = data.error;
return;
}
if(this.saveLogin) await setGeneralSettings(this.settings!);
Socket.host = this.settings!.host;
if(this.saveLogin) await setGeneralSettings(this.settings);
Socket.host = this.settings.host;
const connection = new Connection('F-Chat 3.0 (Mobile)', appVersion, Socket,
this.settings!.account, this.settings!.password);
this.settings.account, this.settings.password);
connection.onEvent('connected', () => {
Raven.setUserContext({username: core.connection.character});
document.addEventListener('backbutton', confirmBack);

View File

@ -1,2 +1,3 @@
/build
/debug
/release

View File

@ -2,14 +2,14 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 27
buildToolsVersion "27.0.3"
compileSdkVersion 28
buildToolsVersion "28.0.3"
defaultConfig {
applicationId "net.f_list.fchat"
minSdkVersion 19
minSdkVersion 21
targetSdkVersion 27
versionCode 20
versionName "3.0.9"
versionCode 21
versionName "3.0.10"
}
buildTypes {
release {
@ -20,7 +20,7 @@ android {
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
repositories {
mavenCentral()

View File

@ -5,6 +5,7 @@ import android.webkit.JavascriptInterface
import org.json.JSONArray
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.util.*
class File(private val ctx: Context) {
@ -12,7 +13,7 @@ class File(private val ctx: Context) {
fun read(name: String): String? {
val file = File(ctx.filesDir, name)
if(!file.exists()) return null
Scanner(file).useDelimiter("\\Z").use { return it.next() }
return file.readText()
}
@JavascriptInterface

View File

@ -118,7 +118,7 @@ class MainActivity : Activity() {
}
val view = EditText(this)
view.hint = "Enter character name"
AlertDialog.Builder(this).setView(view).setPositiveButton("OK", { _, _ ->
AlertDialog.Builder(this).setView(view).setPositiveButton("OK") { _, _ ->
val file = java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "test.zip")
val dest = FileOutputStream(file)
val out = ZipOutputStream(dest)
@ -126,7 +126,7 @@ class MainActivity : Activity() {
out.close()
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.addCompletedDownload(file.name, file.name, false, "text/plain", file.absolutePath, file.length(), true)
}).setNegativeButton("Cancel", { dialog, _ -> dialog.dismiss() }).setTitle("DEBUG").show()
}.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }.setTitle("DEBUG").show()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {

View File

@ -22,7 +22,7 @@ class Notifications(private val ctx: Context) {
init {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
manager.createNotificationChannel(NotificationChannel("messages", ctx.getString(R.string.channel_messages), NotificationManager.IMPORTANCE_DEFAULT))
manager.createNotificationChannel(NotificationChannel("messages", ctx.getString(R.string.channel_messages), NotificationManager.IMPORTANCE_HIGH))
}
}

View File

@ -87,7 +87,10 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
let start = str.index(of: ",")!
let file = FileManager.default.temporaryDirectory.appendingPathComponent(str[str.index(str.startIndex, offsetBy: 5)..<start].removingPercentEncoding!)
try! str.suffix(from: str.index(after: start)).removingPercentEncoding!.write(to: file, atomically: false, encoding: .utf8)
self.present(UIActivityViewController(activityItems: [file], applicationActivities: nil), animated: true, completion: nil)
let controller = UIActivityViewController(activityItems: [file], applicationActivities: nil)
controller.popoverPresentationController?.sourceView = webView
controller.popoverPresentationController?.sourceRect = CGRect(origin: webView.bounds.origin, size: CGSize(width: 0, height: 0))
self.present(controller, animated: true, completion: nil)
return
}
let match = profileRegex.matches(in: str, range: NSRange(location: 0, length: str.count))

View File

@ -1 +0,0 @@
../../www

1
mobile/ios/F-Chat/www Symbolic link
View File

@ -0,0 +1 @@
../../www

View File

@ -1,6 +1,6 @@
{
"name": "net.f_list.fchat",
"version": "3.0.9",
"version": "3.0.10",
"displayName": "F-Chat",
"author": "The F-List Team",
"description": "F-List.net Chat Client",

View File

@ -1,16 +1,10 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"es5",
"es2015.promise"
],
"target": "es6",
"module": "commonjs",
"sourceMap": true,
"experimentalDecorators": true,
"allowJs": true,
"outDir": "build",
"noEmitHelpers": true,
"importHelpers": true,
"forceConsistentCasingInFileNames": true,
@ -18,8 +12,5 @@
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["chat.ts", "../**/*.d.ts"],
"exclude": [
"node_modules"
]
"include": ["chat.ts", "../**/*.d.ts"]
}

View File

@ -1,6 +1,7 @@
const path = require('path');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('@f-list/fork-ts-checker-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const vueTransformer = require('@f-list/vue-ts/transform').default;
const config = {
entry: {
@ -19,7 +20,8 @@ const config = {
options: {
appendTsSuffixTo: [/\.vue$/],
configFile: __dirname + '/tsconfig.json',
transpileOnly: true
transpileOnly: true,
getCustomTransformers: () => ({before: [vueTransformer]})
}
},
{

View File

@ -5,42 +5,40 @@
"description": "F-List Exported",
"license": "MIT",
"devDependencies": {
"@fortawesome/fontawesome-free-webfonts": "^1.0.6",
"@types/lodash": "^4.14.116",
"@types/node": "^10.11.2",
"@types/sortablejs": "^1.3.31",
"@f-list/fork-ts-checker-webpack-plugin": "^0.5.2",
"@f-list/vue-ts": "^1.0.2",
"@fortawesome/fontawesome-free": "^5.6.1",
"@types/lodash": "^4.14.119",
"@types/sortablejs": "^1.7.0",
"axios": "^0.18.0",
"bootstrap": "^4.1.3",
"css-loader": "^1.0.0",
"date-fns": "^1.28.5",
"electron": "^3.0.2",
"css-loader": "^2.0.1",
"date-fns": "^1.30.1",
"electron": "3.0.13",
"electron-log": "^2.2.17",
"electron-packager": "^12.1.2",
"electron-packager": "^13.0.1",
"electron-rebuild": "^1.8.2",
"extract-loader": "^3.0.0",
"extract-loader": "^3.1.0",
"file-loader": "^2.0.0",
"fork-ts-checker-webpack-plugin": "^0.4.9",
"lodash": "^4.17.11",
"node-sass": "^4.9.3",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"qs": "^6.5.1",
"qs": "^6.6.0",
"raven-js": "^3.27.0",
"sass-loader": "^7.1.0",
"sortablejs": "^1.6.0",
"style-loader": "^0.23.0",
"ts-loader": "^5.2.1",
"tslib": "^1.7.1",
"tslint": "^5.7.0",
"typescript": "^3.1.1",
"vue": "^2.5.17",
"vue-class-component": "^6.0.0",
"sortablejs": "^1.8.0-rc1",
"style-loader": "^0.23.1",
"ts-loader": "^5.3.1",
"tslib": "^1.9.3",
"tslint": "^5.12.0",
"typescript": "^3.2.2",
"vue": "^2.5.21",
"vue-loader": "^15.4.2",
"vue-property-decorator": "^7.1.1",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2"
"vue-template-compiler": "^2.5.21",
"webpack": "^4.27.1"
},
"dependencies": {
"keytar": "^4.2.1",
"keytar": "^4.3.0",
"spellchecker": "^3.5.0"
},
"optionalDependencies": {

View File

@ -165,7 +165,6 @@
.message {
word-wrap: break-word;
word-break: break-word;
padding-bottom: 1px;
}
@ -185,6 +184,10 @@
color: color-yiq(theme-color("danger"));
}
.messages {
position: relative;
}
.messages-both {
.message-ad {
background-color: theme-color-level("info", -4);

View File

@ -46,8 +46,6 @@
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word; // Non standard form used in some browsers.
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;

View File

@ -11,6 +11,28 @@ hr {
font-weight: bold;
}
.nav-tabs-scroll {
overflow-x: auto;
.nav-tabs {
flex-wrap: nowrap;
.nav-item {
flex-shrink: 0;
}
}
}
sub {
position: static;
vertical-align: sub;
}
sup {
position: static;
vertical-align: super;
}
$theme-is-dark: false !default;
// HACK: Bootstrap offers no way to override these by default, and they are SUPER bright.

View File

@ -5,3 +5,7 @@
@function theme-color-border($color-name: "primary") {
@return theme-color-level($color-name, -9);
}
@mixin text-outline($color) {
text-shadow: $color 1px 0, $color -1px 0, $color 0 1px, $color 0 -1px, $color 1px 1px, $color -1px 1px, $color 1px -1px, $color -1px -1px;
}

View File

@ -1,5 +1,5 @@
$fa-font-path: "~@fortawesome/fontawesome-free-webfonts/webfonts" !default;
@import "~@fortawesome/fontawesome-free-webfonts/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free-webfonts/scss/fa-solid.scss";
@import "~@fortawesome/fontawesome-free-webfonts/scss/fa-regular.scss";
@import "~@fortawesome/fontawesome-free-webfonts/scss/fa-brands.scss";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts" !default;
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
@import "~@fortawesome/fontawesome-free/scss/regular.scss";
@import "~@fortawesome/fontawesome-free/scss/brands.scss";

View File

@ -1,5 +1,5 @@
$blue-color: #06f;
.blackText {
text-shadow: $gray-600 1px 1px, $gray-600 -1px 1px, $gray-600 1px -1px, $gray-600 -1px -1px;
@include text-outline($gray-600);
}

View File

@ -1,9 +1,9 @@
.purpleText {
text-shadow: #306 1px 1px, #306 -1px 1px, #306 1px -1px, #306 -1px -1px;
@include text-outline(#306);
}
.blackText {
text-shadow: $gray-600 1px 1px, $gray-600 -1px 1px, $gray-600 1px -1px, $gray-600 -1px -1px;
@include text-outline($gray-600);
}
$blue-color: #06f;

View File

@ -1,3 +1,3 @@
.whiteText {
text-shadow: $gray-600 1px 1px 1px, $gray-600 -1px 1px 1px, $gray-600 1px -1px 1px, $gray-600 -1px -1px 1px;
@include text-outline($gray-600);
}

View File

@ -1,7 +1,9 @@
<template>
<div class="row character-page" id="pageBody">
<div class="alert alert-info" v-show="loading" style="margin:0 15px;flex:1">Loading character information.</div>
<div class="alert alert-danger" v-show="error" style="margin:0 15px;flex:1">{{error}}</div>
<div class="col-12" style="min-height:0">
<div class="alert alert-info" v-show="loading">Loading character information.</div>
<div class="alert alert-danger" v-show="error">{{error}}</div>
</div>
<div class="col-md-4 col-lg-3 col-xl-2" v-if="!loading && character">
<sidebar :character="character" @memo="memo" @bookmarked="bookmarked" :oldApi="oldApi"></sidebar>
</div>
@ -33,25 +35,25 @@
</div>
<div class="card-body">
<div class="tab-content">
<div role="tabpanel" class="tab-pane" :class="{active: tab == 0}" id="overview">
<div role="tabpanel" class="tab-pane" :class="{active: tab === '0'}" id="overview">
<div v-bbcode="character.character.description" style="margin-bottom: 10px"></div>
<character-kinks :character="character" :oldApi="oldApi" ref="tab0"></character-kinks>
</div>
<div role="tabpanel" class="tab-pane" :class="{active: tab == 1}" id="infotags">
<div role="tabpanel" class="tab-pane" :class="{active: tab === '1'}" id="infotags">
<character-infotags :character="character" ref="tab1"></character-infotags>
</div>
<div role="tabpanel" class="tab-pane" id="groups" :class="{active: tab == 2}" v-if="!oldApi">
<div role="tabpanel" class="tab-pane" id="groups" :class="{active: tab === '2'}" v-if="!oldApi">
<character-groups :character="character" ref="tab2"></character-groups>
</div>
<div role="tabpanel" class="tab-pane" id="images" :class="{active: tab == 3}">
<div role="tabpanel" class="tab-pane" id="images" :class="{active: tab === '3'}">
<character-images :character="character" ref="tab3" :use-preview="imagePreview"></character-images>
</div>
<div v-if="character.settings.guestbook" role="tabpanel" class="tab-pane" :class="{active: tab == 4}"
<div v-if="character.settings.guestbook" role="tabpanel" class="tab-pane" :class="{active: tab === '4'}"
id="guestbook">
<character-guestbook :character="character" :oldApi="oldApi" ref="tab4"></character-guestbook>
</div>
<div v-if="character.is_self || character.settings.show_friends" role="tabpanel" class="tab-pane"
:class="{active: tab == 5}" id="friends">
:class="{active: tab === '5'}" id="friends">
<character-friends :character="character" ref="tab5"></character-friends>
</div>
</div>
@ -64,9 +66,8 @@
</template>
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {standardParser} from '../../bbcode/standard';
import * as Utils from '../utils';
import {methods, Store} from './data_store';
@ -99,29 +100,30 @@
}
})
export default class CharacterPage extends Vue {
//tslint:disable:no-null-keyword
@Prop()
private readonly name?: string;
readonly name?: string;
@Prop()
private readonly characterid?: number;
readonly characterid?: number;
@Prop({required: true})
private readonly authenticated!: boolean;
readonly authenticated!: boolean;
@Prop()
readonly oldApi?: true;
@Prop()
readonly imagePreview?: true;
private shared: SharedStore = Store;
private character: Character | null = null;
shared: SharedStore = Store;
character: Character | undefined;
loading = true;
error = '';
tab = '0';
@Hook('beforeMount')
beforeMount(): void {
this.shared.authenticated = this.authenticated;
}
@Hook('mounted')
async mounted(): Promise<void> {
if(this.character === null) await this._getCharacter();
if(this.character === undefined) await this._getCharacter();
}
@Watch('tab')
@ -147,7 +149,7 @@
private async _getCharacter(): Promise<void> {
this.error = '';
this.character = null;
this.character = undefined;
if(this.name === undefined || this.name.length === 0)
return;
try {

View File

@ -12,9 +12,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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';

View File

@ -1,12 +1,13 @@
import {Component} from '@f-list/vue-ts';
import Vue from 'vue';
@Component
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 = null;
private touchTimer = 0;
position = {left: 0, top: 0};
selectedItem: HTMLElement | undefined;
touchTimer = 0;
abstract itemSelected(element: HTMLElement): void;
@ -16,7 +17,7 @@ export default abstract class ContextMenu extends Vue {
hideMenu(): void {
this.showMenu = false;
this.selectedItem = null;
this.selectedItem = undefined;
}
bindOffclick(): void {
@ -40,7 +41,7 @@ export default abstract class ContextMenu extends Vue {
this.position = {left, top};
}
protected innerClick(): void {
innerClick(): void {
this.itemSelected(this.selectedItem!);
this.hideMenu();
}
@ -84,8 +85,8 @@ export default abstract class ContextMenu extends Vue {
});
}
get positionText(): string {
return `left: ${this.position.left}px; top: ${this.position.top}px;`;
get positionStyle(): object {
return {left: `${this.position.left}px`, top: `${this.position.top}px;`};
}
}

View File

@ -1,11 +1,11 @@
<template>
<modal id="copyCustomDialog" action="Copy Custom Kink" :disabled="!valid || submitting" @submit.prevent="copyCustom">
<modal id="copyCustomDialog" action="Copy Custom Kink" :disabled="!valid || submitting" @submit.prevent="copyCustom()">
<form-group field="name" :errors="formErrors" label="Name" id="copyCustomName">
<input type="text" class="form-control" maxlength="30" required v-model="name" id="copyCustomName"
slot-scope="props" :class="props.cls"/>
<input type="text" class="form-control" maxlength="30" required v-model="name" slot-scope="props" id="copyCustomName"
:class="props.cls"/>
</form-group>
<form-group field="description" :errors="formErrors" label="Description" id="copyCustomDescription">
<input type="text" class="form-control" max-length="250" id="copyCustomDescription" v-model="description" required
<input type="text" class="form-control" max-length="250" v-model="description" required id="copyCustomDescription"
slot-scope="props" :class="props.cls"/>
</form-group>
<form-group field="choice" :errors="formErrors" label="Choice" id="copyCustomChoice">
@ -17,28 +17,28 @@
</select>
</form-group>
<form-group field="target" :errors="formErrors" label="Target Character" id="copyCustomTarget">
<character-select id="copyCustomTarget" v-model="target" slot-scope="props" :class="props.cls"></character-select>
<character-select v-model="target" slot-scope="props" :class="props.cls" id="copyCustomTarget"></character-select>
</form-group>
</modal>
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Component} from '@f-list/vue-ts';
import CustomDialog from '../../components/custom_dialog';
import FormGroup from '../../components/form_group.vue';
import Modal from '../../components/Modal.vue';
import {KinkChoice} from '../../interfaces';
import * as Utils from '../utils';
import {methods} from './data_store';
import {KinkChoice} from './interfaces';
@Component({
components: {'form-group': FormGroup, modal: Modal}
})
export default class CopyCustomDialog extends CustomDialog {
private name = '';
private description = '';
private choice: KinkChoice = 'favorite';
private target = Utils.Settings.defaultCharacter;
name = '';
description = '';
choice: KinkChoice = 'favorite';
target = Utils.Settings.defaultCharacter;
formErrors = {};
submitting = false;

View File

@ -1,7 +1,7 @@
<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">
<ul class="dropdown-menu" role="menu" @click="innerClick" @touchstart="innerClick" @touchend="innerClick"
style="position: fixed; display: block;" :style="positionStyle" ref="menu" v-show="showMenu">
<li><a class="dropdown-item" href="#">Copy Custom</a></li>
</ul>
<copy-dialog ref="copy-dialog"></copy-dialog>
@ -9,15 +9,12 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Hook, Prop} from '@f-list/vue-ts';
import ContextMenu from './context_menu';
import CopyCustomDialog from './copy_custom_dialog.vue';
@Component({
components: {
'copy-dialog': CopyCustomDialog
}
components: {'copy-dialog': CopyCustomDialog}
})
export default class CopyCustomMenu extends ContextMenu {
@Prop({required: true})
@ -35,6 +32,7 @@
(<CopyCustomDialog>this.$refs['copy-dialog']).showDialog(name, description);
}
@Hook('mounted')
mounted(): void {
this.bindOffclick();
}

View File

@ -1,13 +1,12 @@
<template>
<modal id="deleteDialog" :action="'Delete character' + name" :disabled="deleting" @submit.prevent="deleteCharacter">
<modal id="deleteDialog" :action="'Delete character' + name" :disabled="deleting" @submit.prevent="deleteCharacter()">
Are you sure you want to permanently delete {{ name }}?<br/>
Character deletion cannot be undone for any reason.
</modal>
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Prop} from '@f-list/vue-ts';
import CustomDialog from '../../components/custom_dialog';
import Modal from '../../components/Modal.vue';
import * as Utils from '../utils';

View File

@ -1,5 +1,5 @@
<template>
<modal id="duplicateDialog" :action="'Duplicate character' + name" :disabled="duplicating || checking" @submit.prevent="duplicate">
<modal id="duplicateDialog" :action="'Duplicate character' + name" :disabled="duplicating || checking" @submit.prevent="duplicate()">
<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-row mb-2">
@ -17,8 +17,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Prop} from '@f-list/vue-ts';
import CustomDialog from '../../components/custom_dialog';
import FormGroupInputgroup from '../../components/form_group_inputgroup.vue';
import Modal from '../../components/Modal.vue';
@ -31,10 +30,10 @@
})
export default class DuplicateDialog extends CustomDialog {
@Prop({required: true})
private readonly character!: Character;
readonly character!: Character;
errors: {[key: string]: string} = {};
private newName = '';
newName = '';
valid = false;
checking = false;

View File

@ -1,5 +1,5 @@
<template>
<Modal id="memoDialog" :action="'Friends for ' + name" :buttons="false" dialog-class="modal-dialog-centered modal-lg">
<Modal :action="'Friends for ' + name" :buttons="false" dialog-class="modal-dialog-centered modal-lg">
<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">
@ -79,8 +79,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Prop} from '@f-list/vue-ts';
import CustomDialog from '../../components/custom_dialog';
import Modal from '../../components/Modal.vue';
import * as Utils from '../utils';
@ -92,13 +91,13 @@
})
export default class FriendDialog extends CustomDialog {
@Prop({required: true})
private readonly character!: Character;
readonly character!: Character;
private ourCharacter = Utils.Settings.defaultCharacter;
ourCharacter = Utils.Settings.defaultCharacter;
private incoming: FriendRequest[] = [];
private pending: FriendRequest[] = [];
private existing: Friend[] = [];
incoming: FriendRequest[] = [];
pending: FriendRequest[] = [];
existing: Friend[] = [];
requesting = false;
loading = true;

View File

@ -11,9 +11,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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';

View File

@ -11,9 +11,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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';

View File

@ -26,9 +26,8 @@
</template>
<script lang="ts">
import {Component, Prop, Watch} from '@f-list/vue-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';
@ -36,13 +35,11 @@
import GuestbookPostView from './guestbook_post.vue';
@Component({
components: {
'guestbook-post': GuestbookPostView
}
components: {'guestbook-post': GuestbookPostView}
})
export default class GuestbookView extends Vue {
@Prop({required: true})
private readonly character!: Character;
readonly character!: Character;
@Prop()
readonly oldApi?: true;
loading = true;
@ -51,11 +48,11 @@
posts: GuestbookPost[] = [];
private unapprovedOnly = false;
private page = 1;
unapprovedOnly = false;
page = 1;
hasNextPage = false;
canEdit = false;
private newPost = {
newPost = {
posting: false,
privatePost: false,
character: Utils.Settings.defaultCharacter,

View File

@ -48,9 +48,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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';
@ -62,13 +61,13 @@
})
export default class GuestbookPostView extends Vue {
@Prop({required: true})
private readonly post!: GuestbookPost;
readonly post!: GuestbookPost;
@Prop({required: true})
readonly canEdit!: boolean;
replying = false;
replyBox = false;
private replyMessage = this.post.reply;
replyMessage = this.post.reply;
approving = false;
deleting = false;

View File

@ -10,19 +10,19 @@
</template>
<div v-if="!loading && !images.length" class="alert alert-info">No images.</div>
<div class="image-preview" v-show="previewImage" @click="previewImage = ''">
<img :src="previewImage" />
<img :src="previewImage"/>
<div class="modal-backdrop show"></div>
</div>
</div>
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {CharacterImage} from '../../interfaces';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, CharacterImage} from './interfaces';
import {Character} from './interfaces';
@Component
export default class ImagesView extends Vue {

View File

@ -7,9 +7,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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';

View File

@ -9,9 +9,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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';
@ -19,15 +18,14 @@
import InfotagView from './infotag.vue';
interface DisplayInfotagGroup {
id: number
name: string
sortOrder: number
infotags: DisplayInfotag[]
}
@Component({
components: {
infotag: InfotagView
}
components: {infotag: InfotagView}
})
export default class InfotagsView extends Vue {
@Prop({required: true})
@ -63,6 +61,7 @@
return infotagA.name < infotagB.name ? -1 : 1;
});
outputGroups.push({
id: group.id,
name: group.name,
sortOrder: group.sort_order,
infotags: collectedTags

View File

@ -1,8 +1,10 @@
import {Character as CharacterInfo, CharacterImage, CharacterSettings, Infotag, Kink, KinkChoice} from '../../interfaces';
export interface CharacterMenuItem {
label: string
permission: string
link(character: Character): string
handleClick?(evt?: MouseEvent): void
handleClick?(evt: MouseEvent): void
}
export interface SelectItem {
@ -69,7 +71,6 @@ export interface SharedKinks {
}
export type SiteDate = number | string | null;
export type KinkChoice = 'favorite' | 'yes' | 'maybe' | 'no';
export type KinkChoiceFull = KinkChoice | number;
export const CONTACT_GROUP_ID = '1';
@ -93,13 +94,6 @@ export interface DisplayInfotag {
list?: number
}
export interface Kink {
id: number
name: string
description: string
kink_group: number
}
export interface KinkGroup {
id: number
name: string
@ -107,16 +101,6 @@ export interface KinkGroup {
sort_order: number
}
export interface Infotag {
id: number
name: string
type: 'number' | 'text' | 'list'
search_field: string
validator: string
allow_legacy: boolean
infotag_group: string
}
export interface InfotagGroup {
id: number
name: string
@ -141,46 +125,6 @@ export interface CharacterKink {
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 {
@ -211,34 +155,6 @@ export interface CharacterGroup {
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

View File

@ -19,9 +19,8 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {DisplayKink} from './interfaces';
@Component({

View File

@ -9,8 +9,8 @@
</div>
<div class="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>
<option :value="undefined">None</option>
<option v-for="group in kinkGroups" v-if="group" :value="group.id" :key="group.id">{{group.name}}</option>
</select>
</div>
</div>
@ -65,33 +65,29 @@
</template>
<script lang="ts">
import {Component, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {Kink, KinkChoice} from '../../interfaces';
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 {Character, DisplayKink, KinkGroup} from './interfaces';
import KinkView from './kink.vue';
@Component({
components: {
'context-menu': CopyCustomMenu,
kink: KinkView
}
components: {'context-menu': CopyCustomMenu, kink: KinkView}
})
export default class CharacterKinksView extends Vue {
//tslint:disable:no-null-keyword
@Prop({required: true})
private readonly character!: Character;
readonly character!: Character;
@Prop()
readonly oldApi?: true;
private shared = Store;
shared = Store;
characterToCompare = Utils.Settings.defaultCharacter;
highlightGroup: number | null = null;
highlightGroup: number | undefined;
private loading = false;
private comparing = false;
loading = false;
comparing = false;
highlighting: {[key: string]: boolean} = {};
comparison: {[key: string]: KinkChoice} = {};
@ -142,7 +138,7 @@
return this.comparing ? 'Clear' : 'Compare';
}
get groupedKinks(): {[key in KinkChoice]: DisplayKink[]} | undefined {
get groupedKinks(): {[key in KinkChoice]: DisplayKink[]} {
const kinks = this.shared.kinks.kinks;
const characterKinks = this.character.character.kinks;
const characterCustoms = this.character.character.customs;
@ -167,8 +163,9 @@
return a.name < b.name ? -1 : 1;
};
for(const custom of characterCustoms)
displayCustoms[custom.id] = {
for(const id in characterCustoms) {
const custom = characterCustoms[id]!;
displayCustoms[id] = {
id: custom.id,
name: custom.name,
description: custom.description,
@ -179,6 +176,7 @@
ignore: false,
subkinks: []
};
}
for(const kinkId in characterKinks) {
const kinkChoice = characterKinks[kinkId]!;

View File

@ -1,5 +1,5 @@
<template>
<Modal id="memoDialog" :action="'Memo for ' + name" buttonText="Save and Close" @close="onClose" @submit="save" dialog-class="modal-lg modal-dialog-centered">
<Modal :action="'Memo for ' + name" buttonText="Save and Close" @close="onClose" @submit="save" dialog-class="modal-lg modal-dialog-centered">
<div class="form-group" v-if="editing">
<textarea v-model="message" maxlength="1000" class="form-control"></textarea>
</div>
@ -12,33 +12,46 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Prop, Watch} from '@f-list/vue-ts';
import CustomDialog from '../../components/custom_dialog';
import Modal from '../../components/Modal.vue';
import {SimpleCharacter} from '../../interfaces';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character} from './interfaces';
export interface Memo {
id: number
memo: string
character: SimpleCharacter
created_at: number
updated_at: number
}
@Component({
components: {Modal}
})
export default class MemoDialog extends CustomDialog {
@Prop({required: true})
private readonly character!: Character;
private message = '';
readonly character!: {id: number, name: string};
@Prop()
readonly memo?: Memo;
message = '';
editing = false;
saving = false;
get name(): string {
return this.character.character.name;
return this.character.name;
}
show(): void {
super.show();
if(this.character.memo !== undefined)
this.message = this.character.memo.memo;
this.setMemo();
}
@Watch('memo')
setMemo(): void {
if(this.memo !== undefined)
this.message = this.memo.memo;
}
onClose(): void {
@ -48,7 +61,7 @@
async save(): Promise<void> {
try {
this.saving = true;
const memoReply = await methods.memoUpdate(this.character.character.id, this.message);
const memoReply = await methods.memoUpdate(this.character.id, this.message);
this.$emit('memo', this.message !== '' ? memoReply : undefined);
this.hide();
} catch(e) {

View File

@ -1,5 +1,5 @@
<template>
<modal id="reportDialog" :action="'Report character' + name" :disabled="!dataValid || submitting" @submit.prevent="submitReport">
<modal id="reportDialog" :action="'Report character' + name" :disabled="!dataValid || submitting" @submit.prevent="submitReport()">
<div class="form-group">
<label>Type</label>
<select v-select="validTypes" v-model="type" class="form-control"></select>
@ -25,8 +25,7 @@
</template>
<script lang="ts">
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {Component, Prop} from '@f-list/vue-ts';
import CustomDialog from '../../components/custom_dialog';
import Modal from '../../components/Modal.vue';
import * as Utils from '../utils';
@ -38,12 +37,12 @@
})
export default class ReportDialog extends CustomDialog {
@Prop({required: true})
private readonly character!: Character;
readonly character!: Character;
private ourCharacter = Utils.Settings.defaultCharacter;
private type = '';
private violation = '';
private message = '';
ourCharacter = Utils.Settings.defaultCharacter;
type = '';
violation = '';
message = '';
submitting = false;

View File

@ -3,7 +3,8 @@
<div class="card-header">
<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>
<character-action-menu :character="character" @rename="showRename()" @delete="showDelete()"
@block="showBlock()"></character-action-menu>
</div>
<div class="card-body">
<img :src="avatarUrl(character.character.name)" class="character-avatar" style="margin-right:10px">
@ -11,21 +12,21 @@
<template v-if="character.is_self">
<a :href="editUrl" class="edit-link"><i class="fa fa-fw fa-pencil-alt"></i>Edit</a>
<a @click="showDelete" class="delete-link"><i class="fa fa-fw fa-trash"></i>Delete</a>
<a @click="showDuplicate" class="duplicate-link"><i class="fa fa-fw fa-copy"></i>Duplicate</a>
<a @click="showDuplicate()" class="duplicate-link"><i class="fa fa-fw fa-copy"></i>Duplicate</a>
</template>
<template v-else>
<span v-if="character.self_staff || character.settings.prevent_bookmarks !== true">
<a @click.prevent="toggleBookmark" :class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}"
<span v-if="character.self_staff || character.settings.block_bookmarks !== true">
<a @click.prevent="toggleBookmark()" :class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}"
href="#" class="btn">
<i class="fa fa-fw" :class="{'fa-minus': character.bookmarked, 'fa-plus': !character.bookmarked}"></i>Bookmark
</a>
<span v-if="character.settings.prevent_bookmarks" class="prevents-bookmarks">!</span>
<span v-if="character.settings.block_bookmarks" class="prevents-bookmarks">!</span>
</span>
<a href="#" @click.prevent="showFriends" class="friend-link btn"><i class="fa fa-fw fa-user"></i>Friend</a>
<a href="#" v-if="!oldApi" @click.prevent="showReport" class="report-link btn">
<a href="#" @click.prevent="showFriends()" class="friend-link btn"><i class="fa fa-fw fa-user"></i>Friend</a>
<a href="#" v-if="!oldApi" @click.prevent="showReport()" class="report-link btn">
<i class="fa fa-fw fa-exclamation-triangle"></i>Report</a>
</template>
<a href="#" @click.prevent="showMemo" class="memo-link btn"><i class="far fa-sticky-note fa-fw"></i>Memo</a>
<a href="#" @click.prevent="showMemo()" class="memo-link btn"><i class="far fa-sticky-note fa-fw"></i>Memo</a>
</div>
<div v-if="character.badges && character.badges.length > 0" class="badges-block">
<div v-for="badge in character.badges" class="character-badge px-2 py-1" :class="badgeClass(badge)">
@ -35,7 +36,7 @@
<a v-if="authenticated && !character.is_self" :href="noteUrl" class="character-page-note-link btn" style="padding:0 4px">
<i class="far fa-envelope fa-fw"></i>Send Note</a>
<div v-if="character.character.online_chat" @click="showInChat" class="character-page-online-chat">Online In Chat</div>
<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>
@ -67,7 +68,7 @@
</div>
</div>
<div class="character-list-block">
<div class="character-list-block" v-if="character.character_list">
<div v-for="listCharacter in character.character_list">
<img :src="avatarUrl(listCharacter.name)" class="character-avatar icon" style="margin-right:5px">
<character-link :character="listCharacter.name"></character-link>
@ -75,7 +76,7 @@
</div>
</div>
<template>
<memo-dialog :character="character" ref="memo-dialog" @memo="memo"></memo-dialog>
<memo-dialog :character="character.character" :memo="character.memo" 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>
@ -87,20 +88,18 @@
</template>
<script lang="ts">
import {Component, Prop} from '@f-list/vue-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 {Infotag} from '../../interfaces';
import * as Utils from '../utils';
import ContactMethodView from './contact_method.vue';
import {methods, registeredComponents, Store} from './data_store';
import DeleteDialog from './delete_dialog.vue';
import DuplicateDialog from './duplicate_dialog.vue';
import FriendDialog from './friend_dialog.vue';
import InfotagView from './infotag.vue';
import {Character, CONTACT_GROUP_ID, SharedStore} from './interfaces';
import MemoDialog from './memo_dialog.vue';
import ReportDialog from './report_dialog.vue';
@ -177,6 +176,14 @@
return badgeName in badgeMap ? badgeMap[badgeName] : badgeName;
}
showBlock(): void {
(<ShowableVueDialog>this.$refs['block-dialog']).show();
}
showRename(): void {
(<ShowableVueDialog>this.$refs['rename-dialog']).show();
}
showDelete(): void {
(<ShowableVueDialog>this.$refs['delete-dialog']).show();
}
@ -197,6 +204,10 @@
(<ShowableVueDialog>this.$refs['friend-dialog']).show();
}
showInChat(): void {
//TODO implement this
}
async toggleBookmark(): Promise<void> {
const previousState = this.character.bookmarked;
try {
@ -218,7 +229,7 @@
return methods.sendNoteUrl(this.character.character);
}
get contactMethods(): object[] {
get contactMethods(): {id: number, value?: string}[] {
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 = [];
@ -233,7 +244,7 @@
return contactMethods;
}
get quickInfoItems(): object[] {
get quickInfoItems(): {id: number, string?: string, list?: number, number?: number}[] {
const quickItems = [];
for(const id of this.quickInfoIds) {
const infotag = this.character.character.infotags[id];

View File

@ -8,7 +8,6 @@ interface Dictionary<T> {
type flashMessageType = 'info' | 'success' | 'warning' | 'danger';
type flashMessageImpl = (type: flashMessageType, message: string) => void;
let flashImpl: flashMessageImpl = (type: flashMessageType, message: string) => {
console.log(`${type}: ${message}`);
};

View File

@ -65,6 +65,7 @@
"eofline": false,
"file-name-casing": false,
"forin": false,
"increment-decrement": false,
"interface-name": false,
"interface-over-type-literal": false,
"linebreak-style": false,
@ -83,10 +84,9 @@
"no-angle-bracket-type-assertion": false,
"no-bitwise": false,
"no-conditional-assignment": false,
//disabled for Vue components
"no-consecutive-blank-lines": false,
"no-console": false,
"no-default-export": false,
"no-default-import": false,
"no-dynamic-delete": false,
"no-floating-promises": [true, "AxiosPromise"],
"no-implicit-dependencies": false,

View File

@ -1,41 +1,36 @@
"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;
Object.defineProperty(exports, "__esModule", { value: true });
const ts = require("typescript");
const Lint = require("tslint");
class Rule extends Lint.Rules.AbstractRule {
apply(sourceFile) {
return this.applyWithFunction(sourceFile, walk, undefined);
}
Rule.prototype.applyWithProgram = function (sourceFile, program) {
return this.applyWithFunction(sourceFile, walk, undefined, program.getTypeChecker());
};
return Rule;
}(Lint.Rules.TypedRule));
}
exports.Rule = Rule;
function walk(ctx, checker) {
function walk(ctx) {
if (ctx.sourceFile.isDeclarationFile)
return;
return ts.forEachChild(ctx.sourceFile, cb);
function cb(node) {
if (node.kind !== ts.SyntaxKind.PropertyDeclaration || !node.decorators)
if (node.kind !== ts.SyntaxKind.PropertyDeclaration)
return ts.forEachChild(node, cb);
for (var _i = 0, _a = node.decorators; _i < _a.length; _i++) {
var decorator = _a[_i];
var call = decorator.expression;
var propSymbol = checker.getTypeAtLocation(call.expression).symbol;
if (propSymbol.name === 'Prop' &&
propSymbol.parent.name.endsWith('node_modules/vue-property-decorator/lib/vue-property-decorator"')) {
if (!node.modifiers || !node.modifiers.some(function (x) { return x.kind === ts.SyntaxKind.ReadonlyKeyword; }))
ctx.addFailureAtNode(node.name, 'Vue property should be readonly');
if (call.arguments.length > 0 && call.arguments[0].properties.map(function (x) { return x.name.getText(); })
.some(function (x) { return x === 'default' || x === 'required'; })) {
if (node.questionToken !== undefined)
ctx.addFailureAtNode(node.name, 'Vue property is required and should not be optional.');
if (!node.decorators)
return;
const property = node;
for (const decorator of node.decorators) {
const call = decorator.expression.kind == ts.SyntaxKind.CallExpression ? decorator.expression : undefined;
const name = call && call.expression.getText() || decorator.expression.getText();
if (name === 'Prop') {
if (!node.modifiers || !node.modifiers.some((x) => x.kind === ts.SyntaxKind.ReadonlyKeyword))
ctx.addFailureAtNode(property.name, 'Vue property should be readonly');
if (call && call.arguments.length > 0 &&
call.arguments[0].properties.map((x) => x.name.getText()).some((x) => x === 'default' || x === 'required')) {
if (property.questionToken !== undefined)
ctx.addFailureAtNode(property.name, 'Vue property is required and should not be optional.');
}
else if (node.questionToken === undefined)
ctx.addFailureAtNode(node.name, 'Vue property should be optional - it is not required and has no default value.');
else if (property.questionToken === undefined)
ctx.addFailureAtNode(property.name, 'Vue property should be optional - it is not required and has no default value.');
}
}
}

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