0.2.19 - Lots of polish for stable release.

This commit is contained in:
MayaWolf 2018-04-08 02:22:32 +02:00
parent 4a7d97f17a
commit 79d1ee4f48
63 changed files with 1159 additions and 673 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 F-List
Copyright (c) 2018 F-List
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,16 +1,16 @@
<template>
<div class="bbcode-editor">
<slot></slot>
<a tabindex="0" class="btn btn-secondary bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false"
<a tabindex="0" class="btn btn-light bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false"
style="border-bottom-left-radius:0;border-bottom-right-radius:0">
<i class="fa fa-code"></i>
</a>
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? 'display:flex' : ''" @mousedown.stop.prevent>
<div class="btn-group" style="flex-wrap:wrap">
<div class="btn btn-secondary btn-sm" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<div class="btn btn-light btn-sm" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<i :class="(button.class ? button.class : 'fa ') + button.icon"></i>
</div>
<div @click="previewBBCode" class="btn btn-secondary btn-sm" :class="preview ? 'active' : ''"
<div @click="previewBBCode" class="btn btn-light btn-sm" :class="preview ? 'active' : ''"
:title="preview ? 'Close Preview' : 'Preview'">
<i class="fa fa-eye"></i>
</div>

View File

@ -26,8 +26,8 @@ export class CoreBBCodeParser extends BBCodeParser {
this.addTag(new BBCodeSimpleTag('u', 'u'));
this.addTag(new BBCodeSimpleTag('s', 'del'));
this.addTag(new BBCodeSimpleTag('noparse', 'span', [], []));
this.addTag(new BBCodeSimpleTag('sub', 'sub', [], ['b', 'i', 'u', 's']));
this.addTag(new BBCodeSimpleTag('sup', 'sup', [], ['b', 'i', 'u', 's']));
this.addTag(new BBCodeSimpleTag('sub', 'sub', [], ['b', 'i', 'u', 's', 'color']));
this.addTag(new BBCodeSimpleTag('sup', 'sup', [], ['b', 'i', 'u', 's', 'color']));
this.addTag(new BBCodeCustomTag('color', (parser, parent, param) => {
const cregex = /^(red|blue|white|yellow|pink|gray|green|orange|purple|black|brown|cyan)$/;
if(!cregex.test(param)) {

View File

@ -181,8 +181,8 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
el.href = '#';
el.dataset.inlineId = param;
el.onclick = () => {
Array.from(document.getElementsByClassName('unloadedInline')).forEach((e) => {
const showInline = parser.inlines![(<HTMLElement>e).dataset.inlineId!];
(<HTMLElement[]>Array.prototype.slice.call(document.getElementsByClassName('unloadedInline'))).forEach((e) => {
const showInline = parser.inlines![e.dataset.inlineId!];
if(typeof showInline !== 'object') return;
e.parentElement!.replaceChild(parser.createInline(showInline), e);
});

View File

@ -3,9 +3,12 @@
<div style="display:flex;flex-direction:column">
<tabs style="flex-shrink:0" :tabs="[l('channelList.public'), l('channelList.private')]" v-model="tab"></tabs>
<div style="display: flex; flex-direction: column">
<div style="display:flex; padding: 10px 0; flex-shrink: 0;">
<div class="input-group" style="padding:10px 0;flex-shrink:0">
<div class="input-group-prepend">
<div class="input-group-text"><span class="fas fa-search"></span></div>
</div>
<input class="form-control" style="flex:1; margin-right:10px;" v-model="filter" :placeholder="l('filter')"/>
<a href="#" @click.prevent="sortCount = !sortCount">
<a href="#" @click.prevent="sortCount = !sortCount" style="align-self:center">
<span class="fa fa-2x" :class="{'fa-sort-amount-down': sortCount, 'fa-sort-alpha-down': !sortCount}"></span>
</a>
</div>
@ -25,8 +28,11 @@
</label>
</div>
</div>
<div style="display:flex; padding: 10px 0; flex-shrink: 0;">
<input class="form-control" style="flex:1; margin-right:10px;" v-model="createName"
<div class="input-group" style="padding:10px 0;flex-shrink:0">
<div class="input-group-prepend">
<div class="input-group-text"><span class="fas fa-plus"></span></div>
</div>
<input class="form-control" style="flex:1;margin-right:10px" v-model="createName"
:placeholder="l('channelList.createName')"/>
<button class="btn btn-primary" @click="create">{{l('channelList.create')}}</button>
</div>

View File

@ -2,7 +2,12 @@
<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">
<div class="alert alert-danger" v-show="error">{{error}}</div>
<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="$refs['logsDialog'].show()" 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">
<h4 class="card-title">{{l('login.selectCharacter')}}</h4>
<select v-model="selectedCharacter" class="form-control custom-select">
@ -21,6 +26,8 @@
<div class="alert alert-danger" v-show="error">{{error}}</div>
{{l('chat.disconnected')}}
</modal>
<logs ref="logsDialog"></logs>
<div v-if="version && !connected" style="position:absolute;bottom:0;right:0">{{version}}</div>
</div>
</template>
@ -37,6 +44,7 @@
import Conversations from './conversations';
import core from './core';
import l from './localize';
import Logs from './Logs.vue';
type BBCodeNode = Node & {bbcodeTag?: string, bbcodeParam?: string, bbcodeHide?: boolean};
@ -71,7 +79,7 @@
}
@Component({
components: {chat: ChatView, modal: Modal}
components: {chat: ChatView, modal: Modal, logs: Logs}
})
export default class Chat extends Vue {
@Prop({required: true})
@ -79,6 +87,8 @@
@Prop({required: true})
readonly defaultCharacter!: string | undefined;
selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions
@Prop()
readonly version?: string;
error = '';
connecting = false;
connected = false;
@ -86,11 +96,7 @@
copyPlain = false;
mounted(): void {
window.addEventListener('beforeunload', (e) => {
if(!this.connected) return;
e.returnValue = l('chat.confirmLeave');
return l('chat.confirmLeave');
});
document.title = l('title', core.connection.character);
document.addEventListener('copy', ((e: ClipboardEvent) => {
if(this.copyPlain) {
this.copyPlain = false;
@ -111,6 +117,7 @@
if(getKey(e) === Keys.KeyC && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) {
this.copyPlain = true;
document.execCommand('copy');
e.preventDefault();
}
});
core.register('characters', Characters(core.connection));
@ -121,6 +128,7 @@
if(this.connected) core.notifications.playSound('logout');
this.connected = false;
this.connecting = false;
document.title = l('title');
});
core.connection.onEvent('connecting', async() => {
this.connecting = true;
@ -132,11 +140,14 @@
this.connecting = false;
this.connected = true;
core.notifications.playSound('login');
document.title = l('title.connected', core.connection.character);
});
core.watch(() => core.conversations.hasNew, (hasNew) => {
document.title = (hasNew ? '💬 ' : '') + l(core.connection.isOpen ? 'title.connected' : 'title', core.connection.character);
});
core.connection.onError((e) => {
this.error = errorToString(e);
this.connecting = false;
this.connected = false;
});
}
@ -147,9 +158,11 @@
connect(): void {
this.connecting = true;
core.connection.connect(this.selectedCharacter).catch((e) => {
if((<Error & {request?: object}>e).request !== undefined) this.error = l('login.connectError'); //catch axios network errors
else throw e;
core.connection.connect(this.selectedCharacter).catch((e: Error) => {
if((<Error & {request?: object}>e).request !== undefined) {//catch axios network errors
this.error = l('login.connectError', e.message);
this.connecting = false;
} else throw e;
});
}
}

View File

@ -5,20 +5,20 @@
<sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars">
<img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/>
<a href="#" target="_blank" :href="ownCharacterLink" class="btn" style="margin-right:5px">{{ownCharacter.name}}</a>
<a href="#" @click.prevent="logOut" class="btn"><i class="fa fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
<a href="#" @click.prevent="logOut" class="btn"><i class="fas fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/>
<div>
{{l('chat.status')}}
<a href="#" @click.prevent="$refs['statusDialog'].show()" class="btn">
<span class="fa fa-fw" :class="getStatusIcon(ownCharacter.status)"></span>{{l('status.' + ownCharacter.status)}}
<span class="fas fa-fw" :class="getStatusIcon(ownCharacter.status)"></span>{{l('status.' + ownCharacter.status)}}
</a>
</div>
<div style="clear:both">
<a href="#" @click.prevent="$refs['searchDialog'].show()" class="btn"><span class="fa fa-search"></span>
<a href="#" @click.prevent="$refs['searchDialog'].show()" class="btn"><span class="fas fa-search"></span>
{{l('characterSearch.open')}}</a>
</div>
<div><a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn"><span class="fa fa-cog"></span>
<div><a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn"><span class="fas fa-cog"></span>
{{l('settings.open')}}</a></div>
<div><a href="#" @click.prevent="$refs['recentDialog'].show()" class="btn"><span class="fa fa-history"></span>
<div><a href="#" @click.prevent="$refs['recentDialog'].show()" class="btn"><span class="fas fa-history"></span>
{{l('chat.recentConversations')}}</a></div>
<div class="list-group conversation-nav">
<a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()"
@ -34,25 +34,25 @@
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<div class="name">
<span>{{conversation.character.name}}</span>
<div style="text-align:right;line-height:0">
<span class="fas"
<div style="line-height:0;display:flex">
<span class="fas fa-reply" v-show="needsReply(conversation)"></span><span class="fas"
:class="{'fa-comment-dots': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="fa fa-reply" v-show="needsReply(conversation)"></span>
<span class="pin fa fa-thumbtack" :class="{'active': conversation.isPinned}" @mousedown.prevent
></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="fa fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
<span class="fas fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</div>
</div>
</a>
</div>
<a href="#" @click.prevent="$refs['channelsDialog'].show()" class="btn"><span class="fa fa-list"></span>
<a href="#" @click.prevent="$refs['channelsDialog'].show()" class="btn"><span class="fas fa-list"></span>
{{l('chat.channels')}}</a>
<div class="list-group conversation-nav" ref="channelConversations">
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel"
:key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fa fa-thumbtack"
:key="conversation.key"><span class="name">{{conversation.name}}</span><span><span class="pin fas fa-thumbtack"
:class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fas fa-times leave"
@click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
</a>
</div>
@ -61,7 +61,7 @@
<div id="quick-switcher" class="list-group">
<a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()"
class="list-group-item list-group-item-action">
<span class="fa fa-home conversation-icon"></span>
<span class="fas fa-home conversation-icon"></span>
{{conversations.consoleTab.name}}
</a>
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
@ -72,7 +72,7 @@
</a>
<a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key">
<span class="fa fa-hashtag conversation-icon"></span>
<span class="fas fa-hashtag conversation-icon"></span>
<div class="name">{{conversation.name}}</div>
</a>
</div>
@ -250,11 +250,9 @@
for(const selector of selectorList)
sheet.insertRule(`${selector} { font-size: ${fontSize}px; }`, sheet.cssRules.length);
const lineHeightBase = 1.428571429;
const lineHeight = Math.floor(fontSize * 1.428571429);
const formHeight = (lineHeight + (6 * 2) + 2);
sheet.insertRule(`.form-control { line-height: ${lineHeightBase}; height: ${formHeight}px; }`, sheet.cssRules.length);
sheet.insertRule(`select.form-control { line-height: ${lineHeightBase}; height: ${formHeight}px; }`, sheet.cssRules.length);
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);
}
logOut(): void {
@ -307,12 +305,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.fa {
.fas {
font-size: 16px;
padding: 0 3px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}

View File

@ -1,5 +1,5 @@
<template>
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @close="init()">
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @close="init()" dialogClass="w-100">
<div class="form-group">
<label class="control-label" :for="'notify' + conversation.key">{{l('conversationSettings.notify')}}</label>
<select class="form-control" :id="'notify' + conversation.key" v-model="notify">

View File

@ -1,5 +1,5 @@
<template>
<div style="height:100%; display:flex; flex-direction:column; flex:1; margin:0 5px; position:relative;" id="conversation">
<div style="height:100%;display:flex;flex-direction:column;flex:1;margin:0 5px;position:relative" id="conversation">
<div style="display:flex" v-if="conversation.character" class="header">
<img :src="characterImage" style="height:60px;width:60px;margin-right:10px" v-if="settings.showAvatars"/>
<div style="flex:1;position:relative;display:flex;flex-direction:column">
@ -58,7 +58,10 @@
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
</div>
<div class="search" v-show="showSearch" style="position:relative">
<div class="search input-group" v-show="showSearch">
<div class="input-group-prepend">
<div class="input-group-text"><span class="fas fa-search"></span></div>
</div>
<input v-model="searchInput" @keydown.esc="showSearch = false; searchInput = ''" @keypress="lastSearchInput = Date.now()"
:placeholder="l('chat.search')" ref="searchField" class="form-control"/>
<a class="btn btn-sm btn-light" style="position:absolute;right:5px;top:50%;transform:translateY(-50%);line-height:0"
@ -72,7 +75,7 @@
</message-view>
<span v-if="message.sfc && message.sfc.action == 'report'" :key="message.id">
<a :href="'https://www.f-list.net/fchat/getLog.php?log=' + message.sfc.logid"
v-if="message.sfc.logid">{{l('events.report.viewLog')}}</a>
v-if="message.sfc.logid" target="_blank">{{l('events.report.viewLog')}}</a>
<span v-else>{{l('events.report.noLog')}}</span>
<span v-show="!message.sfc.confirmed">
| <a href="#" @click.prevent="acceptReport(message.sfc)">{{l('events.report.confirm')}}</a>
@ -111,7 +114,7 @@
class="nav-link" @click.prevent="setSendingAds(true)">{{adsMode}}</a>
</li>
</ul>
<div class="btn btn-sm btn-primary" v-show="!settings.enterSend" @click="conversation.send()">{{l('chat.send')}}</div>
<div class="btn btn-sm btn-primary" v-show="!settings.enterSend" @click="sendButton">{{l('chat.send')}}</div>
</div>
</bbcode-editor>
</div>
@ -205,8 +208,13 @@
}
get messages(): ReadonlyArray<Conversation.Message> {
return this.search !== '' ? this.conversation.messages.filter((x) => x.text.indexOf(this.search) !== -1)
: this.conversation.messages;
if(this.search === '') return this.conversation.messages;
const filter = new RegExp(this.search.replace(/[^\w]/gi, '\\$&'), 'i');
return this.conversation.messages.filter((x) => filter.test(x.text));
}
sendButton(): void {
setImmediate(async() => this.conversation.send());
}
@Watch('conversation')

View File

@ -1,19 +1,28 @@
<template>
<modal :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')"
dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen">
dialogClass="modal-lg w-100 modal-dialog-centered" @open="onOpen" @close="onClose">
<div class="form-group row" style="flex-shrink:0">
<label class="col-2 col-form-label">{{l('logs.conversation')}}</label>
<div class="col-10">
<label for="character" class="col-sm-2 col-form-label">{{l('logs.character')}}</label>
<div class="col-sm-10">
<select class="form-control" v-model="selectedCharacter" id="character" @change="loadCharacter">
<option value="">{{l('logs.selectCharacter')}}</option>
<option v-for="character in characters">{{character}}</option>
</select>
</div>
</div>
<div class="form-group row" style="flex-shrink:0">
<label class="col-sm-2 col-form-label">{{l('logs.conversation')}}</label>
<div class="col-sm-10">
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
:placeholder="l('filter')" @input="loadMessages">
:placeholder="l('filter')">
<template slot-scope="s">
{{s.option && ((s.option.key[0] == '#' ? '#' : '') + s.option.name) || l('logs.selectConversation')}}</template>
</filterable-select>
</div>
</div>
<div class="form-group row" style="flex-shrink:0">
<label for="date" class="col-2 col-form-label">{{l('logs.date')}}</label>
<div class="col-8">
<label for="date" class="col-sm-2 col-form-label">{{l('logs.date')}}</label>
<div class="col-sm-8 col-10">
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
<option :value="null">{{l('logs.selectDate')}}</option>
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
@ -24,10 +33,15 @@
class="fa fa-download"></span></button>
</div>
</div>
<div class="messages-both" style="overflow: auto">
<div class="messages-both" style="overflow: auto" ref="messages">
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
</div>
<input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages" type="text"/>
<div class="input-group" style="flex-shrink:0">
<div class="input-group-prepend">
<div class="input-group-text"><span class="fas fa-search"></span></div>
</div>
<input class="form-control" v-model="filter" :placeholder="l('filter')" v-show="messages" type="text"/>
</div>
</modal>
</template>
@ -38,9 +52,10 @@
import CustomDialog from '../components/custom_dialog';
import FilterableSelect from '../components/FilterableSelect.vue';
import Modal from '../components/Modal.vue';
import {messageToString} from './common';
import {Keys} from '../keys';
import {getKey, messageToString} from './common';
import core from './core';
import {Conversation} from './interfaces';
import {Conversation, Logs as LogInterface} from './interfaces';
import l from './localize';
import MessageView from './message_view';
@ -57,16 +72,19 @@
})
export default class Logs extends CustomDialog {
//tslint:disable:no-null-keyword
@Prop({required: true})
readonly conversation!: Conversation;
selectedConversation: {key: string, name: string} | null = null;
@Prop()
readonly conversation?: Conversation;
selectedConversation: LogInterface.Conversation | null = null;
dates: ReadonlyArray<Date> = [];
selectedDate: string | null = null;
conversations = core.logs.conversations.slice();
conversations: LogInterface.Conversation[] = [];
l = l;
filter = '';
messages: ReadonlyArray<Conversation.Message> = [];
formatDate = formatDate;
keyDownListener?: (e: KeyboardEvent) => void;
characters: ReadonlyArray<string> = [];
selectedCharacter = core.connection.character;
get filteredMessages(): ReadonlyArray<Conversation.Message> {
if(this.filter.length === 0) return this.messages;
@ -76,23 +94,35 @@
}
async mounted(): Promise<void> {
this.characters = await core.logs.getAvailableCharacters();
await this.loadCharacter();
return this.conversationChanged();
}
async loadCharacter(): Promise<void> {
if(this.selectedCharacter === '') return;
this.conversations = (await core.logs.getConversations(this.selectedCharacter)).slice();
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
this.selectedConversation = null;
}
filterConversation(filter: RegExp, conversation: {key: string, name: string}): boolean {
return filter.test(conversation.name);
}
@Watch('conversation')
async conversationChanged(): Promise<void> {
if(this.conversation === undefined) return;
//tslint:disable-next-line:strict-boolean-expressions
this.selectedConversation = this.conversations.filter((x) => x.key === this.conversation.key)[0] || null;
this.selectedConversation = this.conversations.filter((x) => x.key === this.conversation!.key)[0] || null;
}
@Watch('selectedConversation')
async conversationSelected(): Promise<void> {
this.dates = this.selectedConversation === null ? [] :
(await core.logs.getLogDates(this.selectedConversation.key)).slice().reverse();
(await core.logs.getLogDates(this.selectedCharacter, this.selectedConversation.key)).slice().reverse();
this.selectedDate = null;
await this.loadMessages();
}
download(file: string, logs: ReadonlyArray<Conversation.Message>): void {
@ -114,16 +144,37 @@
}
async onOpen(): Promise<void> {
this.conversations = core.logs.conversations.slice();
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
this.$forceUpdate();
await this.loadMessages();
if(this.selectedCharacter !== '') {
this.conversations = (await core.logs.getConversations(this.selectedCharacter)).slice();
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
await this.loadMessages();
}
this.keyDownListener = (e) => {
if(getKey(e) === Keys.KeyA && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
e.preventDefault();
const selection = document.getSelection();
selection.removeAllRanges();
if(this.messages.length > 0) {
const range = document.createRange();
const messages = this.$refs['messages'] as Node;
range.setStartBefore(messages.firstChild!);
range.setEndAfter(messages.lastChild!);
selection.addRange(range);
}
}
};
window.addEventListener('keydown', this.keyDownListener);
}
onClose(): void {
window.removeEventListener('keydown', this.keyDownListener!);
}
async loadMessages(): Promise<ReadonlyArray<Conversation.Message>> {
if(this.selectedDate === null || this.selectedConversation === null)
return this.messages = [];
return this.messages = await core.logs.getLogs(this.selectedConversation.key, new Date(this.selectedDate));
return this.messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
new Date(this.selectedDate));
}
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<modal :action="l('chat.report')" @submit.prevent="submit">
<modal :action="l('chat.report')" @submit.prevent="submit" :disabled="submitting">
<div class="alert alert-danger" v-show="error">{{error}}</div>
<h4>{{reporting}}</h4>
<span v-show="!character">{{l('chat.report.channel.description')}}</span>
@ -31,6 +31,7 @@
text = '';
l = l;
error = '';
submitting = false;
mounted(): void {
(<Element>this.$refs['caption']).appendChild(new BBCodeParser().parseEverything(l('chat.report.description')));
@ -74,6 +75,7 @@
};
if(this.character !== null) data.reportUser = this.character.name;
try {
this.submitting = true;
const report = <{log_id?: number}>(await core.connection.queryApi('report-submit.php', data));
//tslint:disable-next-line:strict-boolean-expressions
if(!report.log_id) return;
@ -81,7 +83,8 @@
this.hide();
} catch(e) {
this.error = errorToString(e);
return;
} finally {
this.submitting = false;
}
}
}

View File

@ -24,6 +24,12 @@
{{l('settings.showAvatars')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="colorBookmarks">
<input type="checkbox" id="colorBookmarks" v-model="colorBookmarks"/>
{{l('settings.colorBookmarks')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="animatedEicons">
<input type="checkbox" id="animatedEicons" v-model="animatedEicons"/>
@ -124,9 +130,9 @@
import {Settings as SettingsInterface} from './interfaces';
import l from './localize';
@Component(
{components: {modal: Modal, tabs: Tabs}}
)
@Component({
components: {modal: Modal, tabs: Tabs}
})
export default class SettingsView extends CustomDialog {
l = l;
availableImports: ReadonlyArray<string> = [];
@ -150,6 +156,7 @@
fontSize!: number;
showNeedsReply!: boolean;
enterSend!: boolean;
colorBookmarks!: boolean;
constructor() {
super();
@ -180,6 +187,7 @@
this.fontSize = settings.fontSize;
this.showNeedsReply = settings.showNeedsReply;
this.enterSend = settings.enterSend;
this.colorBookmarks = settings.colorBookmarks;
};
async doImport(): Promise<void> {
@ -223,7 +231,8 @@
logAds: this.logAds,
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize,
showNeedsReply: this.showNeedsReply,
enterSend: this.enterSend
enterSend: this.enterSend,
colorBookmarks: this.colorBookmarks
};
if(this.notifications) await core.notifications.requestPermission();
}

View File

@ -10,7 +10,7 @@
<slot></slot>
</div>
</div>
<div class="modal-backdrop in" @click="expanded = false"></div>
<div class="modal-backdrop show" @click="expanded = false"></div>
</div>
</template>

View File

@ -11,10 +11,18 @@
<user :character="character" :showStatus="true"></user>
</div>
</div>
<div v-if="channel" class="users" style="padding:5px" v-show="tab == 1">
<h4>{{l('users.memberCount', channel.sortedMembers.length)}}</h4>
<div v-for="member in channel.sortedMembers" :key="member.character.name">
<user :character="member.character" :channel="channel" :showStatus="true"></user>
<div v-if="channel" style="padding-left:5px;flex:1;display:flex;flex-direction:column" v-show="tab == 1">
<div class="users" style="flex:1;padding-left:5px">
<h4>{{l('users.memberCount', channel.sortedMembers.length)}}</h4>
<div v-for="member in filteredMembers" :key="member.character.name">
<user :character="member.character" :channel="channel" :showStatus="true"></user>
</div>
</div>
<div class="input-group" style="margin-top:5px;flex-shrink:0">
<div class="input-group-prepend">
<div class="input-group-text"><span class="fas fa-search"></span></div>
</div>
<input class="form-control" v-model="filter" :placeholder="l('filter')" type="text"/>
</div>
</div>
</sidebar>
@ -36,6 +44,7 @@
export default class UserList extends Vue {
tab = '0';
expanded = window.innerWidth >= 992;
filter = '';
l = l;
sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0));
@ -50,6 +59,12 @@
get channel(): Channel {
return (<Conversation.ChannelConversation>core.conversations.selectedConversation).channel;
}
get filteredMembers(): ReadonlyArray<Channel.Member> {
if(this.filter.length === 0) return this.channel.sortedMembers;
const filter = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
return this.channel.sortedMembers.filter((member) => filter.test(member.character.name));
}
}
</script>

View File

@ -2,12 +2,13 @@
<div>
<div id="userMenu" class="list-group" v-show="showContextMenu" :style="position" v-if="character"
style="position:fixed;padding:10px 10px 5px;display:block;width:220px;z-index:1100" ref="menu">
<div style="min-height: 65px;padding:5px" class="list-group-item" @click.stop>
<div style="min-height: 65px;padding:5px;overflow:auto" class="list-group-item" @click.stop>
<img :src="characterImage" style="width:60px;height:60px;margin-right:5px;float:left" v-if="showAvatars"/>
<h5 style="margin:0;line-height:1">{{character.name}}</h5>
{{l('status.' + character.status)}}
</div>
<bbcode id="userMenuStatus" :text="character.statusText" v-show="character.statusText" class="list-group-item"></bbcode>
<bbcode id="userMenuStatus" :text="character.statusText" v-show="character.statusText" class="list-group-item"
style="max-height:200px;overflow:auto;clear:both"></bbcode>
<a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst" class="list-group-item list-group-item-action">
<span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a>
<a tabindex="-1" href="#" @click.prevent="openConversation(true)" class="list-group-item list-group-item-action">
@ -149,7 +150,7 @@
}
handleEvent(e: MouseEvent | TouchEvent): void {
const touch = e instanceof TouchEvent ? e.changedTouches[0] : e;
const touch = e.type === 'touchstart' ? (<TouchEvent>e).changedTouches[0] : <MouseEvent>e;
let node = <HTMLElement & {character?: Character, channel?: Channel, touched?: boolean}>touch.target;
while(node !== document.body) {
if(e.type !== 'click' && node === this.$refs['menu'] || node.id === 'userMenuStatus') return;
@ -214,9 +215,4 @@
border-top: 0;
z-index: -1;
}
.user-view {
cursor: pointer;
font-weight: 500;
}
</style>

View File

@ -74,7 +74,7 @@ export default class BBCodeParser extends CoreBBCodeParser {
const uregex = /^[a-zA-Z0-9_\-\s]+$/;
if(!uregex.test(content))
return;
const extension = core.state.settings.animatedEicons ? 'gif' : 'png';
const extension = core.connection.isOpen && !core.state.settings.animatedEicons ? 'png' : 'gif';
const img = parser.createElement('img');
img.src = `https://static.f-list.net/images/eicon/${content.toLowerCase()}.${extension}`;
img.title = img.alt = content;

View File

@ -43,6 +43,7 @@ export class Settings implements ISettings {
fontSize = 14;
showNeedsReply = false;
enterSend = true;
colorBookmarks = false;
}
export class ConversationSettings implements Conversation.Settings {

View File

@ -31,8 +31,9 @@ abstract class Conversation implements Interfaces.Conversation {
abstract readonly maxMessageLength: number | undefined;
_settings: Interfaces.Settings | undefined;
protected abstract context: CommandContext;
protected maxMessages = 100;
protected maxMessages = 50;
protected allMessages: Interfaces.Message[] = [];
readonly reportMessages: Interfaces.Message[] = [];
private lastSent = '';
constructor(readonly key: string, public _isPinned: boolean) {
@ -58,10 +59,6 @@ abstract class Conversation implements Interfaces.Conversation {
state.savePinned(); //tslint:disable-line:no-floating-promises
}
get reportMessages(): ReadonlyArray<Interfaces.Message> {
return this.allMessages;
}
async send(): Promise<void> {
if(this.enteredText.length === 0) return;
if(isCommand(this.enteredText)) {
@ -86,7 +83,7 @@ abstract class Conversation implements Interfaces.Conversation {
loadMore(): void {
if(this.messages.length >= this.allMessages.length) return;
this.maxMessages += 100;
this.maxMessages += 50;
this.messages = this.allMessages.slice(-this.maxMessages);
}
@ -97,13 +94,19 @@ abstract class Conversation implements Interfaces.Conversation {
onHide(): void {
this.errorText = '';
this.lastRead = this.messages[this.messages.length - 1];
this.maxMessages = 100;
this.maxMessages = 50;
this.messages = this.allMessages.slice(-this.maxMessages);
}
clear(): void {
this.allMessages = [];
this.messages = [];
}
abstract close(): void;
protected safeAddMessage(message: Interfaces.Message): void {
safeAddMessage(this.reportMessages, message, 500);
safeAddMessage(this.allMessages, message, 500);
safeAddMessage(this.messages, message, this.maxMessages);
}
@ -121,13 +124,13 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
private timer: number | undefined;
private logPromise = core.logs.getBacklog(this).then((messages) => {
this.allMessages.unshift(...messages);
this.reportMessages.unshift(...messages);
this.messages = this.allMessages.slice();
});
constructor(readonly character: Character) {
super(character.name.toLowerCase(), state.pinned.private.indexOf(character.name) !== -1);
this.lastRead = this.messages[this.messages.length - 1];
this.allMessages = [];
}
get enteredText(): string {
@ -206,6 +209,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
this.both.unshift(...messages);
this.chat.unshift(...this.both.filter((x) => x.type !== MessageType.Ad));
this.ads.unshift(...this.both.filter((x) => x.type === MessageType.Ad));
this.reportMessages.unshift(...messages);
this.lastRead = this.messages[this.messages.length - 1];
this.messages = this.allMessages.slice(-this.maxMessages);
});
@ -233,7 +237,7 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
set mode(mode: Channel.Mode) {
this._mode = mode;
this.maxMessages = 100;
this.maxMessages = 50;
this.allMessages = this[mode];
this.messages = this.allMessages.slice(-this.maxMessages);
if(mode === this.channel.mode && this.channel.id in state.modes) delete state.modes[this.channel.id];
@ -251,10 +255,6 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
else this.chatEnteredText = value;
}
get reportMessages(): ReadonlyArray<Interfaces.Message> {
return this.both;
}
addModeMessage(mode: Channel.Mode, message: Interfaces.Message): void {
if(this._mode === mode) this.safeAddMessage(message);
else safeAddMessage(this[mode], message, 500);
@ -525,7 +525,7 @@ export default function(this: void): Interfaces.State {
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
await conversation.addMessage(message);
const words = conversation.settings.highlightWords.slice();
const words = conversation.settings.highlightWords.map((w) => w.replace(/[^\w]/gi, '\\$&'));
if(conversation.settings.defaultHighlights) words.push(...core.state.settings.highlightWords);
if(conversation.settings.highlight === Interfaces.Setting.Default && core.state.settings.highlight ||
conversation.settings.highlight === Interfaces.Setting.True) words.push(core.connection.character);

View File

@ -55,7 +55,7 @@ const data = {
logs: <Logs | undefined>undefined,
settingsStore: <Settings.Store | undefined>undefined,
state: vue.state,
bbCodeParser: <BBCodeParser | undefined>undefined,
bbCodeParser: new BBCodeParser(),
conversations: <Conversation.State | undefined>undefined,
channels: <Channel.State | undefined>undefined,
characters: <Character.State | undefined>undefined,

View File

@ -113,6 +113,7 @@ export namespace Conversation {
readonly unread: UnreadState
settings: Settings
send(): Promise<void>
clear(): void
loadLastSent(): void
show(): void
loadMore(): void
@ -121,12 +122,17 @@ export namespace Conversation {
export type Conversation = Conversation.Conversation;
export namespace Logs {
export type Conversation = {readonly key: string, readonly name: string};
}
export interface Logs {
logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> | void
getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>>
readonly conversations: ReadonlyArray<{readonly key: string, readonly name: string}>
getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>>
getLogDates(key: string): Promise<ReadonlyArray<Date>>
getConversations(character: string): Promise<ReadonlyArray<Logs.Conversation>>
getLogs(character: string, key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>>
getLogDates(character: string, key: string): Promise<ReadonlyArray<Date>>
getAvailableCharacters(): Promise<ReadonlyArray<string>>
}
export namespace Settings {
@ -164,6 +170,7 @@ export namespace Settings {
readonly fontSize: number;
readonly showNeedsReply: boolean;
readonly enterSend: boolean;
readonly colorBookmarks: boolean;
}
}

View File

@ -31,6 +31,7 @@ const strings: {[key: string]: string | undefined} = {
'spellchecker.noCorrections': 'No corrections available',
'window.newTab': 'New tab',
'title': 'F-Chat',
'title.connected': 'F-Chat ({0})',
'version': 'Version {0}',
'filter': 'Type to filter...',
'confirmYes': 'Yes',
@ -46,7 +47,7 @@ const strings: {[key: string]: string | undefined} = {
'login.selectCharacter': 'Select a character',
'login.connect': 'Connect',
'login.connecting': 'Connecting...',
'login.connectError': 'Connection error: Could not connect to server',
'login.connectError': 'Connection error: {0}',
'login.alreadyLoggedIn': 'You are already logged in on this character in another tab or window.',
'channelList.public': 'Official channels',
'channelList.private': 'Open rooms',
@ -78,8 +79,10 @@ const strings: {[key: string]: string | undefined} = {
'chat.search': 'Search in messages...',
'chat.send': 'Send',
'logs.title': 'Logs',
'logs.character': 'Character',
'logs.conversation': 'Conversation',
'logs.date': 'Date',
'logs.selectCharacter': 'Select a character...',
'logs.selectConversation': 'Select a conversation...',
'logs.selectDate': 'Select a date...',
'user.profile': 'Profile',
@ -135,7 +138,7 @@ Logs and recent conversations will not be touched.
You may need to log out and back in for some settings to take effect.
Are you sure?`,
'settings.playSound': 'Play notification sounds',
'settings.notifications': 'Display notifications',
'settings.notifications': 'Show desktop/push notifications',
'settings.clickOpensMessage': 'Clicking users opens messages (instead of their profile)',
'settings.enterSend': 'Enter sends messages (shows send button if disabled)',
'settings.disallowedTags': 'Disallowed BBCode tags (comma-separated)',
@ -154,12 +157,18 @@ Are you sure?`,
'settings.theme': 'Theme',
'settings.profileViewer': 'Use profile viewer',
'settings.logDir': 'Change log location',
'settings.logDir.confirm': 'Do you want to set your log location to {0}?\n\nNo files will be moved. If you click Yes here, F-Chat will shut down. If you would like to keep your log files, please move them manually.\n\nCurrent log location: {1}',
'settings.logDir.confirm': `Do you want to set your log location to {0}?
No files will be moved. If you click Yes here, F-Chat will shut down. If you would like to keep your log files, please move them manually before restarting F-Chat.
Current log location: {1}`,
'settings.logDir.inAppDir': 'Please set your log directory to a location outside of the F-Chat installation directory, as it would otherwise be overwritten during an update.',
'settings.logMessages': 'Log messages',
'settings.logAds': 'Log ads',
'settings.fontSize': 'Font size (experimental)',
'settings.showNeedsReply': 'Show indicator on PM tabs with unanswered messages',
'settings.defaultHighlights': 'Use global highlight words',
'settings.colorBookmarks': 'Show bookmarks in a different colour',
'settings.beta': 'Opt-in to test unstable prerelease updates',
'fixLogs.action': 'Fix corrupted logs',
'fixLogs.text': `There are a few reason log files can become corrupted - log files from old versions with bugs that have since been fixed or incomplete file operations caused by computer crashes are the most common.
@ -248,7 +257,7 @@ Once this process has started, do not interrupt it or your logs will get corrupt
'commands.help.syntax': 'Syntax: {0}',
'commands.help.contextChannel': 'This command can be executed in a channel tab.',
'commands.help.contextPrivate': 'This command can be executed in a private conversation tab.',
'commands.help.contextChonsole': 'This command can be executed in the console tab.',
'commands.help.contextConsole': 'This command can be executed in the console tab.',
'commands.help.permissionRoomOp': 'This command requires you to be an operator in the selected channel.',
'commands.help.permissionRoomOwner': 'This command requires you to be the owner of the selected channel.',
'commands.help.permissionChannelMod': 'This command requires you to be an official channel moderator.',
@ -268,6 +277,8 @@ Once this process has started, do not interrupt it or your logs will get corrupt
'commands.join.param0.help': 'The name/ID of the channel to join. For official channels, this is the name, for private rooms this is the ID.',
'commands.close': 'Close tab',
'commands.close.help': 'Closes the currently viewed PM or channel tab.',
'commands.clear': 'Clear tab',
'commands.clear.help': 'Clears the currently viewed tab, emptying its message list. No logs will be deleted.',
'commands.uptime': 'Uptime',
'commands.uptime.help': 'Requests statistics about server uptime.',
'commands.status': 'Set status',

View File

@ -26,9 +26,9 @@ const MessageView: Component = {
const message = context.props.message;
const children: VNodeChildrenArrayContents =
[createElement('span', {staticClass: 'message-time'}, `[${formatTime(message.time)}] `)];
const separators = core.connection.isOpen ? core.state.settings.messageSeparators : false;
/*tslint:disable-next-line:prefer-template*///unreasonable here
let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` +
(core.state.settings.messageSeparators ? ' message-block' : '') +
let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` + (separators ? ' message-block' : '') +
(message.type !== Conversation.Message.Type.Event && message.sender.name === core.connection.character ? ' message-own' : '') +
((context.props.classes !== undefined) ? ` ${context.props.classes}` : '');
if(message.type !== Conversation.Message.Type.Event) {

View File

@ -72,11 +72,9 @@ export function parse(this: void | never, input: string, context: CommandContext
}
index = endIndex === -1 ? args.length : endIndex + 1;
}
if(command.context !== undefined)
return function(this: Conversation): void {
command.exec(this, ...values);
};
else return () => command.exec(...values);
return function(this: Conversation): void {
command.exec(this, ...values);
};
}
export const enum CommandContext {
@ -104,7 +102,7 @@ export interface Command {
readonly delimiter?: string, //default ' ' (',' for type: Character)
validator?(data: string | number): boolean //default undefined
}[]
exec(context?: Conversation | string | number, ...params: (string | number | undefined)[]): void
exec(context: Conversation, ...params: (string | number | undefined)[]): void
}
const commands: {readonly [key: string]: Command | undefined} = {
@ -114,7 +112,7 @@ const commands: {readonly [key: string]: Command | undefined} = {
params: [{type: ParamType.String}]
},
reward: {
exec: (character: string) => core.connection.send('RWD', {character}),
exec: (_, character: string) => core.connection.send('RWD', {character}),
permission: Permission.Admin,
params: [{type: ParamType.Character}]
},
@ -123,7 +121,7 @@ const commands: {readonly [key: string]: Command | undefined} = {
exec: () => core.connection.send('PCR')
},
join: {
exec: (channel: string) => core.connection.send('JCH', {channel}),
exec: (_, channel: string) => core.connection.send('JCH', {channel}),
params: [{type: ParamType.String}]
},
close: {
@ -131,15 +129,17 @@ const commands: {readonly [key: string]: Command | undefined} = {
context: CommandContext.Private | CommandContext.Channel
},
priv: {
exec: (character: string) => core.conversations.getPrivate(core.characters.get(character)).show(),
exec: (_, character: string) => core.conversations.getPrivate(core.characters.get(character)).show(),
params: [{type: ParamType.Character}]
},
uptime: {
exec: () => core.connection.send('UPT')
},
clear: {
exec: (conv: Conversation) => conv.clear()
},
status: {
//tslint:disable-next-line:no-inferrable-types
exec: (status: Character.Status, statusmsg: string = '') => core.connection.send('STA', {status, statusmsg}),
exec: (_, status: Character.Status, statusmsg: string = '') => core.connection.send('STA', {status, statusmsg}),
params: [{type: ParamType.Enum, options: userStatuses}, {type: ParamType.String, optional: true}]
},
ad: {
@ -203,22 +203,22 @@ const commands: {readonly [key: string]: Command | undefined} = {
params: [{type: ParamType.Character, delimiter: ','}, {type: ParamType.Number, validator: (x) => x >= 1}]
},
gkick: {
exec: (character: string) => core.connection.send('KIK', {character}),
exec: (_, character: string) => core.connection.send('KIK', {character}),
permission: Permission.ChatOp,
params: [{type: ParamType.Character}]
},
gban: {
exec: (character: string) => core.connection.send('ACB', {character}),
exec: (_, character: string) => core.connection.send('ACB', {character}),
permission: Permission.ChatOp,
params: [{type: ParamType.Character}]
},
gunban: {
exec: (character: string) => core.connection.send('UNB', {character}),
exec: (_, character: string) => core.connection.send('UNB', {character}),
permission: Permission.ChatOp,
params: [{type: ParamType.Character}]
},
gtimeout: {
exec: (character: string, time: number, reason: string) =>
exec: (_, character: string, time: number, reason: string) =>
core.connection.send('TMO', {character, time, reason}),
permission: Permission.ChatOp,
params: [{type: ParamType.Character, delimiter: ','}, {type: ParamType.Number, validator: (x) => x >= 1}, {type: ParamType.String}]
@ -231,27 +231,27 @@ const commands: {readonly [key: string]: Command | undefined} = {
params: [{type: ParamType.Character}]
},
ignore: {
exec: (character: string) => core.connection.send('IGN', {action: 'add', character}),
exec: (_, character: string) => core.connection.send('IGN', {action: 'add', character}),
params: [{type: ParamType.Character}]
},
unignore: {
exec: (character: string) => core.connection.send('IGN', {action: 'delete', character}),
exec: (_, character: string) => core.connection.send('IGN', {action: 'delete', character}),
params: [{type: ParamType.Character}]
},
ignorelist: {
exec: () => core.conversations.selectedConversation.infoText = l('chat.ignoreList', core.characters.ignoreList.join(', '))
exec: (conv: Conversation) => conv.infoText = l('chat.ignoreList', core.characters.ignoreList.join(', '))
},
makeroom: {
exec: (channel: string) => core.connection.send('CCR', {channel}),
exec: (_, channel: string) => core.connection.send('CCR', {channel}),
params: [{type: ParamType.String}]
},
gop: {
exec: (character: string) => core.connection.send('AOP', {character}),
exec: (_, character: string) => core.connection.send('AOP', {character}),
permission: Permission.Admin,
params: [{type: ParamType.Character}]
},
gdeop: {
exec: (character: string) => core.connection.send('DOP', {character}),
exec: (_, character: string) => core.connection.send('DOP', {character}),
permission: Permission.Admin,
params: [{type: ParamType.Character}]
},
@ -331,27 +331,27 @@ const commands: {readonly [key: string]: Command | undefined} = {
context: CommandContext.Channel
},
createchannel: {
exec: (channel: string) => core.connection.send('CRC', {channel}),
exec: (_, channel: string) => core.connection.send('CRC', {channel}),
permission: Permission.ChatOp,
params: [{type: ParamType.String}]
},
broadcast: {
exec: (message: string) => core.connection.send('BRO', {message}),
exec: (_, message: string) => core.connection.send('BRO', {message}),
permission: Permission.Admin,
params: [{type: ParamType.String}]
},
reloadconfig: {
exec: (save?: 'save') => core.connection.send('RLD', save !== undefined ? {save} : undefined),
exec: (_, save?: 'save') => core.connection.send('RLD', save !== undefined ? {save} : undefined),
permission: Permission.Admin,
params: [{type: ParamType.Enum, options: ['save'], optional: true}]
},
xyzzy: {
exec: (command: string, arg: string) => core.connection.send('ZZZ', {command, arg}),
exec: (_, command: string, arg: string) => core.connection.send('ZZZ', {command, arg}),
permission: Permission.Admin,
params: [{type: ParamType.String, delimiter: ' '}, {type: ParamType.String}]
},
elf: {
exec: () => core.conversations.selectedConversation.infoText =
exec: (conv: Conversation) => conv.infoText =
'Now no one can say there\'s "not enough Elf." It\'s a well-kept secret, but elves love headpets. You should try it sometime.',
documented: false
}

View File

@ -5,6 +5,7 @@
import Vue, {CreateElement, RenderContext, VNode} from 'vue';
import {Channel, Character} from '../fchat';
import core from './core';
export function getStatusIcon(status: Character.Status): string {
switch(status) {
@ -44,8 +45,10 @@ const UserView = Vue.extend({
if(rankIcon !== '') children.unshift(createElement('span', {staticClass: rankIcon}));
if(props.showStatus !== undefined || character.status === 'crown')
children.unshift(createElement('span', {staticClass: `fa-fw ${getStatusIcon(character.status)}`}));
const gender = character.gender !== undefined ? character.gender.toLowerCase() : 'none';
const isBookmark = core.connection.isOpen && core.state.settings.colorBookmarks && (character.isFriend || character.isBookmarked);
return createElement('span', {
attrs: {class: `user-view gender-${character.gender !== undefined ? character.gender.toLowerCase() : 'none'}`},
attrs: {class: `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''}`},
domProps: {character, channel: props.channel, bbcodeTag: 'user'}
}, children);
}

View File

@ -21,7 +21,7 @@
</div>
</div>
</div>
<div class="modal-backdrop in"></div>
<div class="modal-backdrop show"></div>
</span>
</template>

View File

@ -18,7 +18,12 @@
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
<div 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>
</div>
</div>
</div>
<div class="form-group">
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
@ -71,15 +76,16 @@
import Vue from 'vue';
import Component from 'vue-class-component';
import Chat from '../chat/Chat.vue';
import {Settings} from '../chat/common';
import {getKey, Settings} from '../chat/common';
import core, {init as initCore} from '../chat/core';
import l from '../chat/localize';
import {init as profileApiInit} from '../chat/profile_api';
import Socket from '../chat/WebSocket';
import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection';
import {Keys} from '../keys';
import CharacterPage from '../site/character_page/character_page.vue';
import {GeneralSettings, nativeRequire} from './common';
import {defaultHost, GeneralSettings, nativeRequire} from './common';
import {fixLogs, Logs, SettingsStore} from './filesystem';
import * as SlimcatImporter from './importer';
import Notifications from './notifications';
@ -138,9 +144,9 @@
this.fixCharacter = this.fixCharacters[0];
(<Modal>this.$refs['fixLogsModal']).show();
});
window.addEventListener('beforeunload', () => {
if(this.character !== undefined) electron.ipcRenderer.send('disconnect', this.character);
window.addEventListener('keydown', (e) => {
if(getKey(e) === Keys.Tab && e.ctrlKey && !e.altKey && !e.shiftKey)
parent.send('switch-tab', this.character);
});
}
@ -185,6 +191,7 @@
Raven.setUserContext({username: core.connection.character});
});
connection.onEvent('closed', () => {
if(this.character === undefined) return;
this.character = undefined;
electron.ipcRenderer.send('disconnect', connection.character);
parent.send('disconnect', webContents.id);
@ -216,6 +223,10 @@
}
}
resetHost(): void {
this.settings.host = defaultHost;
}
onMouseOver(e: MouseEvent): void {
const preview = (<HTMLDivElement>this.$refs.linkPreview);
if((<HTMLElement>e.target).tagName === 'A') {

View File

@ -6,7 +6,7 @@
<div class="btn" :class="'btn-' + (hasUpdate ? 'warning' : 'light')" @click="openMenu" id="settings">
<i class="fa fa-cog"></i>
</div>
<ul class="nav nav-tabs" style="border-bottom:0" ref="tabs">
<ul class="nav nav-tabs" style="border-bottom:0;margin-bottom:-2px" ref="tabs">
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item" @auxclick="remove(tab)">
<a href="#" @click.prevent="show(tab)" class="nav-link"
:class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}">
@ -54,8 +54,9 @@
}
function destroyTab(tab: Tab): void {
if(tab.user !== undefined) electron.ipcRenderer.send('disconnect', tab.user);
tab.tray.destroy();
tab.view.webContents.loadURL('about:blank');
tab.view.destroy();
electron.ipcRenderer.send('tab-closed');
}
@ -88,10 +89,7 @@
electron.ipcRenderer.on('open-tab', () => this.addTab());
electron.ipcRenderer.on('update-available', (_: Event, available: boolean) => this.hasUpdate = available);
electron.ipcRenderer.on('fix-logs', () => this.activeTab!.view.webContents.send('fix-logs'));
electron.ipcRenderer.on('quit', () => {
this.tabs.forEach(destroyTab);
this.tabs = [];
});
electron.ipcRenderer.on('quit', () => this.destroyAllTabs());
electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => {
const tab = this.tabMap[id];
tab.user = name;
@ -106,6 +104,7 @@
tab.hasNew = false;
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
}
electron.ipcRenderer.send('disconnect', tab.user);
tab.user = undefined;
tab.tray.setToolTip(l('title'));
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
@ -123,6 +122,13 @@
this.isMaximized = false;
this.activeTab!.view.setBounds(getWindowBounds());
});
electron.ipcRenderer.on('switch-tab', (_: Event) => {
const index = this.tabs.indexOf(this.activeTab!);
this.show(this.tabs[index + 1 === this.tabs.length ? 0 : index + 1]);
});
electron.ipcRenderer.on('show-tab', (_: Event, id: number) => {
this.show(this.tabMap[id]);
});
document.addEventListener('click', () => this.activeTab!.view.webContents.focus());
window.addEventListener('focus', () => this.activeTab!.view.webContents.focus());
@ -140,14 +146,13 @@
window.onbeforeunload = () => {
const isConnected = this.tabs.reduce((cur, tab) => cur || tab.user !== undefined, false);
if(process.env.NODE_ENV !== 'production' || !isConnected) {
this.tabs.forEach(destroyTab);
this.destroyAllTabs();
return;
}
if(!this.settings.closeToTray)
return setImmediate(() => {
if(confirm(l('chat.confirmLeave'))) {
this.tabs.forEach(destroyTab);
this.tabs = [];
this.destroyAllTabs();
browserWindow.close();
}
});
@ -156,6 +161,12 @@
};
}
destroyAllTabs(): void {
browserWindow.setBrowserView(null!);
this.tabs.forEach(destroyTab);
this.tabs = [];
}
get styling(): string {
try {
return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.settings.theme}.css`))}</style>`;
@ -205,6 +216,7 @@
this.activeTab = tab;
browserWindow.setBrowserView(tab.view);
tab.view.setBounds(getWindowBounds());
tab.view.webContents.focus();
}
remove(tab: Tab, shouldConfirm: boolean = true): void {
@ -212,10 +224,10 @@
this.tabs.splice(this.tabs.indexOf(tab), 1);
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
delete this.tabMap[tab.view.webContents.id];
destroyTab(tab);
if(this.tabs.length === 0) {
if(process.env.NODE_ENV === 'production') browserWindow.close();
} else if(this.activeTab === tab) this.show(this.tabs[0]);
destroyTab(tab);
}
minimize(): void {

View File

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

View File

@ -2,7 +2,7 @@
* @license
* MIT License
*
* Copyright (c) 2017 F-List
* Copyright (c) 2018 F-List
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@ -24,11 +24,12 @@
*
* This license header applies to this file and all of the non-third-party assets it includes.
* @file The entry point for the Electron renderer of F-Chat 3.0.
* @copyright 2017 F-List
* @copyright 2018 F-List
* @author Maya Wolf <maya@f-list.net>
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import Axios from 'axios';
import {exec} from 'child_process';
import * as electron from 'electron';
import * as fs from 'fs';
@ -63,6 +64,8 @@ const sc = nativeRequire<{
}>('spellchecker/build/Release/spellchecker.node');
const spellchecker = new sc.Spellchecker();
Axios.defaults.params = { __fchat: `desktop/${electron.remote.app.getVersion()}` };
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: electron.remote.app.getVersion(),

View File

@ -2,11 +2,13 @@ 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 class GeneralSettings {
account = '';
closeToTray = true;
profileViewer = true;
host = 'wss://chat.f-list.net:9799';
host = defaultHost;
logDirectory = path.join(electron.app.getPath('userData'), 'data');
spellcheckLang: string | undefined = 'en_GB';
theme = 'default';

View File

@ -1,4 +1,3 @@
import {addMinutes} from 'date-fns';
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
@ -44,14 +43,14 @@ interface Index {
[key: string]: IndexItem | undefined
}
export function getLogDir(this: void, character: string = core.connection.character): string {
export function getLogDir(this: void, character: string): string {
const dir = path.join(core.state.generalSettings!.logDirectory, character, 'logs');
mkdir(dir);
return dir;
}
function getLogFile(this: void, key: string): string {
return path.join(getLogDir(), key);
function getLogFile(this: void, character: string, key: string): string {
return path.join(getLogDir(character), key);
}
export function checkIndex(this: void, index: Index, message: Message, key: string, name: string,
@ -151,35 +150,42 @@ export function fixLogs(character: string): void {
}
}
function loadIndex(name: string): Index {
const index: Index = {};
const dir = getLogDir(name);
const files = fs.readdirSync(dir);
for(const file of files)
if(file.substr(-4) === '.idx') {
const content = fs.readFileSync(path.join(dir, file));
let offset = content.readUInt8(0, noAssert) + 1;
const item: IndexItem = {
name: content.toString('utf8', 1, offset),
index: {},
offsets: new Array(content.length - offset)
};
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));
}
index[file.slice(0, -4).toLowerCase()] = item;
}
return index;
}
export class Logs implements Logging {
private index: Index = {};
private loadedIndex?: Index;
private loadedCharacter?: string;
constructor() {
core.connection.onEvent('connecting', () => {
this.index = {};
const dir = getLogDir();
const files = fs.readdirSync(dir);
for(const file of files)
if(file.substr(-4) === '.idx') {
const content = fs.readFileSync(path.join(dir, file));
let offset = content.readUInt8(0, noAssert) + 1;
const item: IndexItem = {
name: content.toString('utf8', 1, offset),
index: {},
offsets: new Array(content.length - offset)
};
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));
}
this.index[file.slice(0, -4).toLowerCase()] = item;
}
this.index = loadIndex(core.connection.character);
});
}
async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
const file = getLogFile(conversation.key);
const file = getLogFile(core.connection.character, conversation.key);
if(!fs.existsSync(file)) return [];
let count = 20;
let messages = new Array<Conversation.Message>(count);
@ -198,25 +204,30 @@ export class Logs implements Logging {
return messages;
}
async getLogDates(key: string): Promise<ReadonlyArray<Date>> {
const entry = this.index[key];
private getIndex(name: string): Index {
if(this.loadedCharacter === name) return this.loadedIndex!;
this.loadedCharacter = name;
return this.loadedIndex = name === core.connection.character ? this.index : loadIndex(name);
}
async getLogDates(character: string, key: string): Promise<ReadonlyArray<Date>> {
const entry = this.getIndex(character)[key];
if(entry === undefined) return [];
const dates = [];
for(const item in entry.index) {
const date = new Date(parseInt(item, 10) * dayMs);
dates.push(addMinutes(date, date.getTimezoneOffset()));
}
const offset = new Date().getTimezoneOffset() * 60000;
for(const item in entry.index)
dates.push(new Date(parseInt(item, 10) * dayMs + offset));
return dates;
}
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
const index = this.index[key];
async getLogs(character: string, key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
const index = this.getIndex(character)[key];
if(index === undefined) return [];
const dateOffset = index.index[Math.floor(date.getTime() / dayMs - date.getTimezoneOffset() / 1440)];
if(dateOffset === undefined) return [];
const buffer = Buffer.allocUnsafe(50100);
const messages: Conversation.Message[] = [];
const file = getLogFile(key);
const file = getLogFile(character, key);
const fd = fs.openSync(file, 'r');
let pos = index.offsets[dateOffset];
const size = dateOffset + 1 < index.offsets.length ? index.offsets[dateOffset + 1] : (fs.fstatSync(fd)).size;
@ -231,7 +242,7 @@ export class Logs implements Logging {
}
logMessage(conversation: {key: string, name: string}, message: Message): void {
const file = getLogFile(conversation.key);
const file = getLogFile(core.connection.character, conversation.key);
const buffer = serializeMessage(message).serialized;
const hasIndex = this.index[conversation.key] !== undefined;
const indexBuffer = checkIndex(this.index, message, conversation.key, conversation.name,
@ -240,11 +251,17 @@ export class Logs implements Logging {
writeFile(file, buffer, {flag: 'a'});
}
get conversations(): ReadonlyArray<{key: string, name: string}> {
async getConversations(character: string): Promise<ReadonlyArray<{key: string, name: string}>> {
const index = this.getIndex(character);
const conversations: {key: string, name: string}[] = [];
for(const key in this.index) conversations.push({key, name: this.index[key]!.name});
for(const key in index) conversations.push({key, name: index[key]!.name});
return conversations;
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const baseDir = core.state.generalSettings!.logDirectory;
return (fs.readdirSync(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
}
}
function getSettingsDir(character: string = core.connection.character): string {

View File

@ -2,7 +2,7 @@
* @license
* MIT License
*
* Copyright (c) 2017 F-List
* Copyright (c) 2018 F-List
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@ -24,7 +24,7 @@
*
* This license header applies to this file and all of the non-third-party assets it includes.
* @file The entry point for the Electron main thread of F-Chat 3.0.
* @copyright 2017 F-List
* @copyright 2018 F-List
* @author Maya Wolf <maya@f-list.net>
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
@ -232,6 +232,8 @@ function onReady(): void {
const dir = <string[] | undefined>electron.dialog.showOpenDialog(
{defaultPath: new GeneralSettings().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'));
const button = electron.dialog.showMessageBox(window, {
message: l('settings.logDir.confirm', dir[0], settings.logDirectory),
buttons: [l('confirmYes'), l('confirmNo')],
@ -285,14 +287,17 @@ function onReady(): void {
{
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : undefined,
label: l('action.quit'),
click(_: Electron.MenuItem, w: Electron.BrowserWindow): void {
click(_: Electron.MenuItem, window: Electron.BrowserWindow): void {
if(characters.length === 0) return app.quit();
const button = electron.dialog.showMessageBox(w, {
const button = electron.dialog.showMessageBox(window, {
message: l('chat.confirmLeave'),
buttons: [l('confirmYes'), l('confirmNo')],
cancelId: 1
});
if(button === 0) app.quit();
if(button === 0) {
for(const w of windows) w.webContents.send('quit');
app.quit();
}
}
}
]

View File

@ -19,6 +19,7 @@ export default class Notifications extends BaseNotifications {
silent: true
});
notification.onclick = () => {
browserWindow.webContents.send('show-tab', remote.getCurrentWebContents().id);
conversation.show();
browserWindow.focus();
notification.close();

View File

@ -19,7 +19,7 @@ export default class Connection implements Interfaces.Connection {
private ticket = '';
private cleanClose = false;
private reconnectTimer: NodeJS.Timer | undefined;
private ticketProvider: Interfaces.TicketProvider;
private readonly ticketProvider: Interfaces.TicketProvider;
private reconnectDelay = 0;
private isReconnect = false;
@ -31,15 +31,13 @@ export default class Connection implements Interfaces.Connection {
async connect(character: string): Promise<void> {
this.cleanClose = false;
this.isReconnect = this.character === character;
if(this.character !== character) this.isReconnect = false;
this.character = character;
try {
this.ticket = await this.ticketProvider();
} catch(e) {
for(const handler of this.errorHandlers) handler(<Error>e);
await this.invokeHandlers('closed', true);
this.reconnect();
return;
(<Error & {request: true}>e).request = true;
throw e;
}
await this.invokeHandlers('connecting', this.isReconnect);
if(this.cleanClose) {
@ -71,17 +69,26 @@ export default class Connection implements Interfaces.Connection {
socket.onError((error: Error) => {
for(const handler of this.errorHandlers) handler(error);
});
return new Promise<void>((resolve) => {
return new Promise<void>((resolve, reject) => {
const handler = () => {
resolve();
this.offEvent('connected', handler);
};
this.onEvent('connected', handler);
this.onError(reject);
});
}
private reconnect(): void {
this.reconnectTimer = setTimeout(async() => this.connect(this.character), this.reconnectDelay);
this.reconnectTimer = setTimeout(async() => {
try {
await this.connect(this.character);
} catch(e) {
for(const handler of this.errorHandlers) handler(<Error>e);
await this.invokeHandlers('closed', true);
this.reconnect();
}
}, this.reconnectDelay);
this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000;
}
@ -91,6 +98,10 @@ export default class Connection implements Interfaces.Connection {
if(this.socket !== undefined) this.socket.close();
}
get isOpen(): boolean {
return this.socket !== undefined;
}
async queryApi<T = object>(endpoint: string, data?: {account?: string, ticket?: string}): Promise<T> {
if(data === undefined) data = {};
data.account = this.account;
@ -168,6 +179,7 @@ export default class Connection implements Interfaces.Connection {
if(data.identity === this.character) {
await this.invokeHandlers('connected', this.isReconnect);
this.reconnectDelay = 0;
this.isReconnect = true;
}
}
}

View File

@ -134,6 +134,7 @@ export namespace Connection {
export interface Connection {
readonly character: string
readonly vars: Vars
readonly isOpen: boolean
connect(character: string): Promise<void>
close(): void
onMessage<K extends keyof ServerCommands>(type: K, handler: CommandHandler<K>): void

View File

@ -18,7 +18,12 @@
</div>
<div class="form-group" v-show="showAdvanced">
<label class="control-label" for="host">{{l('login.host')}}</label>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login" :disabled="loggingIn"/>
<div 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>
</div>
</div>
</div>
<div class="form-group">
<label class="control-label" for="theme">{{l('settings.theme')}}</label>
@ -118,6 +123,10 @@
this.settings = settings;
}
resetHost(): void {
this.settings!.host = new GeneralSettings().host;
}
get styling(): string {
if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings!.theme);
//tslint:disable-next-line:no-require-imports

View File

@ -15,18 +15,17 @@ import java.util.*
class Logs(private val ctx: Context) {
data class IndexItem(val name: String, val index: MutableMap<Int, Long> = HashMap(), val dates: MutableList<Int> = LinkedList())
private lateinit var index: MutableMap<String, IndexItem>
private var index: MutableMap<String, IndexItem>? = null
private var loadedIndex: MutableMap<String, IndexItem>? = null
private lateinit var baseDir: File
private var character: String? = null
private val encoder = Charsets.UTF_8.newEncoder()
private val decoder = Charsets.UTF_8.newDecoder()
private val buffer = ByteBuffer.allocateDirect(51000).order(ByteOrder.LITTLE_ENDIAN)
@JavascriptInterface
fun initN(character: String): String {
baseDir = File(ctx.filesDir, "$character/logs")
baseDir.mkdirs()
val files = baseDir.listFiles({ _, name -> name.endsWith(".idx") })
index = HashMap(files.size)
private fun loadIndex(character: String): MutableMap<String, IndexItem> {
val files = File(ctx.filesDir, "$character/logs").listFiles({ _, name -> name.endsWith(".idx") })
val index = HashMap<String, IndexItem>(files.size)
for(file in files) {
FileInputStream(file).use { stream ->
buffer.clear()
@ -49,8 +48,19 @@ class Logs(private val ctx: Context) {
index[file.nameWithoutExtension] = indexItem
}
}
return index
}
@JavascriptInterface
fun initN(character: String): String {
baseDir = File(ctx.filesDir, "$character/logs")
baseDir.mkdirs()
this.character = character
index = loadIndex(character)
loadedIndex = index
val json = JSONStringer().`object`()
for(item in index) json.key(item.key).`object`().key("name").value(item.value.name).key("dates").value(JSONArray(item.value.dates)).endObject()
for(item in index!!)
json.key(item.key).`object`().key("name").value(item.value.name).key("dates").value(JSONArray(item.value.dates)).endObject()
return json.endObject().toString()
}
@ -59,13 +69,13 @@ class Logs(private val ctx: Context) {
val day = time / 86400
val file = File(baseDir, key)
buffer.clear()
if(!index.containsKey(key)) {
index[key] = IndexItem(conversation, HashMap())
if(!index!!.containsKey(key)) {
index!![key] = IndexItem(conversation, HashMap())
buffer.position(1)
encoder.encode(CharBuffer.wrap(conversation), buffer, true)
buffer.put(0, (buffer.position() - 1).toByte())
}
val item = index[key]!!
val item = index!![key]!!
if(!item.index.containsKey(day)) {
buffer.putShort(day.toShort())
val size = file.length()
@ -130,11 +140,11 @@ class Logs(private val ctx: Context) {
}
@JavascriptInterface
fun getLogsN(key: String, date: Int): String {
val offset = index[key]?.index?.get(date) ?: return "[]"
fun getLogsN(character: String, key: String, date: Int): String {
val offset = loadedIndex!![key]?.index?.get(date) ?: return "[]"
val json = JSONStringer()
json.array()
FileInputStream(File(baseDir, key)).use { stream ->
FileInputStream(File(ctx.filesDir, "$character/logs/$key")).use { stream ->
val channel = stream.channel
channel.position(offset)
while(channel.position() < channel.size()) {
@ -150,6 +160,20 @@ class Logs(private val ctx: Context) {
return json.endArray().toString()
}
@JavascriptInterface
fun loadIndexN(character: String): String {
loadedIndex = if(character == this.character) this.index else this.loadIndex(character)
val json = JSONStringer().`object`()
for(item in loadedIndex!!)
json.key(item.key).`object`().key("name").value(item.value.name).key("dates").value(JSONArray(item.value.dates)).endObject()
return json.endObject().toString()
}
@JavascriptInterface
fun getCharactersN(): String {
return JSONArray(ctx.filesDir.listFiles().filter { it.isDirectory }.map { it.name }).toString()
}
private fun deserializeMessage(buffer: ByteBuffer, json: JSONStringer, checkDate: Int = -1) {
val date = buffer.int
if(checkDate != -1 && date / 86400 != checkDate) return

View File

@ -82,8 +82,12 @@ class MainActivity : Activity() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
webView.evaluateJavascript("(function(n){n.listFiles=function(p){return JSON.parse(n.listFilesN(p))};n.listDirectories=function(p){return JSON.parse(n.listDirectoriesN(p))}})(NativeFile)", null)
webView.evaluateJavascript("(function(n){n.init=function(c){return JSON.parse(n.initN(c))};n.getBacklog=function(k){return JSON.parse(n.getBacklogN(k))};n.getLogs=function(k,d){return JSON.parse(n.getLogsN(k,d))}})(NativeLogs)", null)
webView.evaluateJavascript("window.setupPlatform('android')", null)
webView.evaluateJavascript("(function(n){n.listFiles=function(p){return JSON.parse(n.listFilesN(p))};" +
"n.listDirectories=function(p){return JSON.parse(n.listDirectoriesN(p))}})(NativeFile)", null)
webView.evaluateJavascript("(function(n){n.init=function(c){return JSON.parse(n.initN(c))};n.getBacklog=function(k){return JSON.parse(n.getBacklogN(k))};" +
"n.getLogs=function(c,k,d){return JSON.parse(n.getLogsN(c,k,d))};n.loadIndex=function(c){return JSON.parse(n.loadIndexN(c))};" +
"n.getCharacters=function(){return JSON.parse(n.getCharactersN())}})(NativeLogs)", null)
}
}

View File

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.2.21'
ext.kotlin_version = '1.2.30'
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
classpath 'com.android.tools.build:gradle:2.3.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong

View File

@ -1,6 +1,5 @@
#Mon Dec 28 10:00:20 PST 2015
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-bin.zip

View File

@ -2,7 +2,7 @@
* @license
* MIT License
*
* Copyright (c) 2017 F-List
* Copyright (c) 2018 F-List
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@ -24,19 +24,25 @@
*
* This license header applies to this file and all of the non-third-party assets it includes.
* @file The entry point for the mobile version of F-Chat 3.0.
* @copyright 2017 F-List
* @copyright 2018 F-List
* @author Maya Wolf <maya@f-list.net>
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import Axios from 'axios';
import * as Raven from 'raven-js';
import Vue from 'vue';
import VueRaven from '../chat/vue-raven';
import Index from './Index.vue';
const version = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
(<any>window)['setupPlatform'] = (platform: string) => { //tslint:disable-line:no-any
Axios.defaults.params = { __fchat: `mobile-${platform}/${version}` };
};
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: `mobile-${require('./package.json').version}`, //tslint:disable-line:no-require-imports no-unsafe-any
release: `mobile-${version}`,
dataCallback: (data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
data.culprit = `~${data.culprit.substr(data.culprit.lastIndexOf('/'))}`;
for(const ex of data.exception.values)

View File

@ -14,10 +14,12 @@ declare global {
type NativeMessage = {time: number, type: number, sender: string, text: string};
const NativeLogs: {
init(character: string): Promise<Index>
getCharacters(): Promise<ReadonlyArray<string>>
loadIndex(character: string): Promise<Index>
logMessage(key: string, conversation: string, time: number, type: Conversation.Message.Type, sender: string,
message: string): Promise<void>;
getBacklog(key: string): Promise<ReadonlyArray<NativeMessage>>;
getLogs(key: string, date: number): Promise<ReadonlyArray<NativeMessage>>
getLogs(character: string, key: string, date: number): Promise<ReadonlyArray<NativeMessage>>
};
}
@ -36,6 +38,8 @@ type Index = {[key: string]: {name: string, dates: number[]} | undefined};
export class Logs implements Logging {
private index: Index = {};
private loadedIndex?: Index;
private loadedCharacter?: string;
constructor() {
core.connection.onEvent('connecting', async() => {
@ -58,22 +62,35 @@ export class Logs implements Logging {
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
}
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
return (await NativeLogs.getLogs(key, date.getTime() / dayMs))
private async getIndex(name: string): Promise<Index> {
if(this.loadedCharacter === name) return this.loadedIndex!;
this.loadedCharacter = name;
return this.loadedIndex = await NativeLogs.loadIndex(name);
}
async getLogs(character: string, key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
await NativeLogs.loadIndex(character);
return (await NativeLogs.getLogs(character, key, Math.floor(date.getTime() / dayMs - date.getTimezoneOffset() / 1440)))
.map((x) => new MessageImpl(x.type, core.characters.get(x.sender), x.text, new Date(x.time * 1000)));
}
async getLogDates(key: string): Promise<ReadonlyArray<Date>> {
const entry = this.index[key];
async getLogDates(character: string, key: string): Promise<ReadonlyArray<Date>> {
const entry = (await this.getIndex(character))[key];
if(entry === undefined) return [];
return entry.dates.map((x) => new Date(x * dayMs));
const offset = new Date().getTimezoneOffset() * 60000;
return entry.dates.map((x) => new Date(x * dayMs + offset));
}
get conversations(): ReadonlyArray<{key: string, name: string}> {
async getConversations(character: string): Promise<ReadonlyArray<{key: string, name: string}>> {
const index = await this.getIndex(character);
const conversations: {key: string, name: string}[] = [];
for(const key in this.index) conversations.push({key, name: this.index[key]!.name});
for(const key in index) conversations.push({key, name: index[key]!.name});
return conversations;
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
return NativeLogs.getCharacters();
}
}
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {

View File

@ -20,7 +20,9 @@ class Logs: NSObject, WKScriptMessageHandler {
let baseDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
var buffer = UnsafeMutableRawPointer.allocate(bytes: 51000, alignedTo: 1)
var logDir: URL!
var character: String?
var index: [String: IndexItem]!
var loadedIndex: [String: IndexItem]!
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
let data = message.body as! [String: AnyObject]
@ -30,12 +32,16 @@ class Logs: NSObject, WKScriptMessageHandler {
switch(data["_type"] as! String) {
case "init":
result = try initCharacter(data["character"] as! String)
case "loadIndex":
result = try loadIndex(data["character"] as! String)
case "getCharacters":
result = try getCharacters()
case "logMessage":
try logMessage(data["key"] as! String, data["conversation"] as! NSString, (data["time"] as! NSNumber).uint32Value, (data["type"] as! NSNumber).uint8Value, data["sender"] as! NSString, data["message"] as! NSString)
case "getBacklog":
result = try getBacklog(data["key"] as! String)
case "readString":
result = try getLogs(data["key"] as! String, (data["date"] as! NSNumber).uint16Value)
case "getLogs":
result = try getLogs(data["character"] as! String, data["key"] as! String, (data["date"] as! NSNumber).uint16Value)
default:
message.webView!.evaluateJavaScript("nativeError('\(key)',new Error('Unknown message type'))")
return
@ -43,15 +49,13 @@ class Logs: NSObject, WKScriptMessageHandler {
let output = result == nil ? "undefined" : result!;
message.webView!.evaluateJavaScript("nativeMessage('\(key)',\(output))")
} catch(let error) {
message.webView!.evaluateJavaScript("nativeError('\(key)',new Error('File-\(data["_type"]!): \(error.localizedDescription)'))")
message.webView!.evaluateJavaScript("nativeError('\(key)',new Error('Logs-\(data["_type"]!): \(error.localizedDescription)'))")
}
}
func initCharacter(_ name: String) throws -> String {
logDir = baseDir.appendingPathComponent("\(name)/logs", isDirectory: true)
index = [String: IndexItem]()
try fm.createDirectory(at: logDir, withIntermediateDirectories: true, attributes: nil)
let files = try fm.contentsOfDirectory(at: logDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
func getIndex(_ character: String) throws -> [String: IndexItem] {
var index = [String: IndexItem]()
let files = try fm.contentsOfDirectory(at: baseDir.appendingPathComponent("\(character)/logs", isDirectory: true), includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
for file in files {
if(!file.lastPathComponent.hasSuffix(".idx")) { continue }
let data = NSData(contentsOf: file)!
@ -71,15 +75,30 @@ class Logs: NSObject, WKScriptMessageHandler {
}
index[file.deletingPathExtension().lastPathComponent] = indexItem
}
return index
}
func initCharacter(_ name: String) throws -> String {
logDir = baseDir.appendingPathComponent("\(name)/logs", isDirectory: true)
try fm.createDirectory(at: logDir, withIntermediateDirectories: true, attributes: nil)
index = try getIndex(name)
loadedIndex = index
return String(data: try JSONEncoder().encode(index), encoding: .utf8)!
}
func getCharacters() throws -> String {
let entries = try fm.contentsOfDirectory(at: baseDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]).filter {
try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true
}.map { $0.lastPathComponent }
return String(data: try JSONSerialization.data(withJSONObject: entries), encoding: .utf8)!;
}
func logMessage(_ key: String, _ conversation: NSString, _ time: UInt32, _ type: UInt8, _ sender: NSString, _ text: NSString) throws {
var time = time
var type = type
var day = UInt16(time / 86400)
let url = logDir.appendingPathComponent(key, isDirectory: false);
var indexItem = index[key]
var indexItem = index![key]
if(indexItem == nil) { fm.createFile(atPath: url.path, contents: nil) }
let fd = try FileHandle(forWritingTo: url)
fd.seekToEndOfFile()
@ -90,7 +109,7 @@ class Logs: NSObject, WKScriptMessageHandler {
indexFd.seekToEndOfFile()
if(indexItem == nil) {
indexItem = IndexItem(conversation as String)
index[key] = indexItem
index![key] = indexItem
let cstring = conversation.utf8String
var length = strlen(cstring)
write(indexFd.fileDescriptor, &length, 1)
@ -137,9 +156,9 @@ class Logs: NSObject, WKScriptMessageHandler {
return "[" + strings.reversed().joined(separator: ",") + "]"
}
func getLogs(_ key: String, _ date: UInt16) throws -> String {
guard let offset = index[key]?.index[date] else { return "[]" }
let url = logDir.appendingPathComponent(key, isDirectory: false)
func getLogs(_ character: String, _ key: String, _ date: UInt16) throws -> String {
guard let offset = loadedIndex![key]?.index[date] else { return "[]" }
let url = baseDir.appendingPathComponent("\(character)/logs/\(key)", isDirectory: false)
let file = try FileHandle(forReadingFrom: url)
let size = file.seekToEndOfFile()
file.seek(toFileOffset: offset)
@ -154,6 +173,11 @@ class Logs: NSObject, WKScriptMessageHandler {
return json + "]"
}
func loadIndex(_ name: String) throws -> String {
loadedIndex = name == character ? index : try getIndex(name)
return String(data: try JSONEncoder().encode(loadedIndex), encoding: .utf8)!
}
func deserializeMessage(_ checkDate: UInt16 = 0) -> (String, Int) {
let date = buffer.load(as: UInt32.self)
if(checkDate != 0 && date / 86400 != checkDate) { return ("", 0) }
@ -164,4 +188,4 @@ class Logs: NSObject, WKScriptMessageHandler {
let text = String(bytesNoCopy: buffer.advanced(by: 6 + senderLength + 2), length: textLength, encoding: .utf8, freeWhenDone: false)!
return ("{\"time\":\(date),\"type\":\(type),\"sender\":\(File.escape(sender)),\"text\":\(File.escape(text))}", senderLength + textLength + 8)
}
}
}

View File

@ -21,7 +21,7 @@ class Notification: NSObject, WKScriptMessageHandler, UNUserNotificationCenterDe
}
switch(data["_type"] as! String) {
case "notify":
notify(data["notify"] as! Bool, data["title"] as! String, data["text"] as! String, data["icon"] as! String, data["sound"] as! String?, data["data"] as! String, callback)
notify(data["notify"] as! Bool, data["title"] as! String, data["text"] as! String, data["icon"] as! String, data["sound"] as? String, data["data"] as! String, callback)
case "requestPermission":
requestPermission(callback)
default:
@ -39,8 +39,12 @@ class Notification: NSObject, WKScriptMessageHandler, UNUserNotificationCenterDe
func notify(_ notify: Bool, _ title: String, _ text: String, _ icon: String, _ sound: String?, _ data: String, _ cb: (String?) -> Void) {
if(!notify) {
let player = try! AVAudioPlayer(contentsOf: Bundle.main.url(forResource: "www/sounds/" + sound!, withExtension: "wav")!)
player.play()
if(sound != nil) {
let player = try! AVAudioPlayer(contentsOf: Bundle.main.url(forResource: "www/sounds/" + sound!, withExtension: "wav")!)
player.play()
}
cb(nil)
return
}
let content = UNMutableNotificationContent()
content.title = title

View File

@ -13,6 +13,7 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
let js = try! String(contentsOfFile: scriptPath!)
let userScript = WKUserScript(source: js, injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: false)
controller.addUserScript(userScript)
controller.addUserScript(WKUserScript(source: "window.setupPlatform('ios')", injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: false))
controller.add(File(), name: "File")
controller.add(Notification(), name: "Notification")
controller.add(Background(), name: "Background")
@ -22,6 +23,7 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
config.setValue(true, forKey: "_alwaysRunsAtForegroundPriority")
webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self
webView.navigationDelegate = self
view = webView
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow), name: NSNotification.Name.UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
@ -75,28 +77,28 @@ class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
present(alertController, animated: true, completion: nil)
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url!
if(url.isFileURL) {
decisionHandler(.allow)
return
}
decisionHandler(.cancel)
let str = url.absoluteString
if(url.scheme == "data") {
let start = str.index(of: ",")!
let file = FileManager.default.temporaryDirectory.appendingPathComponent(str[str.index(str.startIndex, offsetBy: 5)..<start].removingPercentEncoding!)
try! str.suffix(from: str.index(after: start)).removingPercentEncoding!.write(to: file, atomically: false, encoding: .utf8)
self.present(UIActivityViewController(activityItems: [file], applicationActivities: nil), animated: true, completion: nil)
return nil
return
}
let match = profileRegex.matches(in: str, range: NSRange(location: 0, length: str.count))
if(match.count == 1) {
let char = str[Range(match[0].range(at: 2), in: str)!].removingPercentEncoding!;
webView.evaluateJavaScript("document.dispatchEvent(new CustomEvent('open-profile',{detail:'\(char)'}))", completionHandler: nil)
return nil
return
}
UIApplication.shared.open(navigationAction.request.url!)
return nil
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
decisionHandler(.cancel)
}
}

View File

@ -69,7 +69,13 @@ window.NativeLogs = {
getBacklog: function(key) {
return sendMessage('Logs', 'getBacklog', {key: key});
},
getLogs: function(key, date) {
return sendMessage('Logs', 'getBacklog', {key: key, date: date});
getLogs: function(character, key, date) {
return sendMessage('Logs', 'getLogs', {character: character, key: key, date: date});
},
loadIndex: function(character) {
return sendMessage('Logs', 'loadIndex', {character: character});
},
getCharacters: function() {
return sendMessage('Logs', 'getCharacters', {});
}
};

View File

@ -5,41 +5,41 @@
"description": "F-List Exported",
"license": "MIT",
"devDependencies": {
"@fortawesome/fontawesome-free-webfonts": "^1.0.4",
"@types/node": "^9.4.6",
"@fortawesome/fontawesome-free-webfonts": "^1.0.5",
"@types/node": "^9.6.0",
"@types/sortablejs": "^1.3.31",
"axios": "^0.18.0",
"bootstrap": "^4.0.0",
"css-loader": "^0.28.10",
"css-loader": "^0.28.11",
"date-fns": "^1.28.5",
"electron": "^1.8.1",
"electron-builder": "^20.2.0",
"electron": "^1.8.4",
"electron-builder": "^20.8.1",
"electron-log": "^2.2.9",
"electron-updater": "^2.8.9",
"electron-updater": "^2.21.4",
"extract-text-webpack-plugin": "4.0.0-beta.0",
"file-loader": "^1.1.11",
"fork-ts-checker-webpack-plugin": "^0.4.0",
"fork-ts-checker-webpack-plugin": "^0.4.1",
"lodash": "^4.16.4",
"node-sass": "^4.7.2",
"optimize-css-assets-webpack-plugin": "^3.2.0",
"node-sass": "^4.8.3",
"optimize-css-assets-webpack-plugin": "^4.0.0",
"qs": "^6.5.1",
"raven-js": "^3.23.1",
"raven-js": "^3.24.0",
"sass-loader": "^6.0.7",
"sortablejs": "^1.6.0",
"ts-loader": "^4.0.1",
"ts-loader": "^4.1.0",
"tslib": "^1.7.1",
"tslint": "^5.7.0",
"typescript": "^2.4.2",
"vue": "^2.4.2",
"typescript": "^2.8.1",
"vue": "^2.5.16",
"vue-class-component": "^6.0.0",
"vue-loader": "^14.1.1",
"vue-loader": "^14.2.2",
"vue-property-decorator": "^6.0.0",
"vue-template-compiler": "^2.4.2",
"webpack": "^4.0.1"
"vue-template-compiler": "^2.5.16",
"webpack": "^4.3.0"
},
"dependencies": {
"@types/lodash": "^4.14.104",
"keytar": "^4.2.0",
"@types/lodash": "^4.14.106",
"keytar": "^4.2.1",
"spellchecker": "^3.4.3"
}
}

View File

@ -26,6 +26,11 @@ See https://electron.atom.io/docs/tutorial/application-distribution/
- For Android, change into the `android` directory and run `./gradlew assembleDebug`. The generated APK is placed into `app/build/outputs/apk`.
- For iOS, change into the `ios` directory and open `F-Chat.xcodeproj` using XCode. From there, simply run the app using the play button.
## Building for Web
- Change into the `webchat` directory.
- Run `yarn build`/`yarn watch` to build assets. They are placed into the `dist` directory.
- The compiled main.js file can be included by an HTML file that is expected to provide a global `const chatSettings: {account: string, theme: string, characters: ReadonlyArray<string>, defaultCharacter: string | null};`. It should also normalize the page to 100% height.
## Building a custom theme
See [the wiki](https://wiki.f-list.net/F-Chat_3.0/Themes) for instructions on how to create a custom theme.
- Change into the `scss` directory.

View File

@ -1,6 +1,9 @@
.bbcode-editor-text-area {
textarea {
min-height: 150px;
&:focus {
box-shadow: 0 0 0 ($input-btn-focus-width / 2) $input-btn-focus-color;
}
}
}

View File

@ -69,10 +69,10 @@
padding: 10px;
.body {
height: 100%;
display: none;
width: 200px;
flex-direction: column;
max-height: 100%;
overflow: auto;
}
@ -155,7 +155,12 @@
}
.border-bottom {
border-bottom: solid 1px $card-border-color;
border-bottom: solid 2px $gray-300;
}
.user-view {
cursor: pointer;
font-weight: 600;
}
.message {
@ -189,11 +194,11 @@
}
.message-event {
color: $gray-500;
color: $text-muted;
}
.message-time {
color: $gray-700;
color: $text-dark;
}
.message-highlight {
@ -211,40 +216,33 @@
border-bottom: solid 2px theme-color-level("success", -2) !important;
}
.fa.active {
.fas.active {
color: theme-color("success");
}
.gender-shemale {
color: #CC66FF;
$genders: (
"shemale": #CC66FF,
"herm": #9B30FF,
"none": $gray-500,
"female": #FF6699,
"male": #6699FF,
"male-herm": #007FFF,
"transgender": #EE8822,
"cunt-boy": #00CC66,
);
@each $gender, $color in $genders {
.gender-#{$gender} {
color: $color;
}
.message-event .gender-#{$gender} {
color: lighten($color, 5%)
}
}
.gender-herm {
color: #9B30FF;
}
.gender-none {
color: $gray-500;
}
.gender-female {
color: #FF6699;
}
.gender-male {
color: #6699FF;
}
.gender-male-herm {
color: #007FFF;
}
.gender-transgender {
color: #EE8822;
}
.gender-cunt-boy {
color: #00CC66;
.user-bookmark {
color: #66CC33;
}
#character-page-sidebar {

View File

@ -56,4 +56,5 @@
* {
-webkit-overflow-scrolling: touch;
min-height: 0;
}

View File

@ -13,7 +13,7 @@ $gray-color: #ccc !default;
$orange-color: #f60 !default;
$collapse-header-bg: $card-bg !default;
$collapse-border: darken($card-border-color, 25%) !default;
$text-dark: $text-muted !default;
// Character page quick kink comparison
$quick-compare-active-border: $black-color !default;
@ -53,4 +53,5 @@ $text-background-color-disabled: $gray-800 !default;
select {
@extend .custom-select;
-webkit-appearance: none;
-moz-appearance: none;
}

View File

@ -15,13 +15,14 @@ $black: #fff;
// Theme and brand colors
$primary: #003399;
$secondary: $gray-200;
$secondary: $gray-300;
$success: darken(#009900, 5%);
$info: #003399;
$warning: #c26c00;
$danger: darken(#930300, 5%);
$light: $gray-100;
$text-muted: $gray-400;
$text-dark: $gray-500;
$component-active-color: $gray-800;
// Core page element colors
@ -32,9 +33,8 @@ $link-hover-color: lighten($link-color, 15%);
$dropdown-bg: $gray-200;
// Modal Dialogs
$modal-backdrop-bg: $white;
$modal-content-bg: $gray-100;
$modal-backdrop-opacity: .7;
$modal-backdrop-bg: rgba($white, $modal-backdrop-opacity);
$modal-header-border-color: $gray-200;
// Fix YIQ(automatic text contrast) preferring black over white on dark themes.
@ -46,6 +46,7 @@ $grid-gutter-width: 20px;
// Form Elements
$input-bg: $gray-100;
$input-color: $black;
$input-border-color: $secondary;
$custom-select-bg: $gray-200;
// List groups

View File

@ -15,13 +15,14 @@ $black: #fff;
// Theme and brand colors
$primary: #0447af;
$secondary: $gray-300;
$secondary: $gray-400;
$success: darken(#009900, 5%);
$info: #0447af;
$warning: #f29c00;
$danger: #930300;
$light: $gray-200;
$text-muted: $gray-500;
$text-dark: $gray-600;
$component-active-color: $gray-900;
// Core page element colors
@ -31,8 +32,7 @@ $link-hover-color: lighten($link-color, 15%);
$dropdown-bg: $gray-200;
// Modal Dialogs
$modal-backdrop-opacity: .7;
$modal-backdrop-bg: rgba($white, $modal-backdrop-opacity);
$modal-backdrop-bg: $white;
$modal-content-bg: $gray-100;
$modal-header-border-color: $gray-300;

View File

@ -1 +1,14 @@
$warning: #e09d3e;
$warning: #e09d3e;
$gray-100: #e5e5e5 !default;
$gray-200: #cccccc !default;
$gray-300: #b2b2b2 !default;
$gray-400: #999999 !default;
$gray-500: #7f7f7f !default;
$gray-600: #666666 !default;
$gray-700: #4c4c4c !default;
$gray-800: #333333 !default;
$gray-900: #191919 !default;
$secondary: $gray-400;
$body-bg: #eeeeee;
$text-muted: $gray-500;

View File

@ -48,7 +48,7 @@
</div>
<div v-if="character.settings.guestbook" role="tabpanel" class="tab-pane" :class="{active: tab == 4}"
id="guestbook">
<character-guestbook :character="character" ref="tab4"></character-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">

View File

@ -10,7 +10,7 @@
<template v-if="!loading">
<div class="alert alert-info" v-show="posts.length === 0">No guestbook posts.</div>
<guestbook-post :post="post" :can-edit="canEdit" v-for="post in posts" :key="post.id" @reload="getPage"></guestbook-post>
<div v-if="authenticated" class="form-horizontal">
<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"/>
<label class="control-label" for="guestbookPostPrivate">Private(only visible to owner)</label><br/>
@ -43,7 +43,8 @@
export default class GuestbookView extends Vue {
@Prop({required: true})
private readonly character!: Character;
@Prop()
readonly oldApi?: true;
loading = true;
error = '';
authenticated = Store.authenticated;

View File

@ -11,7 +11,7 @@
<div v-if="!loading && !images.length" class="alert alert-info">No images.</div>
<div class="image-preview" v-show="previewImage" @click="previewImage = ''">
<img :src="previewImage" />
<div class="modal-backdrop in"></div>
<div class="modal-backdrop show"></div>
</div>
</div>
</template>

View File

@ -36,7 +36,7 @@ export function groupObjectBy<K extends string, T extends {[k in K]: string}>(ob
const realItem = <T>obj[objkey];
const newKey = realItem[key];
if(newObject[<string>newKey] === undefined) newObject[newKey] = [];
newObject[newKey]!.push(realItem);
newObject[<string>newKey]!.push(realItem);
}
return newObject;
}

View File

@ -2,7 +2,7 @@
* @license
* MIT License
*
* Copyright (c) 2017 F-List
* Copyright (c) 2018 F-List
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@ -24,7 +24,7 @@
*
* This license header applies to this file and all of the non-third-party assets it includes.
* @file The entry point for the web version of F-Chat 3.0.
* @copyright 2017 F-List
* @copyright 2018 F-List
* @author Maya Wolf <maya@f-list.net>
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
@ -34,6 +34,7 @@ import * as Raven from 'raven-js';
import Vue from 'vue';
import Chat from '../chat/Chat.vue';
import {init as initCore} from '../chat/core';
import l from '../chat/localize';
import Notifications from '../chat/notifications';
import VueRaven from '../chat/vue-raven';
import Socket from '../chat/WebSocket';
@ -41,17 +42,23 @@ import Connection from '../fchat/connection';
import '../scss/fa.scss'; //tslint:disable-line:no-import-side-effect
import {Logs, SettingsStore} from './logs';
declare const chatSettings: {account: string, theme: string, characters: ReadonlyArray<string>, defaultCharacter: string | null};
if(typeof Promise !== 'function' || typeof Notification !== 'function') //tslint:disable-line:strict-type-predicates
alert('Your browser is too old to be supported by F-Chat 3.0. Please update to a newer version.');
const version = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
Axios.defaults.params = { __fchat: `web/${version}` };
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: `web-${require('./package.json').version}`, //tslint:disable-line:no-require-imports no-unsafe-any
release: `web-${version}`,
dataCallback: (data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
data.culprit = `~${data.culprit.substr(data.culprit.lastIndexOf('/'))}`;
const end = data.culprit.lastIndexOf('?');
data.culprit = `~${data.culprit.substring(data.culprit.lastIndexOf('/'), end === -1 ? undefined : end)}`;
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
const endIndex = frame.filename.lastIndexOf('?');
frame.filename = `~${frame.filename.substring(index !== -1 ? index : 0, endIndex === -1 ? undefined : endIndex)}`;
}
}
}).addPlugin(VueRaven, Vue).install();
@ -60,6 +67,8 @@ if(process.env.NODE_ENV === 'production') {
};
}
declare const chatSettings: {account: string, theme: string, characters: ReadonlyArray<string>, defaultCharacter: string | null};
const ticketProvider = async() => {
const data = (await Axios.post<{ticket?: string, error: string}>(
'/json/getApiTicket.php?no_friends=true&no_bookmarks=true&no_characters=true')).data;
@ -67,14 +76,18 @@ const ticketProvider = async() => {
throw new Error(data.error);
};
initCore(new Connection('F-Chat 3.0 (Web)', '3.0', Socket, chatSettings.account, ticketProvider), Logs, SettingsStore, Notifications);
const connection = new Connection('F-Chat 3.0 (Web)', '3.0', Socket, chatSettings.account, ticketProvider);
initCore(connection, Logs, SettingsStore, Notifications);
window.addEventListener('beforeunload', (e) => {
if(!connection.isOpen) return;
e.returnValue = l('chat.confirmLeave');
return l('chat.confirmLeave');
});
require(`../scss/themes/chat/${chatSettings.theme}.scss`);
new Chat({ //tslint:disable-line:no-unused-expression
el: '#app',
propsData: {
ownCharacters: chatSettings.characters,
defaultCharacter: chatSettings.defaultCharacter
}
propsData: {ownCharacters: chatSettings.characters, defaultCharacter: chatSettings.defaultCharacter, version}
});

View File

@ -4,7 +4,7 @@ import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
type StoredConversation = {id: number, key: string, name: string};
type StoredMessage = {
id: number, conversation: number, type: Conversation.Message.Type, sender: string, text: string, time: Date, day: number
id: number, conversation: number, type: Conversation.Message.Type, sender: string, text: string, time: Date, day: number | string
};
async function promisifyRequest<T>(req: IDBRequest): Promise<T> {
@ -14,19 +14,12 @@ async function promisifyRequest<T>(req: IDBRequest): Promise<T> {
});
}
async function promisifyTransaction(req: IDBTransaction): Promise<Event> {
return new Promise<Event>((resolve, reject) => {
req.oncomplete = resolve;
req.onerror = reject;
});
}
async function iterate<S, T>(request: IDBRequest, map: (stored: S) => T): Promise<ReadonlyArray<T>> {
async function iterate<S, T>(request: IDBRequest, map: (stored: S) => T, count: number = -1): Promise<T[]> {
const array: T[] = [];
return new Promise<ReadonlyArray<T>>((resolve, reject) => {
return new Promise<T[]>((resolve, reject) => {
request.onsuccess = function(): void {
const c = <IDBCursorWithValue | undefined>this.result;
if(!c) return resolve(array); //tslint:disable-line:strict-boolean-expressions
if(!c || count !== -1 && array.length >= count) return resolve(array); //tslint:disable-line:strict-boolean-expressions
array.push(map(<S>c.value));
c.continue();
};
@ -35,87 +28,136 @@ async function iterate<S, T>(request: IDBRequest, map: (stored: S) => T): Promis
}
const dayMs = 86400000;
const charactersKey = 'fchat.characters';
let hasComposite = true;
let getComposite: (conv: number, day: number) => string | number[] = (conv, day) => [conv, day];
const decode = (str: string) => (str.charCodeAt(0) << 16) + str.charCodeAt(1);
try {
IDBKeyRange.only([]);
} catch {
hasComposite = false;
const encode = (num: number) => String.fromCharCode((num >> 16) % 65536) + String.fromCharCode(num % 65536);
getComposite = (conv, day) => `${encode(conv)}${encode(day)}`;
}
type Index = {[key: string]: StoredConversation | undefined};
async function openDatabase(character: string): Promise<IDBDatabase> {
const request = window.indexedDB.open(`logs-${character}`);
request.onupgradeneeded = () => {
const db = <IDBDatabase>request.result;
const logsStore = db.createObjectStore('logs', {keyPath: 'id', autoIncrement: true});
logsStore.createIndex('conversation', 'conversation');
logsStore.createIndex('conversation-day', hasComposite ? ['conversation', 'day'] : 'day');
db.createObjectStore('conversations', {keyPath: 'id', autoIncrement: true});
};
return promisifyRequest<IDBDatabase>(request);
}
async function getIndex(db: IDBDatabase): Promise<Index> {
const trans = db.transaction(['conversations']);
const index: Index = {};
await iterate(trans.objectStore('conversations').openCursor(), (x: StoredConversation) => index[x.key] = x);
return index;
}
export class Logs implements Logging {
index!: {[key: string]: StoredConversation | undefined};
index?: Index;
loadedDb?: IDBDatabase;
loadedCharacter?: string;
loadedIndex?: Index;
db!: IDBDatabase;
constructor() {
core.connection.onEvent('connecting', async() => {
const request = window.indexedDB.open('logs');
request.onupgradeneeded = () => {
const db = <IDBDatabase>request.result;
const logsStore = db.createObjectStore('logs', {keyPath: 'id', autoIncrement: true});
logsStore.createIndex('conversation', 'conversation');
logsStore.createIndex('conversation-day', ['conversation', 'day']);
db.createObjectStore('conversations', {keyPath: 'id', autoIncrement: true});
};
this.db = await promisifyRequest<IDBDatabase>(request);
const trans = this.db.transaction(['conversations']);
this.index = {};
await iterate(trans.objectStore('conversations').openCursor(), (x: StoredConversation) => this.index[x.key] = x);
const characters = (await this.getAvailableCharacters());
if(characters.indexOf(core.connection.character) === -1)
window.localStorage.setItem(charactersKey, JSON.stringify(characters.concat(core.connection.character)));
this.db = await openDatabase(core.connection.character);
this.index = await getIndex(this.db);
});
}
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
const trans = this.db.transaction(['logs', 'conversations'], 'readwrite');
let conv = this.index[conversation.key];
let conv = this.index![conversation.key];
if(conv === undefined) {
const convId = await promisifyRequest<number>(trans.objectStore('conversations').add(
const cTrans = this.db.transaction(['conversations'], 'readwrite');
const convId = await promisifyRequest<number>(cTrans.objectStore('conversations').add(
{key: conversation.key, name: conversation.name}));
this.index[conversation.key] = conv = {id: convId, key: conversation.key, name: conversation.name};
this.index![conversation.key] = conv = {id: convId, key: conversation.key, name: conversation.name};
}
const lTrans = this.db.transaction(['logs'], 'readwrite');
const sender = message.type === Conversation.Message.Type.Event ? undefined : message.sender.name;
const day = Math.floor(message.time.getTime() / dayMs - message.time.getTimezoneOffset() / 1440);
await promisifyRequest<number>(trans.objectStore('logs').put(
{conversation: conv.id, type: message.type, sender, text: message.text, date: message.time, day}));
await promisifyTransaction(trans);
const dayValue = hasComposite ? day : getComposite(conv.id, day);
await promisifyRequest<number>(lTrans.objectStore('logs').put(
{conversation: conv.id, type: message.type, sender, text: message.text, time: message.time, day: dayValue}));
}
async getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>> {
const trans = this.db.transaction(['logs', 'conversations']);
const conv = this.index[conversation.key];
const trans = this.db.transaction(['logs']);
const conv = this.index![conversation.key];
if(conv === undefined) return [];
return iterate(trans.objectStore('logs').index('conversation').openCursor(conv.id, 'prev'),
return (await iterate(trans.objectStore('logs').index('conversation').openCursor(conv.id, 'prev'),
(value: StoredMessage) => value.type === Conversation.Message.Type.Event ? new EventMessage(value.text, value.time) :
new Message(value.type, core.characters.get(value.sender), value.text, value.time));
new Message(value.type, core.characters.get(value.sender), value.text, value.time), 20)).reverse();
}
get conversations(): ReadonlyArray<{key: string, name: string}> {
return Object.keys(this.index).map((k) => this.index[k]!);
private async loadIndex(character: string): Promise<Index> {
if(character === this.loadedCharacter) return this.loadedIndex!;
this.loadedCharacter = character;
if(character === core.connection.character) {
this.loadedDb = this.db;
this.loadedIndex = this.index;
} else {
this.loadedDb = await openDatabase(character);
this.loadedIndex = await getIndex(this.loadedDb);
}
return this.loadedIndex!;
}
async getLogs(key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
const trans = this.db.transaction(['logs']);
const id = this.index[key]!.id;
async getConversations(character: string): Promise<ReadonlyArray<{key: string, name: string}>> {
const index = await this.loadIndex(character);
return Object.keys(index).map((k) => index[k]!);
}
async getLogs(character: string, key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {
const id = (await this.loadIndex(character))[key]!.id;
const trans = this.loadedDb!.transaction(['logs']);
const day = Math.floor(date.getTime() / dayMs - date.getTimezoneOffset() / 1440);
return iterate(trans.objectStore('logs').index('conversation-day').openCursor([id, day]),
return iterate(trans.objectStore('logs').index('conversation-day').openCursor(getComposite(id, day)),
(value: StoredMessage) => value.type === Conversation.Message.Type.Event ? new EventMessage(value.text, value.time) :
new Message(value.type, core.characters.get(value.sender), value.text, value.time));
}
async getLogDates(key: string): Promise<ReadonlyArray<Date>> {
const trans = this.db.transaction(['logs']);
const offset = new Date().getTimezoneOffset() * 1440;
const id = this.index[key]!.id;
const bound = IDBKeyRange.bound([id, 0], [id, 100000]);
return iterate(trans.objectStore('logs').index('conversation-day').openCursor(bound, 'nextunique'),
(value: StoredMessage) => new Date(value.day * dayMs + offset));
async getLogDates(character: string, key: string): Promise<ReadonlyArray<Date>> {
const id = (await this.loadIndex(character))[key]!.id;
const trans = this.loadedDb!.transaction(['logs']);
const offset = new Date().getTimezoneOffset() * 60000;
const bound = IDBKeyRange.bound(getComposite(id, 0), getComposite(id, 1000000));
return iterate(trans.objectStore('logs').index('conversation-day').openCursor(bound, 'nextunique'), (value: StoredMessage) =>
new Date((hasComposite ? <number>value.day : decode((<string>value.day).substr(2))) * dayMs + offset));
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const stored = window.localStorage.getItem(charactersKey);
return stored !== null ? JSON.parse(stored) as string[] : [];
}
}
export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K): Promise<Settings.Keys[K]> {
const stored = window.localStorage.getItem(`settings.${key}`);
return Promise.resolve(stored !== null ? JSON.parse(stored) : undefined);
async get<K extends keyof Settings.Keys>(key: K): Promise<Settings.Keys[K] | undefined> {
const stored = window.localStorage.getItem(`${core.connection.character}.settings.${key}`);
return stored !== null ? JSON.parse(stored) as Settings.Keys[K] : undefined;
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
window.localStorage.setItem(`settings.${key}`, JSON.stringify(value));
window.localStorage.setItem(`${core.connection.character}.settings.${key}`, JSON.stringify(value));
return Promise.resolve();
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
return Promise.resolve([]);
const stored = window.localStorage.getItem(charactersKey);
return stored !== null ? JSON.parse(stored) as string[] : [];
}
}

View File

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

669
yarn.lock

File diff suppressed because it is too large Load Diff