0.2.19 - Lots of polish for stable release.
This commit is contained in:
parent
4a7d97f17a
commit
79d1ee4f48
2
LICENSE
2
LICENSE
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -43,6 +43,7 @@ export class Settings implements ISettings {
|
|||
fontSize = 14;
|
||||
showNeedsReply = false;
|
||||
enterSend = true;
|
||||
colorBookmarks = false;
|
||||
}
|
||||
|
||||
export class ConversationSettings implements Conversation.Settings {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop in"></div>
|
||||
<div class="modal-backdrop show"></div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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', {});
|
||||
}
|
||||
};
|
36
package.json
36
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -56,4 +56,5 @@
|
|||
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-height: 0;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
});
|
148
webchat/logs.ts
148
webchat/logs.ts
|
@ -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[] : [];
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue