This commit is contained in:
MayaWolf 2018-01-06 17:14:21 +01:00
parent b1a63ab6fb
commit 690ae19404
155 changed files with 4399 additions and 2694 deletions

4
.gitignore vendored
View File

@ -1,7 +1,5 @@
node_modules/
/electron/app
/electron/dist
/cordova/platforms
/cordova/plugins
/cordova/www
/mobile/www
*.vue.ts

View File

@ -1,7 +1,10 @@
<template>
<div class="bbcodeEditorContainer">
<slot></slot>
<div class="btn-group" role="toolbar">
<a tabindex="0" class="btn bbcodeEditorButton bbcode-btn" role="button" @click="showToolbar = true" @blur="showToolbar = false">
<span class="fa fa-code"></span></a>
<div class="bbcode-toolbar" role="toolbar" :style="showToolbar ? 'display:block' : ''" @mousedown.stop.prevent>
<button type="button" class="close" aria-label="Close" style="margin-left:10px" @click="showToolbar = false">&times;</button>
<div class="bbcodeEditorButton btn" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<span :class="'fa ' + button.icon"></span>
</div>
@ -11,7 +14,7 @@
</div>
</div>
<div class="bbcodeEditorTextarea">
<textarea ref="input" :value="text" @input="$emit('input', $event.target.value)" v-show="!preview" :maxlength="maxlength"
<textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength"
:class="'bbcodeTextAreaTextArea ' + classes" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste"
:placeholder="placeholder" @keypress="$emit('keypress', $event)" @keydown="onKeyDown"></textarea>
<div class="bbcodePreviewArea" v-show="preview">
@ -57,9 +60,13 @@
element: HTMLTextAreaElement;
maxHeight: number;
minHeight: number;
showToolbar = false;
protected parser: BBCodeParser;
protected defaultButtons = defaultButtons;
private isShiftPressed = false;
private undoStack: string[] = [];
private undoIndex = 0;
private lastInput = 0;
created(): void {
this.parser = new CoreBBCodeParser();
@ -71,6 +78,12 @@
this.maxHeight = parseInt($element.css('max-height'), 10);
//tslint:disable-next-line:strict-boolean-expressions
this.minHeight = parseInt($element.css('min-height'), 10) || $element.outerHeight() || 50;
setInterval(() => {
if(Date.now() - this.lastInput >= 500 && this.text !== this.undoStack[0] && this.undoIndex === 0) {
if(this.undoStack.length >= 30) this.undoStack.pop();
this.undoStack.unshift(this.text);
}
}, 500);
}
get buttons(): EditorButton[] {
@ -83,8 +96,12 @@
@Watch('value')
watchValue(newValue: string): void {
this.text = newValue;
this.$nextTick(() => this.resize());
if(this.text === newValue) return;
this.text = newValue;
this.lastInput = 0;
this.undoIndex = 0;
this.undoStack = [];
}
getSelection(): EditorSelection {
@ -138,11 +155,35 @@
if(button.endText === undefined)
button.endText = `[/${button.tag}]`;
this.applyText(button.startText, button.endText);
this.lastInput = Date.now();
}
onInput(): void {
if(this.undoIndex > 0) {
this.undoStack = this.undoStack.slice(this.undoIndex);
this.undoIndex = 0;
}
this.$emit('input', this.text);
this.lastInput = Date.now();
}
onKeyDown(e: KeyboardEvent): void {
const key = getKey(e);
if((e.metaKey || e.ctrlKey) && !e.shiftKey && key !== 'Control' && key !== 'Meta') { //tslint:disable-line:curly
if((e.metaKey || e.ctrlKey) && !e.shiftKey && key !== 'control' && key !== 'meta') {
if(key === 'z') {
e.preventDefault();
if(this.undoIndex === 0 && this.undoStack[0] !== this.text) this.undoStack.unshift(this.text);
if(this.undoStack.length > this.undoIndex + 1) {
this.text = this.undoStack[++this.undoIndex];
this.lastInput = Date.now();
}
} else if(key === 'y') {
e.preventDefault();
if(this.undoIndex > 0) {
this.text = this.undoStack[--this.undoIndex];
this.lastInput = Date.now();
}
}
for(const button of this.buttons)
if(button.key === key) {
e.stopPropagation();
@ -150,12 +191,12 @@
this.apply(button);
break;
}
} else if(key === 'Shift') this.isShiftPressed = true;
} else if(key === 'shift') this.isShiftPressed = true;
this.$emit('keydown', e);
}
onKeyUp(e: KeyboardEvent): void {
if(getKey(e) === 'Shift') this.isShiftPressed = false;
if(getKey(e) === 'shift') this.isShiftPressed = false;
this.$emit('keyup', e);
}

View File

@ -1,6 +1,6 @@
import {BBCodeCustomTag, BBCodeParser, BBCodeSimpleTag} from './parser';
const urlFormat = '((?:(?:https?|ftps?|irc):)?\\/\\/[^\\s\\/$.?#"\']+\\.[^\\s"]*)';
const urlFormat = '((?:https?|ftps?|irc)://[^\\s/$.?#"\']+\\.[^\\s"]+)';
export const findUrlRegex = new RegExp(`(\\[url[=\\]]\\s*)?${urlFormat}`, 'gi');
export const urlRegex = new RegExp(`^${urlFormat}$`);

View File

@ -54,13 +54,13 @@ export let defaultButtons: ReadonlyArray<EditorButton> = [
title: 'Superscript (Ctrl+↑)\n\nLifts text above the text baseline. Makes text slightly smaller. Cannot be nested.',
tag: 'sup',
icon: 'fa-superscript',
key: 'ArrowUp'
key: 'arrowup'
},
{
title: 'Subscript (Ctrl+↓)\n\nPushes text below the text baseline. Makes text slightly smaller. Cannot be nested.',
tag: 'sub',
icon: 'fa-subscript',
key: 'ArrowDown'
key: 'arrowdown'
},
{
title: 'URL (Ctrl+L)\n\nCreates a clickable link to another page of your choosing.',

View File

@ -160,7 +160,7 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
const showP1 = showInline.hash.substr(0, 2);
const showP2 = showInline.hash.substr(2, 2);
//tslint:disable-next-line:max-line-length
$(element).replaceWith(`<div><img class="imageBlock" src="${this.settings.staticDomain}images/charinline/${showP1}/${showP2}/${showInline.hash}.${showInline.extension}"/></div>`);
$(element).replaceWith(`<div><img class="inline-image" src="${this.settings.staticDomain}images/charinline/${showP1}/${showP2}/${showInline.hash}.${showInline.extension}"/></div>`);
});
return false;
};
@ -171,7 +171,7 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
} else {
const outerEl = parser.createElement('div');
const el = parser.createElement('img');
el.className = 'imageBlock';
el.className = 'inline-image';
el.src = `${this.settings.staticDomain}images/charinline/${p1}/${p2}/${inline.hash}.${inline.extension}`;
outerEl.appendChild(el);
parent.appendChild(outerEl);
@ -179,7 +179,7 @@ export class StandardBBCodeParser extends CoreBBCodeParser {
}
}, (_, element, __, ___) => {
// Need to remove any appended contents, because this is a total hack job.
if(element.className !== 'imageBlock')
if(element.className !== 'inline-image')
return;
while(element.firstChild !== null)
element.removeChild(element.firstChild);

View File

@ -1,7 +1,7 @@
<template>
<modal :buttons="false" :action="l('chat.channels')" @close="closed">
<div style="display: flex; flex-direction: column;">
<ul class="nav nav-tabs">
<ul class="nav nav-tabs" style="flex-shrink:0">
<li role="presentation" :class="{active: !privateTabShown}">
<a href="#" @click.prevent="privateTabShown = false">{{l('channelList.public')}}</a>
</li>
@ -73,7 +73,6 @@
const channels: Channel.ListItem[] = [];
if(this.filter.length > 0) {
const search = new RegExp(this.filter.replace(/[^\w]/gi, '\\$&'), 'i');
//tslint:disable-next-line:forin
for(const key in list) {
const item = list[key]!;
if(search.test(item.name)) channels.push(item);

View File

@ -112,8 +112,10 @@
this.error = l('characterSearch.error.tooManyResults');
}
});
core.connection.onMessage('FKS', (data) => this.results = data.characters.filter((x) =>
core.state.hiddenUsers.indexOf(x) === -1).map((x) => core.characters.get(x)).sort(sort));
core.connection.onMessage('FKS', (data) => {
this.results = data.characters.filter((x) => core.state.hiddenUsers.indexOf(x) === -1)
.map((x) => core.characters.get(x)).sort(sort);
});
(<Modal>this.$children[0]).fixDropdowns();
}

View File

