This commit is contained in:
Maya 2019-09-17 19:14:14 +02:00
parent a5e57cd52c
commit 8b0fe1aaed
103 changed files with 2688 additions and 2596 deletions

View File

@ -38,26 +38,25 @@
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import {BBCodeElement} from '../chat/bbcode';
import {getKey} from '../chat/common';
import {Keys} from '../keys';
import {CoreBBCodeParser, urlRegex} from './core';
import {BBCodeElement, CoreBBCodeParser, urlRegex} from './core';
import {defaultButtons, EditorButton, EditorSelection} from './editor';
import {BBCodeParser} from './parser';
@Component
export default class Editor extends Vue {
@Prop()
@Prop
readonly extras?: EditorButton[];
@Prop({default: 1000})
readonly maxlength!: number;
@Prop()
@Prop
readonly classes?: string;
@Prop()
@Prop
readonly value?: string;
@Prop()
@Prop
readonly disabled?: boolean;
@Prop()
@Prop
readonly placeholder?: string;
@Prop({default: true})
readonly hasToolbar!: boolean;
@ -256,7 +255,7 @@
}
onPaste(e: ClipboardEvent): void {
const data = e.clipboardData.getData('text/plain');
const data = e.clipboardData!.getData('text/plain');
if(!this.isShiftPressed && urlRegex.test(data)) {
e.preventDefault();
this.applyText(`[url=${data}]`, '[/url]');

View File

@ -4,6 +4,8 @@ const urlFormat = '((?:https?|ftps?|irc)://[^\\s/$.?#"\']+\\.[^\\s"]+)';
export const findUrlRegex = new RegExp(`(\\[url[=\\]]\\s*)?${urlFormat}`, 'gi');
export const urlRegex = new RegExp(`^${urlFormat}$`);
export type BBCodeElement = HTMLElement & {cleanup?(): void};
function domain(url: string): string | undefined {
const pieces = urlRegex.exec(url);
if(pieces === null) return;

View File

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

View File

@ -185,9 +185,9 @@ export class BBCodeParser {
if(parent !== undefined)
parent.appendChild(document.createTextNode(input.substring(mark, selfAllowed ? tagStart : i + 1)));
return i;
} else if(!selfAllowed)
return mark - 1;
else if(isAllowed(tagKey))
}
if(!selfAllowed) return mark - 1;
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,19 +1,11 @@
import {InlineImage} from '../interfaces';
import {InlineDisplayMode, InlineImage} from '../interfaces';
import * as Utils from '../site/utils';
import {CoreBBCodeParser} from './core';
import {InlineDisplayMode} from './interfaces';
import {BBCodeCustomTag, BBCodeSimpleTag, BBCodeTextTag} from './parser';
interface StandardParserSettings {
siteDomain: string
staticDomain: string
animatedIcons: boolean
inlineDisplayMode: InlineDisplayMode
}
const usernameRegex = /^[a-zA-Z0-9_\-\s]+$/;
export class StandardBBCodeParser extends CoreBBCodeParser {
allowInlines = true;
inlines: {[key: string]: InlineImage | undefined} | undefined;
createInline(inline: InlineImage): HTMLElement {
@ -23,12 +15,12 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
const el = this.createElement('img');
el.className = 'inline-image';
el.title = el.alt = inline.name;
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
el.src = `${Utils.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
return outerEl;
}
constructor(public settings: StandardParserSettings) {
constructor() {
super();
const hrTag = new BBCodeSimpleTag('hr', 'hr', [], []);
hrTag.noClosingTag = true;
@ -107,7 +99,7 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
if(!usernameRegex.test(content))
return;
const a = parser.createElement('a');
a.href = `${this.settings.siteDomain}c/${content}`;
a.href = `${Utils.siteDomain}c/${content}`;
a.target = '_blank';
a.className = 'character-link';
a.appendChild(document.createTextNode(content));
@ -120,10 +112,10 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
if(!usernameRegex.test(content))
return;
const a = parser.createElement('a');
a.href = `${this.settings.siteDomain}c/${content}`;
a.href = `${Utils.siteDomain}c/${content}`;
a.target = '_blank';
const img = parser.createElement('img');
img.src = `${this.settings.staticDomain}images/avatar/${content.toLowerCase()}.png`;
img.src = `${Utils.staticDomain}images/avatar/${content.toLowerCase()}.png`;
img.className = 'character-avatar icon';
img.title = img.alt = content;
a.appendChild(img);
@ -137,10 +129,10 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
if(!usernameRegex.test(content))
return;
let extension = '.gif';
if(!this.settings.animatedIcons)
if(!Utils.settings.animateEicons)
extension = '.png';
const img = parser.createElement('img');
img.src = `${this.settings.staticDomain}images/eicon/${content.toLowerCase()}${extension}`;
img.src = `${Utils.staticDomain}images/eicon/${content.toLowerCase()}${extension}`;
img.className = 'character-avatar icon';
img.title = img.alt = content;
parent.appendChild(img);
@ -148,15 +140,11 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
}));
this.addTag(new BBCodeTextTag('img', (p, parent, param, content) => {
const parser = <StandardBBCodeParser>p;
if(!this.allowInlines) {
parser.warning('Inline images are not allowed here.');
return undefined;
}
if(typeof parser.inlines === 'undefined') {
parser.warning('This page does not support inline images.');
return undefined;
}
const displayMode = this.settings.inlineDisplayMode;
const displayMode = Utils.settings.inlineDisplayMode;
if(!/^\d+$/.test(param)) {
parser.warning('img tag parameters must be numbers.');
return undefined;
@ -188,10 +176,4 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
return element;
}));
}
}
export let standardParser: StandardBBCodeParser;
export function initParser(settings: StandardParserSettings): void {
standardParser = new StandardBBCodeParser(settings);
}

26
bbcode/view.ts Normal file
View File

@ -0,0 +1,26 @@
import {CreateElement, FunctionalComponentOptions, RenderContext, VNode} from 'vue';
import {DefaultProps, RecordPropsDefinition} from 'vue/types/options'; //tslint:disable-line:no-submodule-imports
import {BBCodeElement} from './core';
import {BBCodeParser} from './parser';
export const BBCodeView = (parser: BBCodeParser): FunctionalComponentOptions<DefaultProps, RecordPropsDefinition<DefaultProps>> => ({
functional: true,
render(createElement: CreateElement, context: RenderContext): VNode {
/*tslint:disable:no-unsafe-any*///because we're not actually supposed to do any of this
context.data.hook = {
insert(node: VNode): void {
node.elm!.appendChild(parser.parseEverything(
context.props.text !== undefined ? context.props.text : context.props.unsafeText));
if(context.props.afterInsert !== undefined) context.props.afterInsert(node.elm);
},
destroy(node: VNode): void {
const element = (<BBCodeElement>(<Element>node.elm).firstChild);
if(element.cleanup !== undefined) element.cleanup();
}
};
const vnode = createElement('span', context.data);
vnode.key = context.props.text;
return vnode;
//tslint:enable
}
});

View File

@ -28,10 +28,10 @@
<script lang="ts">
import {Component, Hook} from '@f-list/vue-ts';
import Axios from 'axios';
import {BBCodeView} from '../bbcode/view';
import CustomDialog from '../components/custom_dialog';
import FilterableSelect from '../components/FilterableSelect.vue';
import Modal from '../components/Modal.vue';
import {BBCodeView} from './bbcode';
import {characterImage} from './common';
import core from './core';
import {Character, Connection} from './interfaces';
@ -66,7 +66,7 @@
}
@Component({
components: {modal: Modal, user: UserView, 'filterable-select': FilterableSelect, bbcode: BBCodeView}
components: {modal: Modal, user: UserView, 'filterable-select': FilterableSelect, bbcode: BBCodeView(core.bbCodeParser)}
})
export default class CharacterSearch extends CustomDialog {
l = l;

View File

@ -1,3 +1,4 @@
import {InlineDisplayMode} from '../interfaces';
<template>
<div style="display:flex; flex-direction: column; height:100%; justify-content: center">
<div class="card bg-light" style="width:400px;max-width:100%;margin:0 auto" v-if="!connected">
@ -11,7 +12,7 @@
<div class="card-body">
<h4 class="card-title">{{l('login.selectCharacter')}}</h4>
<select v-model="selectedCharacter" class="form-control custom-select">
<option v-for="character in ownCharacters" :value="character">{{character}}</option>
<option v-for="character in ownCharacters" :value="character">{{character.name}}</option>
</select>
<div style="text-align:right;margin-top:10px">
<button class="btn btn-primary" @click="connect" :disabled="connecting">
@ -35,15 +36,14 @@
import {Component, Hook, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Modal from '../components/Modal.vue';
import Channels from '../fchat/channels';
import Characters from '../fchat/characters';
import {InlineDisplayMode, SimpleCharacter} from '../interfaces';
import {Keys} from '../keys';
import ChatView from './ChatView.vue';
import {errorToString, getKey} from './common';
import Conversations from './conversations';
import core from './core';
import l from './localize';
import Logs from './Logs.vue';
import {init as profileApiInit} from './profile_api';
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string};
@ -53,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);
@ -78,11 +78,12 @@
})
export default class Chat extends Vue {
@Prop({required: true})
readonly ownCharacters!: string[];
readonly ownCharacters!: SimpleCharacter[];
@Prop({required: true})
readonly defaultCharacter!: string | undefined;
selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions
@Prop()
readonly defaultCharacter!: number;
//tslint:disable-next-line:strict-boolean-expressions
selectedCharacter = this.ownCharacters.find((x) => x.id === this.defaultCharacter) || this.ownCharacters[0];
@Prop
readonly version?: string;
error = '';
connecting = false;
@ -110,7 +111,7 @@
} 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];
e.clipboardData.setData('text/plain', copyNode(startValue, start, end, range, {}));
e.clipboardData!.setData('text/plain', copyNode(startValue, start, end, range, {}));
e.preventDefault();
}) as EventListener);
window.addEventListener('keydown', (e) => {
@ -120,10 +121,7 @@
e.preventDefault();
}
});
core.register('characters', Characters(core.connection));
core.register('channels', Channels(core.connection, core.characters));
core.register('conversations', Conversations());
core.connection.onEvent('closed', async(isReconnect) => {
core.connection.onEvent('closed', (isReconnect) => {
if(isReconnect) (<Modal>this.$refs['reconnecting']).show(true);
if(this.connected) core.notifications.playSound('logout');
this.connected = false;
@ -132,9 +130,13 @@
});
core.connection.onEvent('connecting', async() => {
this.connecting = true;
profileApiInit({
defaultCharacter: this.defaultCharacter, animateEicons: core.state.settings.animatedEicons, fuzzyDates: true,
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
}, this.ownCharacters);
if(core.state.settings.notifications) await core.notifications.requestPermission();
});
core.connection.onEvent('connected', async() => {
core.connection.onEvent('connected', () => {
(<Modal>this.$refs['reconnecting']).hide();
this.error = '';
this.connecting = false;
@ -165,7 +167,7 @@
async connect(): Promise<void> {
this.connecting = true;
await core.notifications.initSounds(['attention', 'login', 'logout', 'modalert', 'newnote']);
core.connection.connect(this.selectedCharacter);
core.connection.connect(this.selectedCharacter.name);
}
}
</script>

View File

@ -29,7 +29,8 @@
<div class="list-group conversation-nav" ref="privateConversations">
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" :data-character="conversation.character.name" data-touch="false"
class="list-group-item list-group-item-action item-private" :key="conversation.key" @click.middle="conversation.close()">
class="list-group-item list-group-item-action item-private" :key="conversation.key"
@click.middle.prevent="conversation.close()">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<div class="name">
<span>{{conversation.character.name}}</span>
@ -39,8 +40,8 @@
:class="{'fa-comment-dots': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span>
<span style="flex:1"></span>
<span class="pin fas fa-thumbtack" :class="{'active': conversation.isPinned}" @mousedown.prevent
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="pin fas fa-thumbtack" :class="{'active': conversation.isPinned}"
@click="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="fas fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</div>
</div>
@ -51,7 +52,7 @@
<div class="list-group conversation-nav" ref="channelConversations">
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel" :key="conversation.key"
@click.middle="conversation.close()">
@click.middle.prevent="conversation.close()">
<span class="name">{{conversation.name}}</span>
<span>
<span class="pin fas fa-thumbtack" :class="{'active': conversation.isPinned}" :aria-label="l('chat.pinTab')"
@ -145,6 +146,7 @@
this.setFontSize(core.state.settings.fontSize);
Sortable.create(<HTMLElement>this.$refs['privateConversations'], {
animation: 50,
fallbackTolerance: 5,
onEnd: async(e) => {
if(e.oldIndex === e.newIndex) return;
return core.conversations.privateConversations[e.oldIndex!].sort(e.newIndex!);
@ -152,6 +154,7 @@
});
Sortable.create(<HTMLElement>this.$refs['channelConversations'], {
animation: 50,
fallbackTolerance: 5,
onEnd: async(e) => {
if(e.oldIndex === e.newIndex) return;
return core.conversations.channelConversations[e.oldIndex!].sort(e.newIndex!);
@ -256,13 +259,8 @@
overrideEl.id = 'overrideFontSize';
document.body.appendChild(overrideEl);
const sheet = <CSSStyleSheet>overrideEl.sheet;
const selectorList = ['#chatView', '.btn', '.form-control'];
for(const selector of selectorList)
sheet.insertRule(`${selector} { font-size: ${fontSize}px; }`, sheet.cssRules.length);
const lineHeight = 1.428571429;
sheet.insertRule(`.form-control { line-height: ${lineHeight} }`, sheet.cssRules.length);
sheet.insertRule(`select.form-control { line-height: ${lineHeight} }`, sheet.cssRules.length);
sheet.insertRule(`#chatView, .btn, .form-control, .custom-select { font-size: ${fontSize}px; }`, sheet.cssRules.length);
sheet.insertRule(`.form-control, select.form-control { line-height: 1.428571429 }`, sheet.cssRules.length);
}
logOut(): void {
@ -419,6 +417,7 @@
#sidebar {
.body a.btn {
padding: 2px 0;
text-align: left;
}
@media (min-width: breakpoint-min(md)) {
.sidebar {

View File

@ -2,7 +2,7 @@
<div style="height:100%;display:flex;flex-direction:column;flex:1;margin:0 5px;position:relative" id="conversation">
<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 style="flex:1;position:relative;display:flex;flex-direction:column;user-select:text">
<div>
<user :character="conversation.character"></user>
<a href="#" @click.prevent="showLogs()" class="btn">
@ -14,7 +14,7 @@
<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">
<div style="overflow:auto;overflow-x:hidden;max-height:50px;user-select:text">
{{l('status.' + conversation.character.status)}}
<span v-show="conversation.character.statusText"> <bbcode :text="conversation.character.statusText"></bbcode></span>
</div>
@ -129,9 +129,10 @@
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import {EditorButton, EditorSelection} from '../bbcode/editor';
import {BBCodeView} from '../bbcode/view';
import {isShowing as anyDialogsShown} from '../components/Modal.vue';
import {Keys} from '../keys';
import {BBCodeView, Editor} from './bbcode';
import {Editor} from './bbcode';
import CommandHelp from './CommandHelp.vue';
import {characterImage, getByteLength, getKey} from './common';
import ConversationSettings from './ConversationSettings.vue';
@ -148,7 +149,7 @@
@Component({
components: {
user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, settings: ConversationSettings,
logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp
logs: Logs, 'message-view': MessageView, bbcode: BBCodeView(core.bbCodeParser), 'command-help': CommandHelp
}
})
export default class ConversationView extends Vue {
@ -281,8 +282,13 @@
if(this.messageView.scrollTop < 20) {
if(!this.scrolledUp) {
const firstMessage = this.messageView.firstElementChild;
if(this.conversation.loadMore() && firstMessage !== null)
this.$nextTick(() => setTimeout(() => this.messageView.scrollTop = (<HTMLElement>firstMessage).offsetTop, 0));
if(this.conversation.loadMore() && firstMessage !== null) {
this.messageView.style.overflow = 'hidden';
this.$nextTick(() => {
this.messageView.scrollTop = (<HTMLElement>firstMessage).offsetTop;
this.messageView.style.overflow = 'auto';
});
}
}
this.scrolledUp = true;
} else this.scrolledUp = false;

View File

@ -85,7 +85,7 @@
components: {modal: Modal, 'message-view': MessageView, 'filterable-select': FilterableSelect}
})
export default class Logs extends CustomDialog {
@Prop()
@Prop
readonly conversation?: Conversation;
conversations: LogInterface.Conversation[] = [];
selectedConversation: LogInterface.Conversation | undefined;

View File

@ -16,9 +16,10 @@
<script lang="ts">
import {Component, Hook} from '@f-list/vue-ts';
import {BBCodeElement} from '../bbcode/core';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import BBCodeParser, {BBCodeElement} from './bbcode';
import BBCodeParser from './bbcode';
import {errorToString, messageToString} from './common';
import core from './core';
import {Character, Conversation} from './interfaces';

View File

@ -20,9 +20,9 @@
@Component
export default class Sidebar extends Vue {
@Prop()
@Prop
readonly right?: true;
@Prop()
@Prop
readonly label?: string;
@Prop({required: true})
readonly icon!: string;

View File

@ -2,7 +2,7 @@
<modal :action="l('chat.setStatus')" @submit="setStatus" @close="reset" dialogClass="w-100 modal-lg">
<div class="form-group" id="statusSelector">
<label class="control-label">{{l('chat.setStatus.status')}}</label>
<dropdown>
<dropdown linkClass="custom-select">
<span slot="title"><span class="fa fa-fw" :class="getStatusIcon(status)"></span>{{l('status.' + status)}}</span>
<a href="#" class="dropdown-item" v-for="item in statuses" @click.prevent="status = item">
<span class="fa fa-fw" :class="getStatusIcon(item)"></span>{{l('status.' + item)}}

View File

@ -42,8 +42,8 @@
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import {BBCodeView} from '../bbcode/view';
import Modal from '../components/Modal.vue';
import {BBCodeView} from './bbcode';
import {characterImage, errorToString, getByteLength, profileLink} from './common';
import core from './core';
import {Channel, Character} from './interfaces';
@ -51,7 +51,7 @@
import ReportDialog from './ReportDialog.vue';
@Component({
components: {bbcode: BBCodeView, modal: Modal}
components: {bbcode: BBCodeView(core.bbCodeParser), modal: Modal}
})
export default class UserMenu extends Vue {
@Prop({required: true})
@ -156,7 +156,7 @@
node = node.parentElement!;
}
if(node.dataset['touch'] === 'false' && e.type !== 'contextmenu') return;
if(node.character === undefined)
if(!node.character)
if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!);
else {
this.showContextMenu = false;
@ -166,7 +166,7 @@
switch(e.type) {
case 'click':
if(node.dataset['character'] === undefined)
if(node === this.touchedElement) this.openMenu(touch, node.character, node.channel);
if(node === this.touchedElement) this.openMenu(touch, node.character, node.channel || undefined);
else this.onClick(node.character);
e.preventDefault();
break;
@ -174,7 +174,7 @@
this.touchedElement = node;
break;
case 'contextmenu':
this.openMenu(touch, node.character, node.channel);
this.openMenu(touch, node.character, node.channel || undefined);
e.preventDefault();
}
}

View File

@ -1,7 +1,7 @@
import {WebSocketConnection} from '../fchat';
export default class Socket implements WebSocketConnection {
static host = 'wss://chat.f-list.net:9799';
static host = 'wss://chat.f-list.net/chat2';
private socket: WebSocket;
private lastHandler: Promise<void> = Promise.resolve();

View File

@ -1,5 +1,5 @@
import Vue, {Component, CreateElement, RenderContext, VNode} from 'vue';
import {CoreBBCodeParser} from '../bbcode/core';
import Vue from 'vue';
import {BBCodeElement, CoreBBCodeParser} from '../bbcode/core';
//tslint:disable-next-line:match-default-export-name
import BaseEditor from '../bbcode/Editor.vue';
import {BBCodeTextTag} from '../bbcode/parser';
@ -9,34 +9,10 @@ import core from './core';
import {Character} from './interfaces';
import UserView from './user_view';
export const BBCodeView: Component = {
functional: true,
render(createElement: CreateElement, context: RenderContext): VNode {
/*tslint:disable:no-unsafe-any*///because we're not actually supposed to do any of this
context.data.hook = {
insert(node: VNode): void {
node.elm!.appendChild(core.bbCodeParser.parseEverything(
context.props.text !== undefined ? context.props.text : context.props.unsafeText));
if(context.props.afterInsert !== undefined) context.props.afterInsert(node.elm);
},
destroy(node: VNode): void {
const element = (<BBCodeElement>(<Element>node.elm).firstChild);
if(element.cleanup !== undefined) element.cleanup();
}
};
const vnode = createElement('span', context.data);
vnode.key = context.props.text;
return vnode;
//tslint:enable
}
};
export class Editor extends BaseEditor {
parser = core.bbCodeParser;
}
export type BBCodeElement = HTMLElement & {cleanup?(): void};
export default class BBCodeParser extends CoreBBCodeParser {
cleanup: Vue[] = [];

View File

@ -79,6 +79,7 @@ abstract class Conversation implements Interfaces.Conversation {
}
}
//tslint:disable-next-line:no-async-without-await
abstract async addMessage(message: Interfaces.Message): Promise<void>;
loadLastSent(): void {
@ -182,7 +183,8 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
if(this.character.status === 'offline') {
this.errorText = l('chat.errorOffline', this.character.name);
return;
} else if(this.character.isIgnored) {
}
if(this.character.isIgnored) {
this.errorText = l('chat.errorIgnored', this.character.name);
return;
}
@ -402,6 +404,7 @@ class State implements Interfaces.State {
}
show(conversation: Conversation): void {
if(conversation === this.selectedConversation) return;
this.selectedConversation.onHide();
conversation.unread = Interfaces.UnreadState.None;
this.selectedConversation = conversation;
@ -419,12 +422,9 @@ class State implements Interfaces.State {
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;
settings[key] = Object.assign(new ConversationSettings(), settings[key]);
const conv = this.byKey(key);
if(conv !== undefined) conv._settings = settingsItem;
if(conv !== undefined) conv._settings = settings[key];
}
this.settings = settings;
//tslint:enable
@ -443,11 +443,6 @@ function isOfInterest(this: void, character: Character): boolean {
return character.isFriend || character.isBookmarked || state.privateMap[character.name.toLowerCase()] !== undefined;
}
function isOp(conv: ChannelConversation): boolean {
const ownChar = core.characters.ownCharacter;
return ownChar.isChatOp || conv.channel.members[ownChar.name]!.rank > Channel.Rank.Member;
}
export default function(this: void): Interfaces.State {
state = new State();
window.addEventListener('focus', () => {
@ -523,7 +518,7 @@ export default function(this: void): Interfaces.State {
const char = core.characters.get(data.character);
const conversation = state.channelMap[data.channel.toLowerCase()];
if(conversation === undefined) return core.channels.leave(data.channel);
if(char.isIgnored && !isOp(conversation)) return;
if(char.isIgnored) return;
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
await conversation.addMessage(message);
@ -552,7 +547,7 @@ export default function(this: void): Interfaces.State {
const char = core.characters.get(data.character);
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
if((char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) && !isOp(conv)) return;
if(char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) return;
await conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
});
connection.onMessage('RLL', async(data, time) => {
@ -569,7 +564,7 @@ export default function(this: void): Interfaces.State {
const channel = (<{channel: string}>data).channel.toLowerCase();
const conversation = state.channelMap[channel];
if(conversation === undefined) return core.channels.leave(channel);
if(sender.isIgnored && !isOp(conversation)) return;
if(sender.isIgnored) return;
if(data.type === 'bottle' && data.target === core.connection.character) {
await core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');

View File

@ -1,6 +1,8 @@
import Vue, {WatchHandler} from 'vue';
import {Channels, Characters} from '../fchat';
import BBCodeParser from './bbcode';
import {Settings as SettingsImpl} from './common';
import Conversations from './conversations';
import {Channel, Character, Connection, Conversation, Logs, Notifications, Settings, State as StateInterface} from './interfaces';
function createBBCodeParser(): BBCodeParser {
@ -44,8 +46,9 @@ const vue = <Vue & VueState>new Vue({
state
},
watch: {
'state.hiddenUsers': async(newValue: string[]) => {
if(data.settingsStore !== undefined) await data.settingsStore.set('hiddenUsers', newValue);
'state.hiddenUsers': async(newValue: string[], oldValue: string[]) => {
if(data.settingsStore !== undefined && newValue !== oldValue)
await data.settingsStore.set('hiddenUsers', newValue);
}
}
});
@ -60,20 +63,15 @@ const data = {
channels: <Channel.State | undefined>undefined,
characters: <Character.State | undefined>undefined,
notifications: <Notifications | undefined>undefined,
register(this: void | never, module: 'characters' | 'conversations' | 'channels',
subState: Channel.State | Character.State | Conversation.State): void {
register<K extends 'characters' | 'conversations' | 'channels'>(module: K, subState: VueState[K]): void {
Vue.set(vue, module, subState);
data[module] = subState;
(<VueState[K]>data[module]) = subState;
},
watch<T>(getter: (this: VueState) => T, callback: WatchHandler<T>): void {
vue.$watch(getter, callback);
},
async reloadSettings(): Promise<void> {
const settings = new SettingsImpl();
const loadedSettings = <SettingsImpl | undefined>await core.settingsStore.get('settings');
if(loadedSettings !== undefined)
for(const key in loadedSettings) settings[<keyof Settings>key] = loadedSettings[<keyof Settings>key];
state._settings = settings;
state._settings = Object.assign(new SettingsImpl(), await core.settingsStore.get('settings'));
const hiddenUsers = await core.settingsStore.get('hiddenUsers');
state.hiddenUsers = hiddenUsers !== undefined ? hiddenUsers : [];
}
@ -85,6 +83,9 @@ export function init(this: void, connection: Connection, logsClass: new() => Log
data.logs = new logsClass();
data.settingsStore = new settingsClass();
data.notifications = new notificationsClass();
data.register('characters', Characters(connection));
data.register('channels', Channels(connection, core.characters));
data.register('conversations', Conversations());
connection.onEvent('connecting', async() => {
await data.reloadSettings();
data.bbCodeParser = createBBCodeParser();
@ -101,9 +102,6 @@ export interface Core {
readonly channels: Channel.State
readonly bbCodeParser: BBCodeParser
readonly notifications: Notifications
register(module: 'conversations', state: Conversation.State): void
register(module: 'channels', state: Channel.State): void
register(module: 'characters', state: Character.State): void
watch<T>(getter: (this: VueState) => T, callback: WatchHandler<T>): void
}

View File

@ -321,10 +321,6 @@ Once this process has started, do not interrupt it or your logs will get corrupt
'commands.roll.param0.help': 'Syntax: [1-9]d[1-100]. Addition and subtraction of rolls and fixed numbers is also possible. Example: /roll 1d6+1d20-5',
'commands.bottle': 'Spin the bottle',
'commands.bottle.help': 'Spins a bottle, randomly selecting a member of the current tab and displaying it to all.',
'commands.ad': 'Post as ad',
'commands.ad.help': 'A quick way to post an ad in the current channel. You may receive an error if ads are not allowed in that channel.',
'commands.ad.param0': 'Message',
'commands.ad.param0.help': 'The message to post as an ad.',
'commands.me': 'Post as action',
'commands.me.help': 'This will cause your message to be formatted differently, as an action your character is performing.',
'commands.me.param0': 'Message',
@ -427,9 +423,10 @@ Any existing FChat 3.0 data for this character will be overwritten.`,
export default function l(key: string, ...args: (string | number)[]): string {
let i = args.length;
let str = strings[key];
if(str === undefined)
if(str === undefined) {
if(process.env.NODE_ENV !== 'production') throw new Error(`String ${key} does not exist.`);
else return '';
return '';
}
while(i-- > 0)
str = str.replace(new RegExp(`\\{${i}\\}`, 'igm'), args[i].toString());
return str;

View File

@ -1,7 +1,7 @@
import {Component, Prop} from '@f-list/vue-ts';
import {CreateElement, default as Vue, VNode, VNodeChildrenArrayContents} from 'vue';
import {BBCodeView} from '../bbcode/view';
import {Channel} from '../fchat';
import {BBCodeView} from './bbcode';
import {formatTime} from './common';
import core from './core';
import {Conversation} from './interfaces';
@ -29,7 +29,8 @@ const userPostfix: {[key: number]: string | undefined} = {
if(message.isHighlight) classes += ' message-highlight';
}
const isAd = message.type === Conversation.Message.Type.Ad && !this.logs;
children.push(createElement(BBCodeView, {props: {unsafeText: message.text, afterInsert: isAd ? (elm: HTMLElement) => {
children.push(createElement(BBCodeView(core.bbCodeParser),
{props: {unsafeText: message.text, afterInsert: isAd ? (elm: HTMLElement) => {
setImmediate(() => {
elm = elm.parentElement!;
if(elm.scrollHeight > elm.offsetHeight) {

View File

@ -1,32 +1,23 @@
import Axios from 'axios';
import Vue from 'vue';
import Editor from '../bbcode/Editor.vue';
import {InlineDisplayMode} from '../bbcode/interfaces';
import {initParser, standardParser} from '../bbcode/standard';
import {StandardBBCodeParser} from '../bbcode/standard';
import {BBCodeView} from '../bbcode/view';
import CharacterLink from '../components/character_link.vue';
import CharacterSelect from '../components/character_select.vue';
import {setCharacters} from '../components/character_select/character_list';
import DateDisplay from '../components/date_display.vue';
import SimplePager from '../components/simple_pager.vue';
import {
Character as CharacterInfo, CharacterImage, CharacterImageOld, CharacterInfotag, CharacterSettings, KinkChoice
Character as CharacterInfo, CharacterImage, CharacterImageOld, CharacterInfotag, CharacterSettings, Infotag, InfotagGroup, Kink,
KinkChoice, KinkGroup, ListItem, Settings, SimpleCharacter
} from '../interfaces';
import {registerMethod, Store} from '../site/character_page/data_store';
import {
Character, CharacterFriend, CharacterKink, Friend, FriendRequest, FriendsByCharacter, GuestbookState, KinkChoiceFull,
SharedKinks
Character, CharacterKink, Friend, FriendRequest, FriendsByCharacter, Guestbook, GuestbookPost, KinkChoiceFull
} from '../site/character_page/interfaces';
import '../site/directives/vue-select'; //tslint:disable-line:no-import-side-effect
import * as Utils from '../site/utils';
import core from './core';
const parserSettings = {
siteDomain: 'https://www.f-list.net/',
staticDomain: 'https://static.f-list.net/',
animatedIcons: true,
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
};
async function characterData(name: string | undefined): Promise<Character> {
const data = await core.connection.queryApi<CharacterInfo & {
badges: string[]
@ -47,7 +38,7 @@ async function characterData(name: string | undefined): Promise<Character> {
}>('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]);
newKinks[key] = <KinkChoiceFull>(data.kinks[key] === 'fave' ? 'favorite' : data.kinks[key]);
for(const key in data.custom_kinks) {
const custom = data.custom_kinks[key];
if((<'fave'>custom.choice) === 'fave') custom.choice = 'favorite';
@ -59,12 +50,12 @@ async function characterData(name: string | undefined): Promise<Character> {
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;
const infotag = Store.shared.infotags[key];
if(!infotag) continue;
newInfotags[key] = infotag.type === 'list' ? {list: parseInt(characterInfotag, 10)} : {string: characterInfotag};
}
parserSettings.inlineDisplayMode = data.current_user.inline_mode;
parserSettings.animatedIcons = core.state.settings.animatedEicons;
Utils.settings.inlineDisplayMode = data.current_user.inline_mode;
Utils.settings.animateEicons = core.state.settings.animatedEicons;
return {
is_self: false,
character: {
@ -98,13 +89,22 @@ function contactMethodIconUrl(name: string): string {
}
async function fieldsGet(): Promise<void> {
if(Store.kinks !== undefined) return; //tslint:disable-line:strict-type-predicates
if(Store.shared !== undefined) return; //tslint:disable-line:strict-type-predicates
try {
const fields = (await (Axios.get(`${Utils.siteDomain}json/api/mapping-list.php`))).data as SharedKinks & {
kinks: {[key: string]: {group_id: number}}
infotags: {[key: string]: {list: string, group_id: string}}
const fields = (await (Axios.get(`${Utils.siteDomain}json/api/mapping-list.php`))).data as {
listitems: {[key: string]: ListItem}
kinks: {[key: string]: Kink & {group_id: number}}
kink_groups: {[key: string]: KinkGroup}
infotags: {[key: string]: Infotag & {list: string, group_id: string}}
infotag_groups: {[key: string]: InfotagGroup & {id: string}}
};
const kinks: SharedKinks = {kinks: {}, kink_groups: {}, infotags: {}, infotag_groups: {}, listitems: {}};
const kinks: {
listItems: {[key: string]: ListItem}
kinks: {[key: string]: Kink}
kinkGroups: {[key: string]: KinkGroup}
infotags: {[key: string]: Infotag}
infotagGroups: {[key: string]: InfotagGroup}
} = {kinks: {}, kinkGroups: {}, infotags: {}, infotagGroups: {}, listItems: {}};
for(const id in fields.kinks) {
const oldKink = fields.kinks[id];
kinks.kinks[oldKink.id] = {
@ -115,8 +115,8 @@ async function fieldsGet(): Promise<void> {
};
}
for(const id in fields.kink_groups) {
const oldGroup = fields.kink_groups[id]!;
kinks.kink_groups[oldGroup.id] = {
const oldGroup = fields.kink_groups[id];
kinks.kinkGroups[oldGroup.id] = {
id: oldGroup.id,
name: oldGroup.name,
description: '',
@ -132,12 +132,12 @@ async function fieldsGet(): Promise<void> {
validator: oldInfotag.list,
search_field: '',
allow_legacy: true,
infotag_group: oldInfotag.group_id
infotag_group: parseInt(oldInfotag.group_id, 10)
};
}
for(const id in fields.listitems) {
const oldListItem = fields.listitems[id]!;
kinks.listitems[oldListItem.id] = {
const oldListItem = fields.listitems[id];
kinks.listItems[oldListItem.id] = {
id: oldListItem.id,
name: oldListItem.name,
value: oldListItem.value,
@ -145,31 +145,33 @@ async function fieldsGet(): Promise<void> {
};
}
for(const id in fields.infotag_groups) {
const oldGroup = fields.infotag_groups[id]!;
kinks.infotag_groups[oldGroup.id] = {
id: oldGroup.id,
const oldGroup = fields.infotag_groups[id];
kinks.infotagGroups[oldGroup.id] = {
id: parseInt(oldGroup.id, 10),
name: oldGroup.name,
description: oldGroup.description,
sort_order: oldGroup.id
};
}
Store.kinks = kinks;
Store.shared = kinks;
} catch(e) {
Utils.ajaxError(e, 'Error loading character fields');
throw e;
}
}
async function friendsGet(id: number): Promise<CharacterFriend[]> {
return (await core.connection.queryApi<{friends: CharacterFriend[]}>('character-friends.php', {id})).friends;
async function friendsGet(id: number): Promise<SimpleCharacter[]> {
return (await core.connection.queryApi<{friends: SimpleCharacter[]}>('character-friends.php', {id})).friends;
}
async function imagesGet(id: number): Promise<CharacterImage[]> {
return (await core.connection.queryApi<{images: CharacterImage[]}>('character-images.php', {id})).images;
}
async function guestbookGet(id: number, page: number): Promise<GuestbookState> {
return core.connection.queryApi<GuestbookState>('character-guestbook.php', {id, page: page - 1});
async function guestbookGet(id: number, offset: number): Promise<Guestbook> {
const data = await core.connection.queryApi<{nextPage: boolean, posts: GuestbookPost[]}>('character-guestbook.php',
{id, page: offset / 10});
return {posts: data.posts, total: data.nextPage ? offset + 100 : offset};
}
async function kinksGet(id: number): Promise<CharacterKink[]> {
@ -180,23 +182,18 @@ async function kinksGet(id: number): Promise<CharacterKink[]> {
});
}
export function init(characters: {[key: string]: number}): void {
Utils.setDomains(parserSettings.siteDomain, parserSettings.staticDomain);
initParser(parserSettings);
export function init(settings: Settings, characters: SimpleCharacter[]): void {
Utils.setDomains('https://www.f-list.net/', 'https://static.f-list.net/');
Vue.component('character-select', CharacterSelect);
Vue.component('character-link', CharacterLink);
Vue.component('date-display', DateDisplay);
Vue.component('simple-pager', SimplePager);
Vue.component('bbcode', BBCodeView(new StandardBBCodeParser()));
Vue.component('bbcode-editor', Editor);
setCharacters(Object.keys(characters).map((name) => ({name, id: characters[name]})));
Utils.init(settings, characters);
core.connection.onEvent('connecting', () => {
Utils.Settings.defaultCharacter = characters[core.connection.character];
});
Vue.directive('bbcode', (el, binding) => {
while(el.firstChild !== null)
el.removeChild(el.firstChild);
el.appendChild(standardParser.parseEverything(<string>binding.value));
Utils.settings.defaultCharacter = characters.find((x) => x.name === core.connection.character)!.id;
});
registerMethod('characterData', characterData);
registerMethod('contactMethodIconUrl', contactMethodIconUrl);
@ -209,12 +206,10 @@ export function init(characters: {[key: string]: number}): void {
registerMethod('imageUrl', (image: CharacterImageOld) => image.url);
registerMethod('memoUpdate', async(id: number, memo: string) => {
await core.connection.queryApi('character-memo-save.php', {target: id, note: memo});
return {id, memo, updated_at: Date.now() / 1000};
});
registerMethod('imageThumbUrl', (image: CharacterImage) => `${Utils.staticDomain}images/charthumb/${image.id}.${image.extension}`);
registerMethod('bookmarkUpdate', async(id: number, state: boolean) => {
await core.connection.queryApi(`bookmark-${state ? 'add' : 'remove'}.php`, {id});
return state;
});
registerMethod('characterFriends', async(id: number) =>
core.connection.queryApi<FriendsByCharacter>('character-friend-list.php', {id}));

View File

@ -37,16 +37,18 @@ export function parse(this: void | never, input: string, context: CommandContext
for(let i = 0; i < command.params.length; ++i) {
while(args[index] === ' ') ++index;
const param = command.params[i];
if(index === -1)
if(index === -1) {
if(param.optional !== undefined) continue;
else return l('commands.tooFewParams');
return l('commands.tooFewParams');
}
let delimiter = param.delimiter !== undefined ? param.delimiter : defaultDelimiters[param.type];
if(delimiter === undefined) delimiter = ' ';
const endIndex = delimiter.length > 0 ? args.indexOf(delimiter, index) : args.length;
const value = args.substring(index, endIndex !== -1 ? endIndex : undefined);
if(value.length === 0)
if(value.length === 0) {
if(param.optional !== undefined) continue;
else return l('commands.tooFewParams');
return l('commands.tooFewParams');
}
values[i] = value;
switch(param.type) {
case ParamType.String:
@ -142,12 +144,6 @@ const commands: {readonly [key: string]: Command | undefined} = {
exec: (_, status: Character.Status, statusmsg: string = '') => core.connection.send('STA', {status, statusmsg}),
params: [{type: ParamType.Enum, options: userStatuses}, {type: ParamType.String, optional: true}]
},
ad: {
exec: (conv: ChannelConversation, message: string) =>
core.connection.send('LRP', {channel: conv.channel.id, message}),
context: CommandContext.Channel,
params: [{type: ParamType.String}]
},
roll: {
exec: (conv: ChannelConversation | PrivateConversation, dice: string) => {
if(Conversation.isChannel(conv)) core.connection.send('RLL', {channel: conv.channel.id, dice});

View File

@ -47,6 +47,7 @@ function VueRaven(this: void, raven: Raven.RavenStatic): Raven.RavenStatic {
//tslint:enable
export function setupRaven(dsn: string, version: string): void {
return; //TODO sentry temporarily disabled
Raven.config(dsn, {
release: version,
dataCallback: (data: {culprit?: string, exception?: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {

View File

@ -1,13 +1,10 @@
<template>
<div class="dropdown">
<a class="form-control custom-select" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
@blur="isOpen = false" style="width:100%;text-align:left;display:flex;align-items:center" role="button" tabindex="-1">
<div style="flex:1">
<slot name="title" style="flex:1"></slot>
</div>
<div class="dropdown" @focusout="blur">
<a :class="linkClass" aria-haspopup="true" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen" href="#"
style="width:100%;text-align:left;align-items:center" role="button" tabindex="-1" ref="button">
<slot name="title">{{title}}</slot>
</a>
<div class="dropdown-menu" :style="open ? {display: 'block'} : undefined" @mousedown.stop.prevent @click="isOpen = false"
ref="menu">
<div class="dropdown-menu" ref="menu" @mousedown.prevent.stop @click.prevent.stop="menuClick()">
<slot></slot>
</div>
</div>
@ -20,33 +17,39 @@
@Component
export default class Dropdown extends Vue {
isOpen = false;
@Prop()
@Prop({default: 'btn btn-secondary dropdown-toggle'})
readonly linkClass!: string;
@Prop
readonly keepOpen?: boolean;
@Prop
readonly title?: string;
get open(): boolean {
return this.keepOpen || this.isOpen;
}
@Watch('open')
@Watch('isOpen')
onToggle(): void {
const menu = this.$refs['menu'] as HTMLElement;
if(!this.isOpen) {
menu.style.cssText = '';
return;
}
let element = <HTMLElement | null>this.$el;
while(element !== null) {
if(getComputedStyle(element).position === 'fixed') {
menu.style.display = 'block';
const offset = menu.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.left = `${offset.left}px`;
menu.style.top = (offset.bottom < window.innerHeight) ? menu.style.top = `${offset.top}px` :
`${this.$el.getBoundingClientRect().top - offset.bottom + offset.top}px`;
break;
}
element = element.parentElement;
menu.style.display = 'block';
const offset = menu.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.left = offset.right < window.innerWidth ? `${offset.left}px` : `${window.innerWidth - offset.width}px`;
menu.style.top = (offset.bottom < window.innerHeight) ? `${offset.top}px` :
`${offset.top - offset.height - (<HTMLElement>this.$el).offsetHeight}px`;
}
blur(event: FocusEvent): void {
let elm = <HTMLElement | null>event.relatedTarget;
while(elm) {
if(elm === this.$refs['menu']) return;
elm = elm.parentElement;
}
this.isOpen = false;
}
menuClick(): void {
if(!this.keepOpen) this.isOpen = false;
}
}
</script>

View File

@ -1,11 +1,10 @@
<template>
<dropdown class="filterable-select" :keepOpen="keepOpen">
<dropdown class="filterable-select" linkClass="custom-select" :keepOpen="true">
<template slot="title" v-if="multiple">{{label}}</template>
<slot v-else slot="title" :option="selected">{{label}}</slot>
<div style="padding:10px;">
<input v-model="filter" class="form-control" :placeholder="placeholder" @mousedown.stop @focus="keepOpen = true"
@blur="keepOpen = false"/>
<input v-model="filter" class="form-control" :placeholder="placeholder" @mousedown.stop/>
</div>
<div class="dropdown-items">
<template v-if="multiple">
@ -32,21 +31,20 @@
components: {dropdown: Dropdown}
})
export default class FilterableSelect extends Vue {
@Prop()
@Prop
readonly placeholder?: string;
@Prop({required: true})
readonly options!: object[];
@Prop({default: () => ((filter: RegExp, value: string) => filter.test(value))})
readonly filterFunc!: (filter: RegExp, value: object) => boolean;
@Prop()
@Prop
readonly multiple?: true;
@Prop()
@Prop
readonly value?: object | object[];
@Prop()
@Prop
readonly title?: string;
filter = '';
selected: object | object[] | undefined = this.value !== undefined ? this.value : (this.multiple !== undefined ? [] : undefined);
keepOpen = false;
@Watch('value')
watchValue(newValue: object | object[] | undefined): void {
@ -59,10 +57,8 @@
const index = selected.indexOf(item);
if(index === -1) selected.push(item);
else selected.splice(index, 1);
} else {
this.keepOpen = false;
} else
this.selected = item;
}
this.$emit('input', this.selected);
}
@ -91,6 +87,7 @@
max-height: 200px;
overflow-y: auto;
}
button {
display: flex;
text-align: left

View File

@ -1,6 +1,6 @@
<template>
<span v-show="isShown">
<div class="modal" @click.self="hideWithCheck()" style="display:flex;justify-content:center">
<div class="modal" @mousedown.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">
@ -49,17 +49,17 @@
export default class Modal extends Vue {
@Prop({default: ''})
readonly action!: string;
@Prop()
@Prop
readonly dialogClass?: {string: boolean};
@Prop({default: true})
readonly buttons!: boolean;
@Prop({default: () => ({'btn-primary': true})})
readonly buttonClass!: {string: boolean};
@Prop()
@Prop
readonly disabled?: boolean;
@Prop({default: true})
readonly showCancel!: boolean;
@Prop()
@Prop
readonly buttonText?: string;
isShown = false;
keepOpen = false;

View File

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

View File

@ -1,6 +1,6 @@
<template>
<select class="form-control" :value="value" @change="emit">
<option v-for="o in characters" :value="o.value" v-once>{{o.text}}</option>
<select :value="value" @change="emit">
<option v-for="character in characters" :value="character.id">{{character.name}}</option>
<slot></slot>
</select>
</template>
@ -8,24 +8,16 @@
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import {getCharacters} from './character_select/character_list';
interface SelectItem {
value: number
text: string
}
import {SimpleCharacter} from '../interfaces';
import * as Utils from '../site/utils';
@Component
export default class CharacterSelect extends Vue {
@Prop({required: true})
readonly value!: number;
get characters(): SelectItem[] {
const characterList = getCharacters();
const characters: SelectItem[] = [];
for(const character of characterList)
characters.push({value: character.id, text: character.name});
return characters;
get characters(): SimpleCharacter[] {
return Utils.characters;
}
emit(evt: Event): void {

View File

@ -1,14 +0,0 @@
export interface CharacterItem {
readonly name: string
readonly id: number
}
let characterList: ReadonlyArray<CharacterItem> = [];
export function setCharacters(characters: ReadonlyArray<CharacterItem>): void {
characterList = characters;
}
export function getCharacters(): ReadonlyArray<CharacterItem> {
return characterList;
}

45
components/collapse.vue Normal file
View File

@ -0,0 +1,45 @@
<template>
<div class="card bg-light">
<div class="card-header" @click="toggle()" style="cursor:pointer" :class="headerClass">
<h4>{{title}} <span class="fas" :class="'fa-chevron-' + (collapsed ? 'down' : 'up')"></span></h4>
</div>
<div :style="style" style="overflow:hidden">
<div class="card-body" ref="content">
<slot></slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop} from '@f-list/vue-ts';
@Component
export default class Collapse extends Vue {
@Prop({required: true})
readonly title!: string;
@Prop
readonly headerClass?: string;
collapsed = true;
timeout = 0;
style = {height: <string | undefined>'0', transition: 'height .2s'};
toggle(state?: boolean) {
clearTimeout(this.timeout);
this.collapsed = state !== undefined ? state : !this.collapsed;
this.$emit(this.collapsed ? 'close' : 'open');
if(this.collapsed) {
this.style.transition = 'initial';
this.style.height = `${(<HTMLElement>this.$refs['content']).scrollHeight}px`;
setTimeout(() => {
this.style.transition = 'height .2s';
this.style.height = '0';
}, 0);
} else {
this.style.height = `${(<HTMLElement>this.$refs['content']).scrollHeight}px`;
this.timeout = window.setTimeout(() => this.style.height = undefined, 200);
}
}
}
</script>

View File

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

View File

@ -6,7 +6,7 @@
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import {distanceInWordsToNow, format} from 'date-fns';
import Vue from 'vue';
import {Settings} from '../site/utils';
import {settings} from '../site/utils';
@Component
export default class DateDisplay extends Vue {
@ -23,7 +23,7 @@
const date = isNaN(+this.time) ? new Date(`${this.time}+00:00`) : new Date(+this.time * 1000);
const absolute = format(date, 'YYYY-MM-DD HH:mm');
const relative = distanceInWordsToNow(date, {addSuffix: true});
if(Settings.fuzzyDates) {
if(settings.fuzzyDates) {
this.primary = relative;
this.secondary = absolute;
} else {

View File

@ -23,13 +23,13 @@
readonly field!: string;
@Prop({required: true})
readonly errors!: {[key: string]: ReadonlyArray<string> | undefined};
@Prop()
@Prop
readonly label?: string;
@Prop()
@Prop
readonly id?: string;
@Prop({default: false})
readonly valid!: boolean;
@Prop()
@Prop
readonly helptext?: string;
get hasErrors(): boolean {

View File

@ -26,13 +26,13 @@
readonly field!: string;
@Prop({required: true})
readonly errors!: {[key: string]: ReadonlyArray<string> | undefined};
@Prop()
@Prop
readonly label?: string;
@Prop()
@Prop
readonly id?: string;
@Prop({default: false})
readonly valid!: boolean;
@Prop()
@Prop
readonly helptext?: string;
get hasErrors(): boolean {

View File

@ -46,7 +46,7 @@
readonly prev!: boolean;
@Prop({default: false})
readonly routed!: boolean;
@Prop({default: () => ({})})
@Prop({default(this: Vue & {$route: RouteParams}): RouteParams { return this.$route; }})
readonly route!: RouteParams;
@Prop({default: 'page'})
readonly paramName!: string;

View File

@ -22,7 +22,8 @@ const Tabs = Vue.extend({
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) }
attrs: {href: '#'},
staticClass: 'nav-link', class: {active: this._v === key}, on: {click: () => this.$emit('input', key)}
}, [children[key]!])])))]);
}
});

View File

@ -3,25 +3,33 @@
<div v-html="styling"></div>
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
<div class="card bg-light" style="width: 400px;">
<h3 class="card-header" style="margin-top:0">{{l('title')}}</h3>
<h3 class="card-header" style="margin-top:0;display:flex">
{{l('title')}}
<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>
<div class="card-body">
<div class="alert alert-danger" v-show="error">
{{error}}
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login()" :disabled="loggingIn"/>
<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"/>
<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>
@ -61,6 +69,7 @@
</select>
</div>
</modal>
<logs ref="logsDialog"></logs>
</div>
</template>
@ -77,18 +86,17 @@
import Vue from 'vue';
import Chat from '../chat/Chat.vue';
import {getKey, Settings} from '../chat/common';
import core, {init as initCore} from '../chat/core';
import core from '../chat/core';
import l from '../chat/localize';
import {init as profileApiInit} from '../chat/profile_api';
import Logs from '../chat/Logs.vue';
import Socket from '../chat/WebSocket';
import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection';
import {SimpleCharacter} from '../interfaces';
import {Keys} from '../keys';
import CharacterPage from '../site/character_page/character_page.vue';
import {defaultHost, GeneralSettings, nativeRequire} from './common';
import {fixLogs, Logs, SettingsStore} from './filesystem';
import {fixLogs} from './filesystem';
import * as SlimcatImporter from './importer';
import Notifications from './notifications';
const webContents = electron.remote.getCurrentWebContents();
const parent = electron.remote.getCurrentWindow().webContents;
@ -106,17 +114,17 @@
log.info('Loaded keytar.');
@Component({
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
components: {chat: Chat, modal: Modal, characterPage: CharacterPage, logs: Logs}
})
export default class Index extends Vue {
showAdvanced = false;
saveLogin = false;
loggingIn = false;
password = '';
character: string | undefined;
characters: string[] | undefined;
character?: string;
characters?: SimpleCharacter[];
error = '';
defaultCharacter: string | undefined;
defaultCharacter?: number;
l = l;
settings!: GeneralSettings;
importProgress = 0;
@ -125,7 +133,7 @@
fixCharacter = '';
@Hook('created')
async created(): Promise<void> {
created(): void {
if(this.settings.account.length > 0) this.saveLogin = true;
keyStore.getPassword(this.settings.account)
.then((value: string) => this.password = value, (err: Error) => this.error = err.message);
@ -140,7 +148,7 @@
profileViewer.show();
});
electron.ipcRenderer.on('fix-logs', async() => {
this.fixCharacters = await new SettingsStore().getAvailableCharacters();
this.fixCharacters = await core.settingsStore.getAvailableCharacters();
this.fixCharacter = this.fixCharacters[0];
(<Modal>this.$refs['fixLogsModal']).show();
});
@ -169,15 +177,14 @@
await keyStore.setPassword(this.settings.account, this.password);
}
Socket.host = this.settings.host;
const connection = new Connection(`F-Chat 3.0 (${process.platform})`, electron.remote.app.getVersion(), Socket,
this.settings.account, this.password);
connection.onEvent('connecting', async() => {
core.connection.onEvent('connecting', async() => {
if(!electron.ipcRenderer.sendSync('connect', core.connection.character) && process.env.NODE_ENV === 'production') {
alert(l('login.alreadyLoggedIn'));
return core.connection.close();
}
parent.send('connect', webContents.id, core.connection.character);
this.character = connection.character;
this.character = core.connection.character;
if((await core.settingsStore.get('settings')) === undefined &&
SlimcatImporter.canImportCharacter(core.connection.character)) {
if(!confirm(l('importer.importGeneral'))) return core.settingsStore.set('settings', new Settings());
@ -186,22 +193,21 @@
(<Modal>this.$refs['importModal']).hide();
}
});
connection.onEvent('connected', () => {
core.connection.onEvent('connected', () => {
core.watch(() => core.conversations.hasNew, (newValue) => parent.send('has-new', webContents.id, newValue));
Raven.setUserContext({username: core.connection.character});
});
connection.onEvent('closed', () => {
core.connection.onEvent('closed', () => {
if(this.character === undefined) return;
electron.ipcRenderer.send('disconnect', this.character);
this.character = undefined;
parent.send('disconnect', webContents.id);
Raven.setUserContext();
});
initCore(connection, Logs, SettingsStore, Notifications);
const charNames = Object.keys(data.characters);
this.characters = charNames.sort();
this.defaultCharacter = charNames.find((x) => data.characters[x] === data.default_character)!;
profileApiInit(data.characters);
core.connection.setCredentials(this.settings.account, this.password);
this.characters = Object.keys(data.characters).map((name) => ({name, id: data.characters[name], deleted: false}))
.sort((x, y) => x.name.localeCompare(y.name));
this.defaultCharacter = data.default_character;
} catch(e) {
this.error = l('login.error');
if(process.env.NODE_ENV !== 'production') throw e;
@ -244,8 +250,8 @@
preview.style.display = 'none';
}
openProfileInBrowser(): void {
electron.remote.shell.openExternal(`https://www.f-list.net/c/${this.profileName}`);
async openProfileInBrowser(): Promise<void> {
return electron.remote.shell.openExternal(`https://www.f-list.net/c/${this.profileName}`);
}
get styling(): string {
@ -259,6 +265,10 @@
throw e;
}
}
showLogs(): void {
(<Logs>this.$refs['logsDialog']).show();
}
}
</script>

View File

@ -75,7 +75,7 @@
tabs: Tab[] = [];
activeTab: Tab | undefined;
tabMap: {[key: number]: Tab} = {};
isMaximized = browserWindow.isMaximized();
isMaximized = false;
canOpenTab = true;
l = l;
hasUpdate = false;
@ -83,8 +83,8 @@
lockTab = false;
@Hook('mounted')
mounted(): void {
this.addTab();
async mounted(): Promise<void> {
await this.addTab();
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => this.settings = settings);
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
electron.ipcRenderer.on('open-tab', () => this.addTab());
@ -153,6 +153,7 @@
browserWindow.hide();
return false;
};
this.isMaximized = browserWindow.isMaximized();
}
destroyAllTabs(): void {
@ -186,12 +187,12 @@
];
}
addTab(): void {
async addTab(): Promise<void> {
if(this.lockTab) return;
const tray = new electron.remote.Tray(trayIcon);
tray.setToolTip(l('title'));
tray.on('click', (_) => this.trayClicked(tab));
const view = new electron.remote.BrowserView();
const view = new electron.remote.BrowserView({webPreferences: {nodeIntegration: true}});
view.setAutoResize({width: true, height: true});
electron.ipcRenderer.send('tab-added', view.webContents.id);
const tab = {active: false, view, user: undefined, hasNew: false, tray};
@ -200,13 +201,14 @@
this.tabMap[view.webContents.id] = tab;
this.show(tab);
this.lockTab = true;
view.webContents.loadURL(url.format({
await view.webContents.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true,
query: {settings: JSON.stringify(this.settings)}
}));
view.webContents.on('did-stop-loading', () => this.lockTab = false);
tab.view.setBounds(getWindowBounds());
this.lockTab = false;
}
show(tab: Tab): void {
@ -259,6 +261,7 @@
align-items: center;
line-height: 1;
-webkit-app-region: no-drag;
flex-grow: 0;
}
.btn-default {

View File

@ -35,12 +35,17 @@ import * as electron from 'electron';
import * as path from 'path';
import * as qs from 'querystring';
import {getKey} from '../chat/common';
import {init as initCore} from '../chat/core';
import l from '../chat/localize';
import {setupRaven} from '../chat/vue-raven';
import Socket from '../chat/WebSocket';
import Connection from '../fchat/connection';
import {Keys} from '../keys';
import {GeneralSettings, nativeRequire} from './common';
import {Logs, SettingsStore} from './filesystem';
import * as SlimcatImporter from './importer';
import Index from './Index.vue';
import Notifications from './notifications';
document.addEventListener('keydown', (e: KeyboardEvent) => {
if(e.ctrlKey && e.shiftKey && getKey(e) === Keys.KeyI)
@ -79,7 +84,7 @@ function openIncognito(url: string): void {
} catch(e) {
console.error(e);
}
const commands = {
const commands = {
chrome: 'chrome.exe -incognito', firefox: 'firefox.exe -private-window', vivaldi: 'vivaldi.exe -incognito',
opera: 'opera.exe -private'
};
@ -99,7 +104,7 @@ webContents.on('context-menu', (_, props) => {
menuTemplate.push({
id: 'copy',
label: l('action.copy'),
role: can('Copy') ? 'copy' : '',
role: can('Copy') ? 'copy' : undefined,
accelerator: 'CmdOrCtrl+C',
enabled: can('Copy')
});
@ -107,13 +112,13 @@ webContents.on('context-menu', (_, props) => {
menuTemplate.push({
id: 'cut',
label: l('action.cut'),
role: can('Cut') ? 'cut' : '',
role: can('Cut') ? 'cut' : undefined,
accelerator: 'CmdOrCtrl+X',
enabled: can('Cut')
}, {
id: 'paste',
label: l('action.paste'),
role: props.editFlags.canPaste ? 'paste' : '',
role: props.editFlags.canPaste ? 'paste' : undefined,
accelerator: 'CmdOrCtrl+V',
enabled: props.editFlags.canPaste
});
@ -165,7 +170,7 @@ webContents.on('context-menu', (_, props) => {
let dictDir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');
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)});
electron.webFrame.setSpellCheckProvider('', {spellCheck: (words, callback) => callback(words.filter((x) => spellchecker.isMisspelled(x)))});
function onSettings(s: GeneralSettings): void {
settings = s;
@ -187,6 +192,10 @@ if(params['import'] !== undefined)
alert(l('importer.error'));
}
onSettings(settings);
const connection = new Connection(`F-Chat 3.0 (${process.platform})`, electron.remote.app.getVersion(), Socket);
initCore(connection, Logs, SettingsStore, Notifications);
//tslint:disable-next-line:no-unused-expression
new Index({
el: '#app',

View File

@ -1,8 +1,7 @@
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
export const defaultHost = 'wss://chat.f-list.net:9799';
export const defaultHost = 'wss://chat.f-list.net/chat2';
export class GeneralSettings {
account = '';
@ -18,30 +17,6 @@ export class GeneralSettings {
hwAcceleration = true;
}
export function mkdir(dir: string): void {
try {
fs.mkdirSync(dir);
} catch(e) {
if(!(e instanceof Error)) throw e;
switch((<Error & {code: string}>e).code) {
case 'ENOENT':
const dirname = path.dirname(dir);
if(dirname === dir) throw e;
mkdir(dirname);
mkdir(dir);
break;
default:
try {
const stat = fs.statSync(dir);
if(stat.isDirectory()) return;
} catch(e) {
console.log(e);
}
throw e;
}
}
}
//tslint:disable
const Module = require('module');

View File

@ -4,11 +4,9 @@ import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import * as fs from 'fs';
import * as path from 'path';
import {promisify} from 'util';
import {mkdir} from './common';
const dictDir = path.join(electron.app.getPath('userData'), 'spellchecker');
mkdir(dictDir);
const requestConfig = {responseType: 'arraybuffer'};
fs.mkdirSync(dictDir, {recursive: true});
const downloadedPath = path.join(dictDir, 'downloaded.json');
const downloadUrl = 'https://client.f-list.net/dicts/';
@ -40,7 +38,8 @@ export async function ensureDictionary(lang: string): Promise<void> {
const filePath = path.join(dictDir, `${lang}.${type}`);
const downloaded = downloadedDictionaries[file.name];
if(downloaded === undefined || downloaded.hash !== file.hash || !fs.existsSync(filePath)) {
await writeFile(filePath, Buffer.from((await Axios.get<string>(`${downloadUrl}${file.name}`, requestConfig)).data));
const dictionary = (await Axios.get<string>(`${downloadUrl}${file.name}`, {responseType: 'arraybuffer'})).data;
await writeFile(filePath, Buffer.from(dictionary));
downloadedDictionaries[file.name] = file;
await writeFile(downloadedPath, JSON.stringify(downloadedDictionaries));
}

View File

@ -6,7 +6,7 @@ import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core';
import {Character, Conversation, Logs as Logging, Settings} from '../chat/interfaces';
import l from '../chat/localize';
import {GeneralSettings, mkdir} from './common';
import {GeneralSettings} from './common';
declare module '../chat/interfaces' {
interface State {
@ -16,7 +16,6 @@ declare module '../chat/interfaces' {
const dayMs = 86400000;
const read = promisify(fs.read);
const noAssert = process.env.NODE_ENV === 'production';
function writeFile(p: fs.PathLike | number, data: string | object | number,
options?: {encoding?: string | null; mode?: number | string; flag?: string} | string | null): void {
@ -46,7 +45,7 @@ interface Index {
export function getLogDir(this: void, character: string): string {
const dir = path.join(core.state.generalSettings!.logDirectory, character, 'logs');
mkdir(dir);
fs.mkdirSync(dir, {recursive: true});
return dir;
}
@ -66,15 +65,15 @@ export function checkIndex(this: void, index: Index, message: Message, key: stri
index[key] = item = {name, index: {}, offsets: []};
const nameLength = Buffer.byteLength(name);
buffer = Buffer.allocUnsafe(nameLength + 8);
buffer.writeUInt8(nameLength, 0, noAssert);
buffer.writeUInt8(nameLength, 0);
buffer.write(name, 1);
offset = nameLength + 1;
}
const newValue = typeof size === 'function' ? size() : size;
item.index[date] = item.offsets.length;
item.offsets.push(newValue);
buffer.writeUInt16LE(date, offset, noAssert);
buffer.writeUIntLE(newValue, offset + 2, 5, noAssert);
buffer.writeUInt16LE(date, offset);
buffer.writeUIntLE(newValue, offset + 2, 5);
return buffer;
}
@ -83,25 +82,25 @@ export function serializeMessage(message: Message): {serialized: Buffer, size: n
const senderLength = Buffer.byteLength(name);
const messageLength = Buffer.byteLength(message.text);
const buffer = Buffer.allocUnsafe(senderLength + messageLength + 10);
buffer.writeUInt32LE(message.time.getTime() / 1000, 0, noAssert);
buffer.writeUInt8(message.type, 4, noAssert);
buffer.writeUInt8(senderLength, 5, noAssert);
buffer.writeUInt32LE(message.time.getTime() / 1000, 0);
buffer.writeUInt8(message.type, 4);
buffer.writeUInt8(senderLength, 5);
buffer.write(name, 6);
let offset = senderLength + 6;
buffer.writeUInt16LE(messageLength, offset, noAssert);
buffer.writeUInt16LE(messageLength, offset);
buffer.write(message.text, offset += 2);
buffer.writeUInt16LE(offset += messageLength, offset, noAssert);
buffer.writeUInt16LE(offset += messageLength, offset);
return {serialized: buffer, size: offset + 2};
}
function deserializeMessage(buffer: Buffer, offset: number = 0,
characterGetter: (name: string) => Character = (name) => core.characters.get(name),
unsafe: boolean = noAssert): {size: number, message: Conversation.Message} {
const time = buffer.readUInt32LE(offset, unsafe);
const type = buffer.readUInt8(offset += 4, unsafe);
const senderLength = buffer.readUInt8(offset += 1, unsafe);
characterGetter: (name: string) => Character = (name) => core.characters.get(name)
): {size: number, message: Conversation.Message} {
const time = buffer.readUInt32LE(offset);
const type = buffer.readUInt8(offset += 4);
const senderLength = buffer.readUInt8(offset += 1);
const sender = buffer.toString('utf8', offset += 1, offset += senderLength);
const messageLength = buffer.readUInt16LE(offset, unsafe);
const messageLength = buffer.readUInt16LE(offset);
const text = buffer.toString('utf8', offset += 2, offset + messageLength);
const message = new MessageImpl(type, characterGetter(sender), text, new Date(time * 1000));
return {message, size: senderLength + messageLength + 10};
@ -126,7 +125,7 @@ export function fixLogs(character: string): void {
const indexFd = fs.openSync(indexPath, 'r+');
fs.readSync(indexFd, buffer, 0, 1, 0);
let pos = 0, lastDay = 0;
const nameEnd = buffer.readUInt8(0, noAssert) + 1;
const nameEnd = buffer.readUInt8(0) + 1;
fs.ftruncateSync(indexFd, nameEnd);
fs.readSync(indexFd, buffer, 0, nameEnd, null); //tslint:disable-line:no-null-keyword
const size = (fs.fstatSync(fd)).size;
@ -137,12 +136,12 @@ export function fixLogs(character: string): void {
const deserialized = deserializeMessage(buffer, 0, (name) => ({
gender: 'None', status: 'online', statusText: '', isFriend: false, isBookmarked: false, isChatOp: false,
isIgnored: false, name
}), false);
}));
const time = deserialized.message.time;
const day = Math.floor(time.getTime() / dayMs - time.getTimezoneOffset() / 1440);
if(day > lastDay) {
buffer.writeUInt16LE(day, 0, noAssert);
buffer.writeUIntLE(pos, 2, 5, noAssert);
buffer.writeUInt16LE(day, 0);
buffer.writeUIntLE(pos, 2, 5);
fs.writeSync(indexFd, buffer, 0, 7);
lastDay = day;
}
@ -166,7 +165,7 @@ function loadIndex(name: string): Index {
if(file.substr(-4) === '.idx')
try {
const content = fs.readFileSync(path.join(dir, file));
let offset = content.readUInt8(0, noAssert) + 1;
let offset = content.readUInt8(0) + 1;
const item: IndexItem = {
name: content.toString('utf8', 1, offset),
index: {},
@ -175,7 +174,7 @@ function loadIndex(name: string): Index {
for(; offset < content.length; offset += 7) {
const key = content.readUInt16LE(offset);
item.index[key] = item.offsets.length;
item.offsets.push(content.readUIntLE(offset + 2, 5, noAssert));
item.offsets.push(content.readUIntLE(offset + 2, 5));
}
index[file.slice(0, -4).toLowerCase()] = item;
} catch(e) {
@ -289,14 +288,14 @@ export class Logs implements Logging {
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const baseDir = core.state.generalSettings!.logDirectory;
mkdir(baseDir);
fs.mkdirSync(baseDir, {recursive: true});
return (fs.readdirSync(baseDir)).filter((x) => fs.statSync(path.join(baseDir, x)).isDirectory());
}
}
function getSettingsDir(character: string = core.connection.character): string {
const dir = path.join(core.state.generalSettings!.logDirectory, character, 'settings');
mkdir(dir);
fs.mkdirSync(dir, {recursive: true});
return dir;
}
@ -315,6 +314,7 @@ export class SettingsStore implements Settings.Store {
return (fs.readdirSync(baseDir)).filter((x) => fs.statSync(path.join(baseDir, x)).isDirectory());
}
//tslint:disable-next-line:no-async-without-await
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
writeFile(path.join(getSettingsDir(), key), JSON.stringify(value));
}

View File

@ -231,7 +231,8 @@ export async function importCharacter(ownCharacter: string, progress: (progress:
}
++index;
continue;
} else if(char === '\r' || char === '\n') {
}
if(char === '\r' || char === '\n') {
const nextLine = content.substr(index + (char === '\r' ? 2 : 1), 29);
if(logRegex.test(nextLine) || content.length - index <= 2) {
const line = content.substring(start, index);

View File

@ -35,7 +35,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
import l from '../chat/localize';
import {GeneralSettings, mkdir} from './common';
import {defaultHost, GeneralSettings} from './common';
import {ensureDictionary, getAvailableDictionaries} from './dictionaries';
import * as windowState from './window_state';
import BrowserWindow = Electron.BrowserWindow;
@ -51,11 +51,11 @@ const characters: string[] = [];
let tabCount = 0;
const baseDir = app.getPath('userData');
mkdir(baseDir);
fs.mkdirSync(baseDir, {recursive: true});
let shouldImportSettings = false;
const settingsDir = path.join(baseDir, 'data');
mkdir(settingsDir);
fs.mkdirSync(settingsDir, {recursive: true});
const settingsFile = path.join(settingsDir, 'settings');
const settings = new GeneralSettings();
@ -107,7 +107,7 @@ function setUpWebContents(webContents: Electron.WebContents): void {
e.preventDefault();
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/([^/#]+)\/?#?/);
if(profileMatch !== null && settings.profileViewer) webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
else electron.shell.openExternal(linkUrl);
else return electron.shell.openExternal(linkUrl);
};
webContents.on('will-navigate', openLinkExternally);
@ -118,14 +118,14 @@ function createWindow(): Electron.BrowserWindow | undefined {
if(tabCount >= 3) return;
const lastState = windowState.getSavedWindowState();
const windowProperties: Electron.BrowserWindowConstructorOptions & {maximized: boolean} = {
...lastState, center: lastState.x === undefined, show: false
...lastState, center: lastState.x === undefined, show: false, webPreferences: {nodeIntegration: true}
};
if(process.platform === 'darwin') windowProperties.titleBarStyle = 'hiddenInset';
else windowProperties.frame = false;
const window = new electron.BrowserWindow(windowProperties);
windows.push(window);
window.loadURL(url.format({
window.loadURL(url.format({ //tslint:disable-line:no-floating-promises
pathname: path.join(__dirname, 'window.html'),
protocol: 'file:',
slashes: true,
@ -145,7 +145,7 @@ function createWindow(): Electron.BrowserWindow | undefined {
}
function showPatchNotes(): void {
electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0#Changelog');
electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0#Changelog'); //tslint:disable-line:no-floating-promises
}
function onReady(): void {
@ -160,6 +160,8 @@ function onReady(): void {
if(settings.version !== app.getVersion()) {
showPatchNotes();
if(settings.host === 'wss://chat.f-list.net:9799')
settings.host = defaultHost;
settings.version = app.getVersion();
setGeneralSettings(settings);
}
@ -235,8 +237,8 @@ function onReady(): void {
{
label: l('settings.logDir'),
click: (_, window: BrowserWindow) => {
const dir = <string[] | undefined>electron.dialog.showOpenDialog(
{defaultPath: new GeneralSettings().logDirectory, properties: ['openDirectory']});
const dir = electron.dialog.showOpenDialog(
{defaultPath: settings.logDirectory, properties: ['openDirectory']});
if(dir !== undefined) {
if(dir[0].startsWith(path.dirname(app.getPath('exe'))))
return electron.dialog.showErrorBox(l('settings.logDir'), l('settings.logDir.inAppDir'));
@ -369,7 +371,7 @@ function onReady(): void {
});
electron.ipcMain.on('connect', (e: Event & {sender: Electron.WebContents}, character: string) => {
if(characters.indexOf(character) !== -1) return e.returnValue = false;
else characters.push(character);
characters.push(character);
e.returnValue = true;
});
electron.ipcMain.on('dictionary-add', (_: Event, word: string) => {

View File

@ -4,36 +4,12 @@ const pkg = require(path.join(__dirname, 'package.json'));
const fs = require('fs');
const child_process = require('child_process');
function mkdir(dir) {
try {
fs.mkdirSync(dir);
} catch(e) {
if(!(e instanceof Error)) throw e;
switch(e.code) {
case 'ENOENT':
const dirname = path.dirname(dir);
if(dirname === dir) throw e;
mkdir(dirname);
mkdir(dir);
break;
default:
try {
const stat = fs.statSync(dir);
if(stat.isDirectory()) return;
} catch(e) {
console.log(e);
}
throw e;
}
}
}
const distDir = path.join(__dirname, 'dist');
const isBeta = pkg.version.indexOf('beta') !== -1;
const spellcheckerPath = 'spellchecker/build/Release/spellchecker.node', keytarPath = 'keytar/build/Release/keytar.node';
const modules = path.join(__dirname, 'app', 'node_modules');
mkdir(path.dirname(path.join(modules, spellcheckerPath)));
mkdir(path.dirname(path.join(modules, keytarPath)));
fs.mkdirSync(path.dirname(path.join(modules, spellcheckerPath)), {recursive: true});
fs.mkdirSync(path.dirname(path.join(modules, keytarPath)), {recursive: true});
fs.copyFileSync(require.resolve(spellcheckerPath), path.join(modules, spellcheckerPath));
fs.copyFileSync(require.resolve(keytarPath), path.join(modules, keytarPath));
@ -69,7 +45,7 @@ require('electron-packager')({
setupExe: setupName,
remoteReleases: 'https://client.f-list.net/win32/' + (isBeta ? '?channel=beta' : ''),
signWithParams: process.argv.length > 3 ? `/a /f ${process.argv[2]} /p ${process.argv[3]} /fd sha256 /tr http://timestamp.digicert.com /td sha256` : undefined
}).catch((e) => console.log(`Error while creating installer: ${e.message}`));
}).catch((e) => console.error(`Error while creating installer: ${e.message}`));
} else if(process.platform === 'darwin') {
console.log('Creating Mac DMG');
const target = path.join(distDir, `F-Chat.dmg`);
@ -104,7 +80,7 @@ require('electron-packager')({
fs.renameSync(path.join(appPaths[0], 'F-Chat'), path.join(appPaths[0], 'AppRun'));
fs.copyFileSync(path.join(__dirname, 'build', 'icon.png'), path.join(appPaths[0], 'icon.png'));
const libDir = path.join(appPaths[0], 'usr', 'lib'), libSource = path.join(__dirname, 'build', 'linux-libs');
mkdir(libDir);
fs.mkdirSync(libDir, {recursive: true});
for(const file of fs.readdirSync(libSource))
fs.copyFileSync(path.join(libSource, file), path.join(libDir, file));
fs.symlinkSync(path.join(appPaths[0], 'icon.png'), path.join(appPaths[0], '.DirIcon'));
@ -114,13 +90,13 @@ require('electron-packager')({
const stream = fs.createWriteStream(downloaded);
res.data.pipe(stream);
stream.on('close', () => {
const args = [appPaths[0], 'fchat.AppImage', '-u', 'zsync|httpos://client.f-list.net/fchat.AppImage.zsync'];
const args = [appPaths[0], 'fchat.AppImage', '-u', 'zsync|https://client.f-list.net/fchat.AppImage.zsync'];
if(process.argv.length > 2) args.push('-s', '--sign-key', process.argv[2]);
else console.warn('Warning: Creating unsigned AppImage');
if(process.argv.length > 3) args.push('--sign-args', `--passphrase=${process.argv[3]}`);
if(process.argv.length > 3) args.push('--sign-args', `--no-tty --passphrase=${process.argv[3]}`);
fs.chmodSync(downloaded, 0o755);
child_process.spawn(downloaded, ['--appimage-extract'], {cwd: distDir}).on('close', () => {
const child = child_process.spawn(path.join(distDir, 'squashfs-root', 'AppRun'), args, {cwd: distDir});
const child = child_process.spawn(path.join(distDir, 'squashfs-root', 'AppRun'), args, {cwd: distDir, env: {ARCH: 'x86_64'}});
child.stdout.on('data', (data) => console.log(data.toString()));
child.stderr.on('data', (data) => console.error(data.toString()));
});

View File

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

View File

@ -7,8 +7,8 @@
"allowJs": true,
"noEmitHelpers": true,
"importHelpers": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},

View File

@ -33,7 +33,6 @@ const mainConfig = {
},
plugins: [
new ForkTsCheckerWebpackPlugin({
workers: 2,
async: false,
tslint: path.join(__dirname, '../tslint.json'),
tsconfig: './tsconfig-main.json'
@ -90,7 +89,6 @@ const mainConfig = {
},
plugins: [
new ForkTsCheckerWebpackPlugin({
workers: 2,
async: false,
tslint: path.join(__dirname, '../tslint.json'),
tsconfig: './tsconfig-renderer.json',

View File

@ -1,6 +1,7 @@
import Axios, {AxiosError, AxiosResponse} from 'axios';
import * as qs from 'qs';
import {Connection as Interfaces, WebSocketConnection} from './interfaces';
import ReadyState = WebSocketConnection.ReadyState;
const fatalErrors = [2, 3, 4, 9, 30, 31, 33, 39, 40, 62, -4];
const dieErrors = [9, 30, 31, 39, 40];
@ -11,25 +12,32 @@ async function queryApi(this: void, endpoint: string, data: object): Promise<Axi
export default class Connection implements Interfaces.Connection {
character = '';
vars: Interfaces.Vars & {[key: string]: string} = <any>{}; //tslint:disable-line:no-any
vars: Interfaces.Vars = <any>{}; //tslint:disable-line:no-any
protected socket: WebSocketConnection | undefined = undefined;
private messageHandlers: {[key in keyof Interfaces.ServerCommands]?: Interfaces.CommandHandler<key>[]} = {};
private connectionHandlers: {[key in Interfaces.EventType]?: Interfaces.EventHandler[]} = {};
//tslint:disable-next-line:no-object-literal-type-assertion
private messageHandlers = <{ [key in keyof Interfaces.ServerCommands]: Interfaces.CommandHandler<key>[] }>{};
private connectionHandlers: { [key in Interfaces.EventType]?: Interfaces.EventHandler[] } = {};
private errorHandlers: ((error: Error) => void)[] = [];
private ticket = '';
private cleanClose = false;
private reconnectTimer: NodeJS.Timer | undefined;
private readonly ticketProvider: Interfaces.TicketProvider;
private account = '';
private ticketProvider?: Interfaces.TicketProvider;
private reconnectDelay = 0;
private isReconnect = false;
private pinTimeout?: NodeJS.Timer;
constructor(private readonly clientName: string, private readonly version: string,
private readonly socketProvider: new() => WebSocketConnection, private readonly account: string,
ticketProvider: Interfaces.TicketProvider | string) {
private readonly socketProvider: new() => WebSocketConnection) {
}
setCredentials(account: string, ticketProvider: Interfaces.TicketProvider | string): void {
this.account = account;
this.ticketProvider = typeof ticketProvider === 'string' ? async() => this.getTicket(ticketProvider) : ticketProvider;
}
async connect(character: string): Promise<void> {
if(!this.ticketProvider) throw new Error('No credentials set!');
this.cleanClose = false;
if(this.character !== character) this.isReconnect = false;
this.character = character;
@ -67,6 +75,7 @@ export default class Connection implements Interfaces.Connection {
method: 'ticket',
ticket: this.ticket
});
this.resetPinTimeout();
});
this.socket.onMessage(async(msg: string) => {
const type = <keyof Interfaces.ServerCommands>msg.substr(0, 3);
@ -74,6 +83,7 @@ export default class Connection implements Interfaces.Connection {
return this.handleMessage(type, data);
});
this.socket.onClose(async() => {
if(this.pinTimeout) clearTimeout(this.pinTimeout);
if(!this.cleanClose) this.reconnect();
this.socket = undefined;
await this.invokeHandlers('closed', !this.cleanClose);
@ -95,10 +105,11 @@ export default class Connection implements Interfaces.Connection {
}
get isOpen(): boolean {
return this.socket !== undefined;
return this.socket !== undefined && this.socket.readyState === ReadyState.OPEN;
}
async queryApi<T = object>(endpoint: string, data?: {account?: string, ticket?: string}): Promise<T> {
if(!this.ticketProvider) throw new Error('No credentials set!');
if(data === undefined) data = {};
data.account = this.account;
data.ticket = this.ticket;
@ -156,10 +167,11 @@ export default class Connection implements Interfaces.Connection {
for(const handler of handlers) await handler(data, time);
switch(type) {
case 'VAR':
this.vars[data.variable] = data.value;
this.vars[<keyof Interfaces.Vars>data.variable] = data.value;
break;
case 'PIN':
this.send('PIN');
this.resetPinTimeout();
break;
case 'ERR':
if(fatalErrors.indexOf(data.number) !== -1) {
@ -198,4 +210,9 @@ export default class Connection implements Interfaces.Connection {
if(request) (<Error & {request: true}>error).request = true;
for(const handler of this.errorHandlers) handler(error);
}
private resetPinTimeout(): void {
if(this.pinTimeout) clearTimeout(this.pinTimeout);
this.pinTimeout = setTimeout(() => this.socket!.close(), 90000);
}
}

View File

@ -122,21 +122,22 @@ export namespace Connection {
export type EventHandler = (isReconnect: boolean) => Promise<void> | void;
export interface Vars {
readonly chat_max: number
readonly priv_max: number
readonly lfrp_max: number
readonly cds_max: number
readonly lfrp_flood: number
readonly msg_flood: number
readonly sta_flood: number
readonly permissions: number
readonly icon_blacklist: ReadonlyArray<string>
chat_max: number
priv_max: number
lfrp_max: number
cds_max: number
lfrp_flood: number
msg_flood: number
sta_flood: number
permissions: number
icon_blacklist: ReadonlyArray<string>
}
export interface Connection {
readonly character: string
readonly vars: Vars
readonly vars: Readonly<Vars>
readonly isOpen: boolean
setCredentials(account: string, ticketProvider: TicketProvider | string): void
connect(character: string): void
close(keepState?: boolean): void
onMessage<K extends keyof ServerCommands>(type: K, handler: CommandHandler<K>): void

View File

@ -46,10 +46,9 @@ export interface Infotag {
name: string
type: InfotagType
search_field: string
validator: string
validator?: string
allow_legacy: boolean
infotag_group: string
list?: number
infotag_group: number
}
export interface Character extends SimpleCharacter {
@ -57,7 +56,7 @@ export interface Character extends SimpleCharacter {
name: string
title: string
description: string
kinks: {[key: string]: KinkChoice | number | undefined}
kinks: {[key: number]: KinkChoice | number | undefined}
inlines: {[key: string]: InlineImage}
customs: {[key: string]: CustomKink | undefined}
infotags: {[key: number]: CharacterInfotag | undefined}
@ -96,4 +95,42 @@ export interface CustomKink {
name: string
choice: KinkChoice
description: string
}
export interface KinkGroup {
id: number
name: string
description: string
sort_order: number
}
export interface InfotagGroup {
id: number
name: string
description: string
sort_order: number
}
export interface ListItem {
id: number
name: string
value: string
sort_order: number
}
export const enum InlineDisplayMode {DISPLAY_ALL, DISPLAY_SFW, DISPLAY_NONE}
export interface Settings {
animateEicons: boolean
inlineDisplayMode: InlineDisplayMode
defaultCharacter: number
fuzzyDates: boolean
}
export interface SharedDefinitions {
readonly listItems: {readonly [key: string]: Readonly<ListItem>}
readonly kinks: {readonly [key: string]: Readonly<Kink>}
readonly kinkGroups: {readonly [key: string]: Readonly<KinkGroup>}
readonly infotags: {readonly [key: string]: Readonly<Infotag>}
readonly infotagGroups: {readonly [key: string]: Readonly<InfotagGroup>}
}

View File

@ -63,15 +63,13 @@
import * as Raven from 'raven-js';
import Vue from 'vue';
import Chat from '../chat/Chat.vue';
import core, {init as initCore} from '../chat/core';
import core from '../chat/core';
import l from '../chat/localize';
import {init as profileApiInit} from '../chat/profile_api';
import Socket from '../chat/WebSocket';
import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection';
import {SimpleCharacter} from '../interfaces';
import CharacterPage from '../site/character_page/character_page.vue';
import {appVersion, GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import Notifications from './notifications';
import {appVersion, GeneralSettings, getGeneralSettings, setGeneralSettings, SettingsStore} from './filesystem';
declare global {
interface Window {
@ -97,9 +95,9 @@
showAdvanced = false;
saveLogin = false;
loggingIn = false;
characters?: ReadonlyArray<string>;
characters?: ReadonlyArray<SimpleCharacter>;
error = '';
defaultCharacter?: string;
defaultCharacter?: number;
settingsStore = new SettingsStore();
l = l;
settings!: GeneralSettings;
@ -147,27 +145,20 @@
}
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);
connection.onEvent('connected', () => {
core.connection.setCredentials(this.settings.account, this.settings.password);
core.connection.onEvent('connected', () => {
Raven.setUserContext({username: core.connection.character});
document.addEventListener('backbutton', confirmBack);
NativeBackground.start();
});
connection.onEvent('closed', () => {
core.connection.onEvent('closed', () => {
Raven.setUserContext();
document.removeEventListener('backbutton', confirmBack);
NativeBackground.stop();
});
initCore(connection, Logs, SettingsStore, Notifications);
const charNames = Object.keys(data.characters);
this.characters = charNames.sort();
for(const character of charNames)
if(data.characters[character] === data.default_character) {
this.defaultCharacter = character;
break;
}
profileApiInit(data.characters);
this.characters = Object.keys(data.characters).map((name) => ({name, id: data.characters[name], deleted: false}))
.sort((x, y) => x.name.localeCompare(y.name));
this.defaultCharacter = data.default_character;
} catch(e) {
this.error = l('login.error');
if(process.env.NODE_ENV !== 'production') throw e;

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK" />
</project>

View File

@ -8,8 +8,8 @@ android {
applicationId "net.f_list.fchat"
minSdkVersion 21
targetSdkVersion 27
versionCode 21
versionName "3.0.10"
versionCode 25
versionName "3.0.12"
}
buildTypes {
release {

View File

@ -2,19 +2,24 @@ package net.f_list.fchat
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.webkit.JavascriptInterface
class Background(private val ctx: Context) {
private val serviceIntent: Intent by lazy { Intent(ctx, BackgroundService::class.java) }
private val powerManager: PowerManager by lazy { ctx.getSystemService(Context.POWER_SERVICE) as PowerManager }
private var wakeLock: PowerManager.WakeLock? = null
@JavascriptInterface
fun start() {
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "fchat")
wakeLock!!.acquire()
ctx.startService(serviceIntent)
}
@JavascriptInterface
fun stop() {
if(wakeLock != null && wakeLock!!.isHeld) wakeLock!!.release()
ctx.stopService(serviceIntent)
}
}

View File

@ -13,6 +13,7 @@ import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.JsResult
import android.webkit.WebChromeClient
@ -96,7 +97,7 @@ class MainActivity : Activity() {
"n.getCharacters=function(){return JSON.parse(n.getCharactersN())}})(NativeLogs)", null)
}
}
debugHandler.postDelayed(keepAlive, 10000);
}
private fun addFolder(folder: java.io.File, out: ZipOutputStream, path: String) {
@ -109,6 +110,13 @@ class MainActivity : Activity() {
}
}
val keepAlive = object : Runnable {
override fun run() {
webView.dispatchWindowVisibilityChanged(View.VISIBLE);
debugHandler.postDelayed(this, 10000)
}
}
val debug = Runnable {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val permission = checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)

View File

@ -30,8 +30,13 @@
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import Axios from 'axios';
import {init as initCore} from '../chat/core';
import {setupRaven} from '../chat/vue-raven';
import Socket from '../chat/WebSocket';
import Connection from '../fchat/connection';
import {appVersion, Logs, SettingsStore} from './filesystem';
import Index from './Index.vue';
import Notifications from './notifications';
const version = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
(<any>window)['setupPlatform'] = (platform: string) => { //tslint:disable-line:no-any
@ -41,6 +46,9 @@ const version = (<{version: string}>require('./package.json')).version; //tslint
if(process.env.NODE_ENV === 'production')
setupRaven('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', `mobile-${version}`);
const connection = new Connection('F-Chat 3.0 (Mobile)', appVersion, Socket);
initCore(connection, Logs, SettingsStore, Notifications);
new Index({ //tslint:disable-line:no-unused-expression
el: '#app'
});

View File

@ -31,7 +31,7 @@ export const appVersion = (<{version: string}>require('./package.json')).version
export class GeneralSettings {
account = '';
password = '';
host = 'wss://chat.f-list.net:9799';
host = 'wss://chat.f-list.net/chat2';
theme = 'default';
version = appVersion;
}
@ -135,7 +135,9 @@ export class Logs implements Logging {
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
const file = await NativeFile.read('!settings');
if(file === undefined) return undefined;
return <GeneralSettings>JSON.parse(file);
const settings = <GeneralSettings>JSON.parse(file);
if(settings.host === 'wss://chat.f-list.net:9799') settings.host = 'wss://chat.f-list.net/chat2';
return settings;
}
export async function setGeneralSettings(value: GeneralSettings): Promise<void> {

View File

@ -121,7 +121,7 @@ class Logs: NSObject, WKScriptMessageHandler {
indexItem = IndexItem(conversation as String)
index![key] = indexItem
let cstring = conversation.utf8String
var length = strlen(cstring)
var length = strlen(cstring!)
write(indexFd.fileDescriptor, &length, 1)
write(indexFd.fileDescriptor, cstring, length)
}
@ -135,11 +135,11 @@ class Logs: NSObject, WKScriptMessageHandler {
write(fd.fileDescriptor, &time, 4)
write(fd.fileDescriptor, &type, 1)
var cstring = sender.utf8String
var length = strlen(cstring)
var length = strlen(cstring!)
write(fd.fileDescriptor, &length, 1)
write(fd.fileDescriptor, cstring, length)
cstring = text.utf8String
length = strlen(cstring)
length = strlen(cstring!)
write(fd.fileDescriptor, &length, 2)
write(fd.fileDescriptor, cstring, length)
var size = fd.offsetInFile - start

View File

@ -20,7 +20,11 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
controller.add(Logs(), name: "Logs")
config.userContentController = controller
config.mediaTypesRequiringUserActionForPlayback = [.video]
config.setValue(true, forKey: "_alwaysRunsAtForegroundPriority")
if #available(iOS 12.2, *) {
config.setValue(true, forKey: "alwaysRunsAtForegroundPriority")
} else {
config.setValue(true, forKey: "_alwaysRunsAtForegroundPriority")
}
webView = WKWebView(frame: UIApplication.shared.windows[0].frame, configuration: config)
webView.uiDelegate = self
webView.navigationDelegate = self

View File

@ -22,6 +22,6 @@ export default class Notifications extends BaseNotifications {
}
async requestPermission(): Promise<void> {
NativeNotification.requestPermission();
return NativeNotification.requestPermission();
}
}

View File

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

View File

@ -45,7 +45,7 @@ const config = {
]
},
plugins: [
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')}),
new ForkTsCheckerWebpackPlugin({async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')}),
new VueLoaderPlugin()
],
resolve: {

View File

@ -5,48 +5,48 @@
"description": "F-List Exported",
"license": "MIT",
"devDependencies": {
"@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": "^2.0.1",
"@f-list/fork-ts-checker-webpack-plugin": "^1.3.7",
"@f-list/vue-ts": "^1.0.3",
"@fortawesome/fontawesome-free": "^5.9.0",
"@types/lodash": "^4.14.134",
"@types/sortablejs": "^1.7.2",
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"css-loader": "^3.0.0",
"date-fns": "^1.30.1",
"electron": "3.0.13",
"electron-log": "^2.2.17",
"electron-packager": "^13.0.1",
"electron-rebuild": "^1.8.2",
"electron": "^5.0.4",
"electron-log": "^3.0.1",
"electron-packager": "^14.0.0",
"electron-rebuild": "^1.8.4",
"extract-loader": "^3.1.0",
"file-loader": "^2.0.0",
"file-loader": "^4.0.0",
"lodash": "^4.17.11",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"qs": "^6.6.0",
"raven-js": "^3.27.0",
"raven-js": "^3.27.2",
"sass-loader": "^7.1.0",
"sortablejs": "^1.8.0-rc1",
"sortablejs": "~1.9.0",
"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-template-compiler": "^2.5.21",
"webpack": "^4.27.1"
"ts-loader": "^6.0.3",
"tslib": "^1.10.0",
"tslint": "^5.17.0",
"typescript": "^3.5.2",
"vue": "^2.6.8",
"vue-loader": "^15.7.0",
"vue-template-compiler": "^2.6.8",
"webpack": "^4.35.0"
},
"dependencies": {
"keytar": "^4.3.0",
"spellchecker": "^3.5.0"
"keytar": "^4.10.0",
"spellchecker": "^3.6.0"
},
"optionalDependencies": {
"appdmg": "^0.5.2",
"appdmg": "^0.6.0",
"electron-squirrel-startup": "^1.0.0",
"electron-winstaller": "^2.7.0"
"electron-winstaller": "^3.0.4"
},
"scripts": {
"postinstall": "electron-rebuild -o spellchecker,keytar"
"postinstall": "electron-rebuild -fo spellchecker,keytar"
}
}

View File

@ -113,7 +113,7 @@ div.indentText {
}
}
.styledText, .bbcode {
.bbcode {
@include force-word-wrapping;
max-width: 100%;
a {

View File

@ -83,6 +83,7 @@
border-color: theme-color("secondary");
border-top-right-radius: 0;
border-top-left-radius: 0;
white-space: nowrap;
@media (min-width: breakpoint-min(sm)) {
.name {
display: none;

View File

@ -54,5 +54,6 @@
* {
min-height: 0;
min-width: 0;
-webkit-overflow-scrolling: touch;
}

View File

@ -59,4 +59,12 @@ select {
@extend .custom-select;
-webkit-appearance: none;
-moz-appearance: none;
}
a.btn {
color: $link-color;
&:hover {
color: $link-hover-color;
}
}

View File

@ -1,4 +0,0 @@
.kink-editor-panel {
position: fixed;
width: 55%;
}

View File

@ -1,24 +1,15 @@
.note-folder {
clear: both;
display: flex;
width: 100%;
.note-folder-total, .note-folder-unread {
width: 3.5rem;
display: inline-block;
float: left;
text-align: center;
}
.note-folder-unread {
font-weight: bold;
float: right;
}
.note-folder-name {
width: auto;
display: inline-block;
float: left;
}
.note-folder-icon {
display: inline-block;
float: right;
flex: 1;
}
}

View File

@ -5,9 +5,9 @@
"description": "F-Chat Themes",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free-webfonts": "^1.0.3",
"bootstrap": "^4.0.0",
"node-sass": "^4.7.2"
"@fortawesome/fontawesome-free": "^5.6.3",
"bootstrap": "^4.2.1",
"node-sass": "^4.11.0"
},
"scripts": {
"build": "node-sass --importer=./importer --output css"

View File

@ -9,6 +9,5 @@
@import "../tickets";
@import "../notes";
@import "../threads";
@import "../kink_editor";
@import "../flist_overrides";
@import "../tag_input";

View File

@ -30,6 +30,7 @@ $body-bg: $gray-100;
$link-color: $gray-800;
$link-hover-color: lighten($link-color, 15%);
$dropdown-bg: $gray-200;
$dropdown-divider-bg: $gray-300;
// Modal Dialogs
$modal-backdrop-bg: $white;

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
<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>
<sidebar :character="character" @memo="memo" :oldApi="oldApi"></sidebar>
</div>
<div class="col-md-8 col-lg-9 col-xl-10 profile-body" v-if="!loading && character">
<div id="characterView">
@ -34,28 +34,26 @@
</tabs>
</div>
<div class="card-body">
<div class="tab-content">
<div role="tabpanel" class="tab-pane" :class="{active: tab === '0'}" id="overview">
<div v-bbcode="character.character.description" 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">
<character-infotags :character="character" ref="tab1"></character-infotags>
</div>
<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'}">
<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'}"
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">
<character-friends :character="character" ref="tab5"></character-friends>
<div role="tabpanel" v-show="tab === '0'">
<div style="margin-bottom:10px">
<bbcode :text="character.character.description"></bbcode>
</div>
<character-kinks :character="character" :oldApi="oldApi" ref="tab0"></character-kinks>
</div>
<div role="tabpanel" v-show="tab === '1'">
<character-infotags :character="character" ref="tab1"></character-infotags>
</div>
<div role="tabpanel" v-show="tab === '2'" v-if="!oldApi">
<character-groups :character="character" ref="tab2"></character-groups>
</div>
<div role="tabpanel" v-show="tab === '3'">
<character-images :character="character" ref="tab3" :use-preview="imagePreview"></character-images>
</div>
<div v-if="character.settings.guestbook" role="tabpanel" v-show="tab === '4'">
<character-guestbook :character="character" :oldApi="oldApi" ref="tab4"></character-guestbook>
</div>
<div v-if="character.is_self || character.settings.show_friends" role="tabpanel" v-show="tab === '5'">
<character-friends :character="character" ref="tab5"></character-friends>
</div>
</div>
</div>
@ -68,18 +66,18 @@
<script lang="ts">
import {Component, Hook, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import {standardParser} from '../../bbcode/standard';
import * as Utils from '../utils';
import {methods, Store} from './data_store';
import {Character, SharedStore} from './interfaces';
import {StandardBBCodeParser} from '../../bbcode/standard';
import {BBCodeView} from '../../bbcode/view';
import DateDisplay from '../../components/date_display.vue';
import Tabs from '../../components/tabs';
import * as Utils from '../utils';
import {methods, Store} from './data_store';
import FriendsView from './friends.vue';
import GroupsView from './groups.vue';
import GuestbookView from './guestbook.vue';
import ImagesView from './images.vue';
import InfotagsView from './infotags.vue';
import {Character, SharedStore} from './interfaces';
import CharacterKinksView from './kinks.vue';
import Sidebar from './sidebar.vue';
@ -87,28 +85,25 @@
show?(): void
}
const standardParser = new StandardBBCodeParser();
@Component({
components: {
sidebar: Sidebar,
date: DateDisplay, tabs: Tabs,
'character-friends': FriendsView,
'character-guestbook': GuestbookView,
'character-groups': GroupsView,
'character-infotags': InfotagsView,
'character-images': ImagesView,
'character-kinks': CharacterKinksView
sidebar: Sidebar, date: DateDisplay, 'character-friends': FriendsView, 'character-guestbook': GuestbookView,
'character-groups': GroupsView, 'character-infotags': InfotagsView, 'character-images': ImagesView, tabs: Tabs,
'character-kinks': CharacterKinksView, bbcode: BBCodeView(standardParser)
}
})
export default class CharacterPage extends Vue {
@Prop()
@Prop
readonly name?: string;
@Prop()
readonly characterid?: number;
@Prop
readonly id?: number;
@Prop({required: true})
readonly authenticated!: boolean;
@Prop()
@Prop
readonly oldApi?: true;
@Prop()
@Prop
readonly imagePreview?: true;
shared: SharedStore = Store;
character: Character | undefined;
@ -143,20 +138,15 @@
Vue.set(this.character!, 'memo', memo);
}
bookmarked(state: boolean): void {
Vue.set(this.character!, 'bookmarked', state);
}
private async _getCharacter(): Promise<void> {
this.error = '';
this.character = undefined;
if(this.name === undefined || this.name.length === 0)
if((this.name === undefined || this.name.length === 0) && !this.id)
return;
try {
this.loading = true;
await methods.fieldsGet();
this.character = await methods.characterData(this.name, this.characterid);
standardParser.allowInlines = true;
this.character = await methods.characterData(this.name, this.id);
standardParser.inlines = this.character.character.inlines;
} catch(e) {
this.error = Utils.isJSONError(e) ? <string>e.response.data.error : (<Error>e).message;

View File

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

View File

@ -1,5 +1,5 @@
import {urlRegex as websitePattern} from '../../bbcode/core';
import {Store} from './data_store';
import {Infotag} from '../../interfaces';
const daUsernamePattern = /^([a-z0-9_\-]+)$/i;
const daSitePattern = /^https?:\/\/([a-z0-9_\-]+)\.deviantart\.com\//i;
@ -30,10 +30,7 @@ function normalizeSiteUsernamePair(site: RegExp, username: RegExp): (value: stri
};
}
export function formatContactValue(id: number, value: string): string {
const infotag = Store.kinks.infotags[id];
if(typeof infotag === 'undefined')
return value;
export function formatContactValue(infotag: Infotag, value: string): string {
const methodName = infotag.name.toLowerCase();
const formatters: {[key: string]: (() => string | undefined) | undefined} = {
deviantart(): string | undefined {
@ -56,10 +53,7 @@ export function formatContactValue(id: number, value: string): string {
return value;
}
export function formatContactLink(id: number, value: string): string | undefined {
const infotag = Store.kinks.infotags[id];
if(typeof infotag === 'undefined')
return;
export function formatContactLink(infotag: Infotag, value: string): string | undefined {
const methodName = infotag.name.toLowerCase();
const formatters: {[key: string]: (() => string | undefined) | undefined} = {
deviantart(): string | undefined {

View File

@ -38,7 +38,7 @@
name = '';
description = '';
choice: KinkChoice = 'favorite';
target = Utils.Settings.defaultCharacter;
target = Utils.settings.defaultCharacter;
formErrors = {};
submitting = false;

View File

@ -2,7 +2,7 @@ import {Component} from 'vue';
import {SharedStore, StoreMethods} from './interfaces';
export let Store: SharedStore = {
kinks: <any>undefined, //tslint:disable-line:no-any
shared: undefined!,
authenticated: false
};

View File

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

View File

@ -46,8 +46,8 @@
async checkName(): Promise<boolean> {
try {
this.checking = true;
const result = await methods.characterNameCheck(this.newName);
this.valid = result.valid;
await methods.characterNameCheck(this.newName);
this.valid = true;
this.errors = {};
return true;
} catch(e) {
@ -64,9 +64,8 @@
async duplicate(): Promise<void> {
try {
this.duplicating = true;
const result = await methods.characterDuplicate(this.character.character.id, this.newName);
await methods.characterDuplicate(this.character.character.id, this.newName);
this.hide();
window.location.assign(result.next);
} catch(e) {
Utils.ajaxError(e, 'Unable to duplicate character');
this.valid = false;

View File

@ -93,7 +93,7 @@
@Prop({required: true})
readonly character!: Character;
ourCharacter = Utils.Settings.defaultCharacter;
ourCharacter = Utils.settings.defaultCharacter;
incoming: FriendRequest[] = [];
pending: FriendRequest[] = [];
@ -113,7 +113,12 @@
try {
this.requesting = true;
const newRequest = await methods.friendRequest(this.character.character.id, this.ourCharacter);
this.pending.push(newRequest);
if(typeof newRequest === 'number')
this.pending.push({
id: newRequest, source: Utils.characters.find((x) => x.id === this.ourCharacter)!, target: this.character.character,
createdAt: Date.now() / 1000
});
else this.existing.push(newRequest);
} catch(e) {
if(Utils.isJSONError(e))
this.error = <string>e.response.data.error;

View File

@ -15,14 +15,15 @@
import Vue from 'vue';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, CharacterFriend} from './interfaces';
import {Character} from './interfaces';
import {SimpleCharacter} from '../../interfaces';
@Component
export default class FriendsView extends Vue {
@Prop({required: true})
private readonly character!: Character;
private shown = false;
friends: CharacterFriend[] = [];
friends: SimpleCharacter[] = [];
loading = true;
error = '';

View File

@ -5,11 +5,12 @@
<label v-show="canEdit" class="control-label">Unapproved only:
<input type="checkbox" v-model="unapprovedOnly"/>
</label>
<simple-pager :next="hasNextPage" :prev="page > 1" @next="nextPage" @prev="previousPage"></simple-pager>
<simple-pager :next="hasNextPage" :prev="page > 1" @next="++page" @prev="--page"></simple-pager>
</div>
<template v-if="!loading">
<div class="alert alert-info" v-show="posts.length === 0">No guestbook posts.</div>
<guestbook-post :post="post" :can-edit="canEdit" v-for="post in posts" :key="post.id" @reload="getPage"></guestbook-post>
<guestbook-post :character="character" :post="post" :can-edit="canEdit" v-for="post in posts" :key="post.id"
@reload="getPage"></guestbook-post>
<div v-if="authenticated && !oldApi" class="form-horizontal">
<bbcode-editor v-model="newPost.message" :maxlength="5000" classes="form-control"></bbcode-editor>
<input type="checkbox" id="guestbookPostPrivate" v-model="newPost.privatePost"/>
@ -20,7 +21,7 @@
</div>
</template>
<div class="guestbook-controls">
<simple-pager :next="hasNextPage" :prev="page > 1" @next="nextPage" @prev="previousPage"></simple-pager>
<simple-pager :next="hasNextPage" :prev="page > 1" @next="++page" @prev="--page"></simple-pager>
</div>
</div>
</template>
@ -40,14 +41,12 @@
export default class GuestbookView extends Vue {
@Prop({required: true})
readonly character!: Character;
@Prop()
@Prop
readonly oldApi?: true;
loading = true;
error = '';
authenticated = Store.authenticated;
posts: GuestbookPost[] = [];
unapprovedOnly = false;
page = 1;
hasNextPage = false;
@ -55,28 +54,18 @@
newPost = {
posting: false,
privatePost: false,
character: Utils.Settings.defaultCharacter,
character: Utils.settings.defaultCharacter,
message: ''
};
async nextPage(): Promise<void> {
this.page += 1;
return this.getPage();
}
async previousPage(): Promise<void> {
this.page -= 1;
return this.getPage();
}
@Watch('unapprovedOnly')
@Watch('page')
async getPage(): Promise<void> {
try {
this.loading = true;
const guestbookState = await methods.guestbookPageGet(this.character.character.id, this.page, this.unapprovedOnly);
this.posts = guestbookState.posts;
this.hasNextPage = guestbookState.nextPage;
this.canEdit = guestbookState.canEdit;
const guestbook = await methods.guestbookPageGet(this.character.character.id, (this.page - 1) * 10, 10, this.unapprovedOnly);
this.posts = guestbook.posts;
this.hasNextPage = (this.page + 1) * 10 < guestbook.total;
} catch(e) {
this.posts = [];
this.hasNextPage = false;

View File

@ -19,16 +19,15 @@
{{ (post.approved) ? 'Unapprove' : 'Approve' }}
</button>
</div>
<button class="btn btn-danger" v-show="!post.deleted && (canEdit || post.canEdit)"
@click="deletePost" :disabled="deleting">Delete
</button>
<!-- TODO proper permission handling -->
<button class="btn btn-danger" v-show="!post.deleted && canEdit" @click="deletePost" :disabled="deleting">Delete</button>
</div>
<div class="row">
<div class="col-12">
<div class="bbcode guestbook-message" v-bbcode="post.message"></div>
<bbcode class="bbcode guestbook-message" :text="post.message"></bbcode>
<div v-if="post.reply && !replyBox" class="guestbook-reply">
<date-display v-if="post.repliedAt" :time="post.repliedAt"></date-display>
<div class="reply-message" v-bbcode="post.reply"></div>
<bbcode class="reply-message" :text="post.reply"></bbcode>
</div>
</div>
</div>
@ -54,12 +53,14 @@
import DateDisplay from '../../components/date_display.vue';
import * as Utils from '../utils';
import {methods} from './data_store';
import {GuestbookPost} from './interfaces';
import {Character, GuestbookPost} from './interfaces';
@Component({
components: {'date-display': DateDisplay, 'character-link': CharacterLink}
})
export default class GuestbookPostView extends Vue {
@Prop({required: true})
readonly character!: Character;
@Prop({required: true})
readonly post!: GuestbookPost;
@Prop({required: true})
@ -79,7 +80,7 @@
async deletePost(): Promise<void> {
try {
this.deleting = true;
await methods.guestbookPostDelete(this.post.id);
await methods.guestbookPostDelete(this.character.character.id, this.post.id);
Vue.set(this.post, 'deleted', true);
this.$emit('reload');
} catch(e) {
@ -92,7 +93,7 @@
async approve(): Promise<void> {
try {
this.approving = true;
await methods.guestbookPostApprove(this.post.id, !this.post.approved);
await methods.guestbookPostApprove(this.character.character.id, this.post.id, !this.post.approved);
this.post.approved = !this.post.approved;
} catch(e) {
Utils.ajaxError(e, 'Unable to change post approval.');
@ -104,9 +105,9 @@
async postReply(): Promise<void> {
try {
this.replying = true;
const replyData = await methods.guestbookPostReply(this.post.id, this.replyMessage);
this.post.reply = replyData.reply;
this.post.repliedAt = replyData.repliedAt;
await methods.guestbookPostReply(this.character.character.id, this.post.id, this.replyMessage);
this.post.reply = this.replyMessage;
this.post.repliedAt = Date.now() / 1000;
this.replyBox = false;
} catch(e) {
Utils.ajaxError(e, 'Unable to post guestbook reply.');

View File

@ -28,7 +28,7 @@
export default class ImagesView extends Vue {
@Prop({required: true})
private readonly character!: Character;
@Prop()
@Prop
private readonly usePreview?: boolean;
private shown = false;
previewImage = '';

View File

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

View File

@ -1,9 +1,10 @@
<template>
<div class="infotags row">
<div class="infotag-group col-sm-3" v-for="group in groupedInfotags" :key="group.id" style="margin-top:5px">
<div class="infotag-group col-sm-3" v-for="group in groups" :key="group.id" style="margin-top:5px">
<div class="infotag-title">{{group.name}}</div>
<hr>
<infotag :infotag="infotag" v-for="infotag in group.infotags" :key="infotag.id"></infotag>
<infotag v-for="infotag in getInfotags(group.id)" :key="infotag.id" :infotag="infotag"
:data="character.character.infotags[infotag.id]"></infotag>
</div>
</div>
</template>
@ -11,66 +12,25 @@
<script lang="ts">
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import * as Utils from '../utils';
import {Infotag, InfotagGroup} from '../../interfaces';
import {Store} from './data_store';
import {Character, CONTACT_GROUP_ID, DisplayInfotag} from './interfaces';
import InfotagView from './infotag.vue';
interface DisplayInfotagGroup {
id: number
name: string
sortOrder: number
infotags: DisplayInfotag[]
}
import {Character} from './interfaces';
@Component({
components: {infotag: InfotagView}
})
export default class InfotagsView extends Vue {
@Prop({required: true})
private readonly character!: Character;
readonly character!: Character;
get groupedInfotags(): DisplayInfotagGroup[] {
const groups = Store.kinks.infotag_groups;
const infotags = Store.kinks.infotags;
const characterTags = this.character.character.infotags;
const outputGroups: DisplayInfotagGroup[] = [];
const groupedTags = Utils.groupObjectBy(infotags, 'infotag_group');
for(const groupId in groups) {
const group = groups[groupId]!;
const groupedInfotags = groupedTags[groupId];
if(groupedInfotags === undefined) continue;
const collectedTags: DisplayInfotag[] = [];
for(const infotag of groupedInfotags) {
const characterInfotag = characterTags[infotag.id];
if(typeof characterInfotag === 'undefined')
continue;
const newInfotag: DisplayInfotag = {
id: infotag.id,
isContact: infotag.infotag_group === CONTACT_GROUP_ID,
string: characterInfotag.string,
number: characterInfotag.number,
list: characterInfotag.list
};
collectedTags.push(newInfotag);
}
collectedTags.sort((a, b): number => {
const infotagA = infotags[a.id]!;
const infotagB = infotags[b.id]!;
return infotagA.name < infotagB.name ? -1 : 1;
});
outputGroups.push({
id: group.id,
name: group.name,
sortOrder: group.sort_order,
infotags: collectedTags
});
}
get groups(): {readonly [key: string]: Readonly<InfotagGroup>} {
return Store.shared.infotagGroups;
}
outputGroups.sort((a, b) => a.sortOrder < b.sortOrder ? -1 : 1);
return outputGroups.filter((a) => a.infotags.length > 0);
getInfotags(group: number): Infotag[] {
return Object.keys(Store.shared.infotags).map((x) => Store.shared.infotags[x])
.filter((x) => x.infotag_group === group && this.character.character.infotags[x.id] !== undefined);
}
}
</script>

View File

@ -1,33 +1,23 @@
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
}
export interface SelectItem {
text: string
value: string | number
}
import {
Character as CharacterInfo, CharacterImage, CharacterSettings, KinkChoice, SharedDefinitions, SimpleCharacter
} from '../../interfaces';
export interface SharedStore {
kinks: SharedKinks
shared: SharedDefinitions
authenticated: boolean
}
export interface StoreMethods {
bookmarkUpdate(id: number, state: boolean): Promise<boolean>
bookmarkUpdate(id: number, state: boolean): Promise<void>
characterBlock?(id: number, block: boolean, reason?: string): Promise<void>
characterCustomKinkAdd(id: number, name: string, description: string, choice: KinkChoice): Promise<void>
characterData(name: string | undefined, id: number | undefined): Promise<Character>
characterDelete(id: number): Promise<void>
characterDuplicate(id: number, name: string): Promise<DuplicateResult>
characterDuplicate(id: number, name: string): Promise<void>
characterFriends(id: number): Promise<FriendsByCharacter>
characterNameCheck(name: string): Promise<CharacterNameCheckResult>
characterRename?(id: number, name: string, renamedFor?: string): Promise<RenameResult>
characterNameCheck(name: string): Promise<void>
characterRename?(id: number, name: string, renamedFor?: string): Promise<void>
characterReport(reportData: CharacterReportData): Promise<void>
contactMethodIconUrl(name: string): string
@ -36,20 +26,20 @@ export interface StoreMethods {
fieldsGet(): Promise<void>
friendDissolve(friend: Friend): Promise<void>
friendRequest(target: number, source: number): Promise<FriendRequest>
friendRequest(target: number, source: number): Promise<Friend | number>
friendRequestAccept(request: FriendRequest): Promise<Friend>
friendRequestIgnore(request: FriendRequest): Promise<void>
friendRequestCancel(request: FriendRequest): Promise<void>
friendsGet(id: number): Promise<CharacterFriend[]>
friendsGet(id: number): Promise<SimpleCharacter[]>
groupsGet(id: number): Promise<CharacterGroup[]>
guestbookPageGet(id: number, page: number, unapproved: boolean): Promise<GuestbookState>
guestbookPostApprove(id: number, approval: boolean): Promise<void>
guestbookPostDelete(id: number): Promise<void>
guestbookPageGet(character: number, offset?: number, limit?: number, unapproved_only?: boolean): Promise<Guestbook>
guestbookPostApprove(character: number, id: number, approval: boolean): Promise<void>
guestbookPostDelete(character: number, id: number): Promise<void>
guestbookPostPost(target: number, source: number, message: string, privatePost: boolean): Promise<void>
guestbookPostReply(id: number, reply: string | null): Promise<GuestbookReply>
guestbookPostReply(character: number, id: number, reply: string | null): Promise<void>
hasPermission?(permission: string): boolean
@ -59,20 +49,12 @@ export interface StoreMethods {
kinksGet(id: number): Promise<CharacterKink[]>
memoUpdate(id: number, memo: string): Promise<MemoReply>
}
export interface SharedKinks {
kinks: {[key: string]: Kink | undefined}
kink_groups: {[key: string]: KinkGroup | undefined}
infotags: {[key: string]: Infotag | undefined}
infotag_groups: {[key: string]: InfotagGroup | undefined}
listitems: {[key: string]: ListItem | undefined}
memoUpdate(id: number, memo: string): Promise<void>
}
export type SiteDate = number | string | null;
export type KinkChoiceFull = KinkChoice | number;
export const CONTACT_GROUP_ID = '1';
export const CONTACT_GROUP_ID = 1;
export interface DisplayKink {
id: number
@ -84,40 +66,7 @@ export interface DisplayKink {
hasSubkinks: boolean
subkinks: DisplayKink[]
ignore: boolean
}
export interface DisplayInfotag {
id: number
isContact: boolean
string?: string
number?: number | null
list?: number
}
export interface KinkGroup {
id: number
name: string
description: string
sort_order: number
}
export interface InfotagGroup {
id: number
name: string
description: string
sort_order: number
}
export interface ListItem {
id: number
name: string
value: string
sort_order: number
}
export interface CharacterFriend {
id: number
name: string
key: string
}
export interface CharacterKink {
@ -125,14 +74,6 @@ export interface CharacterKink {
choice: KinkChoice
}
export type CharacterName = string | CharacterNameDetails;
export interface CharacterNameDetails {
id: number
name: string
deleted: boolean
}
export type ThreadOrderMode = 'post' | 'explicit';
export interface GroupPermissions {
@ -151,7 +92,7 @@ export interface CharacterGroup {
orderMode: ThreadOrderMode
createdAt: SiteDate
myPermissions: GroupPermissions
character: CharacterName
character: SimpleCharacter
owner: boolean
}
@ -178,7 +119,7 @@ export interface Character {
export interface GuestbookPost {
readonly id: number
readonly character: CharacterNameDetails
readonly character: SimpleCharacter
approved: boolean
readonly private: boolean
postedAt: SiteDate
@ -189,33 +130,9 @@ export interface GuestbookPost {
deleted?: boolean
}
export interface GuestbookReply {
readonly reply: string
readonly postId: number
readonly repliedAt: SiteDate
}
export interface GuestbookState {
posts: GuestbookPost[]
readonly nextPage: boolean
readonly canEdit: boolean
}
export interface MemoReply {
readonly id: number
readonly memo: string
readonly updated_at: SiteDate
}
export interface DuplicateResult {
// Url to redirect user to when duplication is complete.
readonly next: string
}
export type RenameResult = DuplicateResult;
export interface CharacterNameCheckResult {
valid: boolean
export interface Guestbook {
readonly posts: GuestbookPost[]
readonly total: number
}
export interface CharacterReportData {
@ -229,8 +146,8 @@ export interface CharacterReportData {
export interface Friend {
id: number
source: CharacterNameDetails
target: CharacterNameDetails
source: SimpleCharacter
target: SimpleCharacter
createdAt: SiteDate
}

View File

@ -1,5 +1,5 @@
<template>
<div class="character-kink" :class="kinkClasses" :id="kinkId" @click="toggleSubkinks" :data-custom="customId"
<div class="character-kink" :class="kinkClasses" :id="kink.key" @click="toggleSubkinks"
@mouseover.stop="showTooltip = true" @mouseout.stop="showTooltip = false">
<i v-show="kink.hasSubkinks" class="fa" :class="{'fa-minus': !listClosed, 'fa-plus': listClosed}"></i>
<i v-show="!kink.hasSubkinks && kink.isCustom" class="far fa-dot-circle custom-kink-icon"></i>
@ -42,10 +42,6 @@
this.listClosed = !this.listClosed;
}
get kinkId(): number {
return this.kink.isCustom ? -this.kink.id : this.kink.id;
}
get kinkClasses(): {[key: string]: boolean} {
const classes: {[key: string]: boolean} = {
'stock-kink': !this.kink.isCustom,
@ -53,7 +49,7 @@
highlighted: !this.kink.isCustom && this.highlights[this.kink.id],
subkink: this.kink.hasSubkinks
};
classes[`kink-id-${this.kinkId}`] = true;
classes[`kink-id-${this.kink.key}`] = true;
classes[`kink-group-${this.kink.group}`] = true;
if(!this.kink.isCustom && typeof this.comparisons[this.kink.id] !== 'undefined')
classes[`comparison-${this.comparisons[this.kink.id]}`] = true;

View File

@ -21,7 +21,7 @@
<h4>Favorites</h4>
</div>
<div class="card-body">
<kink v-for="kink in groupedKinks['favorite']" :kink="kink" :key="kink.id" :highlights="highlighting"
<kink v-for="kink in groupedKinks['favorite']" :kink="kink" :key="kink.key" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
@ -32,7 +32,7 @@
<h4>Yes</h4>
</div>
<div class="card-body">
<kink v-for="kink in groupedKinks['yes']" :kink="kink" :key="kink.id" :highlights="highlighting"
<kink v-for="kink in groupedKinks['yes']" :kink="kink" :key="kink.key" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
@ -43,7 +43,7 @@
<h4>Maybe</h4>
</div>
<div class="card-body">
<kink v-for="kink in groupedKinks['maybe']" :kink="kink" :key="kink.id" :highlights="highlighting"
<kink v-for="kink in groupedKinks['maybe']" :kink="kink" :key="kink.key" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
@ -54,7 +54,7 @@
<h4>No</h4>
</div>
<div class="card-body">
<kink v-for="kink in groupedKinks['no']" :kink="kink" :key="kink.id" :highlights="highlighting"
<kink v-for="kink in groupedKinks['no']" :kink="kink" :key="kink.key" :highlights="highlighting"
:comparisons="comparison"></kink>
</div>
</div>
@ -67,11 +67,11 @@
<script lang="ts">
import {Component, Prop, Watch} from '@f-list/vue-ts';
import Vue from 'vue';
import {Kink, KinkChoice} from '../../interfaces';
import {Kink, KinkChoice, KinkGroup} from '../../interfaces';
import * as Utils from '../utils';
import CopyCustomMenu from './copy_custom_menu.vue';
import {methods, Store} from './data_store';
import {Character, DisplayKink, KinkGroup} from './interfaces';
import {Character, DisplayKink} from './interfaces';
import KinkView from './kink.vue';
@Component({
@ -80,10 +80,10 @@
export default class CharacterKinksView extends Vue {
@Prop({required: true})
readonly character!: Character;
@Prop()
@Prop
readonly oldApi?: true;
shared = Store;
characterToCompare = Utils.Settings.defaultCharacter;
characterToCompare = Utils.settings.defaultCharacter;
highlightGroup: number | undefined;
loading = false;
@ -120,8 +120,8 @@
this.highlighting = {};
if(group === null) return;
const toAssign: {[key: string]: boolean} = {};
for(const kinkId in this.shared.kinks.kinks) {
const kink = this.shared.kinks.kinks[kinkId]!;
for(const kinkId in Store.shared.kinks) {
const kink = Store.shared.kinks[kinkId];
if(kink.kink_group === group)
toAssign[kinkId] = true;
}
@ -129,7 +129,7 @@
}
get kinkGroups(): {[key: string]: KinkGroup | undefined} {
return this.shared.kinks.kink_groups;
return Store.shared.kinkGroups;
}
get compareButtonText(): string {
@ -139,7 +139,7 @@
}
get groupedKinks(): {[key in KinkChoice]: DisplayKink[]} {
const kinks = this.shared.kinks.kinks;
const kinks = Store.shared.kinks;
const characterKinks = this.character.character.kinks;
const characterCustoms = this.character.character.customs;
const displayCustoms: {[key: string]: DisplayKink | undefined} = {};
@ -152,7 +152,8 @@
isCustom: false,
hasSubkinks: false,
ignore: false,
subkinks: []
subkinks: [],
key: kink.id.toString()
});
const kinkSorter = (a: DisplayKink, b: DisplayKink) => {
if(this.character.settings.customs_first && a.isCustom !== b.isCustom)
@ -174,13 +175,14 @@
isCustom: true,
hasSubkinks: false,
ignore: false,
subkinks: []
subkinks: [],
key: `c${custom.id}`
};
}
for(const kinkId in characterKinks) {
const kinkChoice = characterKinks[kinkId]!;
const kink = kinks[kinkId];
const kink = <Kink | undefined>kinks[kinkId];
if(kink === undefined) continue;
const newKink = makeKink(kink);
if(typeof kinkChoice === 'number' && typeof displayCustoms[kinkChoice] !== 'undefined') {

View File

@ -33,7 +33,7 @@
export default class MemoDialog extends CustomDialog {
@Prop({required: true})
readonly character!: {id: number, name: string};
@Prop()
@Prop
readonly memo?: Memo;
message = '';
editing = false;

View File

@ -2,12 +2,27 @@
<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>
<select v-model="type" class="form-control">
<option>None</option>
<option value="profile">Profile Violation</option>
<option value="name_request">Name Request</option>
<option value="takedown">Art Takedown</option>
<option value="other">Other</option>
</select>
</div>
<div v-if="type !== 'takedown'">
<div class="form-group" v-if="type === 'profile'">
<label>Violation Type</label>
<select v-select="violationTypes" v-model="violation" class="form-control"></select>
<select v-model="violation" class="form-control">
<option>Real life images on underage character</option>
<option>Real life animal images on sexual character</option>
<option>Amateur/farmed real life images</option>
<option>Defamation</option>
<option>OOC Kinks</option>
<option>Real life contact information</option>
<option>Solicitation for real life contact</option>
<option>Other</option>
</select>
</div>
<div class="form-group">
<label>Your Character</label>
@ -30,7 +45,7 @@
import Modal from '../../components/Modal.vue';
import * as Utils from '../utils';
import {methods} from './data_store';
import {Character, SelectItem} from './interfaces';
import {Character} from './interfaces';
@Component({
components: {modal: Modal}
@ -38,34 +53,13 @@
export default class ReportDialog extends CustomDialog {
@Prop({required: true})
readonly character!: Character;
ourCharacter = Utils.Settings.defaultCharacter;
ourCharacter = Utils.settings.defaultCharacter;
type = '';
violation = '';
message = '';
submitting = false;
ticketUrl = `${Utils.siteDomain}tickets/new`;
validTypes: ReadonlyArray<SelectItem> = [
{text: 'None', value: ''},
{text: 'Profile Violation', value: 'profile'},
{text: 'Name Request', value: 'name_request'},
{text: 'Art Takedown', value: 'takedown'},
{text: 'Other', value: 'other'}
];
violationTypes: ReadonlyArray<string> = [
'Real life images on underage character',
'Real life animal images on sexual character',
'Amateur/farmed real life images',
'Defamation',
'OOC Kinks',
'Real life contact information',
'Solicitation for real life contact',
'Other'
];
get name(): string {
return this.character.character.name;
}

View File

@ -16,8 +16,8 @@
</template>
<template v-else>
<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">
<a @click.prevent="toggleBookmark()" href="#" class="btn"
:class="{bookmarked: character.bookmarked, unbookmarked: !character.bookmarked}">
<i class="fa fa-fw" :class="{'fa-minus': character.bookmarked, 'fa-plus': !character.bookmarked}"></i>Bookmark
</a>
<span v-if="character.settings.block_bookmarks" class="prevents-bookmarks">!</span>
@ -39,11 +39,13 @@
<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>
<contact-method v-for="method in contactMethods" :infotag="method" :key="method.id"
:data="character.character.infotags[method.id]"></contact-method>
</div>
<div class="quick-info-block">
<infotag-item v-for="infotag in quickInfoItems" :infotag="infotag" :key="infotag.id"></infotag-item>
<infotag-item v-for="id in quickInfoIds" v-if="character.character.infotags[id]" :infotag="getInfotag(id)"
:data="character.character.infotags[id]" :key="id"></infotag-item>
<div class="quick-info">
<span class="quick-info-label">Created: </span>
<span class="quick-info-value"><date :time="character.character.created_at"></date></span>
@ -99,7 +101,7 @@
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 {Character, CONTACT_GROUP_ID} from './interfaces';
import MemoDialog from './memo_dialog.vue';
import ReportDialog from './report_dialog.vue';
@ -139,9 +141,8 @@
export default class Sidebar extends Vue {
@Prop({required: true})
readonly character!: Character;
@Prop()
@Prop
readonly oldApi?: true;
readonly shared: SharedStore = Store;
readonly quickInfoIds: ReadonlyArray<number> = [1, 3, 2, 49, 9, 29, 15, 41, 25]; // Do not sort these.
readonly avatarUrl = Utils.avatarURL;
@ -209,20 +210,16 @@
}
async toggleBookmark(): Promise<void> {
const previousState = this.character.bookmarked;
try {
const state = !this.character.bookmarked;
this.$emit('bookmarked', state);
const actualState = await methods.bookmarkUpdate(this.character.character.id, state);
this.$emit('bookmarked', actualState);
await methods.bookmarkUpdate(this.character.character.id, !this.character.bookmarked);
this.character.bookmarked = !this.character.bookmarked;
} catch(e) {
this.$emit('bookmarked', previousState);
Utils.ajaxError(e, 'Unable to change bookmark state.');
}
}
get editUrl(): string {
return `${Utils.siteDomain}character/${this.character.character.id}/`;
return `${Utils.siteDomain}character/${this.character.character.id}/edit`;
}
get noteUrl(): string {
@ -230,33 +227,13 @@
}
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 = [];
for(const infotag of contactInfotags[CONTACT_GROUP_ID]!) {
const charTag = this.character.character.infotags[infotag.id];
if(charTag === undefined) continue;
contactMethods.push({
id: infotag.id,
value: charTag.string
});
}
return contactMethods;
return Object.keys(Store.shared.infotags).map((x) => Store.shared.infotags[x])
.filter((x) => x.infotag_group === CONTACT_GROUP_ID && this.character.character.infotags[x.id] !== undefined)
.sort((a, b) => a.name < b.name ? -1 : 1);
}
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];
if(infotag === undefined) continue;
quickItems.push({
id,
string: infotag.string,
list: infotag.list,
number: infotag.number
});
}
return quickItems;
getInfotag(id: number): Infotag {
return Store.shared.infotags[id];
}
get authenticated(): boolean {

View File

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

View File

@ -1,18 +1,14 @@
import Axios, {AxiosError, AxiosResponse} from 'axios';
import {InlineDisplayMode} from '../bbcode/interfaces';
import {InlineDisplayMode, Settings, SimpleCharacter} from '../interfaces';
interface Dictionary<T> {
[key: string]: T | undefined;
}
type FlashMessageType = 'info' | 'success' | 'warning' | 'danger';
type FlashMessageImpl = (type: FlashMessageType, message: string) => void;
type flashMessageType = 'info' | 'success' | 'warning' | 'danger';
type flashMessageImpl = (type: flashMessageType, message: string) => void;
let flashImpl: flashMessageImpl = (type: flashMessageType, message: string) => {
let flashImpl: FlashMessageImpl = (type: FlashMessageType, message: string) => {
console.log(`${type}: ${message}`);
};
export function setFlashMessageImplementation(impl: flashMessageImpl): void {
export function setFlashMessageImplementation(impl: FlashMessageImpl): void {
flashImpl = impl;
}
@ -28,32 +24,6 @@ export function characterURL(name: string): string {
return `${siteDomain}c/${name}`;
}
export function groupObjectBy<K extends string, T extends {[k in K]: string}>(obj: Dictionary<T>, key: K): Dictionary<T[]> {
const newObject: Dictionary<T[]> = {};
for(const objkey in obj) {
if(!(objkey in obj)) continue;
const realItem = <T>obj[objkey];
const newKey = realItem[key];
if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
newObject[<string>newKey]!.push(realItem);
}
return newObject;
}
export function groupArrayBy<K extends string, T extends {[k in K]: string}>(arr: T[], key: K): Dictionary<T[]> {
const newObject: Dictionary<T[]> = {};
arr.map((item) => {
const newKey = item[key];
if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
newObject[<string>newKey]!.push(item);
});
return newObject;
}
export function filterOut<K extends string, V, T extends {[key in K]: V}>(arr: ReadonlyArray<T>, field: K, value: V): T[] {
return arr.filter((item) => item[field] !== value);
}
//tslint:disable-next-line:no-any
export function isJSONError(error: any): error is Error & {response: AxiosResponse<{[key: string]: object | string | number}>} {
return (<AxiosError>error).response !== undefined && typeof (<AxiosError>error).response!.data === 'object';
@ -75,6 +45,7 @@ export function ajaxError(error: any, prefix: string, showFlashMessage: boolean
message = (<Error & {response?: AxiosResponse}>error).response !== undefined ?
(<Error & {response: AxiosResponse}>error).response.statusText : error.name;
} else message = <string>error;
console.error(error);
if(showFlashMessage) flashError(`[ERROR] ${prefix}: ${message}`);
}
@ -86,35 +57,28 @@ export function flashSuccess(message: string): void {
flashMessage('success', message);
}
export function flashMessage(type: flashMessageType, message: string): void {
export function flashMessage(type: FlashMessageType, message: string): void {
flashImpl(type, message);
}
export let siteDomain = '';
export let staticDomain = '';
interface Settings {
animatedIcons: boolean
inlineDisplayMode: InlineDisplayMode
defaultCharacter: number
fuzzyDates: boolean
}
export let Settings: Settings = {
animatedIcons: false,
export let settings: Settings = {
animateEicons: true,
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL,
defaultCharacter: -1,
fuzzyDates: true
};
export let characters: SimpleCharacter[] = [];
export function setDomains(site: string, stat: string): void {
siteDomain = site;
staticDomain = stat;
}
export function copySettings(settings: Settings): void {
Settings.animatedIcons = settings.animatedIcons;
Settings.inlineDisplayMode = settings.inlineDisplayMode;
Settings.defaultCharacter = settings.defaultCharacter;
Settings.fuzzyDates = settings.fuzzyDates;
export function init(s: Settings, c: SimpleCharacter[]): void {
settings = s;
characters = c;
}

View File

@ -119,6 +119,7 @@
true,
"allow-declarations"
],
"ordered-imports": true,
"prefer-function-over-method": [
true,
"allow-public"
@ -138,7 +139,8 @@
true,
"never"
],
"strict-boolean-expressions": [true, "allow-boolean-or-undefined"],
"strict-boolean-expressions": false,
"strict-comparisons": false,
"switch-default": false,
"trailing-comma": [
true,

View File

@ -36,6 +36,7 @@ import l from '../chat/localize';
import {setupRaven} from '../chat/vue-raven';
import Socket from '../chat/WebSocket';
import Connection from '../fchat/connection';
import {SimpleCharacter} from '../interfaces';
import '../scss/fa.scss'; //tslint:disable-line:no-import-side-effect
import {Logs, SettingsStore} from './logs';
import Notifications from './notifications';
@ -49,7 +50,7 @@ Axios.defaults.params = { __fchat: `web/${version}` };
if(process.env.NODE_ENV === 'production')
setupRaven('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', `web-${version}`);
declare const chatSettings: {account: string, theme: string, characters: ReadonlyArray<string>, defaultCharacter: string | null};
declare const chatSettings: {account: string, theme: string, characters: ReadonlyArray<SimpleCharacter>, defaultCharacter: number | null};
const ticketProvider = async() => {
const data = (await Axios.post<{ticket?: string, error: string}>(
@ -58,7 +59,8 @@ const ticketProvider = async() => {
throw new Error(data.error);
};
const connection = new Connection('F-Chat 3.0 (Web)', version, Socket, chatSettings.account, ticketProvider);
const connection = new Connection('F-Chat 3.0 (Web)', version, Socket);
connection.setCredentials(chatSettings.account, ticketProvider);
initCore(connection, Logs, SettingsStore, Notifications);
window.addEventListener('beforeunload', (e) => {

View File

@ -120,7 +120,7 @@ export class Logs implements Logging {
if(character === this.loadedCharacter) return this.loadedIndex!;
if(character === core.connection.character) {
this.loadedDb = this.db;
this.loadedIndex = this.index !== undefined ? this.index : {};
this.loadedIndex = this.index;
} else
try {
this.loadedDb = await openDatabase(character);
@ -130,7 +130,7 @@ export class Logs implements Logging {
return {};
}
this.loadedCharacter = character;
return this.loadedIndex;
return this.loadedIndex!;
}
async getConversations(character: string): Promise<ReadonlyArray<{key: string, name: string}>> {
@ -179,7 +179,7 @@ export class SettingsStore implements Settings.Store {
const pinned: Settings.Keys['pinned'] = {channels: [], private: []};
pinned.channels = tabs.filter((x) => x.type === 'channel').map((x) => x.id.toLowerCase());
pinned.private = tabs.filter((x) => x.type === 'user').map((x) => x.title);
return pinned;
return pinned as Settings.Keys[K];
} catch {
return undefined;
}
@ -203,7 +203,7 @@ export class SettingsStore implements Settings.Store {
settings.playSound = old.html5Audio;
settings.joinMessages = old.joinLeaveAlerts;
settings.clickOpensMessage = !old.leftClickOpensFlist;
return settings;
return settings as unknown as Settings.Keys[K];
} catch {
return undefined;
}

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