@ -32,7 +32,7 @@
import Channels from '../fchat/channels';
import Characters from '../fchat/characters';
import ChatView from './ChatView.vue';
import {errorToString, requestNotificationsPermission} from './common';
import {errorToString} from './common';
import Conversations from './conversations';
import core from './core';
import l from './localize';
@ -44,8 +44,8 @@
@Prop({required: true})
readonly ownCharacters: string[];
@Prop({required: true})
readonly defaultCharacter: string;
selectedCharacter = this.defaultCharacter;
readonly defaultCharacter: string | undefined;
selectedCharacter = this.defaultCharacter || this.ownCharacters[0]; //tslint:disable-line:strict-boolean-expressions
error = '';
connecting = false;
connected = false;
@ -59,10 +59,11 @@
if(isReconnect) (<Modal>this.$refs['reconnecting']).show(true);
if(this.connected) core.notifications.playSound('logout');
this.connected = false;
this.connecting = false;
});
core.connection.onEvent('connecting', async() => {
this.connecting = true;
if(core.state.settings.notifications) await requestNotificationsPermission();
if(core.state.settings.notifications) await core.notifications.requestPermission();
});
core.connection.onEvent('connected', () => {
(<Modal>this.$refs['reconnecting']).hide();

View File

@ -1,77 +1,68 @@
<template>
<div style="height:100%; display: flex; position: relative;" id="chatView" @click="$refs['userMenu'].handleEvent($event)"
@contextmenu="$refs['userMenu'].handleEvent($event)" @touchstart="$refs['userMenu'].handleEvent($event)"
@contextmenu="$refs['userMenu'].handleEvent($event)" @touchstart.passive="$refs['userMenu'].handleEvent($event)"
@touchend="$refs['userMenu'].handleEvent($event)">
<div class="sidebar sidebar-left" id="sidebar">
<button @click="sidebarExpanded = !sidebarExpanded" class="btn btn-default btn-xs expander" :aria-label="l('chat.menu')">
<span class="fa" :class="{'fa-chevron-up': sidebarExpanded, 'fa-chevron-down': !sidebarExpanded}"></span>
<span class="fa fa-bars fa-rotate-90" style="vertical-align: middle"></span>
</button>
<div class="body" :style="sidebarExpanded ? 'display:block' : ''"
style="width: 200px; padding-right: 5px; height: 100%; overflow: auto;">
<img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left; margin-right:5px; width:60px;"/>
{{ownCharacter.name}}
<a href="#" @click.prevent="logOut" class="btn"><span class="fa fa-sign-out"></span>{{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)}}
</a>
</div>
<div style="clear:both;">
<a href="#" @click.prevent="$refs['searchDialog'].show()" class="btn"><span class="fa fa-search"></span>
{{l('characterSearch.open')}}</a>
</div>
<div><a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn"><span class="fa fa-cog"></span>
{{l('settings.open')}}</a></div>
<div><a href="#" @click.prevent="$refs['recentDialog'].show()" class="btn"><span class="fa fa-history"></span>
{{l('chat.recentConversations')}}</a></div>
<div>
<div class="list-group conversation-nav">
<a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()"
class="list-group-item list-group-item-action">
{{conversations.consoleTab.name}}
</a>
</div>
</div>
<div>
{{l('chat.pms')}}
<div class="list-group conversation-nav" ref="privateConversations">
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" :data-character="conversation.character.name"
class="list-group-item list-group-item-action item-private" :key="conversation.key">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<div class="name">
<span>{{conversation.character.name}}</span>
<div style="text-align:right;line-height:0">
<span class="fa"
:class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}" @mousedown.prevent
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="fa fa-times leave" @click.stop="conversation.close()"
:aria-label="l('chat.closeTab')"></span>
</div>
</div>
</a>
</div>
</div>
<div>
<a href="#" @click.prevent="$refs['channelsDialog'].show()" class="btn"><span class="fa 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-thumb-tack"
:class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave"
@click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
</a>
</div>
</div>
<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"/>
{{ownCharacter.name}}
<a href="#" @click.prevent="logOut" class="btn"><span class="fa fa-sign-out"></span>{{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)}}
</a>
</div>
</div>
<div style="clear:both">
<a href="#" @click.prevent="$refs['searchDialog'].show()" class="btn"><span class="fa fa-search"></span>
{{l('characterSearch.open')}}</a>
</div>
<div><a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn"><span class="fa fa-cog"></span>
{{l('settings.open')}}</a></div>
<div><a href="#" @click.prevent="$refs['recentDialog'].show()" class="btn"><span class="fa 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()"
class="list-group-item list-group-item-action">
{{conversations.consoleTab.name}}
</a>
</div>
{{l('chat.pms')}}
<div class="list-group conversation-nav" ref="privateConversations">
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" :data-character="conversation.character.name" data-touch="false"
class="list-group-item list-group-item-action item-private" :key="conversation.key">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<div class="name">
<span>{{conversation.character.name}}</span>
<div style="text-align:right;line-height:0">
<span class="fa"
:class="{'fa-commenting': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span class="pin fa fa-thumb-tack" :class="{'active': conversation.isPinned}" @mousedown.prevent
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="fa fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</div>
</div>
</a>
</div>
<a href="#" @click.prevent="$refs['channelsDialog'].show()" class="btn"><span class="fa 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-thumb-tack"
:class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fa fa-times leave"
@click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
</a>
</div>
</sidebar>
<div style="width: 100%; display:flex; flex-direction:column;">
<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>
{{conversations.consoleTab.name}}
</a>
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
@ -112,6 +103,7 @@
import RecentConversations from './RecentConversations.vue';
import ReportDialog from './ReportDialog.vue';
import SettingsView from './SettingsView.vue';
import Sidebar from './Sidebar.vue';
import StatusSwitcher from './StatusSwitcher.vue';
import {getStatusIcon} from './user_view';
import UserList from './UserList.vue';
@ -120,13 +112,13 @@
const unreadClasses = {
[Conversation.UnreadState.None]: '',
[Conversation.UnreadState.Mention]: 'list-group-item-warning',
[Conversation.UnreadState.Unread]: 'has-new'
[Conversation.UnreadState.Unread]: 'list-group-item-danger'
};
@Component({
components: {
'user-list': UserList, channels: ChannelList, 'status-switcher': StatusSwitcher, 'character-search': CharacterSearch,
settings: SettingsView, conversation: ConversationView, 'report-dialog': ReportDialog,
settings: SettingsView, conversation: ConversationView, 'report-dialog': ReportDialog, sidebar: Sidebar,
'user-menu': UserMenu, 'recent-conversations': RecentConversations
}
})
@ -140,19 +132,25 @@
mounted(): void {
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
document.addEventListener('keydown', this.keydownListener);
window.addEventListener('keydown', this.keydownListener);
this.setFontSize(core.state.settings.fontSize);
Sortable.create(this.$refs['privateConversations'], {
animation: 50,
onEnd: (e: {oldIndex: number, newIndex: number}) => core.conversations.privateConversations[e.oldIndex].sort(e.newIndex)
onEnd: async(e: {oldIndex: number, newIndex: number}) => {
if(e.oldIndex === e.newIndex) return;
return core.conversations.privateConversations[e.oldIndex].sort(e.newIndex);
}
});
Sortable.create(this.$refs['channelConversations'], {
animation: 50,
onEnd: (e: {oldIndex: number, newIndex: number}) => core.conversations.channelConversations[e.oldIndex].sort(e.newIndex)
onEnd: async(e: {oldIndex: number, newIndex: number}) => {
if(e.oldIndex === e.newIndex) return;
return core.conversations.channelConversations[e.oldIndex].sort(e.newIndex);
}
});
const ownCharacter = core.characters.ownCharacter;
let idleTimer: number | undefined, idleStatus: Connection.ClientCommands['STA'] | undefined, lastUpdate = 0;
window.focus = () => {
window.addEventListener('focus', () => {
core.notifications.isInBackground = false;
if(idleTimer !== undefined) {
clearTimeout(idleTimer);
@ -164,8 +162,8 @@
idleStatus = undefined;
}
}, Math.max(lastUpdate + 5 /*core.connection.vars.sta_flood*/ * 1000 + 1000 - Date.now(), 0));
};
window.blur = () => {
});
window.addEventListener('blur', () => {
core.notifications.isInBackground = true;
if(idleTimer !== undefined) clearTimeout(idleTimer);
if(core.state.settings.idleTimer !== 0)
@ -174,7 +172,7 @@
idleStatus = {status: ownCharacter.status, statusmsg: ownCharacter.statusText};
core.connection.send('STA', {status: 'idle', statusmsg: ownCharacter.statusText});
}, core.state.settings.idleTimer * 60000);
};
});
core.connection.onEvent('closed', () => {
if(idleTimer !== undefined) {
window.clearTimeout(idleTimer);
@ -189,7 +187,7 @@
}
destroyed(): void {
document.removeEventListener('keydown', this.keydownListener);
window.removeEventListener('keydown', this.keydownListener);
}
onKeyDown(e: KeyboardEvent): void {
@ -197,9 +195,11 @@
const pms = this.conversations.privateConversations;
const channels = this.conversations.channelConversations;
const console = this.conversations.consoleTab;
if(getKey(e) === 'ArrowUp' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
if(selected === console) return;
if(Conversation.isPrivate(selected)) {
if(getKey(e) === 'arrowup' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(selected === console) { //tslint:disable-line:curly
if(channels.length > 0) channels[channels.length - 1].show();
else if(pms.length > 0) pms[pms.length - 1].show();
} else if(Conversation.isPrivate(selected)) {
const index = pms.indexOf(selected);
if(index === 0) console.show();
else pms[index - 1].show();
@ -210,7 +210,7 @@
else console.show();
else channels[index - 1].show();
}
} else if(getKey(e) === 'ArrowDown' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
else if(getKey(e) === 'arrowdown' && e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey)
if(selected === console) { //tslint:disable-line:curly - false positive
if(pms.length > 0) pms[0].show();
else if(channels.length > 0) channels[0].show();
@ -221,7 +221,8 @@
} else pms[index + 1].show();
} else {
const index = channels.indexOf(<Conversation.ChannelConversation>selected);
if(index !== channels.length - 1) channels[index + 1].show();
if(index < channels.length - 1) channels[index + 1].show();
else console.show();
}
}
@ -257,13 +258,13 @@
}
getClasses(conversation: Conversation): string {
return unreadClasses[conversation.unread] + (conversation === core.conversations.selectedConversation ? ' active' : '');
return conversation === core.conversations.selectedConversation ? ' active' : unreadClasses[conversation.unread];
}
}
</script>
<style lang="less">
@import '~bootstrap/less/variables.less';
@import "../less/flist_variables.less";
.list-group.conversation-nav {
margin-bottom: 10px;
@ -271,6 +272,9 @@
padding: 5px;
display: flex;
align-items: center;
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
.name {
flex: 1;
overflow: hidden;
@ -303,6 +307,10 @@
border-bottom-left-radius: 4px;
}
}
.list-group-item-danger:not(.active) {
color: inherit;
}
}
#quick-switcher {
@ -319,6 +327,8 @@
text-align: center;
line-height: 1;
padding: 5px 5px 0;
overflow: hidden;
flex-shrink: 0;
&:first-child {
border-radius: 4px 0 0 4px;
&:last-child {
@ -343,6 +353,10 @@
font-size: 2em;
height: 30px;
}
.list-group-item-danger:not(.active) {
color: inherit;
}
}
#sidebar {
@ -350,7 +364,13 @@
padding: 2px 0;
}
@media (min-width: @screen-sm-min) {
position: static;
.sidebar {
position: static;
margin: 0;
padding: 0;
height: 100%;
}
.body {
display: block;
}

View File

@ -49,7 +49,6 @@
mounted(): void {
const permissions = core.connection.vars.permissions;
//tslint:disable-next-line:forin
for(const key in commands) {
const command = commands[key]!;
if(command.documented !== undefined ||

View File

@ -7,10 +7,10 @@
<user :character="conversation.character"></user>
<logs :conversation="conversation"></logs>
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
<span class="fa fa-cog"></span> {{l('conversationSettings.title')}}
<span class="fa fa-cog"></span> <span class="btn-text">{{l('conversationSettings.title')}}</span>
</a>
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation"></span>
{{l('chat.report')}}</a>
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation-triangle"></span>
<span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<div style="overflow: auto">
{{l('status.' + conversation.character.status)}}
@ -26,15 +26,15 @@
<h4 style="margin: 0; display:inline; vertical-align: middle;">{{conversation.name}}</h4>
<a @click="descriptionExpanded = !descriptionExpanded" class="btn">
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
{{l('channel.description')}}
<span class="btn-text">{{l('channel.description')}}</span>
</a>
<manage-channel :channel="conversation.channel" v-if="isChannelMod"></manage-channel>
<logs :conversation="conversation"></logs>
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
<span class="fa fa-cog"></span> {{l('conversationSettings.title')}}
<span class="fa fa-cog"></span> <span class="btn-text">{{l('conversationSettings.title')}}</span>
</a>
<a href="#" @click.prevent="reportDialog.report();" class="btn"><span class="fa fa-exclamation-triangle"></span>
{{l('chat.report')}}</a>
<span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<ul class="nav nav-pills mode-switcher">
<li v-for="mode in modes" :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}">
@ -72,18 +72,18 @@
{{l('chat.typing.' + conversation.typingStatus, conversation.name)}}
</span>
<div v-show="conversation.infoText" style="display:flex;align-items:center">
<span class="fa fa-times" style="cursor:pointer" @click.stop="conversation.infoText = '';"></span>
<span class="fa fa-times" style="cursor:pointer" @click.stop="conversation.infoText = ''"></span>
<span style="flex:1;margin-left:5px">{{conversation.infoText}}</span>
</div>
<div v-show="conversation.errorText" style="display:flex;align-items:center">
<span class="fa fa-times" style="cursor:pointer" @click.stop="conversation.errorText = '';"></span>
<span class="fa fa-times" style="cursor:pointer" @click.stop="conversation.errorText = ''"></span>
<span class="redText" style="flex:1;margin-left:5px">{{conversation.errorText}}</span>
</div>
<div style="position:relative; margin-top:5px;">
<div class="overlay-disable" v-show="adCountdown">{{adCountdown}}</div>
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="onInput"
classes="form-control chat-text-box" :disabled="adCountdown" ref="textBox" style="position:relative;"
:maxlength="conversation.maxMessageLength">
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')" :disabled="adCountdown"
ref="textBox" style="position:relative" :maxlength="conversation.maxMessageLength">
<div style="float:right;text-align:right;display:flex;align-items:center">
<div v-show="conversation.maxMessageLength" style="margin-right: 5px;">
{{getByteLength(conversation.enteredText)}} / {{conversation.maxMessageLength}}
@ -206,9 +206,9 @@
});
}
onKeyDown(e: KeyboardEvent): void {
async onKeyDown(e: KeyboardEvent): Promise<void> {
const editor = <Editor>this.$refs['textBox'];
if(getKey(e) === 'Tab') {
if(getKey(e) === 'tab') {
e.preventDefault();
if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return;
if(this.tabOptions === undefined) {
@ -242,13 +242,13 @@
}
} else {
if(this.tabOptions !== undefined) this.tabOptions = undefined;
if(getKey(e) === 'ArrowUp' && this.conversation.enteredText.length === 0
if(getKey(e) === 'arrowup' && this.conversation.enteredText.length === 0
&& !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey)
this.conversation.loadLastSent();
else if(getKey(e) === 'Enter') {
else if(getKey(e) === 'enter') {
if(e.shiftKey) return;
e.preventDefault();
this.conversation.send();
await this.conversation.send();
}
}
}
@ -302,8 +302,7 @@
</script>
<style lang="less">
@import '~bootstrap/less/variables.less';
@import "../less/flist_variables.less";
#conversation {
.header {
@media (min-width: @screen-sm-min) {

View File

@ -1,7 +1,8 @@
<template>
<span>
<a href="#" @click.prevent="showLogs" class="btn">
<span class="fa" :class="isPersistent ? 'fa-file-text-o' : 'fa-download'"></span> {{l('logs.title')}}
<span class="fa" :class="isPersistent ? 'fa-file-text-o' : 'fa-download'"></span>
<span class="btn-text">{{l('logs.title')}}</span>
</a>
<modal v-if="isPersistent" :buttons="false" ref="dialog" id="logs-dialog" :action="l('logs.title')" dialogClass="modal-lg"
@open="onOpen" class="form-horizontal">
@ -9,7 +10,7 @@
<label class="col-sm-2">{{l('logs.conversation')}}</label>
<div class="col-sm-10">
<filterable-select v-model="selectedConversation" :options="conversations" :filterFunc="filterConversation"
buttonClass="form-control" :placeholder="l('filter')">
buttonClass="form-control" :placeholder="l('filter')" @input="loadMessages">
<template slot-scope="s">{{s.option && ((s.option.id[0] == '#' ? '#' : '') + s.option.name)}}</template>
</filterable-select>
</div>
@ -60,7 +61,7 @@
@Prop({required: true})
readonly conversation: Conversation;
selectedConversation: {id: string, name: string} | null = null;
selectedDate: Date | null = null;
selectedDate: string | null = null;
isPersistent = LogInterfaces.isPersistent(core.logs);
conversations = LogInterfaces.isPersistent(core.logs) ? core.logs.conversations : undefined;
l = l;

View File

@ -1,7 +1,7 @@
<template>
<span>
<a href="#" @click.prevent="openDialog" class="btn">
<span class="fa fa-edit"></span> {{l('manageChannel.open')}}
<span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span>
</a>
<modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit">
<div class="form-group" v-show="channel.id.substr(0, 4) === 'adh-'">

View File

@ -52,7 +52,7 @@
</div>
<div class="form-group">
<label class="control-label" for="fontSize">{{l('settings.fontSize')}}</label>
<input id="fontSize" type="number" min="10" max="24" number class="form-control" v-model="fontSize"/>
<input id="fontSize" type="number" min="10" max="24" class="form-control" v-model="fontSize"/>
</div>
</div>
<div v-show="selectedTab == 'notifications'">
@ -111,7 +111,6 @@
import Component from 'vue-class-component';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import {requestNotificationsPermission} from './common';
import core from './core';
import {Settings as SettingsInterface} from './interfaces';
import l from './localize';
@ -206,9 +205,9 @@
alwaysNotify: this.alwaysNotify,
logMessages: this.logMessages,
logAds: this.logAds,
fontSize: this.fontSize
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize
};
if(this.notifications) await requestNotificationsPermission();
if(this.notifications) await core.notifications.requestPermission();
}
}
</script>

39
chat/Sidebar.vue Normal file
View File

@ -0,0 +1,39 @@
<template>
<div class="sidebar-wrapper" :class="{open: expanded}">
<div :class="'sidebar sidebar-' + (right ? 'right' : 'left')">
<button @click="expanded = !expanded" class="btn btn-default btn-xs expander" :aria-label="label">
<span :class="'fa fa-rotate-270 ' + icon" style="vertical-align: middle" v-if="right"></span>
<span class="fa" :class="{'fa-chevron-down': !expanded, 'fa-chevron-up': expanded}"></span>
<span :class="'fa fa-rotate-90 ' + icon" style="vertical-align: middle" v-if="!right"></span>
</button>
<div class="body">
<slot></slot>
</div>
</div>
<div class="modal-backdrop in" @click="expanded = false"></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
@Component
export default class Sidebar extends Vue {
@Prop()
readonly right?: true;
@Prop()
readonly label?: string;
@Prop({required: true})
readonly icon: string;
@Prop({default: false})
readonly open: boolean;
expanded = this.open;
@Watch('open')
watchOpen(): void {
this.expanded = this.open;
}
}
</script>

View File

@ -1,35 +1,30 @@
<template>
<div id="user-list" class="sidebar sidebar-right">
<button @click="expanded = !expanded" class="btn btn-default btn-xs expander" :aria-label="l('users.title')">
<span class="fa fa-users fa-rotate-270" style="vertical-align: middle"></span>
<span class="fa" :class="{'fa-chevron-down': !expanded, 'fa-chevron-up': expanded}"></span>
</button>
<div class="body" :style="expanded ? 'display:flex' : ''" style="min-width: 200px; flex-direction:column; max-height: 100%;">
<ul class="nav nav-tabs" style="flex-shrink:0">
<li role="presentation" :class="{active: !channel || !memberTabShown}">
<a href="#" @click.prevent="memberTabShown = false">{{l('users.friends')}}</a>
</li>
<li role="presentation" :class="{active: memberTabShown}" v-show="channel">
<a href="#" @click.prevent="memberTabShown = true">{{l('users.members')}}</a>
</li>
</ul>
<div v-show="!channel || !memberTabShown" class="users" style="padding-left: 10px;">
<h4>{{l('users.friends')}}</h4>
<div v-for="character in friends" :key="character.name">
<user :character="character" :showStatus="true"></user>
</div>
<h4>{{l('users.bookmarks')}}</h4>
<div v-for="character in bookmarks" :key="character.name">
<user :character="character" :showStatus="true"></user>
</div>
<sidebar id="user-list" :label="l('users.title')" icon="fa-users" :right="true" :open="expanded">
<ul class="nav nav-tabs" style="flex-shrink:0">
<li role="presentation" :class="{active: !channel || !memberTabShown}">
<a href="#" @click.prevent="memberTabShown = false">{{l('users.friends')}}</a>
</li>
<li role="presentation" :class="{active: memberTabShown}" v-show="channel">
<a href="#" @click.prevent="memberTabShown = true">{{l('users.members')}}</a>
</li>
</ul>
<div v-show="!channel || !memberTabShown" class="users" style="padding-left:10px">
<h4>{{l('users.friends')}}</h4>
<div v-for="character in friends" :key="character.name">
<user :character="character" :showStatus="true"></user>
</div>
<div v-if="channel" v-show="memberTabShown" class="users" style="padding: 5px;">
<div v-for="member in channel.sortedMembers" :key="member.character.name">
<user :character="member.character" :channel="channel" :showStatus="true"></user>
</div>
<h4>{{l('users.bookmarks')}}</h4>
<div v-for="character in bookmarks" :key="character.name">
<user :character="character" :showStatus="true"></user>
</div>
</div>
</div>
<div v-if="channel" v-show="memberTabShown" class="users" style="padding:5px">
<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>
</div>
</sidebar>
</template>
<script lang="ts">
@ -38,14 +33,15 @@
import core from './core';
import {Channel, Character, Conversation} from './interfaces';
import l from './localize';
import Sidebar from './Sidebar.vue';
import UserView from './user_view';
@Component({
components: {user: UserView}
components: {user: UserView, sidebar: Sidebar}
})
export default class UserList extends Vue {
memberTabShown = false;
expanded = window.innerWidth >= 992;
expanded = window.innerWidth >= 900;
l = l;
sorter = (x: Character, y: Character) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0));
@ -64,8 +60,7 @@
</script>
<style lang="less">
@import '~bootstrap/less/variables.less';
@import "../less/flist_variables.less";
#user-list {
flex-direction: column;
h4 {
@ -82,8 +77,21 @@
border-top-left-radius: 0;
}
@media (min-width: @screen-sm-min) {
position: static;
@media (min-width: @screen-md-min) {
.sidebar {
position: static;
margin: 0;
padding: 0;
height: 100%;
}
.modal-backdrop {
display: none;
}
}
&.open .body {
display: flex;
}
}
</style>

View File

@ -115,10 +115,10 @@
this.memo = '';
(<Modal>this.$refs['memo']).show();
try {
const memo = <{note: string, id: number}>await core.connection.queryApi('character-memo-get.php',
const memo = <{note: string | null, id: number}>await core.connection.queryApi('character-memo-get2.php',
{target: this.character!.name});
this.memoId = memo.id;
this.memo = memo.note;
this.memo = memo.note !== null ? memo.note : '';
this.memoLoading = false;
} catch(e) {
alert(errorToString(e));
@ -165,6 +165,7 @@
if(node.character !== undefined || node.dataset['character'] !== undefined || node.parentNode === null) break;
node = node.parentElement!;
}
if(node.dataset['touch'] === 'false' && e.type !== 'contextmenu') return;
if(node.character === undefined)
if(node.dataset['character'] !== undefined) node.character = core.characters.get(node.dataset['character']!);
else {
@ -174,6 +175,7 @@
switch(e.type) {
case 'click':
if(node.dataset['character'] === undefined) this.onClick(node.character);
e.preventDefault();
break;
case 'touchstart':
this.touchTimer = window.setTimeout(() => {
@ -190,8 +192,8 @@
break;
case 'contextmenu':
this.openMenu(touch, node.character, node.channel);
e.preventDefault();
}
e.preventDefault();
}
private onClick(character: Character): void {

View File

@ -1,9 +1,11 @@
import {WebSocketConnection} from '../fchat/interfaces';
import {WebSocketConnection} from '../fchat';
import l from './localize';
export default class Socket implements WebSocketConnection {
static host = 'wss://chat.f-list.net:9799';
socket: WebSocket;
private socket: WebSocket;
private errorHandler: (error: Error) => void;
private lastHandler: Promise<void> = Promise.resolve();
constructor() {
this.socket = new WebSocket(Socket.host);
@ -14,7 +16,9 @@ export default class Socket implements WebSocketConnection {
}
onMessage(handler: (message: string) => void): void {
this.socket.addEventListener('message', (e) => handler(<string>e.data));
this.socket.addEventListener('message', (e) => {
this.lastHandler = this.lastHandler.then(() => handler(<string>e.data), this.errorHandler);
});
}
onOpen(handler: () => void): void {
@ -26,6 +30,7 @@ export default class Socket implements WebSocketConnection {
}
onError(handler: (error: Error) => void): void {
this.errorHandler = handler;
this.socket.addEventListener('error', () => handler(new Error(l('login.connectError'))));
}

View File

@ -65,7 +65,7 @@ export function messageToString(this: void | never, msg: Conversation.Message, t
export function getKey(e: KeyboardEvent): string {
/*tslint:disable-next-line:strict-boolean-expressions no-any*///because of old browsers.
return e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier;
return (e.key || (<KeyboardEvent & {keyIdentifier: string}>e).keyIdentifier).toLowerCase();
}
/*tslint:disable:no-any no-unsafe-any*///because errors can be any
@ -74,10 +74,6 @@ export function errorToString(e: any): string {
}
//tslint:enable
export async function requestNotificationsPermission(): Promise<void> {
if((<Window & {Notification: Notification | undefined}>window).Notification !== undefined) await Notification.requestPermission();
}
let messageId = 0;
export class Message implements Conversation.ChatMessage {

View File

@ -1,4 +1,3 @@
//tslint:disable:no-floating-promises
import {queuedJoin} from '../fchat/channels';
import {decodeHTML} from '../fchat/common';
import {characterImage, ConversationSettings, EventMessage, Message, messageToString} from './common';
@ -46,7 +45,7 @@ abstract class Conversation implements Interfaces.Conversation {
set settings(value: Interfaces.Settings) {
this._settings = value;
state.setSettings(this.key, value);
state.setSettings(this.key, value); //tslint:disable-line:no-floating-promises
}
get isPinned(): boolean {
@ -56,14 +55,14 @@ abstract class Conversation implements Interfaces.Conversation {
set isPinned(value: boolean) {
if(value === this._isPinned) return;
this._isPinned = value;
state.savePinned();
state.savePinned(); //tslint:disable-line:no-floating-promises
}
get reportMessages(): ReadonlyArray<Interfaces.Message> {
return this.allMessages;
}
send(): void {
async send(): Promise<void> {
if(this.enteredText.length === 0) return;
if(isCommand(this.enteredText)) {
const parsed = parseCommand(this.enteredText, this.context);
@ -75,11 +74,11 @@ abstract class Conversation implements Interfaces.Conversation {
}
} else {
this.lastSent = this.enteredText;
this.doSend();
await this.doSend();
}
}
abstract addMessage(message: Interfaces.Message): void;
abstract async addMessage(message: Interfaces.Message): Promise<void>;
loadLastSent(): void {
this.enteredText = this.lastSent;
@ -109,7 +108,7 @@ abstract class Conversation implements Interfaces.Conversation {
safeAddMessage(this.messages, message, this.maxMessages);
}
protected abstract doSend(): void;
protected abstract doSend(): Promise<void> | void;
}
class PrivateConversation extends Conversation implements Interfaces.PrivateConversation {
@ -144,32 +143,34 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
} else if(this.ownTypingStatus !== 'clear') this.setOwnTyping('clear');
}
addMessage(message: Interfaces.Message): void {
async addMessage(message: Interfaces.Message): Promise<void> {
await this.logPromise;
this.safeAddMessage(message);
if(message.type !== Interfaces.Message.Type.Event) {
if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
if(core.state.settings.logMessages) await core.logs.logMessage(this, message);
if(this.settings.notify !== Interfaces.Setting.False && message.sender !== core.characters.ownCharacter)
core.notifications.notify(this, message.sender.name, message.text, characterImage(message.sender.name), 'attention');
if(this !== state.selectedConversation)
if(this !== state.selectedConversation || !state.windowFocused)
this.unread = Interfaces.UnreadState.Mention;
this.typingStatus = 'clear';
}
}
close(): void {
async close(): Promise<void> {
state.privateConversations.splice(state.privateConversations.indexOf(this), 1);
delete state.privateMap[this.character.name.toLowerCase()];
state.savePinned();
await state.savePinned();
if(state.selectedConversation === this) state.show(state.consoleTab);
}
sort(newIndex: number): void {
async sort(newIndex: number): Promise<void> {
state.privateConversations.splice(state.privateConversations.indexOf(this), 1);
state.privateConversations.splice(newIndex, 0, this);
state.savePinned();
return state.savePinned();
}
protected doSend(): void {
protected async doSend(): Promise<void> {
await this.logPromise;
if(this.character.status === 'offline') {
this.errorText = l('chat.errorOffline', this.character.name);
return;
@ -180,7 +181,7 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
core.connection.send('PRI', {recipient: this.name, message: this.enteredText});
const message = createMessage(MessageType.Message, core.characters.ownCharacter, this.enteredText);
this.safeAddMessage(message);
if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
if(core.state.settings.logMessages) await core.logs.logMessage(this, message);
this.enteredText = '';
}
@ -255,7 +256,8 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
else safeAddMessage(this[mode], message, 500);
}
addMessage(message: Interfaces.Message): void {
async addMessage(message: Interfaces.Message): Promise<void> {
await this.logPromise;
if((message.type === MessageType.Message || message.type === MessageType.Ad) && message.text.match(/^\/warn\b/) !== null) {
const member = this.channel.members[message.sender.name];
if(member !== undefined && member.rank > Channel.Rank.Member || message.sender.isChatOp)
@ -264,13 +266,13 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
if(message.type === MessageType.Ad) {
this.addModeMessage('ads', message);
if(core.state.settings.logAds) this.logPromise.then(() => core.logs.logMessage(this, message));
if(core.state.settings.logAds) await core.logs.logMessage(this, message);
} else {
this.addModeMessage('chat', message);
if(message.type !== Interfaces.Message.Type.Event) {
if(message.type === Interfaces.Message.Type.Warn) this.addModeMessage('ads', message);
if(core.state.settings.logMessages) this.logPromise.then(() => core.logs.logMessage(this, message));
if(this !== state.selectedConversation && this.unread === Interfaces.UnreadState.None)
if(core.state.settings.logMessages) await core.logs.logMessage(this, message);
if(this !== state.selectedConversation && this.unread === Interfaces.UnreadState.None || !state.windowFocused)
this.unread = Interfaces.UnreadState.Unread;
} else this.addModeMessage('ads', message);
}
@ -281,16 +283,16 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
core.connection.send('LCH', {channel: this.channel.id});
}
sort(newIndex: number): void {
async sort(newIndex: number): Promise<void> {
state.channelConversations.splice(state.channelConversations.indexOf(this), 1);
state.channelConversations.splice(newIndex, 0, this);
state.savePinned();
return state.savePinned();
}
protected doSend(): void {
protected async doSend(): Promise<void> {
const isAd = this.isSendingAds;
core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText});
this.addMessage(
await this.addMessage(
createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date()));
if(isAd) {
this.adCountdown = core.connection.vars.lfrp_flood;
@ -317,10 +319,10 @@ class ConsoleConversation extends Conversation {
close(): void {
}
addMessage(message: Interfaces.Message): void {
async addMessage(message: Interfaces.Message): Promise<void> {
this.safeAddMessage(message);
if(core.state.settings.logMessages) core.logs.logMessage(this, message);
if(this !== state.selectedConversation) this.unread = Interfaces.UnreadState.Unread;
if(core.state.settings.logMessages) await core.logs.logMessage(this, message);
if(this !== state.selectedConversation || !state.windowFocused) this.unread = Interfaces.UnreadState.Unread;
}
protected doSend(): void {
@ -338,6 +340,12 @@ class State implements Interfaces.State {
recent: Interfaces.RecentConversation[] = [];
pinned: {channels: string[], private: string[]};
settings: {[key: string]: Interfaces.Settings};
windowFocused: boolean;
get hasNew(): boolean {
return this.privateConversations.some((x) => x.unread === Interfaces.UnreadState.Mention) ||
this.channelConversations.some((x) => x.unread === Interfaces.UnreadState.Mention);
}
getPrivate(character: Character): PrivateConversation {
const key = character.name.toLowerCase();
@ -346,7 +354,7 @@ class State implements Interfaces.State {
conv = new PrivateConversation(character);
this.privateConversations.push(conv);
this.privateMap[key] = conv;
state.addRecent(conv);
state.addRecent(conv); //tslint:disable-line:no-floating-promises
return conv;
}
@ -355,18 +363,18 @@ class State implements Interfaces.State {
return (key[0] === '#' ? this.channelMap : this.privateMap)[key];
}
savePinned(): void {
async savePinned(): Promise<void> {
this.pinned.channels = this.channelConversations.filter((x) => x.isPinned).map((x) => x.channel.id);
this.pinned.private = this.privateConversations.filter((x) => x.isPinned).map((x) => x.name);
core.settingsStore.set('pinned', this.pinned);
await core.settingsStore.set('pinned', this.pinned);
}
setSettings(key: string, value: Interfaces.Settings): void {
async setSettings(key: string, value: Interfaces.Settings): Promise<void> {
this.settings[key] = value;
core.settingsStore.set('conversationSettings', this.settings);
await core.settingsStore.set('conversationSettings', this.settings);
}
addRecent(conversation: Conversation): void {
async addRecent(conversation: Conversation): Promise<void> {
const remove = <T extends Interfaces.RecentConversation>(predicate: (item: T) => boolean) => {
for(let i = 0; i < this.recent.length; ++i)
if(predicate(<T>this.recent[i])) {
@ -382,7 +390,7 @@ class State implements Interfaces.State {
state.recent.unshift({character: conversation.name});
}
if(this.recent.length >= 50) this.recent.pop();
core.settingsStore.set('recent', this.recent);
await core.settingsStore.set('recent', this.recent);
}
show(conversation: Conversation): void {
@ -400,7 +408,6 @@ class State implements Interfaces.State {
conversation._isPinned = this.pinned.private.indexOf(conversation.name) !== -1;
this.recent = await core.settingsStore.get('recent') || [];
const settings = <{[key: string]: ConversationSettings}> await core.settingsStore.get('conversationSettings') || {};
//tslint:disable-next-line:forin
for(const key in settings) {
const settingsItem = new ConversationSettings();
for(const itemKey in settings[key])
@ -416,9 +423,10 @@ class State implements Interfaces.State {
let state: State;
function addEventMessage(this: void, message: Interfaces.Message): void {
state.consoleTab.addMessage(message);
if(core.state.settings.eventMessages && state.selectedConversation !== state.consoleTab) state.selectedConversation.addMessage(message);
async function addEventMessage(this: void, message: Interfaces.Message): Promise<void> {
await state.consoleTab.addMessage(message);
if(core.state.settings.eventMessages && state.selectedConversation !== state.consoleTab)
await state.selectedConversation.addMessage(message);
}
function isOfInterest(this: void, character: Character): boolean {
@ -427,6 +435,11 @@ function isOfInterest(this: void, character: Character): boolean {
export default function(this: void): Interfaces.State {
state = new State();
window.addEventListener('focus', () => {
state.windowFocused = true;
if(state.selectedConversation !== undefined!) state.selectedConversation.unread = Interfaces.UnreadState.None;
});
window.addEventListener('blur', () => state.windowFocused = false);
const connection = core.connection;
connection.onEvent('connecting', async(isReconnect) => {
state.channelConversations = [];
@ -444,49 +457,49 @@ export default function(this: void): Interfaces.State {
for(const item of state.pinned.private) state.getPrivate(core.characters.get(item));
queuedJoin(state.pinned.channels.slice());
});
core.channels.onEvent((type, channel, member) => {
core.channels.onEvent(async(type, channel, member) => {
if(type === 'join')
if(member === undefined) {
const conv = new ChannelConversation(channel);
state.channelMap[channel.id] = conv;
state.channelConversations.push(conv);
state.addRecent(conv);
await state.addRecent(conv);
} else {
const conv = state.channelMap[channel.id]!;
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
!core.state.settings.joinMessages) return;
const text = l('events.channelJoin', `[user]${member.character.name}[/user]`);
conv.addMessage(new EventMessage(text));
await conv.addMessage(new EventMessage(text));
}
else if(member === undefined) {
const conv = state.channelMap[channel.id]!;
state.channelConversations.splice(state.channelConversations.indexOf(conv), 1);
delete state.channelMap[channel.id];
state.savePinned();
await state.savePinned();
if(state.selectedConversation === conv) state.show(state.consoleTab);
} else {
const conv = state.channelMap[channel.id]!;
if(conv.settings.joinMessages === Interfaces.Setting.False || conv.settings.joinMessages === Interfaces.Setting.Default &&
!core.state.settings.joinMessages) return;
const text = l('events.channelLeave', `[user]${member.character.name}[/user]`);
conv.addMessage(new EventMessage(text));
await conv.addMessage(new EventMessage(text));
}
});
connection.onMessage('PRI', (data, time) => {
connection.onMessage('PRI', async(data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character});
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
const conv = state.getPrivate(char);
conv.addMessage(message);
await conv.addMessage(message);
});
connection.onMessage('MSG', (data, time) => {
connection.onMessage('MSG', async(data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored) return;
const conversation = state.channelMap[data.channel.toLowerCase()];
if(conversation === undefined) return core.channels.leave(data.channel);
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
conversation.addMessage(message);
await conversation.addMessage(message);
const words = conversation.settings.highlightWords.slice();
if(conversation.settings.defaultHighlights) words.push(...core.state.settings.highlightWords);
@ -497,20 +510,20 @@ export default function(this: void): Interfaces.State {
if(results !== null) {
core.notifications.notify(conversation, data.character, l('chat.highlight', results[0], conversation.name, message.text),
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation) conversation.unread = Interfaces.UnreadState.Mention;
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
message.isHighlight = true;
} else if(conversation.settings.notify === Interfaces.Setting.True)
core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
});
connection.onMessage('LRP', (data, time) => {
connection.onMessage('LRP', async(data, time) => {
const char = core.characters.get(data.character);
if(char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) return;
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
await conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time));
});
connection.onMessage('RLL', (data, time) => {
connection.onMessage('RLL', async(data, time) => {
const sender = core.characters.get(data.character);
if(sender.isIgnored) return;
let text: string;
@ -525,7 +538,7 @@ export default function(this: void): Interfaces.State {
const channel = (<{channel: string}>data).channel.toLowerCase();
const conversation = state.channelMap[channel];
if(conversation === undefined) return core.channels.leave(channel);
conversation.addMessage(message);
await conversation.addMessage(message);
if(data.type === 'bottle' && data.target === core.connection.character)
core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
@ -534,63 +547,69 @@ export default function(this: void): Interfaces.State {
data.character === connection.character ? (<{recipient: string}>data).recipient : data.character);
if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character});
const conversation = state.getPrivate(char);
conversation.addMessage(message);
await conversation.addMessage(message);
}
});
connection.onMessage('NLN', (data, time) => {
connection.onMessage('NLN', async(data, time) => {
const message = new EventMessage(l('events.login', `[user]${data.identity}[/user]`), time);
if(isOfInterest(core.characters.get(data.identity))) addEventMessage(message);
if(isOfInterest(core.characters.get(data.identity))) await addEventMessage(message);
const conv = state.privateMap[data.identity.toLowerCase()];
if(conv !== undefined && (!core.state.settings.eventMessages || conv !== state.selectedConversation)) conv.addMessage(message);
if(conv !== undefined && (!core.state.settings.eventMessages || conv !== state.selectedConversation))
await conv.addMessage(message);
});
connection.onMessage('FLN', (data, time) => {
connection.onMessage('FLN', async(data, time) => {
const message = new EventMessage(l('events.logout', `[user]${data.character}[/user]`), time);
if(isOfInterest(core.characters.get(data.character))) addEventMessage(message);
if(isOfInterest(core.characters.get(data.character))) await addEventMessage(message);
const conv = state.privateMap[data.character.toLowerCase()];
if(conv === undefined) return;
conv.typingStatus = 'clear';
if(!core.state.settings.eventMessages || conv !== state.selectedConversation) conv.addMessage(message);
if(!core.state.settings.eventMessages || conv !== state.selectedConversation) await conv.addMessage(message);
});
connection.onMessage('TPN', (data) => {
const conv = state.privateMap[data.character.toLowerCase()];
if(conv !== undefined) conv.typingStatus = data.status;
});
connection.onMessage('CBU', (data, time) => {
connection.onMessage('CBU', async(data, time) => {
const text = l('events.ban', data.channel, data.character, data.operator);
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.infoText = text;
addEventMessage(new EventMessage(text, time));
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('CKU', (data, time) => {
connection.onMessage('CKU', async(data, time) => {
const text = l('events.kick', data.channel, data.character, data.operator);
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.infoText = text;
addEventMessage(new EventMessage(text, time));
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('CTU', (data, time) => {
connection.onMessage('CTU', async(data, time) => {
const text = l('events.timeout', data.channel, data.character, data.operator, data.length.toString());
const conv = state.channelMap[data.channel.toLowerCase()];
if(conv === undefined) return core.channels.leave(data.channel);
conv.infoText = text;
addEventMessage(new EventMessage(text, time));
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('HLO', (data, time) => addEventMessage(new EventMessage(data.message, time)));
connection.onMessage('BRO', (data, time) => {
connection.onMessage('HLO', async(data, time) => addEventMessage(new EventMessage(data.message, time)));
connection.onMessage('BRO', async(data, time) => {
const text = data.character === undefined ? decodeHTML(data.message) :
l('events.broadcast', `[user]${data.character}[/user]`, decodeHTML(data.message.substr(data.character.length + 23)));
addEventMessage(new EventMessage(text, time));
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('CIU', (data, time) => {
connection.onMessage('CIU', async(data, time) => {
const text = l('events.invite', `[user]${data.sender}[/user]`, `[session=${data.title}]${data.name}[/session]`);
addEventMessage(new EventMessage(text, time));
return addEventMessage(new EventMessage(text, time));
});
connection.onMessage('ERR', (data, time) => {
connection.onMessage('ERR', async(data, time) => {
state.selectedConversation.errorText = data.message;
addEventMessage(new EventMessage(`[color=red]${l('events.error', data.message)}[/color]`, time));
return addEventMessage(new EventMessage(`[color=red]${l('events.error', data.message)}[/color]`, time));
});
connection.onMessage('RTB', (data, time) => {
connection.onMessage('IGN', async(data, time) => {
if(data.action !== 'add' && data.action !== 'delete') return;
return addEventMessage(new EventMessage(l(`events.ignore_${data.action}`, data.character), time));
});
connection.onMessage('RTB', async(data, time) => {
let url = 'https://www.f-list.net/';
let text: string, character: string;
if(data.type === 'comment') { //tslint:disable-line:prefer-switch
@ -640,13 +659,13 @@ export default function(this: void): Interfaces.State {
data.title !== undefined ? `[url=${url}]${data.title}[/url]` : url);
character = data.name;
}
addEventMessage(new EventMessage(text, time));
await addEventMessage(new EventMessage(text, time));
if(data.type === 'note')
core.notifications.notify(state.consoleTab, character, text, characterImage(character), 'newnote');
});
type SFCMessage = (Interfaces.Message & {sfc: Connection.ServerCommands['SFC'] & {confirmed?: true}});
const sfcList: SFCMessage[] = [];
connection.onMessage('SFC', (data, time) => {
connection.onMessage('SFC', async(data, time) => {
let text: string, message: Interfaces.Message;
if(data.action === 'report') {
text = l('events.report', `[user]${data.character}[/user]`, decodeHTML(data.tab), decodeHTML(data.report));
@ -663,11 +682,11 @@ export default function(this: void): Interfaces.State {
}
message = new EventMessage(text, time);
}
addEventMessage(message);
return addEventMessage(message);
});
connection.onMessage('STA', (data, time) => {
connection.onMessage('STA', async(data, time) => {
if(data.character === core.connection.character) {
addEventMessage(new EventMessage(l(data.statusmsg.length > 0 ? 'events.status.ownMessage' : 'events.status.own',
await addEventMessage(new EventMessage(l(data.statusmsg.length > 0 ? 'events.status.ownMessage' : 'events.status.own',
l(`status.${data.status}`), decodeHTML(data.statusmsg)), time));
return;
}
@ -676,17 +695,17 @@ export default function(this: void): Interfaces.State {
const status = l(`status.${data.status}`);
const key = data.statusmsg.length > 0 ? 'events.status.message' : 'events.status';
const message = new EventMessage(l(key, `[user]${data.character}[/user]`, status, decodeHTML(data.statusmsg)), time);
addEventMessage(message);
await addEventMessage(message);
const conv = state.privateMap[data.character.toLowerCase()];
if(conv !== undefined && core.state.settings.eventMessages && conv !== state.selectedConversation) conv.addMessage(message);
if(conv !== undefined && core.state.settings.eventMessages && conv !== state.selectedConversation) await conv.addMessage(message);
});
connection.onMessage('SYS', (data, time) => {
connection.onMessage('SYS', async(data, time) => {
state.selectedConversation.infoText = data.message;
addEventMessage(new EventMessage(data.message, time));
return addEventMessage(new EventMessage(data.message, time));
});
connection.onMessage('ZZZ', (data, time) => {
connection.onMessage('ZZZ', async(data, time) => {
state.selectedConversation.infoText = data.message;
addEventMessage(new EventMessage(data.message, time));
return addEventMessage(new EventMessage(data.message, time));
});
//TODO connection.onMessage('UPT', data =>
return state;

View File

@ -44,9 +44,8 @@ const vue = <Vue & VueState>new Vue({
state
},
watch: {
'state.hiddenUsers': (newValue: string[]) => {
//tslint:disable-next-line:no-floating-promises
if(data.settingsStore !== undefined) data.settingsStore.set('hiddenUsers', newValue);
'state.hiddenUsers': async(newValue: string[]) => {
if(data.settingsStore !== undefined) await data.settingsStore.set('hiddenUsers', newValue);
}
}
});
@ -92,7 +91,7 @@ export function init(this: void, connection: Connection, logsClass: new() => Log
});
}
const core = <{
export interface Core {
readonly connection: Connection
readonly logs: Logs.Basic
readonly state: StateInterface
@ -107,6 +106,8 @@ const core = <{
register(module: 'characters', state: Character.State): void
reloadSettings(): void
watch<T>(getter: (this: VueState) => T, callback: WatchHandler<T>): void
}><any>data; /*tslint:disable-line:no-any*///hack
}
const core = <Core><any>data; /*tslint:disable-line:no-any*///hack
export default core;

View File

@ -50,8 +50,8 @@ export namespace Conversation {
interface TabConversation extends Conversation {
isPinned: boolean
readonly maxMessageLength: number
close(): void
sort(newIndex: number): void
close(): Promise<void> | void
sort(newIndex: number): Promise<void>
}
export interface PrivateConversation extends TabConversation {
@ -80,6 +80,7 @@ export namespace Conversation {
readonly consoleTab: Conversation
readonly recent: ReadonlyArray<RecentConversation>
readonly selectedConversation: Conversation
readonly hasNew: boolean;
byKey(key: string): Conversation | undefined
getPrivate(character: Character): PrivateConversation
reloadSettings(): void
@ -110,7 +111,7 @@ export namespace Conversation {
readonly key: string
readonly unread: UnreadState
settings: Settings
send(): void
send(): Promise<void>
loadLastSent(): void
show(): void
loadMore(): void
@ -121,7 +122,7 @@ export type Conversation = Conversation.Conversation;
export namespace Logs {
export interface Basic {
logMessage(conversation: Conversation, message: Conversation.Message): void
logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> | void
getBacklog(conversation: Conversation): Promise<ReadonlyArray<Conversation.Message>>
}
@ -177,6 +178,7 @@ export interface Notifications {
isInBackground: boolean
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void
playSound(sound: string): void
requestPermission(): Promise<void>
}
export interface State {

View File

@ -8,7 +8,10 @@ const strings: {[key: string]: string | undefined} = {
'action.copyLink': 'Copy Link',
'action.suggestions': 'Suggestions',
'action.open': 'Show',
'action.close': 'Close',
'action.quit': 'Quit',
'action.newWindow': 'Open new window',
'action.newTab': 'Open new tab',
'action.updateAvailable': 'UPDATE AVAILABLE',
'action.update': 'Restart now!',
'action.cancel': 'Cancel',
@ -21,9 +24,13 @@ const strings: {[key: string]: string | undefined} = {
'help.faq': 'F-List FAQ',
'help.report': 'How to report a user',
'help.changelog': 'Changelog',
'title': 'FChat 3.0',
'fs.error': 'Error writing to disk',
'window.newTab': 'New tab',
'title': 'F-Chat',
'version': 'Version {0}',
'filter': 'Type to filter...',
'confirmYes': 'Yes',
'confirmNo': 'No',
'login.account': 'Username',
'login.password': 'Password',
'login.host': 'Host',
@ -36,6 +43,7 @@ const strings: {[key: string]: string | undefined} = {
'login.connect': 'Connect',
'login.connecting': 'Connecting...',
'login.connectError': 'Connection error: Could not connect to server',
'login.alreadyLoggedIn': 'You are already logged in on this character in another tab or window.',
'channelList.public': 'Official channels',
'channelList.private': 'Open rooms',
'channelList.create': 'Create room',
@ -85,6 +93,7 @@ const strings: {[key: string]: string | undefined} = {
'users.friends': 'Friends',
'users.bookmarks': 'Bookmarks',
'users.members': 'Members',
'users.memberCount': '{0} Members',
'chat.report': 'Alert Staff',
'chat.report.description': `
[color=red]Before you alert the moderators, PLEASE READ:[/color]
@ -136,6 +145,8 @@ Are you sure?`,
'settings.spellcheck.disabled': 'Disabled',
'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.logMessages': 'Log messages',
'settings.logAds': 'Log ads',
'settings.fontSize': 'Font size (experimental)',
@ -206,6 +217,8 @@ Are you sure?`,
'events.logout': '{0} has logged out.',
'events.channelJoin': '{0} has joined the channel.',
'events.channelLeave': '{0} has left the channel.',
'events.ignore_add': 'You are now ignoring {0}\'s messages. Should they go around this by any means, please report it using the Alert Staff button.',
'events.ignore_delete': '{0} is now allowed to send you messages again.',
'commands.unknown': 'Unknown command. For a list of valid commands, please click the ? button.',
'commands.badContext': 'This command cannot be used here. Please use the Help (click the ? button) if you need further information.',
'commands.tooFewParams': 'This command requires more parameters. Please use the Help (click the ? button) if you need further information.',

View File

@ -1,4 +1,5 @@
import {Component, CreateElement, RenderContext, VNode, VNodeChildren} from 'vue';
import {Channel} from '../fchat';
import {BBCodeView} from './bbcode';
import {formatTime} from './common';
import core from './core';
@ -20,9 +21,9 @@ const userPostfix: {[key: number]: string | undefined} = {
//tslint:disable-next-line:variable-name
const MessageView: Component = {
functional: true,
render(createElement: CreateElement, context: RenderContext): VNode {
/*tslint:disable:no-unsafe-any*///context.props is any
const message: Conversation.Message = context.props.message;
render(createElement: CreateElement,
context: RenderContext<{message: Conversation.Message, classes?: string, channel?: Channel}>): VNode {
const message = context.props.message;
const children: (VNode | string | VNodeChildren)[] = [`[${formatTime(message.time)}] `];
/*tslint:disable-next-line:prefer-template*///unreasonable here
let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` +
@ -39,7 +40,6 @@ const MessageView: Component = {
const node = createElement('div', {attrs: {class: classes}}, children);
node.key = context.data.key;
return node;
//tslint:enable
}
};

View File

@ -27,7 +27,6 @@ export default class Notifications implements Interface {
if(audio === null) {
audio = document.createElement('audio');
audio.id = id;
//tslint:disable-next-line:forin
for(const name in codecs) {
const src = document.createElement('source');
src.type = `audio/${name}`;
@ -39,4 +38,8 @@ export default class Notifications implements Interface {
//tslint:disable-next-line:no-floating-promises
audio.play();
}
async requestPermission(): Promise<void> {
await Notification.requestPermission();
}
}

View File

@ -2,10 +2,14 @@ import Axios from 'axios';
import Vue from 'vue';
import {InlineDisplayMode} from '../bbcode/interfaces';
import {initParser, standardParser} from '../bbcode/standard';
import CharacterLink from '../components/character_link.vue';
import CharacterSelect from '../components/character_select.vue';
import {setCharacters} from '../components/character_select/character_list';
import DateDisplay from '../components/date_display.vue';
import {registerMethod, Store} from '../site/character_page/data_store';
import {
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterSettings,
GuestbookState, KinkChoiceFull, SharedKinks
Character, CharacterCustom, CharacterFriend, CharacterImage, CharacterImageOld, CharacterInfo, CharacterInfotag, CharacterKink,
CharacterSettings, Friend, FriendRequest, FriendsByCharacter, GuestbookState, KinkChoice, KinkChoiceFull, SharedKinks
} from '../site/character_page/interfaces';
import '../site/directives/vue-select'; //tslint:disable-line:no-import-side-effect
import * as Utils from '../site/utils';
@ -15,9 +19,12 @@ async function characterData(name: string | undefined): Promise<Character> {
const data = await core.connection.queryApi('character-data.php', {name}) as CharacterInfo & {
badges: string[]
customs_first: boolean
character_list: {id: number, name: string}[]
custom_kinks: {[key: number]: {choice: 'fave' | 'yes' | 'maybe' | 'no', name: string, description: string, children: number[]}}
custom_title: string
kinks: {[key: string]: string}
infotags: {[key: string]: string}
memo: {id: number, memo: string}
settings: CharacterSettings
};
const newKinks: {[key: string]: KinkChoiceFull} = {};
@ -33,8 +40,7 @@ async function characterData(name: string | undefined): Promise<Character> {
description: custom.description
});
for(const childId of custom.children)
if(data.kinks[childId] !== undefined)
newKinks[childId] = parseInt(key, 10);
newKinks[childId] = parseInt(key, 10);
}
const newInfotags: {[key: string]: CharacterInfotag} = {};
for(const key in data.infotags) {
@ -61,9 +67,11 @@ async function characterData(name: string | undefined): Promise<Character> {
infotags: newInfotags,
online_chat: false
},
memo: data.memo,
character_list: data.character_list,
badges: data.badges,
settings: data.settings,
bookmarked: false,
bookmarked: core.characters.get(data.name).isBookmarked,
self_staff: false
};
}
@ -147,7 +155,15 @@ async function guestbookGet(id: number, page: number): Promise<GuestbookState> {
return core.connection.queryApi<GuestbookState>('character-guestbook.php', {id, page: page - 1});
}
export function init(): void {
async function kinksGet(id: number): Promise<CharacterKink[]> {
const data = await core.connection.queryApi<{kinks: {[key: string]: string}}>('character-data.php', {id});
return Object.keys(data.kinks).map((key) => {
const choice = data.kinks[key];
return {id: parseInt(key, 10), choice: <KinkChoice>(choice === 'fave' ? 'favorite' : choice)};
});
}
export function init(characters: {[key: string]: number}): void {
Utils.setDomains('https://www.f-list.net/', 'https://static.f-list.net/');
initParser({
siteDomain: Utils.siteDomain,
@ -156,6 +172,13 @@ export function init(): void {
inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL
});
Vue.component('character-select', CharacterSelect);
Vue.component('character-link', CharacterLink);
Vue.component('date-display', DateDisplay);
setCharacters(Object.keys(characters).map((name) => ({name, id: characters[name]})));
core.connection.onEvent('connecting', () => {
Utils.Settings.defaultCharacter = characters[core.connection.character];
});
Vue.directive('bbcode', (el, binding) => {
while(el.firstChild !== null)
el.removeChild(el.firstChild);
@ -163,10 +186,34 @@ export function init(): void {
});
registerMethod('characterData', characterData);
registerMethod('contactMethodIconUrl', contactMethodIconUrl);
registerMethod('sendNoteUrl', (character: CharacterInfo) => `${Utils.siteDomain}read_notes.php?send=${character.name}`);
registerMethod('fieldsGet', fieldsGet);
registerMethod('friendsGet', friendsGet);
registerMethod('kinksGet', kinksGet);
registerMethod('imagesGet', imagesGet);
registerMethod('guestbookPageGet', guestbookGet);
registerMethod('imageUrl', (image: CharacterImageOld) => image.url);
registerMethod('memoUpdate', async(id: number, memo: string) => {
await core.connection.queryApi('character-memo-save.php', {target: id, note: memo});
return {id, memo, updated_at: Date.now() / 1000};
});
registerMethod('imageThumbUrl', (image: CharacterImage) => `${Utils.staticDomain}images/charthumb/${image.id}.${image.extension}`);
registerMethod('bookmarkUpdate', async(id: number, state: boolean) => {
await core.connection.queryApi(`bookmark-${state ? 'add' : 'remove'}.php`, {id});
return state;
});
registerMethod('characterFriends', async(id: number) =>
core.connection.queryApi<FriendsByCharacter>('character-friend-list.php', {id}));
registerMethod('friendRequest', async(target_id: number, source_id: number) =>
(await core.connection.queryApi<{request: FriendRequest}>('request-send2.php', {source_id, target_id})).request);
registerMethod('friendDissolve', async(friend: Friend) =>
core.connection.queryApi<void>('friend-remove.php', {source_id: friend.source.id, dest_id: friend.target.id}));
registerMethod('friendRequestAccept', async(req: FriendRequest) => {
await core.connection.queryApi('request-accept.php', {request_id: req.id});
return { id: undefined!, source: req.target, target: req.source, createdAt: Date.now() / 1000 };
});
registerMethod('friendRequestCancel', async(req: FriendRequest) =>
core.connection.queryApi<void>('request-cancel.php', {request_id: req.id}));
registerMethod('friendRequestIgnore', async(req: FriendRequest) =>
core.connection.queryApi<void>('request-deny.php', {request_id: req.id}));
}

View File

@ -27,6 +27,7 @@ export function parse(this: void | never, input: string, context: CommandContext
if(command.params !== undefined)
for(let i = 0; i < command.params.length; ++i) {
while(args[index] === ' ') ++index;
const param = command.params[i];
if(index === -1)
if(param.optional !== undefined) continue;
@ -48,7 +49,6 @@ export function parse(this: void | never, input: string, context: CommandContext
return l('commands.invalidParam', l(`commands.${name}.param${i}`));
break;
case ParamType.Number:
console.log(value);
const num = parseInt(value, 10);
if(isNaN(num))
return l('commands.invalidParam', l(`commands.${name}.param${i}`));

View File

@ -1,32 +1,41 @@
<template>
<div tabindex="-1" class="modal flex-modal" :style="isShown ? 'display:flex' : ''"
style="align-items: flex-start; padding: 30px; justify-content: center;">
<div class="modal-dialog" :class="dialogClass" style="display: flex; flex-direction: column; max-height: 100%; margin: 0;">
<div class="modal-content" style="display:flex; flex-direction: column;">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&times;</button>
<h4 class="modal-title">
<slot name="title">{{action}}</slot>
</h4>
</div>
<div class="modal-body" style="overflow: auto; display: flex; flex-direction: column">
<slot></slot>
</div>
<div class="modal-footer" v-if="buttons">
<button type="button" class="btn btn-default" data-dismiss="modal" v-if="showCancel">Cancel</button>
<button type="button" class="btn" :class="buttonClass" @click="submit" :disabled="disabled">
{{submitText}}
</button>
<span v-show="isShown">
<div tabindex="-1" class="modal flex-modal" @click.self="hideWithCheck"
style="align-items:flex-start;padding:30px;justify-content:center;display:flex">
<div class="modal-dialog" :class="dialogClass" style="display:flex;flex-direction:column;max-height:100%;margin:0">
<div class="modal-content" style="display:flex;flex-direction:column;flex-grow:1">
<div class="modal-header" style="flex-shrink:0">
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">&times;</button>
<h4 class="modal-title">
<slot name="title">{{action}}</slot>
</h4>
</div>
<div class="modal-body" style="overflow: auto; display: flex; flex-direction: column">
<slot></slot>
</div>
<div class="modal-footer" v-if="buttons">
<button type="button" class="btn btn-default" @click="hideWithCheck" v-if="showCancel">Cancel</button>
<button type="button" class="btn" :class="buttonClass" @click="submit" :disabled="disabled">
{{submitText}}
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop in"></div>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {getKey} from '../chat/common';
const dialogStack: Modal[] = [];
window.addEventListener('keydown', (e) => {
if(getKey(e) === 'escape' && dialogStack.length > 0) dialogStack.pop()!.isShown = false;
});
@Component
export default class Modal extends Vue {
@ -45,7 +54,7 @@
@Prop()
readonly buttonText?: string;
isShown = false;
element: JQuery;
keepOpen = false;
get submitText(): string {
return this.buttonText !== undefined ? this.buttonText : this.action;
@ -53,27 +62,32 @@
submit(e: Event): void {
this.$emit('submit', e);
if(!e.defaultPrevented) this.hide();
if(!e.defaultPrevented) this.hideWithCheck();
}
/*tslint:disable-next-line:typedef*///https://github.com/palantir/tslint/issues/711
show(keepOpen = false): void {
if(keepOpen) this.element.on('hide.bs.modal', (e) => e.preventDefault());
this.element.modal('show');
this.isShown = true;
this.keepOpen = keepOpen;
dialogStack.push(this);
this.$emit('open');
}
hide(): void {
this.element.off('hide.bs.modal');
this.element.modal('hide');
this.isShown = false;
this.$emit('close');
dialogStack.pop();
}
private hideWithCheck(): void {
if(this.keepOpen) return;
this.hide();
}
fixDropdowns(): void {
//tslint:disable-next-line:no-this-assignment
const vm = this;
$('.dropdown', this.$el).on('show.bs.dropdown', function(this: HTMLElement & {menu?: HTMLElement}): void {
$(document).off('focusin.bs.modal');
if(this.menu !== undefined) {
this.menu.style.display = 'block';
return;
@ -96,12 +110,6 @@
});
}
mounted(): void {
this.element = $(this.$el);
this.element.on('shown.bs.modal', () => this.$emit('open'));
this.element.on('hidden.bs.modal', () => this.$emit('close'));
}
beforeDestroy(): void {
if(this.isShown) this.hide();
}

View File

@ -0,0 +1,36 @@
<template>
<select class="form-control" :value="value" @change="emit">
<option v-for="o in characters" :value="o.value" v-once>{{o.text}}</option>
<slot></slot>
</select>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
import {getCharacters} from './character_select/character_list';
interface SelectItem {
value: number
text: string
}
@Component
export default class CharacterSelect extends Vue {
@Prop({required: true, type: Number})
readonly value: number;
get characters(): SelectItem[] {
const characterList = getCharacters();
const characters: SelectItem[] = [];
for(const character of characterList)
characters.push({value: character.id, text: character.name});
return characters;
}
emit(evt: Event): void {
this.$emit('input', parseInt((<HTMLSelectElement>evt.target).value, 10));
}
}
</script>

View File

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

View File

@ -1,28 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<widget id="net.f_list.fchat" version="3.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>F-Chat 3.0</name>
<description>
A cross-platform F-Chat client.
</description>
<author email="maya@f-list.net" href="https://www.f-list.net">The F-list Team</author>
<content src="index.html" />
<icon src="../electron/build/icon.png" />
<access origin="*" />
<allow-intent href="http://*/*" />
<allow-intent href="https://*/*" />
<allow-intent href="tel:*" />
<allow-intent href="sms:*" />
<allow-intent href="mailto:*" />
<allow-intent href="geo:*" />
<platform name="android">
<allow-intent href="market:*" />
</platform>
<platform name="ios">
<allow-intent href="itms:*" />
<allow-intent href="itms-apps:*" />
</platform>
<engine name="android" spec="^6.2.3" />
<plugin name="cordova-plugin-file" spec="^4.3.3" />
<plugin name="cordova-plugin-whitelist" spec="^1.3.2" />
<plugin name="de.appplant.cordova.plugin.local-notification" spec="^0.8.5" />
</widget>

View File

@ -1,263 +0,0 @@
import {getByteLength, Message as MessageImpl} from '../chat/common';
import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
declare global {
class TextEncoder {
readonly encoding: string;
encode(input?: string, options?: {stream: boolean}): Uint8Array;
}
class TextDecoder {
readonly encoding: string;
readonly fatal: boolean;
readonly ignoreBOM: boolean;
constructor(utfLabel?: string, options?: {fatal?: boolean, ignoreBOM?: boolean})
decode(input?: ArrayBufferView, options?: {stream: boolean}): string;
}
}
const dayMs = 86400000;
let fs: FileSystem;
export class GeneralSettings {
account = '';
password = '';
host = 'wss://chat.f-list.net:9799';
theme = 'default';
}
type Index = {[key: string]: {name: string, index: {[key: number]: number | undefined}} | undefined};
/*tslint:disable:promise-function-async*///all of these are simple wrappers
export function init(): Promise<void> {
return new Promise((resolve, reject) => {
document.addEventListener('deviceready', () => {
window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, (f) => {
fs = f;
resolve();
}, reject);
});
});
}
function readAsString(file: Blob): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(<string>reader.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
function readAsArrayBuffer(file: Blob): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(<ArrayBuffer>reader.result);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
function getFile(root: DirectoryEntry, path: string): Promise<File | undefined> {
return new Promise<File | undefined>((resolve, reject) => {
root.getFile(path, {create: false}, (entry) => entry.file((file) => {
resolve(file);
}, reject), (e) => {
if(e.code === FileError.NOT_FOUND_ERR) resolve(undefined);
else reject(e);
});
});
}
function getWriter(root: DirectoryEntry, path: string): Promise<FileWriter> {
return new Promise<FileWriter>((resolve, reject) => root.getFile(path, {create: true},
(file) => file.createWriter(resolve, reject), reject));
}
function getDir(root: DirectoryEntry, name: string): Promise<DirectoryEntry> {
return new Promise<DirectoryEntry>((resolve, reject) => root.getDirectory(name, {create: true}, resolve, reject));
}
function getEntries(root: DirectoryEntry): Promise<ReadonlyArray<Entry>> {
const reader = root.createReader();
return new Promise<ReadonlyArray<Entry>>((resolve, reject) => reader.readEntries(resolve, reject));
}
//tslib:enable
function serializeMessage(message: Conversation.Message): Blob {
const name = message.type !== Conversation.Message.Type.Event ? message.sender.name : '';
const buffer = new ArrayBuffer(8);
const dv = new DataView(buffer);
dv.setUint32(0, message.time.getTime() / 1000);
dv.setUint8(4, message.type);
const senderLength = getByteLength(name);
dv.setUint8(5, senderLength);
const textLength = getByteLength(message.text);
dv.setUint16(6, textLength);
const length = senderLength + textLength + 8;
return new Blob([buffer, name, message.text, String.fromCharCode(length >> 255), String.fromCharCode(length % 255)]);
}
function deserializeMessage(buffer: ArrayBuffer): {message: Conversation.Message, end: number} {
const dv = new DataView(buffer, 0, 8);
const time = dv.getUint32(0) * 1000;
const type = dv.getUint8(4);
const senderLength = dv.getUint8(5);
const messageLength = dv.getUint16(6);
let index = 8;
const sender = decoder.decode(new DataView(buffer, index, senderLength));
index += senderLength;
const text = decoder.decode(new DataView(buffer, index, messageLength));
return {message: new MessageImpl(type, core.characters.get(sender), text, new Date(time)), end: index + messageLength + 2};
}
const decoder = new TextDecoder('utf8');
export class Logs implements Logging.Persistent {
private index: Index = {};
private logDir: DirectoryEntry;
constructor() {
core.connection.onEvent('connecting', async() => {
this.index = {};
const charDir = await getDir(fs.root, core.connection.character);
this.logDir = await getDir(charDir, 'logs');
const entries = await getEntries(this.logDir);
for(const entry of entries)
if(entry.name.substr(-4) === '.idx') {
const file = await new Promise<File>((s, j) => (<FileEntry>entry).file(s, j));
const buffer = await readAsArrayBuffer(file);
const dv = new DataView(buffer);
let offset = dv.getUint8(0);
const name = decoder.decode(new DataView(buffer, 1, offset++));
const index: {[key: number]: number} = {};
for(; offset < dv.byteLength; offset += 7) {
const key = dv.getUint16(offset);
index[key] = dv.getUint32(offset + 2) << 8 | dv.getUint8(offset + 6);
}
this.index[entry.name.slice(0, -4).toLowerCase()] = {name, index};
}
});
}
async logMessage(conversation: Conversation, message: Conversation.Message): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.logDir.getFile(conversation.key, {create: true}, (file) => {
const serialized = serializeMessage(message);
const date = Math.floor(message.time.getTime() / dayMs);
let indexBuffer: {}[] | undefined;
let index = this.index[conversation.key];
if(index !== undefined) {
if(index.index[date] === undefined) indexBuffer = [];
} else {
index = this.index[conversation.key] = {name: conversation.name, index: {}};
const nameLength = getByteLength(conversation.name);
indexBuffer = [String.fromCharCode(nameLength), conversation.name];
}
if(indexBuffer !== undefined)
file.getMetadata((data) => {
index!.index[date] = data.size;
const dv = new DataView(new ArrayBuffer(7));
dv.setUint16(0, date);
dv.setUint32(2, data.size >> 8);
dv.setUint8(6, data.size % 256);
indexBuffer!.push(dv);
this.logDir.getFile(`${conversation.key}.idx`, {create: true}, (indexFile) => {
indexFile.createWriter((writer) => writer.write(new Blob(indexBuffer)), reject);
}, reject);
}, reject);
file.createWriter((writer) => writer.write(serialized), reject);
resolve();
}, reject);
});
}
async getBacklog(conversation: Conversation): Promise<Conversation.Message[]> {
const file = await getFile(this.logDir, conversation.key);
if(file === undefined) return [];
let count = 20;
let messages = new Array<Conversation.Message>(count);
let pos = file.size;
while(pos > 0 && count > 0) {
const length = new DataView(await readAsArrayBuffer(file.slice(pos - 2, pos))).getUint16(0);
pos = pos - length - 2;
messages[--count] = deserializeMessage(await readAsArrayBuffer(file.slice(pos, pos + length))).message;
}
if(count !== 0) messages = messages.slice(count);
return messages;
}
async getLogs(key: string, date: Date): Promise<Conversation.Message[]> {
const file = await getFile(this.logDir, key);
if(file === undefined) return [];
const messages: Conversation.Message[] = [];
const day = date.getTime() / dayMs;
const index = this.index[key];
if(index === undefined) return [];
let pos = index.index[date.getTime() / dayMs];
if(pos === undefined) return [];
while(pos < file.size) {
const deserialized = deserializeMessage(await readAsArrayBuffer(file.slice(pos, pos + 51000)));
if(Math.floor(deserialized.message.time.getTime() / dayMs) !== day) break;
messages.push(deserialized.message);
pos += deserialized.end;
}
return messages;
}
getLogDates(key: string): ReadonlyArray<Date> {
const entry = this.index[key];
if(entry === undefined) return [];
const dates = [];
for(const date in entry.index) //tslint:disable-line:forin
dates.push(new Date(parseInt(date, 10) * dayMs));
return dates;
}
get conversations(): ReadonlyArray<{id: string, name: string}> {
const conversations: {id: string, name: string}[] = [];
for(const key in this.index) conversations.push({id: key, name: this.index[key]!.name});
conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
return conversations;
}
}
export async function getGeneralSettings(): Promise<GeneralSettings | undefined> {
const file = await getFile(fs.root, 'settings');
if(file === undefined) return undefined;
return <GeneralSettings>JSON.parse(await readAsString(file));
}
export async function setGeneralSettings(value: GeneralSettings): Promise<void> {
const writer = await getWriter(fs.root, 'settings');
writer.write(new Blob([JSON.stringify(value)]));
}
async function getSettingsDir(character: string = core.connection.character): Promise<DirectoryEntry> {
return new Promise<DirectoryEntry>((resolve, reject) => {
fs.root.getDirectory(character, {create: true}, resolve, reject);
});
}
export class SettingsStore implements Settings.Store {
async get<K extends keyof Settings.Keys>(key: K, character?: string): Promise<Settings.Keys[K] | undefined> {
const dir = await getSettingsDir(character);
const file = await getFile(dir, key);
if(file === undefined) return undefined;
return <Settings.Keys[K]>JSON.parse(await readAsString(file));
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
const writer = await getWriter(await getSettingsDir(), key);
writer.write(new Blob([JSON.stringify(value)]));
}
async getAvailableCharacters(): Promise<string[]> {
return (await getEntries(fs.root)).filter((x) => x.isDirectory).map((x) => x.name);
}
}

View File

@ -1,66 +0,0 @@
import core from '../chat/core';
import {Conversation} from '../chat/interfaces';
import BaseNotifications from '../chat/notifications'; //tslint:disable-line:match-default-export-name
//tslint:disable
declare global {
interface Options {
id?: number
title?: string
text?: string
every?: string
at?: Date | null
badge?: number
sound?: string
data?: any
icon?: string
smallIcon?: string
ongoing?: boolean
led?: string
}
interface CordovaPlugins {
notification: {
local: {
getDefaults(): Options
setDefaults(options: Options): void
schedule(notification: Options, callback?: Function, scope?: Object, args?: {skipPermissions: boolean}): void
update(notification: Options, callback?: Function, scope?: Object, args?: {skipPermissions: boolean}): void
clear(ids: string, callback?: Function, scope?: Object): void
clearAll(callback?: Function, scope?: Object): void
cancel(ids: string, callback?: Function, scope?: Object): void
cancelAll(callback?: Function, scope?: Object): void
isPresent(id: string, callback?: Function, scope?: Object): void
isTriggered(id: string, callback?: Function, scope?: Object): void
getAllIds(callback?: Function, scope?: Object): void
getScheduledIds(callback?: Function, scope?: Object): void
getTriggeredIds(callback?: Function, scope?: Object): void
get(ids?: number[], callback?: Function, scope?: Object): void
getScheduled(ids?: number[], callback?: Function, scope?: Object): void
getTriggered(ids?: number[], callback?: Function, scope?: Object): void
hasPermission(callback?: Function, scope?: Object): void
registerPermission(callback?: Function, scope?: Object): void
on(event: 'schedule' | 'update' | 'click' | 'trigger', handler: (notification: Options) => void): void
un(event: 'schedule' | 'update' | 'click' | 'trigger', handler: (notification: Options) => void): void
}
}
}
}
//tslint:enable
document.addEventListener('deviceready', () => {
cordova.plugins.notification.local.on('click', (notification) => {
const conv = core.conversations.byKey((<{conversation: string}>notification.data).conversation);
if(conv !== undefined) conv.show();
});
});
export default class Notifications extends BaseNotifications {
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
this.playSound(sound);
if(core.state.settings.notifications)
cordova.plugins.notification.local.schedule({
title, text: body, sound, icon, smallIcon: icon, data: {conversation: conversation.key}
});
}
}

View File

@ -1,36 +0,0 @@
{
"name": "fchat",
"version": "0.2.0",
"author": "The F-List Team",
"description": "F-List.net Chat Client",
"main": "main.js",
"license": "MIT",
"cordova": {
"plugins": {
"cordova-plugin-whitelist": {},
"cordova-plugin-file": {},
"de.appplant.cordova.plugin.local-notification": {}
},
"platforms": [
"android"
]
},
"scripts": {
"build": "../node_modules/.bin/webpack",
"build:dist": "../node_modules/.bin/webpack --env production",
"watch": "../node_modules/.bin/webpack --watch"
},
"dependencies": {
"cordova-android": "^6.2.3",
"cordova-plugin-app-event": "^1.2.1",
"cordova-plugin-compat": "^1.0.0",
"cordova-plugin-device": "^1.1.6",
"cordova-plugin-file": "^4.3.3",
"cordova-plugin-whitelist": "^1.3.2",
"de.appplant.cordova.plugin.local-notification": "^0.8.5"
},
"devDependencies": {
"@types/cordova": "^0.0.34",
"qs": "^6.5.0"
}
}

View File

@ -1,236 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/cordova@^0.0.34":
version "0.0.34"
resolved "https://registry.yarnpkg.com/@types/cordova/-/cordova-0.0.34.tgz#ea7addf74ecec3d7629827a0c39e2c9addc73d04"
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
android-versions@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/android-versions/-/android-versions-1.2.1.tgz#3f50baf693e73a512c3c5403542291cead900063"
ansi@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/ansi/-/ansi-0.3.1.tgz#0c42d4fb17160d5a9af1e484bace1c66922c1b21"
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
base64-js@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978"
big-integer@^1.6.7:
version "1.6.25"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.25.tgz#1de45a9f57542ac20121c682f8d642220a34e823"
bplist-parser@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.1.1.tgz#d60d5dcc20cba6dc7e1f299b35d3e1f95dafbae6"
dependencies:
big-integer "^1.6.7"
brace-expansion@^1.1.7:
version "1.1.8"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292"
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
cordova-android@^6.2.3:
version "6.3.0"
resolved "https://registry.yarnpkg.com/cordova-android/-/cordova-android-6.3.0.tgz#da5418433d25c75a5977b428244bbe437d0128d2"
dependencies:
android-versions "^1.2.0"
cordova-common "^2.1.0"
elementtree "0.1.6"
nopt "^3.0.1"
properties-parser "^0.2.3"
q "^1.4.1"
shelljs "^0.5.3"
cordova-common@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cordova-common/-/cordova-common-2.1.0.tgz#bb357ee1b9825031ed9db3c56b592efe973d1640"
dependencies:
ansi "^0.3.1"
bplist-parser "^0.1.0"
cordova-registry-mapper "^1.1.8"
elementtree "0.1.6"
glob "^5.0.13"
minimatch "^3.0.0"
osenv "^0.1.3"
plist "^1.2.0"
q "^1.4.1"
semver "^5.0.1"
shelljs "^0.5.3"
underscore "^1.8.3"
unorm "^1.3.3"
cordova-plugin-app-event@>=1.1.0, cordova-plugin-app-event@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cordova-plugin-app-event/-/cordova-plugin-app-event-1.2.1.tgz#0eebb14132aa43bb2e5c081a9abdbd97ca2d8132"
cordova-plugin-compat@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/cordova-plugin-compat/-/cordova-plugin-compat-1.2.0.tgz#0bc65757276ebd920c012ce920e274177576373e"
cordova-plugin-device@*, cordova-plugin-device@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/cordova-plugin-device/-/cordova-plugin-device-1.1.6.tgz#2d21764cad7c9b801523e4e09a30e024b249334b"
cordova-plugin-file@^4.3.3:
version "4.3.3"
resolved "https://registry.yarnpkg.com/cordova-plugin-file/-/cordova-plugin-file-4.3.3.tgz#012e97aa1afb91f84916e6341b548366d23de9b9"
cordova-plugin-whitelist@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/cordova-plugin-whitelist/-/cordova-plugin-whitelist-1.3.2.tgz#5b6335feb9f5301f3c013b9096cb8885bdbd5076"
cordova-registry-mapper@^1.1.8:
version "1.1.15"
resolved "https://registry.yarnpkg.com/cordova-registry-mapper/-/cordova-registry-mapper-1.1.15.tgz#e244b9185b8175473bff6079324905115f83dc7c"
de.appplant.cordova.plugin.local-notification@^0.8.5:
version "0.8.5"
resolved "https://registry.yarnpkg.com/de.appplant.cordova.plugin.local-notification/-/de.appplant.cordova.plugin.local-notification-0.8.5.tgz#e0c6a86ea52ac4f41dba67521d91a58a9a42a3bd"
dependencies:
cordova-plugin-app-event ">=1.1.0"
cordova-plugin-device "*"
elementtree@0.1.6:
version "0.1.6"
resolved "https://registry.yarnpkg.com/elementtree/-/elementtree-0.1.6.tgz#2ac4c46ea30516c8c4cbdb5e3ac7418e592de20c"
dependencies:
sax "0.3.5"
glob@^5.0.13:
version "5.0.15"
resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
dependencies:
inflight "^1.0.4"
inherits "2"
minimatch "2 || 3"
once "^1.3.0"
path-is-absolute "^1.0.0"
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
lodash@^3.5.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
"minimatch@2 || 3", minimatch@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies:
brace-expansion "^1.1.7"
nopt@^3.0.1:
version "3.0.6"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
dependencies:
abbrev "1"
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
dependencies:
wrappy "1"
os-homedir@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
os-tmpdir@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
osenv@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644"
dependencies:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
plist@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/plist/-/plist-1.2.0.tgz#084b5093ddc92506e259f874b8d9b1afb8c79593"
dependencies:
base64-js "0.0.8"
util-deprecate "1.0.2"
xmlbuilder "4.0.0"
xmldom "0.1.x"
properties-parser@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/properties-parser/-/properties-parser-0.2.3.tgz#f7591255f707abbff227c7b56b637dbb0373a10f"
q@^1.4.1:
version "1.5.0"
resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1"
qs@^6.5.0:
version "6.5.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
sax@0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/sax/-/sax-0.3.5.tgz#88fcfc1f73c0c8bbd5b7c776b6d3f3501eed073d"
semver@^5.0.1:
version "5.4.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"
shelljs@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.5.3.tgz#c54982b996c76ef0c1e6b59fbdc5825f5b713113"
underscore@^1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
unorm@^1.3.3:
version "1.4.1"
resolved "https://registry.yarnpkg.com/unorm/-/unorm-1.4.1.tgz#364200d5f13646ca8bcd44490271335614792300"
util-deprecate@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
xmlbuilder@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.0.0.tgz#98b8f651ca30aa624036f127d11cc66dc7b907a3"
dependencies:
lodash "^3.5.0"
xmldom@0.1.x:
version "0.1.27"
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"

View File

@ -1,5 +1,5 @@
<template>
<div @mouseover="onMouseOver" id="page" style="position: relative; padding: 10px;">
<div @mouseover="onMouseOver" id="page" style="position:relative;padding:5px 10px 10px">
<div v-html="styling"></div>
<div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;">
<div class="well well-lg" style="width: 400px;">
@ -9,7 +9,7 @@
</div>
<div class="form-group">
<label class="control-label" for="account">{{l('login.account')}}</label>
<input class="form-control" id="account" v-model="account" @keypress.enter="login"/>
<input class="form-control" id="account" v-model="settings.account" @keypress.enter="login"/>
</div>
<div class="form-group">
<label class="control-label" for="password">{{l('login.password')}}</label>
@ -17,7 +17,7 @@
</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="host" @keypress.enter="login"/>
<input class="form-control" id="host" v-model="settings.host" @keypress.enter="login"/>
</div>
<div class="form-group">
<label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label>
@ -25,7 +25,7 @@
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group" style="margin:0">
<div class="form-group text-right" style="margin:0">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
@ -41,7 +41,7 @@
</div>
</modal>
<modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="false" :hideGroups="true" :name="profileName" :image-preview="true"></character-page>
<character-page :authenticated="true" :oldApi="true" :name="profileName" :image-preview="true"></character-page>
<template slot="title">{{profileName}} <a class="btn fa fa-external-link" @click="openProfileInBrowser"></a></template>
</modal>
</div>
@ -55,7 +55,7 @@
import * as qs from 'querystring';
import * as Raven from 'raven-js';
import {promisify} from 'util';
import Vue, {ComponentOptions} from 'vue';
import Vue from 'vue';
import Component from 'vue-class-component';
import Chat from '../chat/Chat.vue';
import {Settings} from '../chat/common';
@ -66,51 +66,19 @@
import Modal from '../components/Modal.vue';
import Connection from '../fchat/connection';
import CharacterPage from '../site/character_page/character_page.vue';
import {nativeRequire} from './common';
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import {GeneralSettings, nativeRequire} from './common';
import {Logs, SettingsStore} from './filesystem';
import * as SlimcatImporter from './importer';
import {createAppMenu, createContextMenu} from './menu';
import Notifications from './notifications';
import * as spellchecker from './spellchecker';
declare module '../chat/interfaces' {
interface State {
generalSettings?: GeneralSettings
}
}
const webContents = electron.remote.getCurrentWebContents();
webContents.on('context-menu', (_, props) => {
const menuTemplate = createContextMenu(<Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}>props);
if(props.misspelledWord !== '') {
const corrections = spellchecker.getCorrections(props.misspelledWord);
if(corrections.length > 0) {
menuTemplate.unshift({type: 'separator'});
menuTemplate.unshift(...corrections.map((correction: string) => ({
label: correction,
click: () => webContents.replaceMisspelling(correction)
})));
}
}
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
});
const defaultTrayMenu = [
{label: l('action.open'), click: () => mainWindow!.show()},
{
label: l('action.quit'),
click: () => {
isClosing = true;
mainWindow!.close();
mainWindow = undefined;
electron.remote.app.quit();
}
}
];
let trayMenu = electron.remote.Menu.buildFromTemplate(defaultTrayMenu);
let isClosing = false;
let mainWindow: Electron.BrowserWindow | undefined = electron.remote.getCurrentWindow();
//tslint:disable-next-line:no-require-imports
const tray = new electron.remote.Tray(path.join(__dirname, <string>require('./build/tray.png')));
tray.setToolTip(l('title'));
tray.on('click', (_) => mainWindow!.show());
tray.setContextMenu(trayMenu);
const parent = electron.remote.getCurrentWindow().webContents;
/*tslint:disable:no-any*///because this is hacky
const keyStore = nativeRequire<{
@ -122,8 +90,6 @@
for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat'));
//tslint:enable
profileApiInit();
@Component({
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
})
@ -132,205 +98,83 @@
showAdvanced = false;
saveLogin = false;
loggingIn = false;
account: string;
password = '';
host: string;
character: string | undefined;
characters: string[] | null = null;
error = '';
defaultCharacter: string | null = null;
settings = new SettingsStore();
l = l;
currentSettings: GeneralSettings;
isConnected = false;
settings: GeneralSettings;
importProgress = 0;
profileName = '';
constructor(options?: ComponentOptions<Index>) {
super(options);
let settings = getGeneralSettings();
if(settings === undefined) {
try {
if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral')))
settings = SlimcatImporter.importGeneral();
} catch {
alert(l('importer.error'));
}
settings = settings !== undefined ? settings : new GeneralSettings();
}
this.account = settings.account;
this.host = settings.host;
this.currentSettings = settings;
}
async created(): Promise<void> {
if(this.settings.account.length > 0) this.saveLogin = true;
keyStore.getPassword(this.settings.account)
.then((value: string) => this.password = value, (err: Error) => this.error = err.message);
created(): void {
if(this.currentSettings.account.length > 0) {
keyStore.getPassword(this.currentSettings.account)
.then((value: string) => this.password = value, (err: Error) => this.error = err.message);
this.saveLogin = true;
}
window.onbeforeunload = () => {
if(process.env.NODE_ENV !== 'production' || isClosing || !this.isConnected) {
tray.destroy();
return;
}
if(!this.currentSettings.closeToTray)
return setImmediate(() => {
if(confirm(l('chat.confirmLeave'))) {
isClosing = true;
mainWindow!.close();
}
});
mainWindow!.hide();
return false;
};
Vue.set(core.state, 'generalSettings', this.settings);
const appMenu = createAppMenu();
const themes = fs.readdirSync(path.join(__dirname, 'themes')).filter((x) => x.substr(-4) === '.css').map((x) => x.slice(0, -4));
const setTheme = (theme: string) => {
this.currentSettings.theme = theme;
setGeneralSettings(this.currentSettings);
};
const spellcheckerMenu = new electron.remote.Menu();
//tslint:disable-next-line:no-floating-promises
this.addSpellcheckerItems(spellcheckerMenu);
appMenu[0].submenu = [
{
label: l('settings.closeToTray'), type: 'checkbox', checked: this.currentSettings.closeToTray,
click: (item: Electron.MenuItem) => {
this.currentSettings.closeToTray = item.checked;
setGeneralSettings(this.currentSettings);
}
}, {
label: l('settings.profileViewer'), type: 'checkbox', checked: this.currentSettings.profileViewer,
click: (item: Electron.MenuItem) => {
this.currentSettings.profileViewer = item.checked;
setGeneralSettings(this.currentSettings);
}
},
{label: l('settings.spellcheck'), submenu: spellcheckerMenu},
{
label: l('settings.theme'),
submenu: themes.map((x) => ({
checked: this.currentSettings.theme === x,
click: () => setTheme(x),
label: x,
type: <'radio'>'radio'
}))
},
{type: 'separator'},
{role: 'minimize'},
{
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : undefined,
label: l('action.quit'),
click(): void {
isClosing = true;
mainWindow!.close();
}
}
];
electron.remote.Menu.setApplicationMenu(electron.remote.Menu.buildFromTemplate(appMenu));
let hasUpdate = false;
electron.ipcRenderer.on('updater-status', (_: Event, status: string) => {
if(status !== 'update-downloaded' || hasUpdate) return;
hasUpdate = true;
const menu = electron.remote.Menu.getApplicationMenu();
menu.append(new electron.remote.MenuItem({
label: l('action.updateAvailable'),
submenu: electron.remote.Menu.buildFromTemplate([{
label: l('action.update'),
click: () => {
if(!this.isConnected || confirm(l('chat.confirmLeave'))) {
isClosing = true;
electron.ipcRenderer.send('install-update');
}
}
}, {
label: l('help.changelog'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0#Changelog')
}])
}));
electron.remote.Menu.setApplicationMenu(menu);
});
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => core.state.generalSettings = this.settings = settings);
electron.ipcRenderer.on('open-profile', (_: Event, name: string) => {
if(this.currentSettings.profileViewer) {
const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = name;
profileViewer.show();
} else electron.remote.shell.openExternal(`https://www.f-list.net/c/${name}`);
const profileViewer = <Modal>this.$refs['profileViewer'];
this.profileName = name;
profileViewer.show();
});
}
async addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
const dictionaries = await spellchecker.getAvailableDictionaries();
const selected = this.currentSettings.spellcheckLang;
menu.append(new electron.remote.MenuItem({
type: 'radio',
label: l('settings.spellcheck.disabled'),
click: this.setSpellcheck.bind(this, undefined)
}));
for(const lang of dictionaries)
menu.append(new electron.remote.MenuItem({
type: 'radio',
label: lang,
checked: lang === selected,
click: this.setSpellcheck.bind(this, lang)
}));
electron.webFrame.setSpellCheckProvider('', false, {spellCheck: spellchecker.check});
await spellchecker.setDictionary(selected);
}
async setSpellcheck(lang: string | undefined): Promise<void> {
this.currentSettings.spellcheckLang = lang;
setGeneralSettings(this.currentSettings);
await spellchecker.setDictionary(lang);
window.addEventListener('beforeunload', () => {
if(this.character !== undefined) electron.ipcRenderer.send('disconnect', this.character);
});
}
async login(): Promise<void> {
if(this.loggingIn) return;
this.loggingIn = true;
try {
if(!this.saveLogin) await keyStore.deletePassword(this.account);
const data = <{ticket?: string, error: string, characters: string[], default_character: string}>
(await Axios.post('https://www.f-list.net/json/getApiTicket.php',
qs.stringify({account: this.account, password: this.password, no_friends: true, no_bookmarks: true}))).data;
if(!this.saveLogin) await keyStore.deletePassword(this.settings.account);
const data = <{ticket?: string, error: string, characters: {[key: string]: number}, default_character: number}>
(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify({
account: this.settings.account, password: this.password, no_friends: true, no_bookmarks: true,
new_character_list: true
}))).data;
if(data.error !== '') {
this.error = data.error;
return;
}
if(this.saveLogin) {
this.currentSettings.account = this.account;
await keyStore.setPassword(this.account, this.password);
this.currentSettings.host = this.host;
setGeneralSettings(this.currentSettings);
}
Socket.host = this.host;
const connection = new Connection(Socket, this.account, this.password);
if(this.saveLogin) electron.ipcRenderer.send('save-login', this.settings.account, this.settings.host);
Socket.host = this.settings.host;
const connection = new Connection(`F-Chat 3.0 (${process.platform})`, electron.remote.app.getVersion(), Socket,
this.settings.account, this.password);
connection.onEvent('connecting', async() => {
if((await this.settings.get('settings')) === undefined && SlimcatImporter.canImportCharacter(core.connection.character)) {
if(!confirm(l('importer.importGeneral'))) return this.settings.set('settings', new Settings());
if(!electron.ipcRenderer.sendSync('connect', core.connection.character) && process.env.NODE_ENV === 'production') {
alert(l('login.alreadyLoggedIn'));
return core.connection.close();
}
this.character = core.connection.character;
if((await core.settingsStore.get('settings')) === undefined &&
SlimcatImporter.canImportCharacter(core.connection.character)) {
if(!confirm(l('importer.importGeneral'))) return core.settingsStore.set('settings', new Settings());
(<Modal>this.$refs['importModal']).show(true);
await SlimcatImporter.importCharacter(core.connection.character, (progress) => this.importProgress = progress);
(<Modal>this.$refs['importModal']).hide();
}
});
connection.onEvent('connected', () => {
this.isConnected = true;
tray.setToolTip(document.title = `FChat 3.0 - ${core.connection.character}`);
core.watch(() => core.conversations.hasNew, (newValue) => parent.send('has-new', webContents.id, newValue));
parent.send('connect', webContents.id, core.connection.character);
Raven.setUserContext({username: core.connection.character});
trayMenu.insert(0, new electron.remote.MenuItem({label: core.connection.character, enabled: false}));
trayMenu.insert(1, new electron.remote.MenuItem({type: 'separator'}));
tray.setContextMenu(trayMenu);
});
connection.onEvent('closed', () => {
this.isConnected = false;
tray.setToolTip(document.title = 'FChat 3.0');
this.character = undefined;
electron.ipcRenderer.send('disconnect', connection.character);
parent.send('disconnect', webContents.id);
Raven.setUserContext();
tray.setContextMenu(trayMenu = electron.remote.Menu.buildFromTemplate(defaultTrayMenu));
});
initCore(connection, Logs, SettingsStore, Notifications);
this.characters = data.characters.sort();
this.defaultCharacter = data.default_character;
const charNames = Object.keys(data.characters);
this.characters = charNames.sort();
this.defaultCharacter = charNames.find((x) => data.characters[x] === data.default_character)!;
profileApiInit(data.characters);
} catch(e) {
this.error = l('login.error');
if(process.env.NODE_ENV !== 'production') throw e;
@ -362,10 +206,10 @@
get styling(): string {
try {
return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.currentSettings.theme}.css`))}</style>`;
return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.settings.theme}.css`))}</style>`;
} catch(e) {
if((<Error & {code: string}>e).code === 'ENOENT' && this.currentSettings.theme !== 'default') {
this.currentSettings.theme = 'default';
if((<Error & {code: string}>e).code === 'ENOENT' && this.settings.theme !== 'default') {
this.settings.theme = 'default';
return this.styling;
}
throw e;

279
electron/Window.vue Normal file
View File

@ -0,0 +1,279 @@
<template>
<div style="display:flex;flex-direction:column;height:100%;padding:1px" :class="'platform-' + platform">
<div v-html="styling"></div>
<div style="display:flex;align-items:stretch;" class="border-bottom" id="window-tabs">
<h4>F-Chat</h4>
<div :class="'fa fa-cog btn btn-' + (hasUpdate ? 'warning' : 'default')" @click="openMenu"></div>
<ul class="nav nav-tabs" style="border-bottom:0" ref="tabs">
<li role="presentation" :class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}" v-for="tab in tabs"
:key="tab.view.id">
<a href="#" @click.prevent="show(tab)">
<img v-if="tab.user" :src="'https://static.f-list.net/images/avatar/' + tab.user.toLowerCase() + '.png'"/>
{{tab.user || l('window.newTab')}}
<a href="#" class="fa fa-close btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit"
@click.stop="remove(tab)">
</a>
</a>
</li>
<li role="presentation" v-show="canOpenTab" class="addTab" id="addTab">
<a href="#" @click.prevent="addTab" class="fa fa-plus"></a>
</li>
</ul>
<div style="flex:1;display:flex;justify-content:flex-end;-webkit-app-region:drag;margin-top:3px" class="btn-group"
id="windowButtons">
<span class="fa fa-window-minimize btn btn-default" @click.stop="minimize"></span>
<span :class="'fa btn btn-default fa-window-' + (isMaximized ? 'restore' : 'maximize')" @click="maximize"></span>
<span class="fa fa-close fa-lg btn btn-default" @click.stop="close"></span>
</div>
</div>
</div>
</template>
<script lang="ts">
import Sortable = require('sortablejs'); //tslint:disable-line:no-require-imports
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
import Vue from 'vue';
import Component from 'vue-class-component';
import l from '../chat/localize';
import {GeneralSettings} from './common';
const browserWindow = electron.remote.getCurrentWindow();
function getWindowBounds(): Electron.Rectangle {
const bounds = browserWindow.getContentBounds();
const height = document.body.offsetHeight;
return {x: 0, y: height, width: bounds.width, height: bounds.height - height};
}
interface Tab {
user: string | undefined,
view: Electron.BrowserView
hasNew: boolean
tray: Electron.Tray
}
const trayIcon = path.join(__dirname, <string>require('./build/tray.png')); //tslint:disable-line:no-require-imports
@Component
export default class Window extends Vue {
//tslint:disable:no-null-keyword
settings: GeneralSettings;
tabs: Tab[] = [];
activeTab: Tab | null = null;
tabMap: {[key: number]: Tab} = {};
isMaximized = browserWindow.isMaximized();
canOpenTab = true;
l = l;
hasUpdate = false;
platform = process.platform;
mounted(): void {
this.addTab();
electron.ipcRenderer.on('settings', (_: Event, settings: GeneralSettings) => this.settings = settings);
electron.ipcRenderer.on('allow-new-tabs', (_: Event, allow: boolean) => this.canOpenTab = allow);
electron.ipcRenderer.on('open-tab', () => this.addTab());
electron.ipcRenderer.on('update-available', () => this.hasUpdate = true);
electron.ipcRenderer.on('connect', (_: Event, id: number, name: string) => {
const tab = this.tabMap[id];
tab.user = name;
tab.tray.setToolTip(`${l('title')} - ${tab.user}`);
const menu = this.createTrayMenu(tab);
menu.unshift({label: tab.user, enabled: false}, {type: 'separator'});
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(menu));
});
electron.ipcRenderer.on('disconnect', (_: Event, id: number) => {
const tab = this.tabMap[id];
tab.user = undefined;
tab.tray.setToolTip(l('title'));
tab.tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
});
electron.ipcRenderer.on('has-new', (_: Event, id: number, hasNew: boolean) => {
const tab = this.tabMap[id];
tab.hasNew = hasNew;
electron.ipcRenderer.send('has-new', this.tabs.reduce((cur, t) => cur || t.hasNew, false));
});
browserWindow.on('maximize', () => {
this.isMaximized = true;
this.activeTab!.view.setBounds(getWindowBounds());
});
browserWindow.on('unmaximize', () => {
this.isMaximized = false;
this.activeTab!.view.setBounds(getWindowBounds());
});
document.addEventListener('click', () => this.activeTab!.view.webContents.focus());
window.addEventListener('focus', () => this.activeTab!.view.webContents.focus());
Sortable.create(this.$refs['tabs'], {
animation: 50,
onEnd: (e: {oldIndex: number, newIndex: number}) => {
if(e.oldIndex === e.newIndex) return;
const tab = this.tabs.splice(e.oldIndex, 1)[0];
this.tabs.splice(e.newIndex, 0, tab);
},
onMove: (e: {related: HTMLElement}) => e.related.id !== 'addTab',
filter: '.addTab'
});
window.onbeforeunload = () => {
const isConnected = this.tabs.reduce((cur, tab) => cur || tab.user !== undefined, false);
if(process.env.NODE_ENV !== 'production' || !isConnected) {
this.tabs.forEach((tab) => this.remove(tab, false));
return;
}
if(!this.settings.closeToTray)
return setImmediate(() => {
if(confirm(l('chat.confirmLeave'))) this.tabs.forEach((tab) => this.remove(tab, false));
});
browserWindow.hide();
return false;
};
}
get styling(): string {
try {
return `<style>${fs.readFileSync(path.join(__dirname, `themes/${this.settings.theme}.css`))}</style>`;
} catch(e) {
if((<Error & {code: string}>e).code === 'ENOENT' && this.settings.theme !== 'default') {
this.settings.theme = 'default';
return this.styling;
}
throw e;
}
}
createTrayMenu(tab: Tab): Electron.MenuItemConstructorOptions[] {
return [
{
label: l('action.open'), click: () => {
browserWindow.show();
this.show(tab);
}
},
{label: l('action.quit'), click: () => this.remove(tab, false)}
];
}
addTab(): void {
const tray = new electron.remote.Tray(trayIcon);
tray.setToolTip(l('title'));
tray.on('click', (_) => browserWindow.show());
const view = new electron.remote.BrowserView();
view.setAutoResize({width: true, height: true});
view.webContents.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true,
query: {settings: JSON.stringify(this.settings)}
}));
electron.ipcRenderer.send('tab-added', view.webContents.id);
const tab = {active: false, view, user: undefined, hasNew: false, tray};
tray.setContextMenu(electron.remote.Menu.buildFromTemplate(this.createTrayMenu(tab)));
this.tabs.push(tab);
this.tabMap[view.webContents.id] = tab;
this.show(tab);
}
show(tab: Tab): void {
this.activeTab = tab;
browserWindow.setBrowserView(tab.view);
tab.view.setBounds(getWindowBounds());
}
remove(tab: Tab, shouldConfirm: boolean = true): void {
if(shouldConfirm && tab.user !== undefined && !confirm(l('chat.confirmLeave'))) return;
this.tabs.splice(this.tabs.indexOf(tab), 1);
delete this.tabMap[tab.view.webContents.id];
tab.tray.destroy();
tab.view.webContents.loadURL('about:blank');
electron.ipcRenderer.send('tab-closed');
delete tab.view;
if(this.tabs.length === 0) {
if(process.env.NODE_ENV === 'production') browserWindow.close();
} else if(this.activeTab === tab) this.show(this.tabs[0]);
}
minimize(): void {
browserWindow.minimize();
}
maximize(): void {
if(browserWindow.isMaximized()) browserWindow.unmaximize();
else browserWindow.maximize();
}
close(): void {
browserWindow.close();
}
openMenu(): void {
electron.remote.Menu.getApplicationMenu().popup();
}
}
</script>
<style lang="less">
#window-tabs {
user-select: none;
.btn {
border-radius: 0;
padding: 5px 15px;
display: flex;
margin: 0px -1px -1px 0;
align-items: center;
-webkit-app-region: no-drag;
}
.btn-default {
background: transparent;
}
li {
height: 100%;
a {
display: flex;
padding: 5px 10px;
height: 100%;
align-items: center;
&:first-child {
border-top-left-radius: 0;
}
}
img {
height: 28px;
margin: -5px 3px -5px -5px;
}
&.active {
margin-bottom: -2px;
}
}
h4 {
margin: 0 10px;
user-select: none;
cursor: default;
align-self: center;
-webkit-app-region: drag;
}
}
#windowButtons .btn {
margin: -4px -1px -1px 0;
border-top: 0;
}
.platform-darwin {
#windowButtons .btn {
display: none;
}
#window-tabs h4 {
margin: 9px 34px 9px 77px;
}
}
</style>

View File

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

BIN
electron/build/badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

View File

@ -31,17 +31,37 @@
*/
import 'bootstrap/js/collapse.js';
import 'bootstrap/js/dropdown.js';
import 'bootstrap/js/modal.js';
import 'bootstrap/js/tab.js';
import 'bootstrap/js/transition.js';
import * as electron from 'electron';
import * as path from 'path';
import * as qs from 'querystring';
import * as Raven from 'raven-js';
import Vue from 'vue';
import {getKey} from '../chat/common';
import l from '../chat/localize';
import VueRaven from '../chat/vue-raven';
import {GeneralSettings, nativeRequire} from './common';
import * as SlimcatImporter from './importer';
import Index from './Index.vue';
document.addEventListener('keydown', (e: KeyboardEvent) => {
if(e.ctrlKey && e.shiftKey && getKey(e) === 'i')
electron.remote.getCurrentWebContents().toggleDevTools();
});
process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
const sc = nativeRequire<{
Spellchecker: {
new(): {
isMisspelled(x: string): boolean,
setDictionary(name: string | undefined, dir: string): void,
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
}
}
}>('spellchecker/build/Release/spellchecker.node');
const spellchecker = new sc.Spellchecker();
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: electron.remote.app.getVersion(),
@ -58,19 +78,81 @@ if(process.env.NODE_ENV === 'production') {
Raven.captureException(<Error>e.reason);
};
document.addEventListener('keydown', (e: KeyboardEvent) => {
if(e.ctrlKey && e.shiftKey && getKey(e) === 'I')
electron.remote.getCurrentWebContents().toggleDevTools();
});
electron.remote.getCurrentWebContents().on('devtools-opened', () => {
console.log(`%c${l('consoleWarning.head')}`, 'background: red; color: yellow; font-size: 30pt');
console.log(`%c${l('consoleWarning.body')}`, 'font-size: 16pt; color:red');
});
}
//tslint:disable-next-line:no-unused-expression
new Index({
el: '#app'
const webContents = electron.remote.getCurrentWebContents();
webContents.on('context-menu', (_, props) => {
const hasText = props.selectionText.trim().length > 0;
const can = (type: string) => (<Electron.EditFlags & {[key: string]: boolean}>props.editFlags)[`can${type}`] && hasText;
const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
if(hasText || props.isEditable)
menuTemplate.push({
id: 'copy',
label: l('action.copy'),
role: can('Copy') ? 'copy' : '',
enabled: can('Copy')
});
if(props.isEditable)
menuTemplate.push({
id: 'cut',
label: l('action.cut'),
role: can('Cut') ? 'cut' : '',
enabled: can('Cut')
}, {
id: 'paste',
label: l('action.paste'),
role: props.editFlags.canPaste ? 'paste' : '',
enabled: props.editFlags.canPaste
});
else if(props.linkURL.length > 0 && props.mediaType === 'none' && props.linkURL.substr(0, props.pageURL.length) !== props.pageURL)
menuTemplate.push({
id: 'copyLink',
label: l('action.copyLink'),
click(): void {
if(process.platform === 'darwin')
electron.clipboard.writeBookmark(props.linkText, props.linkURL);
else
electron.clipboard.writeText(props.linkURL);
}
});
if(props.misspelledWord !== '') {
const corrections = spellchecker.getCorrectionsForMisspelling(props.misspelledWord);
if(corrections.length > 0) {
menuTemplate.unshift({type: 'separator'});
menuTemplate.unshift(...corrections.map((correction: string) => ({
label: correction,
click: () => webContents.replaceMisspelling(correction)
})));
}
}
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
});
electron.ipcRenderer.on('focus', (_: Event, message: boolean) => message ? window.focus() : window.blur());
const dictDir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');
electron.webFrame.setSpellCheckProvider('', false, {spellCheck: (text) => !spellchecker.isMisspelled(text)});
electron.ipcRenderer.on('settings', async(_: Event, s: GeneralSettings) => spellchecker.setDictionary(s.spellcheckLang, dictDir));
const params = <{[key: string]: string | undefined}>qs.parse(window.location.search.substr(1));
const settings = <GeneralSettings>JSON.parse(params['settings']!);
if(params['import'] !== undefined)
try {
if(SlimcatImporter.canImportGeneral() && confirm(l('importer.importGeneral'))) {
SlimcatImporter.importGeneral(settings);
electron.ipcRenderer.send('save-login', settings.account, settings.host);
}
} catch {
alert(l('importer.error'));
}
spellchecker.setDictionary(settings.spellcheckLang, dictDir);
//tslint:disable-next-line:no-unused-expression
new Index({
el: '#app',
data: {settings}
});

View File

@ -1,6 +1,18 @@
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
export class GeneralSettings {
account = '';
closeToTray = true;
profileViewer = true;
host = 'wss://chat.f-list.net:9799';
logDirectory = path.join(electron.app.getPath('userData'), 'data');
spellcheckLang: string | undefined = 'en-GB';
theme = 'default';
version = electron.app.getVersion();
}
export function mkdir(dir: string): void {
try {
fs.mkdirSync(dir);
@ -27,7 +39,9 @@ export function mkdir(dir: string): void {
//tslint:disable
const Module = require('module');
export function nativeRequire<T>(module: string): T {
return Module.prototype.require.call({paths: Module._nodeModulePaths(__dirname)}, module);
}
//tslint:enable

View File

@ -5,21 +5,20 @@ import * as path from 'path';
import {Message as MessageImpl} from '../chat/common';
import core from '../chat/core';
import {Conversation, Logs as Logging, Settings} from '../chat/interfaces';
import l from '../chat/localize';
import {mkdir} from './common';
const dayMs = 86400000;
const baseDir = path.join(electron.remote.app.getPath('userData'), 'data');
mkdir(baseDir);
const noAssert = process.env.NODE_ENV === 'production';
export class GeneralSettings {
account = '';
closeToTray = true;
profileViewer = true;
host = 'wss://chat.f-list.net:9799';
spellcheckLang: string | undefined = 'en-GB';
theme = 'default';
function writeFile(p: fs.PathLike | number, data: string | object | number,
options?: {encoding?: string | null; mode?: number | string; flag?: string} | string | null): void {
try {
fs.writeFileSync(p, data, options);
} catch(e) {
electron.remote.dialog.showErrorBox(l('fs.error'), (<Error>e).message);
}
}
export type Message = Conversation.EventMessage | {
@ -40,7 +39,7 @@ interface Index {
}
export function getLogDir(this: void, character: string = core.connection.character): string {
const dir = path.join(baseDir, character, 'logs');
const dir = path.join(core.state.generalSettings!.logDirectory, character, 'logs');
mkdir(dir);
return dir;
}
@ -152,7 +151,7 @@ export class Logs implements Logging.Persistent {
const entry = this.index[key];
if(entry === undefined) return [];
const dates = [];
for(const item in entry.index) { //tslint:disable:forin
for(const item in entry.index) {
const date = new Date(parseInt(item, 10) * dayMs);
dates.push(addMinutes(date, date.getTimezoneOffset()));
}
@ -185,8 +184,8 @@ export class Logs implements Logging.Persistent {
const hasIndex = this.index[conversation.key] !== undefined;
const indexBuffer = checkIndex(this.index, message, conversation.key, conversation.name,
() => fs.existsSync(file) ? fs.statSync(file).size : 0);
if(indexBuffer !== undefined) fs.writeFileSync(`${file}.idx`, indexBuffer, {flag: hasIndex ? 'a' : 'wx'});
fs.writeFileSync(file, buffer, {flag: 'a'});
if(indexBuffer !== undefined) writeFile(`${file}.idx`, indexBuffer, {flag: hasIndex ? 'a' : 'wx'});
writeFile(file, buffer, {flag: 'a'});
}
get conversations(): ReadonlyArray<{id: string, name: string}> {
@ -197,18 +196,8 @@ export class Logs implements Logging.Persistent {
}
}
export function getGeneralSettings(): GeneralSettings | undefined {
const file = path.join(baseDir, 'settings');
if(!fs.existsSync(file)) return undefined;
return <GeneralSettings>JSON.parse(fs.readFileSync(file, 'utf8'));
}
export function setGeneralSettings(value: GeneralSettings): void {
fs.writeFileSync(path.join(baseDir, 'settings'), JSON.stringify(value));
}
function getSettingsDir(character: string = core.connection.character): string {
const dir = path.join(baseDir, character, 'settings');
const dir = path.join(core.state.generalSettings!.logDirectory, character, 'settings');
mkdir(dir);
return dir;
}
@ -221,10 +210,11 @@ export class SettingsStore implements Settings.Store {
}
async getAvailableCharacters(): Promise<ReadonlyArray<string>> {
const baseDir = core.state.generalSettings!.logDirectory;
return (fs.readdirSync(baseDir)).filter((x) => fs.lstatSync(path.join(baseDir, x)).isDirectory());
}
async set<K extends keyof Settings.Keys>(key: K, value: Settings.Keys[K]): Promise<void> {
fs.writeFileSync(path.join(getSettingsDir(), key), JSON.stringify(value));
writeFile(path.join(getSettingsDir(), key), JSON.stringify(value));
}
}

View File

@ -4,7 +4,8 @@ import * as path from 'path';
import {promisify} from 'util';
import {Settings} from '../chat/common';
import {Conversation} from '../chat/interfaces';
import {checkIndex, GeneralSettings, getLogDir, Message as LogMessage, serializeMessage, SettingsStore} from './filesystem';
import {GeneralSettings} from './common';
import {checkIndex, getLogDir, Message as LogMessage, serializeMessage, SettingsStore} from './filesystem';
function getRoamingDir(): string | undefined {
const appdata = process.env.APPDATA;
@ -37,7 +38,7 @@ export function canImportCharacter(character: string): boolean {
return getSettingsDir(character) !== undefined;
}
export function importGeneral(): GeneralSettings | undefined {
export function importGeneral(data: GeneralSettings): void {
let dir = getLocalDir();
let files: string[] = [];
if(dir !== undefined)
@ -57,7 +58,6 @@ export function importGeneral(): GeneralSettings | undefined {
}
if(file.length === 0) return;
let elm = new DOMParser().parseFromString(fs.readFileSync(file, 'utf8'), 'application/xml').firstElementChild;
const data = new GeneralSettings();
if(file.slice(-3) === 'xml') {
if(elm === null) return;
let elements;
@ -76,7 +76,6 @@ export function importGeneral(): GeneralSettings | undefined {
else if(element.getAttribute('name') === 'Host') data.host = element.firstElementChild.textContent;
}
}
return data;
}
const charRegex = /([A-Za-z0-9][A-Za-z0-9 \-_]{0,18}[A-Za-z0-9\-_])\b/;

View File

@ -2,11 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FChat 3.0</title>
<title>F-Chat</title>
</head>
<body>
<div id="app">
</div>
<script type="text/javascript" src="common.js"></script>
<script type="text/javascript" src="chat.js"></script>
</body>
</html>

View File

@ -29,22 +29,27 @@
* @version 3.0
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
import Axios from 'axios';
import * as electron from 'electron';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
import {autoUpdater} from 'electron-updater';
import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
import {mkdir} from './common';
import {promisify} from 'util';
import l from '../chat/localize';
import {GeneralSettings, mkdir} from './common';
import * as windowState from './window_state';
import BrowserWindow = Electron.BrowserWindow;
// Module to control application life.
const app = electron.app;
const datadir = process.argv.filter((x) => x.startsWith('--datadir='));
if(datadir.length > 0) app.setPath('userData', datadir[0].substr('--datadir='.length));
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
const windows: Electron.BrowserWindow[] = [];
const characters: string[] = [];
let tabCount = 0;
const baseDir = app.getPath('userData');
mkdir(baseDir);
@ -55,70 +60,322 @@ log.transports.file.maxSize = 5 * 1024 * 1024;
log.transports.file.file = path.join(baseDir, 'log.txt');
log.info('Starting application.');
function sendUpdaterStatusToWindow(status: string, progress?: object): void {
log.info(status);
for(const window of windows) window.webContents.send('updater-status', status, progress);
const dictDir = path.join(baseDir, 'spellchecker');
mkdir(dictDir);
const downloadUrl = 'https://client.f-list.net/dictionaries/';
type DictionaryIndex = {[key: string]: {file: string, time: number} | undefined};
let availableDictionaries: DictionaryIndex | undefined;
const writeFile = promisify(fs.writeFile);
const requestConfig = {responseType: 'arraybuffer'};
async function getAvailableDictionaries(): Promise<ReadonlyArray<string>> {
if(availableDictionaries === undefined) {
const indexPath = path.join(dictDir, 'index.json');
try {
if(!fs.existsSync(indexPath) || fs.statSync(indexPath).mtimeMs + 86400000 * 7 < Date.now()) {
availableDictionaries = (await Axios.get<DictionaryIndex>(`${downloadUrl}index.json`)).data;
await writeFile(indexPath, JSON.stringify(availableDictionaries));
} else availableDictionaries = <DictionaryIndex>JSON.parse(fs.readFileSync(indexPath, 'utf8'));
} catch(e) {
availableDictionaries = {};
log.error(`Error loading dictionaries: ${e}`);
}
}
return Object.keys(availableDictionaries).sort();
}
const updaterEvents = ['checking-for-update', 'update-available', 'update-not-available', 'error', 'update-downloaded'];
for(const eventName of updaterEvents)
autoUpdater.on(eventName, () => {
sendUpdaterStatusToWindow(eventName);
});
autoUpdater.on('download-progress', (_, progress: object) => {
sendUpdaterStatusToWindow('download-progress', progress);
});
function runUpdater(): void {
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
electron.ipcMain.on('install-update', () => autoUpdater.quitAndInstall(false, true));
async function setDictionary(lang: string | undefined): Promise<void> {
const dict = availableDictionaries![lang!];
if(dict !== undefined) {
const dicPath = path.join(dictDir, `${lang}.dic`);
if(!fs.existsSync(dicPath) || fs.statSync(dicPath).mtimeMs / 1000 < dict.time) {
await writeFile(dicPath, new Buffer((await Axios.get<string>(`${downloadUrl}${dict.file}.dic`, requestConfig)).data));
await writeFile(path.join(dictDir, `${lang}.aff`),
new Buffer((await Axios.get<string>(`${downloadUrl}${dict.file}.aff`, requestConfig)).data));
fs.utimesSync(dicPath, dict.time, dict.time);
}
}
settings.spellcheckLang = lang;
setGeneralSettings(settings);
}
function bindWindowEvents(window: Electron.BrowserWindow): void {
// Prevent page navigation by opening links in an external browser.
const settingsDir = path.join(electron.app.getPath('userData'), 'data');
const file = path.join(settingsDir, 'settings');
const settings = new GeneralSettings();
let shouldImportSettings = false;
if(!fs.existsSync(file)) shouldImportSettings = true;
else
try {
Object.assign(settings, <GeneralSettings>JSON.parse(fs.readFileSync(file, 'utf8')));
} catch(e) {
log.error(`Error loading settings: ${e}`);
}
function setGeneralSettings(value: GeneralSettings): void {
fs.writeFileSync(path.join(settingsDir, 'settings'), JSON.stringify(value));
for(const w of electron.webContents.getAllWebContents()) w.send('settings', settings);
shouldImportSettings = false;
}
async function addSpellcheckerItems(menu: Electron.Menu): Promise<void> {
const dictionaries = await getAvailableDictionaries();
const selected = settings.spellcheckLang;
menu.append(new electron.MenuItem({
type: 'radio',
label: l('settings.spellcheck.disabled'),
click: async() => setDictionary(undefined)
}));
for(const lang of dictionaries)
menu.append(new electron.MenuItem({
type: 'radio',
label: lang,
checked: lang === selected,
click: async() => setDictionary(lang)
}));
}
function setUpWebContents(webContents: Electron.WebContents): void {
const openLinkExternally = (e: Event, linkUrl: string) => {
e.preventDefault();
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)/);
if(profileMatch !== null) window.webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
const profileMatch = linkUrl.match(/^https?:\/\/(www\.)?f-list.net\/c\/(.+)\/?#?/);
if(profileMatch !== null && settings.profileViewer) webContents.send('open-profile', decodeURIComponent(profileMatch[2]));
else electron.shell.openExternal(linkUrl);
};
window.webContents.on('will-navigate', openLinkExternally);
window.webContents.on('new-window', openLinkExternally);
// Fix focus events not properly propagating down to the document.
window.on('focus', () => window.webContents.send('focus', true));
window.on('blur', () => window.webContents.send('focus', false));
// Save window state when it is being closed.
window.on('close', () => windowState.setSavedWindowState(window));
webContents.on('will-navigate', openLinkExternally);
webContents.on('new-window', openLinkExternally);
}
function createWindow(): void {
function createWindow(): Electron.BrowserWindow | undefined {
if(tabCount >= 3) return;
const lastState = windowState.getSavedWindowState();
const windowProperties = {...lastState, center: lastState.x === undefined};
const windowProperties: Electron.BrowserWindowConstructorOptions & {maximized: boolean} = {
...lastState, center: lastState.x === undefined
};
if(process.platform === 'darwin') windowProperties.titleBarStyle = 'hiddenInset';
else windowProperties.frame = false;
const window = new electron.BrowserWindow(windowProperties);
windows.push(window);
if(lastState.maximized) window.maximize();
window.loadURL(url.format({
pathname: path.join(__dirname, 'index.html'),
pathname: path.join(__dirname, 'window.html'),
protocol: 'file:',
slashes: true
slashes: true,
query: {settings: JSON.stringify(settings), import: shouldImportSettings ? 'true' : []}
}));
bindWindowEvents(window);
setUpWebContents(window.webContents);
// Save window state when it is being closed.
window.on('close', () => windowState.setSavedWindowState(window));
window.on('closed', () => windows.splice(windows.indexOf(window), 1));
if(process.env.NODE_ENV === 'production') runUpdater();
return window;
}
const running = app.makeSingleInstance(() => {
if(windows.length < 3) createWindow();
return true;
});
function showPatchNotes(): void {
electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0#Changelog');
}
function onReady(): void {
app.on('open-file', createWindow);
if(settings.version !== app.getVersion()) {
showPatchNotes();
settings.version = app.getVersion();
setGeneralSettings(settings);
}
if(process.env.NODE_ENV === 'production') {
autoUpdater.checkForUpdates(); //tslint:disable-line:no-floating-promises
const updateTimer = setInterval(async() => autoUpdater.checkForUpdates(), 3600000);
let hasUpdate = false;
autoUpdater.on('update-downloaded', () => {
clearInterval(updateTimer);
if(hasUpdate) return;
hasUpdate = true;
const menu = electron.Menu.getApplicationMenu();
menu.append(new electron.MenuItem({
label: l('action.updateAvailable'),
submenu: electron.Menu.buildFromTemplate([{
label: l('action.update'),
click: () => autoUpdater.quitAndInstall(false, true)
}, {
label: l('help.changelog'),
click: showPatchNotes
}])
}));
electron.Menu.setApplicationMenu(menu);
for(const w of windows) w.webContents.send('update-available');
});
}
const viewItem = {
label: `&${l('action.view')}`,
submenu: <Electron.MenuItemConstructorOptions[]>[
{role: 'resetzoom'},
{role: 'zoomin'},
{role: 'zoomout'},
{type: 'separator'},
{role: 'togglefullscreen'}
]
};
if(process.env.NODE_ENV !== 'production')
viewItem.submenu.unshift({role: 'reload'}, {role: 'forcereload'}, {role: 'toggledevtools'}, {type: 'separator'});
const spellcheckerMenu = new electron.Menu();
//tslint:disable-next-line:no-floating-promises
addSpellcheckerItems(spellcheckerMenu);
const themes = fs.readdirSync(path.join(__dirname, 'themes')).filter((x) => x.substr(-4) === '.css').map((x) => x.slice(0, -4));
const setTheme = (theme: string) => {
settings.theme = theme;
setGeneralSettings(settings);
};
electron.Menu.setApplicationMenu(electron.Menu.buildFromTemplate([
{
label: `&${l('title')}`,
submenu: [
{label: l('action.newWindow'), click: createWindow, accelerator: 'CmdOrCtrl+n'},
{
label: l('action.newTab'),
click: (_: Electron.MenuItem, w: Electron.BrowserWindow) => {
if(tabCount < 3) w.webContents.send('open-tab');
},
accelerator: 'CmdOrCtrl+t'
},
{
label: l('settings.logDir'),
click: (_, window: BrowserWindow) => {
const dir = <string[] | undefined>electron.dialog.showOpenDialog(
{defaultPath: new GeneralSettings().logDirectory, properties: ['openDirectory']});
if(dir !== undefined) {
const button = electron.dialog.showMessageBox(window, {
message: l('settings.logDir.confirm', dir[0], settings.logDirectory),
buttons: [l('confirmYes'), l('confirmNo')],
cancelId: 1
});
if(button === 0) {
for(const w of windows) {
w.webContents.on('will-prevent-unload', (e) => e.preventDefault());
w.close();
}
settings.logDirectory = dir[0];
setGeneralSettings(settings);
app.quit();
}
}
}
},
{
label: l('settings.closeToTray'), type: 'checkbox', checked: settings.closeToTray,
click: (item: Electron.MenuItem) => {
settings.closeToTray = item.checked;
setGeneralSettings(settings);
}
}, {
label: l('settings.profileViewer'), type: 'checkbox', checked: settings.profileViewer,
click: (item: Electron.MenuItem) => {
settings.profileViewer = item.checked;
setGeneralSettings(settings);
}
},
{label: l('settings.spellcheck'), submenu: spellcheckerMenu},
{
label: l('settings.theme'),
submenu: themes.map((x) => ({
checked: settings.theme === x,
click: () => setTheme(x),
label: x,
type: <'radio'>'radio'
}))
},
{type: 'separator'},
{role: 'minimize'},
{
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : undefined,
label: l('action.quit'),
click(_: Electron.MenuItem, w: Electron.BrowserWindow): void {
if(characters.length === 0) return app.quit();
const button = electron.dialog.showMessageBox(w, {
message: l('chat.confirmLeave'),
buttons: [l('confirmYes'), l('confirmNo')],
cancelId: 1
});
if(button === 0) app.quit();
}
}
]
}, {
label: `&${l('action.edit')}`,
submenu: [
{role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
{role: 'selectall'}
]
}, viewItem, {
label: `&${l('help')}`,
submenu: [
{
label: l('help.fchat'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0')
},
{
label: l('help.feedback'),
click: () => electron.shell.openExternal('https://goo.gl/forms/WnLt3Qm3TPt64jQt2')
},
{
label: l('help.rules'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/Rules')
},
{
label: l('help.faq'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/Frequently_Asked_Questions')
},
{
label: l('help.report'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/How_to_Report_a_User#In_chat')
},
{label: l('version', app.getVersion()), click: showPatchNotes}
]
}
]));
electron.ipcMain.on('tab-added', (_: Event, id: number) => {
const webContents = electron.webContents.fromId(id);
setUpWebContents(webContents);
++tabCount;
if(tabCount === 3)
for(const w of windows) w.webContents.send('allow-new-tabs', false);
});
electron.ipcMain.on('tab-closed', () => {
--tabCount;
for(const w of windows) w.webContents.send('allow-new-tabs', true);
});
electron.ipcMain.on('save-login', (_: Event, account: string, host: string) => {
settings.account = account;
settings.host = host;
setGeneralSettings(settings);
});
electron.ipcMain.on('connect', (e: Event & {sender: Electron.WebContents}, character: string) => {
if(characters.indexOf(character) !== -1) return e.returnValue = false;
else characters.push(character);
e.returnValue = true;
});
electron.ipcMain.on('disconnect', (_: Event, character: string) => characters.splice(characters.indexOf(character), 1));
const emptyBadge = electron.nativeImage.createEmpty();
//tslint:disable-next-line:no-require-imports
const badge = electron.nativeImage.createFromPath(path.join(__dirname, <string>require('./build/badge.png')));
electron.ipcMain.on('has-new', (e: Event & {sender: Electron.WebContents}, hasNew: boolean) => {
if(process.platform === 'darwin') app.dock.setBadge(hasNew ? '!' : '');
electron.BrowserWindow.fromWebContents(e.sender).setOverlayIcon(hasNew ? badge : emptyBadge, hasNew ? 'New messages' : '');
});
createWindow();
}
const running = app.makeSingleInstance(createWindow);
if(running) app.quit();
else app.on('ready', createWindow);
else app.on('ready', onReady);
app.on('window-all-closed', () => app.quit());

View File

@ -1,98 +0,0 @@
import * as electron from 'electron';
import l from '../chat/localize';
export function createContextMenu(props: Electron.ContextMenuParams & {editFlags: {[key: string]: boolean}}):
Electron.MenuItemConstructorOptions[] {
const hasText = props.selectionText.trim().length > 0;
const can = (type: string) => props.editFlags[`can${type}`] && hasText;
const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
if(hasText || props.isEditable)
menuTemplate.push({
id: 'copy',
label: l('action.copy'),
role: can('Copy') ? 'copy' : '',
enabled: can('Copy')
});
if(props.isEditable)
menuTemplate.push({
id: 'cut',
label: l('action.cut'),
role: can('Cut') ? 'cut' : '',
enabled: can('Cut')
}, {
id: 'paste',
label: l('action.paste'),
role: props.editFlags.canPaste ? 'paste' : '',
enabled: props.editFlags.canPaste
});
else if(props.linkURL.length > 0 && props.mediaType === 'none' && props.linkURL.substr(0, props.pageURL.length) !== props.pageURL)
menuTemplate.push({
id: 'copyLink',
label: l('action.copyLink'),
click(): void {
if(process.platform === 'darwin')
electron.clipboard.writeBookmark(props.linkText, props.linkURL);
else
electron.clipboard.writeText(props.linkURL);
}
});
return menuTemplate;
}
export function createAppMenu(): Electron.MenuItemConstructorOptions[] {
const viewItem = {
label: `&${l('action.view')}`,
submenu: [
{role: 'resetzoom'},
{role: 'zoomin'},
{role: 'zoomout'},
{type: 'separator'},
{role: 'togglefullscreen'}
]
};
const menu: Electron.MenuItemConstructorOptions[] = [
{
label: `&${l('title')}`
}, {
label: `&${l('action.edit')}`,
submenu: [
{role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
{role: 'selectall'}
]
}, viewItem, {
label: `&${l('help')}`,
submenu: [
{
label: l('help.fchat'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/F-Chat_3.0')
},
{
label: l('help.feedback'),
click: () => electron.shell.openExternal('https://goo.gl/forms/WnLt3Qm3TPt64jQt2')
},
{
label: l('help.rules'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/Rules')
},
{
label: l('help.faq'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/Frequently_Asked_Questions')
},
{
label: l('help.report'),
click: () => electron.shell.openExternal('https://wiki.f-list.net/How_to_Report_a_User#In_chat')
},
{label: l('version', electron.remote.app.getVersion()), enabled: false}
]
}
];
if(process.env.NODE_ENV !== 'production')
viewItem.submenu.unshift({role: 'reload'}, {role: 'forcereload'}, {role: 'toggledevtools'}, {type: 'separator'});
return menu;
}

View File

@ -4,11 +4,13 @@ import {Conversation} from '../chat/interfaces';
//tslint:disable-next-line:match-default-export-name
import BaseNotifications from '../chat/notifications';
const browserWindow = remote.getCurrentWindow();
export default class Notifications extends BaseNotifications {
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
if(!this.isInBackground && conversation === core.conversations.selectedConversation && !core.state.settings.alwaysNotify) return;
this.playSound(sound);
remote.getCurrentWindow().flashFrame(true);
browserWindow.flashFrame(true);
if(core.state.settings.notifications) {
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive
const notification = new Notification(title, <NotificationOptions & {silent: boolean}>{
@ -18,7 +20,7 @@ export default class Notifications extends BaseNotifications {
});
notification.onclick = () => {
conversation.show();
remote.getCurrentWindow().focus();
browserWindow.focus();
notification.close();
};
}

View File

@ -1,4 +1,4 @@
{
{
"name": "fchat",
"version": "3.0.0",
"author": "The F-List Team",

View File

@ -1,57 +0,0 @@
import Axios from 'axios';
import * as electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import {promisify} from 'util';
import {mkdir, nativeRequire} from './common';
process.env.SPELLCHECKER_PREFER_HUNSPELL = '1';
const downloadUrl = 'https://client.f-list.net/dictionaries/';
const dir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');
mkdir(dir);
//tslint:disable-next-line
const sc = nativeRequire<{
Spellchecker: {
new(): {
isMisspelled(x: string): boolean,
setDictionary(name: string | undefined, dir: string): void,
getCorrectionsForMisspelling(word: string): ReadonlyArray<string>
}
}
}>('spellchecker/build/Release/spellchecker.node');
type DictionaryIndex = {[key: string]: {file: string, time: number} | undefined};
let availableDictionaries: DictionaryIndex | undefined;
const writeFile = promisify(fs.writeFile);
const requestConfig = {responseType: 'arraybuffer'};
const spellchecker = new sc.Spellchecker();
export async function getAvailableDictionaries(): Promise<ReadonlyArray<string>> {
if(availableDictionaries === undefined) {
const indexPath = path.join(dir, 'index.json');
if(!fs.existsSync(indexPath) || fs.statSync(indexPath).mtimeMs + 86400000 * 7 < Date.now()) {
availableDictionaries = (await Axios.get<DictionaryIndex>(`${downloadUrl}index.json`)).data;
await writeFile(indexPath, JSON.stringify(availableDictionaries));
} else availableDictionaries = <DictionaryIndex>JSON.parse(fs.readFileSync(indexPath, 'utf8'));
}
return Object.keys(availableDictionaries).sort();
}
export async function setDictionary(lang: string | undefined): Promise<void> {
const dict = availableDictionaries![lang!];
if(dict !== undefined) {
const dicPath = path.join(dir, `${lang}.dic`);
if(!fs.existsSync(dicPath) || fs.statSync(dicPath).mtimeMs / 1000 < dict.time) {
await writeFile(dicPath, new Buffer((await Axios.get<string>(`${downloadUrl}${dict.file}.dic`, requestConfig)).data));
await writeFile(path.join(dir, `${lang}.aff`),
new Buffer((await Axios.get<string>(`${downloadUrl}${dict.file}.aff`, requestConfig)).data));
fs.utimesSync(dicPath, dict.time, dict.time);
}
}
spellchecker.setDictionary(lang, dir);
}
export function getCorrections(word: string): ReadonlyArray<string> {
return spellchecker.getCorrectionsForMisspelling(word);
}
export const check = (text: string) => !spellchecker.isMisspelled(text);

View File

@ -1,4 +1,5 @@
const path = require('path');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const webpack = require('webpack');
const UglifyPlugin = require('uglifyjs-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
@ -6,17 +7,55 @@ const fs = require('fs');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const exportLoader = require('../export-loader');
const config = {
const mainConfig = {
entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'application.json')],
output: {
path: __dirname + '/app',
filename: 'main.js'
},
context: __dirname,
target: 'electron-main',
module: {
loaders: [
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
configFile: __dirname + '/tsconfig.json',
transpileOnly: true
}
},
{test: /application.json$/, loader: 'file-loader?name=package.json'},
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'}
]
},
node: {
__dirname: false,
__filename: false
},
plugins: [
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}),
exportLoader.delayTypecheck
],
resolve: {
extensions: ['.ts', '.js']
},
resolveLoader: {
modules: [
'node_modules', path.join(__dirname, '../')
]
}
}, rendererConfig = {
entry: {
chat: [path.join(__dirname, 'chat.ts')],
main: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'index.html'), path.join(__dirname, 'application.json')]
chat: [path.join(__dirname, 'chat.ts'), path.join(__dirname, 'index.html')],
window: [path.join(__dirname, 'window.ts'), path.join(__dirname, 'window.html')]
},
output: {
path: __dirname + '/app',
filename: '[name].js'
},
context: __dirname,
target: 'electron',
target: 'electron-renderer',
module: {
loaders: [
{
@ -41,8 +80,7 @@ const config = {
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?limit=10000&mimetype=application/octet-stream'},
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url-loader?limit=10000&mimetype=image/svg+xml'},
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
{test: /application.json$/, loader: 'file-loader?name=package.json'}
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'}
]
},
node: {
@ -56,6 +94,7 @@ const config = {
'window.jQuery': 'jquery/dist/jquery.slim.js'
}),
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, tslint: path.join(__dirname, '../tslint.json')}),
new CommonsChunkPlugin({name: 'common', minChunks: 2}),
exportLoader.delayTypecheck
],
resolve: {
@ -77,20 +116,20 @@ module.exports = function(env) {
for(const theme of themes) {
if(!theme.endsWith('.less')) continue;
const absPath = path.join(themesDir, theme);
config.entry.chat.push(absPath);
rendererConfig.entry.chat.push(absPath);
const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css');
config.plugins.push(plugin);
config.module.loaders.push({test: absPath, use: plugin.extract(cssOptions)});
rendererConfig.plugins.push(plugin);
rendererConfig.module.loaders.push({test: absPath, use: plugin.extract(cssOptions)});
}
if(dist) {
config.devtool = 'source-map';
config.plugins.push(
new UglifyPlugin({sourceMap: true}),
mainConfig.devtool = rendererConfig.devtool = 'source-map';
const plugins = [new UglifyPlugin({sourceMap: true}),
new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify('production')}),
new webpack.LoaderOptionsPlugin({minimize: true})
);
new webpack.LoaderOptionsPlugin({minimize: true})];
mainConfig.plugins.push(...plugins);
rendererConfig.plugins.push(...plugins);
} else {
//config.devtool = 'cheap-module-eval-source-map';
}
return config;
return [mainConfig, rendererConfig];
};

12
electron/window.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>F-Chat</title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="common.js"></script>
<script type="text/javascript" src="window.js"></script>
</body>
</html>

11
electron/window.ts Normal file
View File

@ -0,0 +1,11 @@
import * as qs from 'querystring';
import {GeneralSettings} from './common';
import Window from './Window.vue';
const params = <{[key: string]: string | undefined}>qs.parse(window.location.search.substr(1));
const settings = <GeneralSettings>JSON.parse(params['settings']!);
//tslint:disable-next-line:no-unused-expression
new Window({
el: '#app',
data: {settings}
});

File diff suppressed because it is too large Load Diff

View File

@ -42,18 +42,18 @@ class Channel implements Interfaces.Channel {
constructor(readonly id: string, readonly name: string) {
}
addMember(member: SortableMember): void {
async addMember(member: SortableMember): Promise<void> {
this.members[member.character.name] = member;
sortMember(this.sortedMembers, member);
for(const handler of state.handlers) handler('join', this, member);
for(const handler of state.handlers) await handler('join', this, member);
}
removeMember(name: string): void {
async removeMember(name: string): Promise<void> {
const member = this.members[name];
if(member !== undefined) {
delete this.members[name];
this.sortedMembers.splice(this.sortedMembers.indexOf(member), 1);
for(const handler of state.handlers) handler('leave', this, member);
for(const handler of state.handlers) await handler('leave', this, member);
}
}
@ -159,7 +159,7 @@ export default function(this: void, connection: Connection, characters: Characte
}
state.openRooms = channels;
});
connection.onMessage('JCH', (data) => {
connection.onMessage('JCH', async(data) => {
const item = state.getChannelItem(data.channel);
if(data.character.identity === connection.character) {
const id = data.channel.toLowerCase();
@ -170,11 +170,11 @@ export default function(this: void, connection: Connection, characters: Characte
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
const member = channel.createMember(characters.get(data.character.identity));
channel.addMember(member);
await channel.addMember(member);
if(item !== undefined) item.memberCount++;
}
});
connection.onMessage('ICH', (data) => {
connection.onMessage('ICH', async(data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.mode = data.mode;
@ -190,24 +190,24 @@ export default function(this: void, connection: Connection, characters: Characte
channel.sortedMembers = sorted;
const item = state.getChannelItem(data.channel);
if(item !== undefined) item.memberCount = data.users.length;
for(const handler of state.handlers) handler('join', channel);
for(const handler of state.handlers) await handler('join', channel);
});
connection.onMessage('CDS', (data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return state.leave(data.channel);
channel.description = decodeHTML(data.description);
});
connection.onMessage('LCH', (data) => {
connection.onMessage('LCH', async(data) => {
const channel = state.getChannel(data.channel);
if(channel === undefined) return;
const item = state.getChannelItem(data.channel);
if(data.character === connection.character) {
state.joinedChannels.splice(state.joinedChannels.indexOf(channel), 1);
delete state.joinedMap[channel.id];
for(const handler of state.handlers) handler('leave', channel);
for(const handler of state.handlers) await handler('leave', channel);
if(item !== undefined) item.isJoined = false;
} else {
channel.removeMember(data.character);
await channel.removeMember(data.character);
if(item !== undefined) item.memberCount--;
}
});
@ -255,12 +255,11 @@ export default function(this: void, connection: Connection, characters: Characte
if(channel === undefined) return state.leave(data.channel);
channel.mode = data.mode;
});
connection.onMessage('FLN', (data) => {
connection.onMessage('FLN', async(data) => {
for(const key in state.joinedMap)
state.joinedMap[key]!.removeMember(data.character);
await state.joinedMap[key]!.removeMember(data.character);
});
const globalHandler = (data: Connection.ServerCommands['AOP'] | Connection.ServerCommands['DOP']) => {
//tslint:disable-next-line:forin
for(const key in state.joinedMap) {
const channel = state.joinedMap[key]!;
const member = channel.members[data.character];

View File

@ -62,7 +62,6 @@ export default function(this: void, connection: Connection): Interfaces.State {
state.bookmarkList = (<{characters: string[]}>await connection.queryApi('bookmark-list.php')).characters;
state.friendList = ((<{friends: {source: string, dest: string, last_online: number}[]}>await connection.queryApi('friend-list.php'))
.friends).map((x) => x.dest);
//tslint:disable-next-line:forin
for(const key in state.characters) {
const character = state.characters[key]!;
character.isFriend = state.friendList.indexOf(character.name) !== -1;
@ -76,7 +75,6 @@ export default function(this: void, connection: Connection): Interfaces.State {
connection.onEvent('connected', async(isReconnect) => {
if(!isReconnect) return;
connection.send('STA', reconnectStatus);
//tslint:disable-next-line:forin
for(const key in state.characters) {
const char = state.characters[key]!;
char.isIgnored = state.ignoreList.indexOf(key) !== -1;
@ -97,7 +95,9 @@ export default function(this: void, connection: Connection): Interfaces.State {
state.get(data.character).isIgnored = false;
}
});
connection.onMessage('ADL', (data) => state.opList = data.ops.slice());
connection.onMessage('ADL', (data) => {
state.opList = data.ops.slice();
});
connection.onMessage('LIS', (data) => {
for(const char of data.characters) {
const character = state.get(char[0]);
@ -143,6 +143,7 @@ export default function(this: void, connection: Connection): Interfaces.State {
if(character.status !== 'offline') state.bookmarks.splice(state.bookmarks.indexOf(character), 1);
break;
case 'friendadd':
if(character.isFriend) return;
state.friendList.push(data.name);
character.isFriend = true;
if(character.status !== 'offline') state.friends.push(character);

View File

@ -21,44 +21,50 @@ export default class Connection implements Interfaces.Connection {
private reconnectTimer: NodeJS.Timer;
private ticketProvider: Interfaces.TicketProvider;
private reconnectDelay = 0;
private isReconnect = false;
constructor(private readonly socketProvider: new() => WebSocketConnection, private readonly account: string,
constructor(private readonly clientName: string, private readonly version: string,
private readonly socketProvider: new() => WebSocketConnection, private readonly account: string,
ticketProvider: Interfaces.TicketProvider | string) {
this.ticketProvider = typeof ticketProvider === 'string' ? async() => this.getTicket(ticketProvider) : ticketProvider;
}
async connect(character: string): Promise<void> {
this.cleanClose = false;
const isReconnect = this.character === character;
this.isReconnect = this.character === character;
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;
}
await this.invokeHandlers('connecting', this.isReconnect);
if(this.cleanClose) {
this.cleanClose = false;
await this.invokeHandlers('closed', false);
return;
}
await this.invokeHandlers('connecting', isReconnect);
const socket = this.socket = new this.socketProvider();
socket.onOpen(() => {
this.send('IDN', {
account: this.account,
character: this.character,
cname: 'F-Chat',
cversion: '3.0',
cname: this.clientName,
cversion: this.version,
method: 'ticket',
ticket: this.ticket
});
});
socket.onMessage((msg: string) => {
socket.onMessage(async(msg: string) => {
const type = <keyof Interfaces.ServerCommands>msg.substr(0, 3);
const data = msg.length > 6 ? <object>JSON.parse(msg.substr(4)) : undefined;
this.handleMessage(type, data);
return this.handleMessage(type, data);
});
socket.onClose(async() => {
if(!this.cleanClose) {
setTimeout(async() => this.connect(this.character), this.reconnectDelay);
this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000;
}
if(!this.cleanClose) this.reconnect();
this.socket = undefined;
await this.invokeHandlers('closed', !this.cleanClose);
});
@ -74,6 +80,11 @@ export default class Connection implements Interfaces.Connection {
});
}
private reconnect(): void {
this.reconnectTimer = setTimeout(async() => this.connect(this.character), this.reconnectDelay);
this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000;
}
close(): void {
clearTimeout(this.reconnectTimer);
this.cleanClose = true;
@ -131,7 +142,11 @@ export default class Connection implements Interfaces.Connection {
}
//tslint:disable:no-unsafe-any no-any
protected handleMessage<T extends keyof Interfaces.ServerCommands>(type: T, data: any): void {
protected async handleMessage<T extends keyof Interfaces.ServerCommands>(type: T, data: any): Promise<void> {
const time = new Date();
const handlers = <Interfaces.CommandHandler<T>[] | undefined>this.messageHandlers[type];
if(handlers !== undefined)
for(const handler of handlers) await handler(data, time);
switch(type) {
case 'VAR':
this.vars[data.variable] = data.value;
@ -149,14 +164,10 @@ export default class Connection implements Interfaces.Connection {
break;
case 'NLN':
if(data.identity === this.character) {
this.invokeHandlers('connected', this.reconnectDelay !== 0); //tslint:disable-line:no-floating-promises
await this.invokeHandlers('connected', this.isReconnect);
this.reconnectDelay = 0;
}
}
const time = new Date();
const handlers = <Interfaces.CommandHandler<T>[] | undefined>this.messageHandlers[type];
if(handlers !== undefined)
for(const handler of handlers) handler(data, time);
}
//tslint:enable

View File

@ -114,7 +114,7 @@ export namespace Connection {
ZZZ: {message: string}
};
export type CommandHandler<T extends keyof ServerCommands> = (data: ServerCommands[T], date: Date) => void;
export type CommandHandler<T extends keyof ServerCommands> = (data: ServerCommands[T], date: Date) => Promise<void> | void;
export type TicketProvider = () => Promise<string>;
export type EventType = 'connecting' | 'connected' | 'closed';
export type EventHandler = (isReconnect: boolean) => Promise<void> | void;
@ -180,7 +180,7 @@ export namespace Character {
export type Character = Character.Character;
export namespace Channel {
export type EventHandler = (type: 'join' | 'leave', channel: Channel, member?: Member) => void;
export type EventHandler = (type: 'join' | 'leave', channel: Channel, member?: Member) => Promise<void> | void;
export interface State {
readonly officialChannels: {readonly [key: string]: (ListItem | undefined)};
@ -230,7 +230,7 @@ export type Channel = Channel.Channel;
export interface WebSocketConnection {
close(): void
onMessage(handler: (message: string) => void): void
onMessage(handler: (message: string) => Promise<void>): void
onOpen(handler: () => void): void
onClose(handler: () => void): void
onError(handler: (error: Error) => void): void

View File

@ -13,3 +13,30 @@
.alert();
.alert-danger();
}
.bbcode-toolbar {
@media (max-width: @screen-xs-max) {
background: @text-background-color;
padding: 10px;
position: absolute;
top: 0;
border-radius: 3px;
z-index: 20;
display: none;
.btn {
margin: 3px;
}
}
@media (min-width: @screen-sm-min) {
.btn-group();
.close {
display:none;
}
}
}
.bbcode-btn {
@media (min-width: @screen-sm-min) {
display: none;
}
}

View File

@ -24,6 +24,7 @@
}
.character-links-block {
a {
padding: 0 4px;
cursor: pointer;
}
}
@ -78,10 +79,12 @@
}
.character-kinks {
display: flex;
flex-wrap: wrap;
margin-top: 15px;
> .col-xs-3 {
> div {
// Fix up padding on columns so they look distinct without being miles apart.
padding: 0 5px 0 0;
padding: 0 5px 5px 0;
}
.kinks-column {
padding: 15px;
@ -95,6 +98,7 @@
}
.character-kink {
position: relative;
.subkink-list {
.well();
margin-bottom: 0;
@ -143,6 +147,19 @@
background-color: @well-bg;
height: 100%;
margin-top: -20px;
.character-image-container {
@media (max-width: @screen-xs-max) {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
}
}
}
@media (min-width: @screen-sm-min) {
.profile-body {
padding-left: 0;
}
}
// Character Images
@ -150,6 +167,7 @@
.character-image {
.col-xs-2();
.img-thumbnail();
max-width: 100%;
vertical-align: middle;
border: none;
display: inline-block;
@ -213,4 +231,13 @@
max-height: 100%;
max-width: 100%;
}
}
.friend-item {
display: flex;
align-items: center;
.date {
margin-left: 10px;
flex:1;
}
}

View File

@ -1,5 +1,3 @@
@import "~bootstrap/less/variables.less";
.bg-solid-text {
background: @text-background-color
}
@ -43,15 +41,38 @@
color: #000;
}
.sidebar-wrapper {
.modal-backdrop {
display: none;
z-index: 9;
}
&.open {
.modal-backdrop {
display: block;
}
.body {
display: block;
}
}
}
.sidebar {
position: absolute;
top: 0;
bottom: 0;
background: @body-bg;
z-index: 10;
flex-shrink: 0;
margin: -10px;
padding: 10px;
.body {
display: none;
width: 200px;
flex-direction: column;
max-height: 100%;
overflow: auto;
}
.expander {
@ -61,7 +82,7 @@
border-color: @btn-default-border;
border-top-right-radius: 0;
border-top-left-radius: 0;
@media(min-width: @screen-sm-min) {
@media (min-width: @screen-sm-min) {
.name {
display: none;
}
@ -75,10 +96,14 @@
&.sidebar-left {
border-right: solid 1px @panel-default-border;
left: 0;
margin-right: 0;
padding-right: 0;
.expander {
transform: rotate(270deg) translate3d(0, 0, 0);
transform-origin: 100% 0;
-webkit-transform: rotate(270deg) translate3d(0, 0, 0);
-webkit-transform-origin: 100% 0;
right: 0;
}
}
@ -86,16 +111,23 @@
&.sidebar-right {
border-left: solid 1px @panel-default-border;
right: 0;
margin-left: 0;
padding-left: 0;
.expander {
transform: rotate(90deg) translate3d(0, 0, 0);
transform-origin: 0 0;
-webkit-transform: rotate(90deg) translate3d(0, 0, 0);
-webkit-transform-origin: 0 0;
}
}
}
.sidebar-fixed() {
position: static;
margin: 0;
padding: 0;
height: 100%;
.body {
display: block;
}
@ -110,13 +142,22 @@
resize: none;
}
.ads-text-box {
background-color: @state-info-bg;
}
.border-top {
border-top: solid 1px @panel-default-border;
}
.border-bottom {
border-bottom: solid 1px @panel-default-border;
}
.message {
word-wrap: break-word;
word-break: break-word;
padding-bottom: 1px;
}
.message-block {
@ -133,12 +174,14 @@
.messages-both {
.message-ad {
background-color: @state-info-bg;
background-color: @brand-info;
padding: 0 2px 2px 2px;
box-shadow: @gray -2px -2px 2px inset;
}
}
.message-event {
color: @gray-light;
color: @gray;
}
.message-highlight {
@ -198,4 +241,21 @@
.profile-viewer {
width: 98%;
height: 100%;
}
#window-tabs .hasNew > a {
background-color: @state-warning-bg;
border-color: @state-warning-border;
color: @state-warning-text;
&:hover {
background-color: @state-warning-border;
}
}
.btn-text {
margin-left: 3px;
@media (max-width: @screen-xs-max) {
display: none;
}
}

View File

@ -17,9 +17,19 @@ hr {
padding: 15px;
blockquote {
border-color: @blockquote-border-color;
font-size: inherit;
}
}
.well-lg {
padding: 20px;
}
@select-indicator: replace("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 2'%3E%3Cpath fill='@{input-color}' d='M2 2L0 0h4z'/%3E%3C/svg%3E", "#", "%23");
select.form-control {
-webkit-appearance: none;
background: @input-bg url(@select-indicator) no-repeat right 1rem center;
background-size: 8px 10px;
padding-right: 25px;
}

View File

@ -1,15 +1,16 @@
@import "~bootstrap/less/variables.less";
// BBcode colors
@red-color: #f00;
@green-color: #0f0;
@blue-color: #00f;
@yellow-color: #ff0;
@cyan-color: #0ff;
@purple-color: #f0f;
@purple-color: #c0f;
@white-color: #fff;
@black-color: #000;
@brown-color: #8a6d3b;
@pink-color: #faa;
@gray-color: #cccc;
@gray-color: #ccc;
@orange-color: #f60;
@collapse-header-bg: @well-bg;
@collapse-border: darken(@well-border, 25%);
@ -17,10 +18,10 @@
// Character page quick kink comparison
@quick-compare-active-border: @black-color;
@quick-compare-favorite-bg: @brand-success;
@quick-compare-yes-bg: @brand-info;
@quick-compare-maybe-bg: @brand-warning;
@quick-compare-no-bg: @brand-danger;
@quick-compare-favorite-bg: @state-info-bg;
@quick-compare-yes-bg: @state-success-bg;
@quick-compare-maybe-bg: @state-warning-bg;
@quick-compare-no-bg: @state-danger-bg;
// character page badges
@character-badge-bg: darken(@well-bg, 10%);
@ -44,4 +45,9 @@
// General color extensions missing from bootstrap
@text-background-color: @body-bg;
@text-background-color-disabled: @gray-lighter;
@text-background-color-disabled: @gray-lighter;
@screen-sm-min: 700px;
@screen-md-min: 900px;
@container-sm: 680px;
@container-md: 880px;

View File

@ -4,10 +4,6 @@
background-color: @gray-lighter;
}
.whiteText {
text-shadow: 1px 1px @gray;
}
// Apply variables to theme.
@import "../theme_base_chat.less";

View File

@ -43,7 +43,7 @@
// Components w/ JavaScript
@import "~bootstrap/less/modals.less";
//@import "tooltip.less";
//@import "popovers.less";
@import "~bootstrap/less/popovers.less";
//@import "carousel.less";
// Utility classes
@import "~bootstrap/less/utilities.less";

View File

@ -24,7 +24,7 @@
@import "~bootstrap/less/button-groups.less";
//@import "input-groups.less";
@import "~bootstrap/less/navs.less";
@import "~bootstrap/less/navbar.less";
//@import "~bootstrap/less/navbar.less";
//@import "breadcrumbs.less";
//@import "~bootstrap/less/pagination.less";
//@import "~bootstrap/less/pager.less";
@ -36,14 +36,14 @@
@import "~bootstrap/less/progress-bars.less";
//@import "media.less";
@import "~bootstrap/less/list-group.less";
@import "~bootstrap/less/panels.less";
//@import "~bootstrap/less/panels.less";
//@import "responsive-embed.less";
@import "~bootstrap/less/wells.less";
@import "~bootstrap/less/close.less";
// Components w/ JavaScript
@import "~bootstrap/less/modals.less";
//@import "tooltip.less";
//@import "popovers.less";
@import "~bootstrap/less/popovers.less";
//@import "carousel.less";
// Utility classes
@import "~bootstrap/less/utilities.less";
@ -55,3 +55,7 @@
@import "../bbcode.less";
@import "../flist_overrides.less";
@import "../chat.less";
html {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

View File

@ -1,13 +1,12 @@
//Import variable defaults first.
@import "~bootstrap/less/variables.less";
@import "../../flist_variables.less";
@gray-base: #000000;
@gray-darker: lighten(@gray-base, 4%);
@gray-dark: lighten(@gray-base, 20%);
@gray: lighten(@gray-base, 55%);
@gray-light: lighten(@gray-base, 80%);
@gray-lighter: lighten(@gray-base, 95%);
@gray-darker: lighten(@gray-base, 5%);
@gray-dark: lighten(@gray-base, 25%);
@gray: lighten(@gray-base, 50%);
@gray-light: lighten(@gray-base, 65%);
@gray-lighter: lighten(@gray-base, 85%);
@body-bg: @gray-darker;
@text-color: @gray-lighter;
@ -17,7 +16,7 @@
@brand-warning: #a50;
@brand-danger: #800;
@brand-success: #080;
@brand-info: #13b;
@brand-info: #228;
@brand-primary: @brand-info;
@blue-color: #36f;
@ -45,7 +44,7 @@
@panel-default-heading-bg: @gray;
@panel-default-border: @border-color;
@input-color: @gray-light;
@input-color: @gray-lighter;
@input-bg: @text-background-color;
@input-bg-disabled: @text-background-color-disabled;
@input-border: @border-color;
@ -62,8 +61,8 @@
@navbar-default-link-color: @link-color;
@navbar-default-link-hover-color: @link-hover-color;
@nav-link-hover-bg: @gray-light;
@nav-link-hover-color: @gray-dark;
@nav-link-hover-bg: @gray-dark;
@nav-link-hover-color: @gray-darker;
@nav-tabs-border-color: @border-color;
@nav-tabs-link-hover-border-color: @border-color;
@ -97,6 +96,10 @@
@modal-footer-border-color: lighten(spin(@modal-content-bg, -10), 15%);
@modal-header-border-color: @modal-footer-border-color;
@popover-bg: @body-bg;
@popover-border-color: @border-color;
@popover-title-bg: @text-background-color;
@badge-color: @gray-darker;
@close-color: saturate(@text-color, 10%);
@ -111,4 +114,7 @@
@collapse-header-bg: desaturate(@well-bg, 20%);
@white-color: @text-color;
@purple-color: @gray-light;
.blackText {
text-shadow: @gray-lighter 1px 1px 1px;
}

View File

@ -1,12 +1,11 @@
//Import variable defaults first.
@import "~bootstrap/less/variables.less";
@import "../../flist_variables.less";
@gray-base: #080810;
@gray-darker: lighten(@gray-base, 15%);
@gray-dark: lighten(@gray-base, 25%);
@gray: lighten(@gray-base, 55%);
@gray-light: lighten(@gray-base, 73%);
@gray: lighten(@gray-base, 60%);
@gray-light: lighten(@gray-base, 75%);
@gray-lighter: lighten(@gray-base, 95%);
// @body-bg: #262626;
@ -46,7 +45,7 @@
@panel-default-heading-bg: @gray;
@panel-default-border: @border-color;
@input-color: @gray-light;
@input-color: @gray-lighter;
@input-bg: @text-background-color;
@input-bg-disabled: @text-background-color-disabled;
@input-border: @border-color;
@ -63,8 +62,8 @@
@navbar-default-link-color: @link-color;
@navbar-default-link-hover-color: @link-hover-color;
@nav-link-hover-bg: @gray-light;
@nav-link-hover-color: @gray-dark;
@nav-link-hover-bg: @gray-dark;
@nav-link-hover-color: @gray-darker;
@nav-tabs-border-color: @border-color;
@nav-tabs-link-hover-border-color: @border-color;
@ -98,6 +97,10 @@
@modal-footer-border-color: lighten(spin(@modal-content-bg, -10), 15%);
@modal-header-border-color: @modal-footer-border-color;
@popover-bg: @body-bg;
@popover-border-color: @border-color;
@popover-title-bg: @text-background-color;
@badge-color: @gray-darker;
@close-color: saturate(@text-color, 10%);
@ -112,4 +115,7 @@
@collapse-header-bg: desaturate(@well-bg, 20%);
@white-color: @text-color;
@purple-color: @gray-light;
.blackText {
text-shadow: @gray-lighter 1px 1px 1px;
}

View File

@ -1,8 +1,12 @@
//Import variable defaults first.
@import "~bootstrap/less/variables.less";
@import "../../flist_variables.less";
// Update variables here.
// @body-bg: #00ff00;
@hr-border: @text-color;
@body-bg: #fafafa;
@body-bg: #fafafa;
@brand-warning: #e09d3e;
.whiteText {
text-shadow: @gray-darker 1px 1px 1px;
}

View File

@ -2,13 +2,11 @@
# yarn lockfile v1
ajv@^5.1.0:
version "5.2.3"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.3.tgz#c06f598778c44c6b161abafe3466b81ad1814ed2"
ajv@^4.9.1:
version "4.11.8"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536"
dependencies:
co "^4.6.0"
fast-deep-equal "^1.0.0"
json-schema-traverse "^0.3.0"
json-stable-stringify "^1.0.1"
asap@~2.0.3:
@ -23,15 +21,19 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
assert-plus@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
aws-sign2@~0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
aws-sign2@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
aws4@^1.6.0:
aws4@^1.2.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
@ -41,17 +43,11 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
boom@4.x.x:
version "4.3.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31"
boom@2.x.x:
version "2.10.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f"
dependencies:
hoek "4.x.x"
boom@5.x.x:
version "5.2.0"
resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02"
dependencies:
hoek "4.x.x"
hoek "2.x.x"
bootstrap@^3.3.7:
version "3.3.7"
@ -75,11 +71,11 @@ core-util-is@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
cryptiles@3.x.x:
version "3.1.2"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe"
cryptiles@2.x.x:
version "2.0.5"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8"
dependencies:
boom "5.x.x"
boom "2.x.x"
dashdash@^1.12.0:
version "1.14.1"
@ -98,22 +94,22 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
errno@^0.1.1:
version "0.1.4"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d"
version "0.1.6"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.6.tgz#c386ce8a6283f14fc09563b71560908c9bf53026"
dependencies:
prr "~0.0.0"
prr "~1.0.1"
extend@~3.0.1:
extend@~3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
extsprintf@1.3.0, extsprintf@^1.2.0:
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
fast-deep-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
extsprintf@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
font-awesome@^4.7.0:
version "4.7.0"
@ -123,9 +119,9 @@ forever-agent@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
form-data@~2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf"
form-data@~2.1.1:
version "2.1.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1"
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.5"
@ -141,35 +137,35 @@ graceful-fs@^4.1.2:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
har-schema@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
har-schema@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
har-validator@~5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd"
har-validator@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
dependencies:
ajv "^5.1.0"
har-schema "^2.0.0"
ajv "^4.9.1"
har-schema "^1.0.5"
hawk@~6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038"
hawk@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4"
dependencies:
boom "4.x.x"
cryptiles "3.x.x"
hoek "4.x.x"
sntp "2.x.x"
boom "2.x.x"
cryptiles "2.x.x"
hoek "2.x.x"
sntp "1.x.x"
hoek@4.x.x:
version "4.2.0"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d"
hoek@2.x.x:
version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
http-signature@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
http-signature@~1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf"
dependencies:
assert-plus "^1.0.0"
assert-plus "^0.2.0"
jsprim "^1.2.2"
sshpk "^1.7.0"
@ -189,10 +185,6 @@ jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
json-schema-traverse@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
@ -228,8 +220,8 @@ less-plugin-npm-import@^2.1.0:
resolve "~1.1.6"
less@^2.7.2:
version "2.7.2"
resolved "https://registry.yarnpkg.com/less/-/less-2.7.2.tgz#368d6cc73e1fb03981183280918743c5dcf9b3df"
version "2.7.3"
resolved "https://registry.yarnpkg.com/less/-/less-2.7.3.tgz#cc1260f51c900a9ec0d91fb6998139e02507b63b"
optionalDependencies:
errno "^0.1.1"
graceful-fs "^4.1.2"
@ -237,22 +229,22 @@ less@^2.7.2:
mime "^1.2.11"
mkdirp "^0.5.0"
promise "^7.1.1"
request "^2.72.0"
request "2.81.0"
source-map "^0.5.3"
mime-db@~1.30.0:
version "1.30.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
mime-types@^2.1.12, mime-types@~2.1.17:
mime-types@^2.1.12, mime-types@~2.1.7:
version "2.1.17"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
dependencies:
mime-db "~1.30.0"
mime@^1.2.11:
version "1.4.1"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
minimist@0.0.8:
version "0.0.8"
@ -264,13 +256,13 @@ mkdirp@^0.5.0:
dependencies:
minimist "0.0.8"
oauth-sign@~0.8.2:
oauth-sign@~0.8.1:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
performance-now@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
promise@^7.1.1:
version "7.3.1"
@ -284,58 +276,58 @@ promise@~7.0.1:
dependencies:
asap "~2.0.3"
prr@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a"
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
qs@~6.5.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
qs@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
request@^2.72.0:
version "2.83.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356"
request@2.81.0:
version "2.81.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0"
dependencies:
aws-sign2 "~0.7.0"
aws4 "^1.6.0"
aws-sign2 "~0.6.0"
aws4 "^1.2.1"
caseless "~0.12.0"
combined-stream "~1.0.5"
extend "~3.0.1"
extend "~3.0.0"
forever-agent "~0.6.1"
form-data "~2.3.1"
har-validator "~5.0.3"
hawk "~6.0.2"
http-signature "~1.2.0"
form-data "~2.1.1"
har-validator "~4.2.1"
hawk "~3.1.3"
http-signature "~1.1.0"
is-typedarray "~1.0.0"
isstream "~0.1.2"
json-stringify-safe "~5.0.1"
mime-types "~2.1.17"
oauth-sign "~0.8.2"
performance-now "^2.1.0"
qs "~6.5.1"
safe-buffer "^5.1.1"
stringstream "~0.0.5"
tough-cookie "~2.3.3"
mime-types "~2.1.7"
oauth-sign "~0.8.1"
performance-now "^0.2.0"
qs "~6.4.0"
safe-buffer "^5.0.1"
stringstream "~0.0.4"
tough-cookie "~2.3.0"
tunnel-agent "^0.6.0"
uuid "^3.1.0"
uuid "^3.0.0"
resolve@~1.1.6:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
safe-buffer@^5.0.1, safe-buffer@^5.1.1:
safe-buffer@^5.0.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
sntp@2.x.x:
version "2.0.2"
resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b"
sntp@1.x.x:
version "1.0.9"
resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198"
dependencies:
hoek "4.x.x"
hoek "2.x.x"
source-map@^0.5.3:
version "0.5.7"
@ -355,11 +347,11 @@ sshpk@^1.7.0:
jsbn "~0.1.0"
tweetnacl "~0.14.0"
stringstream@~0.0.5:
stringstream@~0.0.4:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
tough-cookie@~2.3.3:
tough-cookie@~2.3.0:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
dependencies:
@ -375,7 +367,7 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
uuid@^3.1.0:
uuid@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"

View File

@ -33,7 +33,7 @@
<div class="form-group">
<label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label>
</div>
<div class="form-group">
<div class="form-group text-right">
<button class="btn btn-primary" @click="login" :disabled="loggingIn">
{{l(loggingIn ? 'login.working' : 'login.submit')}}
</button>
@ -42,7 +42,7 @@
</div>
<chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat>
<modal action="Profile" :buttons="false" ref="profileViewer" dialogClass="profile-viewer">
<character-page :authenticated="false" :hideGroups="true" :name="profileName"></character-page>
<character-page :authenticated="false" :oldApi="true" :name="profileName"></character-page>
</modal>
</div>
</template>
@ -64,12 +64,18 @@
import {GeneralSettings, getGeneralSettings, Logs, setGeneralSettings, SettingsStore} from './filesystem';
import Notifications from './notifications';
declare global {
interface Window {
NativeView: {
setTheme(theme: string): void
} | undefined;
}
}
function confirmBack(): void {
if(confirm(l('chat.confirmLeave'))) (<Navigator & {app: {exitApp(): void}}>navigator).app.exitApp();
}
profileApiInit();
@Component({
components: {chat: Chat, modal: Modal, characterPage: CharacterPage}
})
@ -105,7 +111,7 @@
}
get styling(): string {
//tslint:disable-next-line:no-require-imports
if(window.NativeView !== undefined) window.NativeView.setTheme(this.settings!.theme);
return `<style>${require(`../less/themes/chat/${this.settings!.theme}.less`)}</style>`;
}
@ -113,18 +119,20 @@
if(this.loggingIn) return;
this.loggingIn = true;
try {
const data = <{ticket?: string, error: string, characters: string[], default_character: string}>
(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify(
{account: this.settings!.account, password: this.settings!.password, no_friends: true, no_bookmarks: true})
)).data;
const data = <{ticket?: string, error: string, characters: {[key: string]: number}, default_character: number}>
(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify({
account: this.settings!.account, password: this.settings!.password, no_friends: true, no_bookmarks: true,
new_character_list: true
}))).data;
if(data.error !== '') {
this.error = data.error;
return;
}
if(this.saveLogin)
await setGeneralSettings(this.settings!);
if(this.saveLogin) await setGeneralSettings(this.settings!);
Socket.host = this.settings!.host;
const connection = new Connection(Socket, this.settings!.account, this.settings!.password);
const version = (<{version: string}>require('./package.json')).version; //tslint:disable-line:no-require-imports
const connection = new Connection(`F-Chat 3.0 (Mobile)`, version, Socket,
this.settings!.account, this.settings!.password);
connection.onEvent('connected', () => {
Raven.setUserContext({username: core.connection.character});
document.addEventListener('backbutton', confirmBack);
@ -134,8 +142,14 @@
document.removeEventListener('backbutton', confirmBack);
});
initCore(connection, Logs, SettingsStore, Notifications);
this.characters = data.characters.sort();
this.defaultCharacter = data.default_character;
const charNames = Object.keys(data.characters);
this.characters = charNames.sort();
for(const character of charNames)
if(data.characters[character] === data.default_character) {
this.defaultCharacter = character;
break;
}
profileApiInit(data.characters);
} catch(e) {
this.error = l('login.error');
if(process.env.NODE_ENV !== 'production') throw e;
@ -150,4 +164,4 @@
html, body, #page {
height: 100%;
}
</style>
</style>

11
mobile/android/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
*.iml
*.apk
.gradle
/local.properties
.idea/*
!.idea/modules.xml
!.idea/misc.xml
.DS_Store
/build
/captures
.externalNativeBuild

View File

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

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/android.iml" filepath="$PROJECT_DIR$/.idea/android.iml" />
<module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.iml" />
</modules>
</component>
</project>

1
mobile/android/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,27 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 27
buildToolsVersion "27.0.3"
defaultConfig {
applicationId "net.f_list.fchat"
minSdkVersion 19
targetSdkVersion 27
versionCode 4
versionName "0.1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
repositories {
mavenCentral()
}

25
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,25 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in C:\Android\android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="net.f_list.fchat"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@android:style/Theme.Holo.NoActionBar">
<activity android:name=".MainActivity" android:launchMode="singleInstance"
android:configChanges="orientation|screenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

View File

@ -0,0 +1,54 @@
package net.f_list.fchat
import android.content.Context
import android.webkit.JavascriptInterface
import org.json.JSONArray
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class File(private val ctx: Context) {
@JavascriptInterface
fun readFile(name: String, s: Long, l: Int): String? {
val file = File(ctx.filesDir, name)
if(!file.exists()) return null
FileInputStream(file).use { fs ->
val start = if(s != -1L) s else 0
fs.channel.position(start)
val maxLength = fs.channel.size() - start
val length = if(l != -1 && l < maxLength) l else maxLength.toInt()
val bytes = ByteArray(length)
fs.read(bytes, 0, length)
return String(bytes)
}
}
@JavascriptInterface
fun readFile(name: String): String? {
return readFile(name, -1, -1)
}
@JavascriptInterface
fun getSize(name: String) = File(ctx.filesDir, name).length()
@JavascriptInterface
fun writeFile(name: String, data: String) {
FileOutputStream(File(ctx.filesDir, name)).use { it.write(data.toByteArray()) }
}
@JavascriptInterface
fun append(name: String, data: String) {
FileOutputStream(File(ctx.filesDir, name), true).use { it.write(data.toByteArray()) }
}
@JavascriptInterface
fun listFiles(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isFile }.map { it.name }).toString()
@JavascriptInterface
fun listDirectories(name: String) = JSONArray(File(ctx.filesDir, name).listFiles().filter { it.isDirectory }.map { it.name }).toString()
@JavascriptInterface
fun ensureDirectory(name: String) {
File(ctx.filesDir, name).mkdirs()
}
}

View File

@ -0,0 +1,31 @@
package net.f_list.fchat
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.webkit.WebChromeClient
import android.webkit.WebView
class MainActivity : Activity() {
private lateinit var webView: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.webview)
webView.settings.javaScriptEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.loadUrl("file:///android_asset/www/index.html")
webView.addJavascriptInterface(File(this), "NativeFile")
webView.addJavascriptInterface(Notifications(this), "NativeNotification")
webView.webChromeClient = WebChromeClient()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if(intent.action == "notification") {
val data = intent.extras.getString("data")
webView.evaluateJavascript("document.dispatchEvent(new CustomEvent('notification-clicked',{detail:{data:'$data'}}))", {}) //TODO
}
}
}

View File

@ -0,0 +1,57 @@
package net.f_list.fchat
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.AsyncTask
import android.os.Vibrator
import android.webkit.JavascriptInterface
import java.net.URL
class Notifications(private val ctx: Context) {
@JavascriptInterface
fun notify(notify: Boolean, title: String, text: String, icon: String, sound: String?, data: String?): Int {
val soundUri = if(sound != null) Uri.parse("file://android_asset/www/sounds/$sound.mp3") else null
if(!notify) {
(ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator).vibrate(400)
return 0
}
if(soundUri != null) {
val player = MediaPlayer()
val asset = ctx.assets.openFd("www/sounds/$sound.mp3")
player.setDataSource(asset.fileDescriptor, asset.startOffset, asset.length)
player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION)
player.prepare()
player.start()
}
val intent = Intent(ctx, MainActivity::class.java)
intent.action = "notification"
intent.putExtra("data", data)
val notification = Notification.Builder(ctx).setContentTitle(title).setContentText(text).setSmallIcon(R.drawable.ic_notification).setDefaults(Notification.DEFAULT_VIBRATE)
.setContentIntent(PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT)).setAutoCancel(true)
object : AsyncTask<String, Void, Bitmap>() {
override fun doInBackground(vararg args: String): Bitmap {
val connection = URL(args[0]).openConnection()
return BitmapFactory.decodeStream(connection.getInputStream())
}
override fun onPostExecute(result: Bitmap?) {
notification.setLargeIcon(result)
(ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(1, notification.build())
}
}.execute(icon)
return 1
}
@JavascriptInterface
fun requestPermission() {
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="net.f_list.fchat.MainActivity">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">F-Chat</string>
</resources>

View File

@ -0,0 +1,25 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.2.10'
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,13 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true

Binary file not shown.

View File

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

160
mobile/android/gradlew vendored Normal file
View File

@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

90
mobile/android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1 @@
include ':app'

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