This commit is contained in:
MayaWolf 2018-07-20 03:12:26 +02:00
parent 128c638ad4
commit 4d8f6c3670
58 changed files with 1313 additions and 1082 deletions

View File

@ -1,11 +1,12 @@
<template>
<div class="bbcode-editor">
<div class="bbcode-editor" style="display:flex;flex-wrap:wrap;justify-content:flex-end">
<slot></slot>
<a tabindex="0" class="btn btn-light bbcode-btn btn-sm" role="button" @click="showToolbar = true" @blur="showToolbar = false"
style="border-bottom-left-radius:0;border-bottom-right-radius:0">
style="border-bottom-left-radius:0;border-bottom-right-radius:0" v-if="hasToolbar">
<i class="fa fa-code"></i>
</a>
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? 'display:flex' : ''" @mousedown.stop.prevent>
<div class="bbcode-toolbar btn-toolbar" role="toolbar" :style="showToolbar ? 'display:flex' : ''" @mousedown.stop.prevent
v-if="hasToolbar" style="flex:1 51%">
<div class="btn-group" style="flex-wrap:wrap">
<div class="btn btn-light btn-sm" v-for="button in buttons" :title="button.title" @click.prevent.stop="apply(button)">
<i :class="(button.class ? button.class : 'fa ') + button.icon"></i>
@ -17,10 +18,10 @@
</div>
<button type="button" class="close" aria-label="Close" style="margin-left:10px" @click="showToolbar = false">&times;</button>
</div>
<div class="bbcode-editor-text-area">
<textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength"
:class="finalClasses" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" style="border-top-left-radius:0"
:placeholder="placeholder" @keypress="$emit('keypress', $event)" @keydown="onKeyDown"></textarea>
<div class="bbcode-editor-text-area" style="order:100;width:100%;">
<textarea ref="input" v-model="text" @input="onInput" v-show="!preview" :maxlength="maxlength" :placeholder="placeholder"
:class="finalClasses" @keyup="onKeyUp" :disabled="disabled" @paste="onPaste" @keypress="$emit('keypress', $event)"
:style="hasToolbar ? 'border-top-left-radius:0' : ''"@keydown="onKeyDown"></textarea>
<textarea ref="sizer"></textarea>
<div class="bbcode-preview" v-show="preview">
<div class="bbcode-preview-warnings">
@ -59,6 +60,8 @@
readonly disabled?: boolean;
@Prop()
readonly placeholder?: string;
@Prop({default: true})
readonly hasToolbar!: boolean;
@Prop({default: false, type: Boolean})
readonly invalid!: boolean;
preview = false;
@ -191,6 +194,7 @@
button.startText = `[${button.tag}]`;
if(button.endText === undefined)
button.endText = `[/${button.tag}]`;
if(this.text.length + button.startText.length + button.endText.length > this.maxlength) return;
this.applyText(button.startText, button.endText);
this.lastInput = Date.now();
}

View File

@ -88,7 +88,7 @@
if(options === undefined)
options = <Options | undefined>(await Axios.get('https://www.f-list.net/json/api/mapping-list.php')).data;
if(options === undefined) return;
this.options = {
this.options = Object.freeze({
kinks: options.kinks.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0))),
genders: options.listitems.filter((x) => x.name === 'gender').map((x) => x.value),
orientations: options.listitems.filter((x) => x.name === 'orientation').map((x) => x.value),
@ -96,7 +96,7 @@
furryprefs: options.listitems.filter((x) => x.name === 'furrypref').map((x) => x.value),
roles: options.listitems.filter((x) => x.name === 'subdom').map((x) => x.value),
positions: options.listitems.filter((x) => x.name === 'position').map((x) => x.value)
};
});
}
mounted(): void {

View File

@ -69,7 +69,7 @@
else if(node instanceof HTMLImageElement) str += node.alt;
if(node.firstChild !== null && !flags.endFound) str += scanNode(node.firstChild, end, range, flags, hide);
if(node.bbcodeTag !== undefined) str += `[/${node.bbcodeTag}]`;
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block') str += '\r\n';
if(node instanceof HTMLElement && getComputedStyle(node).display === 'block' && !flags.endFound) str += '\r\n';
if(node.nextSibling !== null && !flags.endFound) str += scanNode(node.nextSibling, end, range, flags, hide);
return hide ? '' : str;
}
@ -108,7 +108,7 @@
startValue = start instanceof HTMLImageElement ? start.alt : scanNode(start.firstChild!, end, range, {});
} else
startValue = start.nodeValue!.substring(range.startOffset, start === range.endContainer ? range.endOffset : undefined);
if(end instanceof HTMLElement) end = end.childNodes[range.endOffset - 1];
if(end instanceof HTMLElement && range.endOffset > 0) end = end.childNodes[range.endOffset - 1];
e.clipboardData.setData('text/plain', copyNode(startValue, start, end, range, {}));
e.preventDefault();
}) as EventListener);
@ -122,9 +122,9 @@
core.register('characters', Characters(core.connection));
core.register('channels', Channels(core.connection, core.characters));
core.register('conversations', Conversations());
core.connection.onEvent('closed', (isReconnect) => {
core.connection.onEvent('closed', async(isReconnect) => {
if(isReconnect) (<Modal>this.$refs['reconnecting']).show(true);
if(this.connected) core.notifications.playSound('logout');
if(this.connected) await core.notifications.playSound('logout');
this.connected = false;
this.connecting = false;
document.title = l('title');
@ -133,12 +133,12 @@
this.connecting = true;
if(core.state.settings.notifications) await core.notifications.requestPermission();
});
core.connection.onEvent('connected', () => {
core.connection.onEvent('connected', async() => {
(<Modal>this.$refs['reconnecting']).hide();
this.error = '';
this.connecting = false;
this.connected = true;
core.notifications.playSound('login');
await core.notifications.playSound('login');
document.title = l('title.connected', core.connection.character);
});
core.watch(() => core.conversations.hasNew, (hasNew) => {
@ -157,8 +157,9 @@
(<Modal>this.$refs['reconnecting']).hide();
}
connect(): void {
async connect(): Promise<void> {
this.connecting = true;
await core.notifications.initSounds(['attention', 'login', 'logout', 'modalert', 'newnote']);
core.connection.connect(this.selectedCharacter);
}
}

View File

@ -30,16 +30,18 @@
<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">
class="list-group-item list-group-item-action item-private" :key="conversation.key" @click.middle="conversation.close()">
<img :src="characterImage(conversation.character.name)" v-if="showAvatars"/>
<div class="name">
<span>{{conversation.character.name}}</span>
<div style="line-height:0;display:flex">
<span class="fas fa-reply" v-show="needsReply(conversation)"></span><span class="fas"
<span class="fas fa-reply" v-show="needsReply(conversation)"></span>
<span class="fas"
:class="{'fa-comment-dots': conversation.typingStatus == 'typing', 'fa-comment': conversation.typingStatus == 'paused'}"
></span><span style="flex:1"></span>
></span>
<span style="flex:1"></span>
<span class="pin fas fa-thumbtack" :class="{'active': conversation.isPinned}" @mousedown.prevent
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
@click.stop="conversation.isPinned = !conversation.isPinned" :aria-label="l('chat.pinTab')"></span>
<span class="fas fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</div>
</div>
@ -49,11 +51,14 @@
{{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 fas fa-thumbtack"
:class="{'active': conversation.isPinned}" @click.stop="conversation.isPinned = !conversation.isPinned"
:aria-label="l('chat.pinTab')" @mousedown.prevent></span><span class="fas fa-times leave"
@click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span></span>
:class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel" :key="conversation.key"
@click.middle="conversation.close()">
<span class="name">{{conversation.name}}</span>
<span>
<span class="pin fas fa-thumbtack" :class="{'active': conversation.isPinned}" :aria-label="l('chat.pinTab')"
@click.stop="conversation.isPinned = !conversation.isPinned" @mousedown.prevent></span>
<span class="fas fa-times leave" @click.stop="conversation.close()" :aria-label="l('chat.closeTab')"></span>
</span>
</a>
</div>
</sidebar>
@ -131,6 +136,8 @@
conversations = core.conversations;
getStatusIcon = getStatusIcon;
keydownListener!: (e: KeyboardEvent) => void;
focusListener!: () => void;
blurListener!: () => void;
mounted(): void {
this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e);
@ -152,7 +159,7 @@
});
const ownCharacter = core.characters.ownCharacter;
let idleTimer: number | undefined, idleStatus: Connection.ClientCommands['STA'] | undefined, lastUpdate = 0;
window.addEventListener('focus', () => {
window.addEventListener('focus', this.focusListener = () => {
core.notifications.isInBackground = false;
if(idleTimer !== undefined) {
clearTimeout(idleTimer);
@ -161,11 +168,11 @@
if(idleStatus !== undefined) {
const status = idleStatus;
window.setTimeout(() => core.connection.send('STA', status),
Math.max(lastUpdate + 5 /*core.connection.vars.sta_flood*/ * 1000 + 1000 - Date.now(), 0));
Math.max(lastUpdate + core.connection.vars.sta_flood * 1000 + 1000 - Date.now(), 0));
idleStatus = undefined;
}
});
window.addEventListener('blur', () => {
window.addEventListener('blur', this.blurListener = () => {
core.notifications.isInBackground = true;
if(idleTimer !== undefined) clearTimeout(idleTimer);
if(core.state.settings.idleTimer > 0)
@ -190,6 +197,8 @@
destroyed(): void {
window.removeEventListener('keydown', this.keydownListener);
window.removeEventListener('focus', this.focusListener);
window.removeEventListener('blur', this.blurListener);
}
needsReply(conversation: Conversation): boolean {

View File

@ -1,5 +1,6 @@
<template>
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @close="init()" dialogClass="w-100">
<modal :action="l('conversationSettings.action', conversation.name)" @submit="submit" ref="dialog" @close="init()" dialogClass="w-100"
:buttonText="l('conversationSettings.save')">
<div class="form-group">
<label class="control-label" :for="'notify' + conversation.key">{{l('conversationSettings.notify')}}</label>
<select class="form-control" :id="'notify' + conversation.key" v-model="notify">
@ -82,7 +83,7 @@
this.conversation.settings = {
notify: this.notify,
highlight: this.highlight,
highlightWords: this.highlightWords.split(',').filter((x) => x.length),
highlightWords: this.highlightWords.split(',').map((x) => x.trim()).filter((x) => x.length),
joinMessages: this.joinMessages,
defaultHighlights: this.defaultHighlights
};

View File

@ -30,7 +30,9 @@
<span class="fa" :class="{'fa-chevron-down': !descriptionExpanded, 'fa-chevron-up': descriptionExpanded}"></span>
<span class="btn-text">{{l('channel.description')}}</span>
</a>
<manage-channel :channel="conversation.channel" v-if="isChannelMod"></manage-channel>
<a href="#" @click.prevent="$refs['manageDialog'].show()" v-show="isChannelMod" class="btn">
<span class="fa fa-edit"></span> <span class="btn-text">{{l('manageChannel.open')}}</span>
</a>
<a href="#" @click.prevent="$refs['logsDialog'].show()" class="btn">
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
@ -83,45 +85,42 @@
</span>
</template>
</div>
<div>
<span v-if="conversation.typingStatus && conversation.typingStatus !== 'clear'">
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')" :hasToolbar="settings.bbCodeBar"
ref="textBox" style="position:relative;margin-top:5px" :maxlength="conversation.maxMessageLength">
<span v-if="conversation.typingStatus && conversation.typingStatus !== 'clear'" class="chat-info-text">
{{l('chat.typing.' + conversation.typingStatus, conversation.name)}}
</span>
<div v-show="conversation.infoText" style="display:flex;align-items:center">
<div v-show="conversation.infoText" class="chat-info-text">
<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">
<div v-show="conversation.errorText" class="chat-info-text">
<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">
<bbcode-editor v-model="conversation.enteredText" @keydown="onKeyDown" :extras="extraButtons" @input="keepScroll"
:classes="'form-control chat-text-box' + (conversation.isSendingAds ? ' ads-text-box' : '')"
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}}
</div>
<ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel"
style="position:relative;z-index:10;margin-right:5px">
<li class="nav-item">
<a href="#" :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
</li>
<li class="nav-item">
<a href="#" :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(true)">{{adsMode}}</a>
</li>
</ul>
<div class="btn btn-sm btn-primary" v-show="!settings.enterSend" @click="sendButton">{{l('chat.send')}}</div>
</div>
</bbcode-editor>
<div class="bbcode-editor-controls">
<div v-show="conversation.maxMessageLength" style="margin-right:5px">
{{getByteLength(conversation.enteredText)}} / {{conversation.maxMessageLength}}
</div>
<ul class="nav nav-pills send-ads-switcher" v-if="conversation.channel"
style="position:relative;z-index:10;margin-right:5px">
<li class="nav-item">
<a href="#" :class="{active: !conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(false)">{{l('channel.mode.chat')}}</a>
</li>
<li class="nav-item">
<a href="#" :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
class="nav-link" @click.prevent="setSendingAds(true)">{{adsMode}}</a>
</li>
</ul>
<div class="btn btn-sm btn-primary" v-show="!settings.enterSend" @click="sendButton">{{l('chat.send')}}</div>
</div>
</div>
</bbcode-editor>
<command-help ref="helpDialog"></command-help>
<settings ref="settingsDialog" :conversation="conversation"></settings>
<logs ref="logsDialog" :conversation="conversation"></logs>
<manage-channel ref="manageDialog" :channel="conversation.channel" v-if="conversation.channel"></manage-channel>
</div>
</template>
@ -130,6 +129,7 @@
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {EditorButton, EditorSelection} from '../bbcode/editor';
import {isShowing as anyDialogsShown} from '../components/Modal.vue';
import {Keys} from '../keys';
import {BBCodeView, Editor} from './bbcode';
import CommandHelp from './CommandHelp.vue';
@ -168,23 +168,30 @@
lastSearchInput = 0;
messageCount = 0;
searchTimer = 0;
windowHeight = window.innerHeight;
resizeHandler = () => {
const messageView = <HTMLElement>this.$refs['messages'];
if(this.windowHeight - window.innerHeight + messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15)
messageView.scrollTop = messageView.scrollHeight - messageView.offsetHeight;
this.windowHeight = window.innerHeight;
}
messageView!: HTMLElement;
resizeHandler!: EventListener;
keydownHandler!: EventListener;
keypressHandler!: EventListener;
scrolledDown = true;
scrolledUp = false;
created(): void {
mounted(): void {
this.extraButtons = [{
title: 'Help\n\nClick this button for a quick overview of slash commands.',
tag: '?',
icon: 'fa-question',
handler: () => (<CommandHelp>this.$refs['helpDialog']).show()
}];
window.addEventListener('resize', this.resizeHandler);
window.addEventListener('resize', this.resizeHandler = () => {
if(this.scrolledDown)
this.messageView.scrollTop = this.messageView.scrollHeight - this.messageView.offsetHeight;
this.onMessagesScroll();
});
window.addEventListener('keypress', this.keypressHandler = () => {
if(document.getSelection().isCollapsed && !anyDialogsShown &&
(document.activeElement === document.body || document.activeElement.tagName === 'A'))
(<Editor>this.$refs['textBox']).focus();
});
window.addEventListener('keydown', this.keydownHandler = ((e: KeyboardEvent) => {
if(getKey(e) === Keys.KeyF && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
this.showSearch = true;
@ -195,11 +202,13 @@
if(Date.now() - this.lastSearchInput > 500 && this.search !== this.searchInput)
this.search = this.searchInput;
}, 500);
this.messageView = <HTMLElement>this.$refs['messages'];
}
destroyed(): void {
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('keydown', this.keydownHandler);
window.removeEventListener('keypress', this.keypressHandler);
clearInterval(this.searchTimer);
}
@ -224,29 +233,30 @@
@Watch('conversation')
conversationChanged(): void {
(<Editor>this.$refs['textBox']).focus();
if(!anyDialogsShown) (<Editor>this.$refs['textBox']).focus();
setTimeout(() => this.messageView.scrollTop = this.messageView.scrollHeight - this.messageView.offsetHeight);
this.scrolledDown = true;
}
@Watch('conversation.messages')
messageAdded(newValue: Conversation.Message[]): void {
const messageView = <HTMLElement>this.$refs['messages'];
if(!this.keepScroll() && newValue.length === this.messageCount)
messageView.scrollTop -= (<HTMLElement>messageView.firstElementChild).clientHeight;
this.keepScroll();
if(!this.scrolledDown && newValue.length === this.messageCount)
this.messageView.scrollTop -= (this.messageView.firstElementChild!).clientHeight;
this.messageCount = newValue.length;
}
keepScroll(): boolean {
const messageView = <HTMLElement>this.$refs['messages'];
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) {
this.$nextTick(() => setTimeout(() => messageView.scrollTop = messageView.scrollHeight, 0));
return true;
}
return false;
keepScroll(): void {
if(this.scrolledDown)
this.$nextTick(() => setTimeout(() => this.messageView.scrollTop = this.messageView.scrollHeight, 0));
}
onMessagesScroll(): void {
const messageView = <HTMLElement | undefined>this.$refs['messages'];
if(messageView !== undefined && messageView.scrollTop < 50) this.conversation.loadMore();
if(this.messageView.scrollTop < 50 && !this.scrolledUp) {
this.scrolledUp = true;
this.conversation.loadMore();
} else this.scrolledUp = false;
this.scrolledDown = this.messageView.scrollTop + this.messageView.offsetHeight >= this.messageView.scrollHeight - 15;
}
@Watch('conversation.errorText')
@ -378,4 +388,13 @@
}
}
}
.chat-info-text {
display:flex;
align-items:center;
flex:1 51%;
@media (max-width: breakpoint-max(xs)) {
flex-basis: 100%;
}
}
</style>

View File

@ -38,7 +38,7 @@
<label for="date" class="col-sm-2 col-form-label">{{l('logs.date')}}</label>
<div class="col-sm-8 col-10 col-xl-9">
<select class="form-control" v-model="selectedDate" id="date" @change="loadMessages">
<option :value="null">{{l('logs.selectDate')}}</option>
<option :value="null">{{l('logs.allDates')}}</option>
<option v-for="date in dates" :value="date.getTime()">{{formatDate(date)}}</option>
</select>
</div>
@ -47,7 +47,7 @@
class="fa fa-download"></span></button>
</div>
</div>
<div class="messages-both" style="overflow: auto" ref="messages" tabindex="-1">
<div class="messages-both" style="overflow:auto" ref="messages" tabindex="-1" @scroll="onMessagesScroll">
<message-view v-for="message in filteredMessages" :message="message" :key="message.id"></message-view>
</div>
<div class="input-group" style="flex-shrink:0">
@ -102,6 +102,7 @@
selectedCharacter = core.connection.character;
showFilters = true;
canZip = core.logs.canZip;
dateOffset = -1;
get filteredMessages(): ReadonlyArray<Conversation.Message> {
if(this.filter.length === 0) return this.messages;
@ -139,9 +140,16 @@
this.dates = this.selectedConversation === null ? [] :
(await core.logs.getLogDates(this.selectedCharacter, this.selectedConversation.key)).slice().reverse();
this.selectedDate = null;
this.dateOffset = -1;
this.filter = '';
await this.loadMessages();
}
@Watch('filter')
onFilterChanged(): void {
this.$nextTick(async() => this.onMessagesScroll());
}
download(file: string, logs: string): void {
const a = document.createElement('a');
a.href = logs;
@ -189,6 +197,8 @@
if(this.selectedCharacter !== '') {
this.conversations = (await core.logs.getConversations(this.selectedCharacter)).slice();
this.conversations.sort((x, y) => (x.name < y.name ? -1 : (x.name > y.name ? 1 : 0)));
this.dates = this.selectedConversation === null ? [] :
(await core.logs.getLogDates(this.selectedCharacter, this.selectedConversation.key)).slice().reverse();
await this.loadMessages();
}
this.keyDownListener = (e) => {
@ -213,10 +223,33 @@
}
async loadMessages(): Promise<ReadonlyArray<Conversation.Message>> {
if(this.selectedDate === null || this.selectedConversation === null)
if(this.selectedConversation === null)
return this.messages = [];
return this.messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
new Date(this.selectedDate));
if(this.selectedDate !== null) {
this.dateOffset = -1;
return this.messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
new Date(this.selectedDate));
}
if(this.dateOffset === -1) {
this.messages = [];
this.dateOffset = 0;
}
this.$nextTick(async() => this.onMessagesScroll());
return this.messages;
}
async onMessagesScroll(): Promise<void> {
const list = <HTMLElement | undefined>this.$refs['messages'];
if(this.selectedConversation === null || this.selectedDate !== null || list === undefined || list.scrollTop > 15
|| !this.dialog.isShown || this.dateOffset >= this.dates.length) return;
const messages = await core.logs.getLogs(this.selectedCharacter, this.selectedConversation.key,
this.dates[this.dateOffset++]);
this.messages = messages.concat(this.messages);
const noOverflow = list.offsetHeight === list.scrollHeight;
this.$nextTick(() => {
if(list.offsetHeight === list.scrollHeight) return this.onMessagesScroll();
else if(noOverflow) list.scrollTop = list.scrollHeight;
});
}
}
</script>

View File

@ -1,51 +1,46 @@
<template>
<span>
<a href="#" @click.prevent="openDialog" class="btn">
<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"
dialogClass="w-100 modal-lg">
<div class="form-group" v-show="channel.id.substr(0, 4) === 'adh-'">
<label class="control-label" for="isPublic">
<input type="checkbox" id="isPublic" v-model="isPublic"/>
{{l('manageChannel.isPublic')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="mode">{{l('manageChannel.mode')}}</label>
<select v-model="mode" class="form-control" id="mode">
<option v-for="mode in modes" :value="mode">{{l('channel.mode.' + mode)}}</option>
</select>
</div>
<div class="form-group">
<label>{{l('manageChannel.description')}}</label>
<bbcode-editor classes="form-control" id="description" v-model="description" style="position:relative" :maxlength="50000">
<div style="float:right;text-align:right;">
{{getByteLength(description)}} / {{maxLength}}
</div>
</bbcode-editor>
</div>
<div v-if="isChannelOwner">
<h4>{{l('manageChannel.mods')}}</h4>
<div v-for="(mod, index) in opList">
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn" style="padding:0;vertical-align:baseline">
<i class="fas fa-times"></i>
</a>
{{mod}}
</div>
<div style="display:flex;margin-top:5px">
<input :placeholder="l('manageChannel.modAddName')" v-model="modAddName" class="form-control"/>
<button class="btn btn-secondary" @click="modAdd" :disabled="!modAddName">{{l('manageChannel.modAdd')}}</button>
<modal ref="dialog" :action="l('manageChannel.action', channel.name)" :buttonText="l('manageChannel.submit')" @submit="submit"
dialogClass="w-100 modal-lg" @open="onOpen">
<div class="form-group" v-show="isChannelOwner && channel.id.substr(0, 4) === 'adh-'">
<label class="control-label" for="isPublic">
<input type="checkbox" id="isPublic" v-model="isPublic"/>
{{l('manageChannel.isPublic')}}
</label>
</div>
<div class="form-group" v-show="isChannelOwner">
<label class="control-label" for="mode">{{l('manageChannel.mode')}}</label>
<select v-model="mode" class="form-control" id="mode">
<option v-for="mode in modes" :value="mode">{{l('channel.mode.' + mode)}}</option>
</select>
</div>
<div class="form-group">
<label>{{l('manageChannel.description')}}</label>
<bbcode-editor classes="form-control" id="description" v-model="description" style="position:relative" :maxlength="50000">
<div class="bbcode-editor-controls">
{{getByteLength(description)}} / {{maxLength}}
</div>
</bbcode-editor>
</div>
<template v-if="isChannelOwner">
<h4>{{l('manageChannel.mods')}}</h4>
<div v-for="(mod, index) in opList">
<a href="#" @click.prevent="opList.splice(index, 1)" class="btn" style="padding:0;vertical-align:baseline">
<i class="fas fa-times"></i>
</a>
{{mod}}
</div>
</modal>
</span>
<div style="display:flex;margin-top:5px">
<input :placeholder="l('manageChannel.modAddName')" v-model="modAddName" class="form-control" style="margin-right:5px"/>
<button class="btn btn-secondary" @click="modAdd" :disabled="!modAddName">{{l('manageChannel.modAdd')}}</button>
</div>
</template>
</modal>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {Prop} from 'vue-property-decorator';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import {Editor} from './bbcode';
import {getByteLength} from './common';
@ -56,7 +51,7 @@
@Component({
components: {modal: Modal, 'bbcode-editor': Editor}
})
export default class ManageChannel extends Vue {
export default class ManageChannel extends CustomDialog {
@Prop({required: true})
readonly channel!: Channel;
modes = channelModes;
@ -66,14 +61,14 @@
l = l;
getByteLength = getByteLength;
modAddName = '';
opList: string[] = [];
maxLength = 50000; //core.connection.vars.cds_max;
opList = this.channel.opList.slice();
maxLength = core.connection.vars.cds_max;
@Watch('channel')
channelChanged(): void {
onOpen(): void {
this.mode = this.channel.mode;
this.isPublic = this.channelIsPublic;
this.description = this.channel.description;
this.opList = this.channel.opList.slice();
}
get channelIsPublic(): boolean {
@ -90,14 +85,13 @@
}
submit(): void {
if(this.isPublic !== this.channelIsPublic) {
core.connection.send('RST', {channel: this.channel.id, status: this.isPublic ? 'public' : 'private'});
core.connection.send('ORS');
}
if(this.mode !== this.channel.mode)
core.connection.send('RMO', {channel: this.channel.id, mode: this.mode});
if(this.description !== this.channel.description)
core.connection.send('CDS', {channel: this.channel.id, description: this.description});
if(!this.isChannelOwner) return;
if(this.isPublic !== this.channelIsPublic)
core.connection.send('RST', {channel: this.channel.id, status: this.isPublic ? 'public' : 'private'});
if(this.mode !== this.channel.mode)
core.connection.send('RMO', {channel: this.channel.id, mode: this.mode});
for(const op of this.channel.opList) {
const index = this.opList.indexOf(op);
if(index !== -1) this.opList.splice(index, 1);
@ -105,10 +99,5 @@
}
for(const op of this.opList) core.connection.send('COA', {channel: this.channel.id, character: op});
}
openDialog(): void {
(<Modal>this.$refs['dialog']).show();
this.opList = this.channel.opList.slice();
}
}
</script>

View File

@ -47,6 +47,12 @@
{{l('settings.messageSeparators')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="bbCodeBar">
<input type="checkbox" id="bbCodeBar" v-model="bbCodeBar"/>
{{l('settings.bbCodeBar')}}
</label>
</div>
<div class="form-group">
<label class="control-label" for="logMessages">
<input type="checkbox" id="logMessages" v-model="logMessages"/>
@ -158,6 +164,7 @@
showNeedsReply!: boolean;
enterSend!: boolean;
colorBookmarks!: boolean;
bbCodeBar!: boolean;
constructor() {
super();
@ -189,6 +196,7 @@
this.showNeedsReply = settings.showNeedsReply;
this.enterSend = settings.enterSend;
this.colorBookmarks = settings.colorBookmarks;
this.bbCodeBar = settings.bbCodeBar;
};
async doImport(): Promise<void> {
@ -226,7 +234,8 @@
fontSize: isNaN(this.fontSize) ? 14 : this.fontSize < 10 ? 10 : this.fontSize > 24 ? 24 : this.fontSize,
showNeedsReply: this.showNeedsReply,
enterSend: this.enterSend,
colorBookmarks: this.colorBookmarks
colorBookmarks: this.colorBookmarks,
bbCodeBar: this.bbCodeBar
};
if(this.notifications) await core.notifications.requestPermission();
}

View File

@ -11,8 +11,8 @@
</div>
<div class="form-group">
<label class="control-label">{{l('chat.setStatus.message')}}</label>
<editor id="text" v-model="text" classes="form-control" maxlength="255" style="position:relative;">
<div style="float:right;text-align:right;">
<editor id="text" v-model="text" classes="form-control" maxlength="255">
<div class="bbcode-editor-controls">
{{getByteLength(text)}} / 255
</div>
</editor>

View File

@ -30,7 +30,7 @@
<a tabindex="-1" href="#" @click.prevent="channelKick" class="list-group-item list-group-item-action" v-show="isChannelMod">
<span class="fa fa-fw fa-ban"></span>{{l('user.channelKick')}}</a>
<a tabindex="-1" href="#" @click.prevent="chatKick" style="color:#f00" class="list-group-item list-group-item-action"
v-show="isChatOp"><span class="far fa-fw fa-trash"></span>{{l('user.chatKick')}}</a>
v-show="isChatOp"><span class="fas fa-fw fa-trash"></span>{{l('user.chatKick')}}</a>
</div>
<modal :action="l('user.memo.action')" ref="memo" :disabled="memoLoading" @submit="updateMemo" dialogClass="w-100">
<div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

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

View File

@ -152,7 +152,7 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
if(message.type !== Interfaces.Message.Type.Event) {
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');
await core.notifications.notify(this, message.sender.name, message.text, characterImage(message.sender.name), 'attention');
if(this !== state.selectedConversation || !state.windowFocused)
this.unread = Interfaces.UnreadState.Mention;
this.typingStatus = 'clear';
@ -525,19 +525,21 @@ export default function(this: void): Interfaces.State {
const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time);
await conversation.addMessage(message);
const words = conversation.settings.highlightWords.map((w) => w.replace(/[^\w]/gi, '\\$&'));
const words = conversation.settings.highlightWords.slice();
if(conversation.settings.defaultHighlights) words.push(...core.state.settings.highlightWords);
if(conversation.settings.highlight === Interfaces.Setting.Default && core.state.settings.highlight ||
conversation.settings.highlight === Interfaces.Setting.True) words.push(core.connection.character);
for(let i = 0; i < words.length; ++i)
words[i] = words[i].replace(/[^\w]/gi, '\\$&');
//tslint:disable-next-line:no-null-keyword
const results = words.length > 0 ? message.text.match(new RegExp(`\\b(${words.join('|')})\\b`, 'i')) : null;
if(results !== null) {
core.notifications.notify(conversation, data.character, l('chat.highlight', results[0], conversation.name, message.text),
await core.notifications.notify(conversation, data.character, l('chat.highlight', results[0], conversation.name, message.text),
characterImage(data.character), 'attention');
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),
await core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused) conversation.unread = Interfaces.UnreadState.Mention;
}
@ -565,7 +567,7 @@ export default function(this: void): Interfaces.State {
if(conversation === undefined) return core.channels.leave(channel);
if(sender.isIgnored && !isOp(conversation)) return;
if(data.type === 'bottle' && data.target === core.connection.character) {
core.notifications.notify(conversation, conversation.name, messageToString(message),
await core.notifications.notify(conversation, conversation.name, messageToString(message),
characterImage(data.character), 'attention');
if(conversation !== state.selectedConversation || !state.windowFocused)
conversation.unread = Interfaces.UnreadState.Mention;
@ -648,13 +650,13 @@ export default function(this: void): Interfaces.State {
url += `newspost/${data.target_id}/#Comment${data.id}`;
break;
case 'bugreport':
url += `view_bugreport.php?id=/${data.target_id}/#${data.id}`;
url += `view_bugreport.php?id=${data.target_id}/#${data.id}`;
break;
case 'changelog':
url += `log.php?id=/${data.target_id}/#${data.id}`;
url += `log.php?id=${data.target_id}/#${data.id}`;
break;
case 'feature':
url += `vote.php?id=/${data.target_id}/#${data.id}`;
url += `vote.php?id=${data.target_id}/#${data.id}`;
}
const key = `events.rtbComment${(data.parent_id !== 0 ? 'Reply' : '')}`;
text = l(key, `[user]${data.name}[/user]`, l(`events.rtbComment_${data.target_type}`), `[url=${url}]${data.target}[/url]`);
@ -691,7 +693,7 @@ export default function(this: void): Interfaces.State {
}
await addEventMessage(new EventMessage(text, time));
if(data.type === 'note')
core.notifications.notify(state.consoleTab, character, text, characterImage(character), 'newnote');
await core.notifications.notify(state.consoleTab, character, text, characterImage(character), 'newnote');
});
type SFCMessage = (Interfaces.Message & {sfc: Connection.ServerCommands['SFC'] & {confirmed?: true}});
const sfcList: SFCMessage[] = [];
@ -699,7 +701,8 @@ export default function(this: void): Interfaces.State {
let text: string, message: Interfaces.Message;
if(data.action === 'report') {
text = l('events.report', `[user]${data.character}[/user]`, decodeHTML(data.tab), decodeHTML(data.report));
core.notifications.notify(state.consoleTab, data.character, text, characterImage(data.character), 'modalert');
if(!data.old)
await core.notifications.notify(state.consoleTab, data.character, text, characterImage(data.character), 'modalert');
message = new EventMessage(text, time);
safeAddMessage(sfcList, message, 500);
(<SFCMessage>message).sfc = data;

View File

@ -172,6 +172,7 @@ export namespace Settings {
readonly showNeedsReply: boolean;
readonly enterSend: boolean;
readonly colorBookmarks: boolean;
readonly bbCodeBar: boolean;
}
}
@ -179,9 +180,10 @@ export type Settings = Settings.Settings;
export interface Notifications {
isInBackground: boolean
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void
playSound(sound: string): void
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): Promise<void>
playSound(sound: string): Promise<void>
requestPermission(): Promise<void>
initSounds(sounds: ReadonlyArray<string>): Promise<void>
}
export interface State {

View File

@ -86,7 +86,7 @@ const strings: {[key: string]: string | undefined} = {
'logs.date': 'Date',
'logs.selectCharacter': 'Select a character...',
'logs.selectConversation': 'Select a conversation...',
'logs.selectDate': 'Select a date...',
'logs.allDates': 'Show all',
'user.profile': 'Profile',
'user.message': 'Open conversation',
'user.messageJump': 'View conversation',
@ -172,6 +172,7 @@ Current log location: {1}`,
'settings.defaultHighlights': 'Use global highlight words',
'settings.colorBookmarks': 'Show friends/bookmarks in a different colour',
'settings.beta': 'Opt-in to test unstable prerelease updates',
'settings.bbCodeBar': 'Show BBCode formatting bar',
'fixLogs.action': 'Fix corrupted logs',
'fixLogs.text': `There are a few reason log files can become corrupted - log files from old versions with bugs that have since been fixed or incomplete file operations caused by computer crashes are the most common.
If one of your log files is corrupted, you may get an "Unknown Type" error when you log in or when you open a specific tab. You may also experience other issues.
@ -182,6 +183,7 @@ Once this process has started, do not interrupt it or your logs will get corrupt
'fixLogs.success': 'Your logs have been fixed. If you experience any more issues, please ask in for further assistance in the Helpdesk channel.',
'conversationSettings.title': 'Tab Settings',
'conversationSettings.action': 'Edit settings for {0}',
'conversationSettings.save': 'Save settings',
'conversationSettings.default': 'Default',
'conversationSettings.true': 'Yes',
'conversationSettings.false': 'No',
@ -286,7 +288,7 @@ Once this process has started, do not interrupt it or your logs will get corrupt
'commands.status': 'Set status',
'commands.status.help': 'Sets your status along with an optional message.',
'commands.status.param0': 'Status',
'commands.status.param0.help': 'A valid status, namely "online", "busy", "looking", "away", "dnd" or "busy".',
'commands.status.param0.help': 'A valid status, namely "online", "busy", "looking", "away" or "dnd".',
'commands.status.param1': 'Message',
'commands.status.param1.help': 'An optional status message of up to 255 bytes.',
'commands.priv': 'Open conversation',

View File

@ -11,26 +11,45 @@ export default class Notifications implements Interface {
conversation !== core.conversations.selectedConversation || core.state.settings.alwaysNotify);
}
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
async notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): Promise<void> {
if(!this.shouldNotify(conversation)) return;
this.playSound(sound);
if(core.state.settings.notifications) {
/*tslint:disable-next-line:no-object-literal-type-assertion*///false positive
const notification = new Notification(title, <NotificationOptions & {silent: boolean}>{body, icon, silent: true});
await this.playSound(sound);
if(core.state.settings.notifications && (<any>Notification).permission === 'granted') { //tslint:disable-line:no-any
const notification = new Notification(title, this.getOptions(conversation, body, icon));
notification.onclick = () => {
conversation.show();
window.focus();
notification.close();
};
window.setTimeout(() => {
notification.close();
}, 5000);
}
}
playSound(sound: string): void {
getOptions(conversation: Conversation, body: string, icon: string):
NotificationOptions & {badge: string, silent: boolean, renotify: boolean} {
const badge = <string>require(`./assets/ic_notification.png`); //tslint:disable-line:no-require-imports
return {
body, icon: core.state.settings.showAvatars ? icon : undefined, badge, silent: true, data: {key: conversation.key},
tag: conversation.key, renotify: true
};
}
async playSound(sound: string): Promise<void> {
if(!core.state.settings.playSound) return;
const id = `soundplayer-${sound}`;
let audio = <HTMLAudioElement | null>document.getElementById(id);
if(audio === null) {
audio = document.createElement('audio');
const audio = <HTMLAudioElement>document.getElementById(`soundplayer-${sound}`);
audio.volume = 1;
audio.muted = false;
return audio.play();
}
initSounds(sounds: ReadonlyArray<string>): Promise<void> { //tslint:disable-line:promise-function-async
const promises = [];
for(const sound of sounds) {
const id = `soundplayer-${sound}`;
if(document.getElementById(id) !== null) continue;
const audio = document.createElement('audio');
audio.id = id;
for(const name in codecs) {
const src = document.createElement('source');
@ -39,9 +58,14 @@ export default class Notifications implements Interface {
src.src = <string>require(`./assets/${sound}.${codecs[name]}`);
audio.appendChild(src);
}
document.body.appendChild(audio);
audio.volume = 0;
audio.muted = true;
const promise = audio.play();
if(promise instanceof Promise)
promises.push(promise);
}
//tslint:disable-next-line:no-floating-promises
audio.play();
return <any>Promise.all(promises); //tslint:disable-line:no-any
}
async requestPermission(): Promise<void> {

View File

@ -40,7 +40,7 @@ async function characterData(name: string | undefined): Promise<Character> {
};
const newKinks: {[key: string]: KinkChoiceFull} = {};
for(const key in data.kinks)
newKinks[key] = <KinkChoiceFull>(data.kinks[key] === 'fave' ? 'favorite' : data.kinks[key]);
newKinks[key] = <KinkChoiceFull>(<string>data.kinks[key] === 'fave' ? 'favorite' : data.kinks[key]);
const newCustoms: CharacterCustom[] = [];
for(const key in data.custom_kinks) {
const custom = data.custom_kinks[key];

View File

@ -281,18 +281,12 @@ const commands: {readonly [key: string]: Command | undefined} = {
params: [{type: ParamType.Character}]
},
closeroom: {
exec: (conv: ChannelConversation) => {
core.connection.send('RST', {channel: conv.channel.id, status: 'private'});
core.connection.send('ORS');
},
exec: (conv: ChannelConversation) => core.connection.send('RST', {channel: conv.channel.id, status: 'private'}),
permission: Permission.RoomOwner,
context: CommandContext.Channel
},
openroom: {
exec: (conv: ChannelConversation) => {
core.connection.send('RST', {channel: conv.channel.id, status: 'public'});
core.connection.send('ORS');
},
exec: (conv: ChannelConversation) => core.connection.send('RST', {channel: conv.channel.id, status: 'public'}),
permission: Permission.RoomOwner,
context: CommandContext.Channel
},

View File

@ -1,11 +1,11 @@
<template>
<div class="dropdown">
<button class="form-control custom-select" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
<a class="form-control custom-select" aria-haspopup="true" :aria-expanded="isOpen" @click="isOpen = true"
@blur="isOpen = false" style="width:100%;text-align:left;display:flex;align-items:center" role="button" tabindex="-1">
<div style="flex:1">
<slot name="title" style="flex:1"></slot>
</div>
</button>
</a>
<div class="dropdown-menu" :style="open ? 'display:block' : ''" @mousedown.stop.prevent @click="isOpen = false"
ref="menu">
<slot></slot>

View File

@ -1,6 +1,6 @@
<template>
<span v-show="isShown">
<div tabindex="-1" class="modal" @click.self="hideWithCheck" style="display:flex">
<div class="modal" @click.self="hideWithCheck" style="display:flex">
<div class="modal-dialog" :class="dialogClass" style="display:flex;align-items:center">
<div class="modal-content" style="max-height:100%">
<div class="modal-header" style="flex-shrink:0">
@ -9,7 +9,7 @@
</h4>
<button type="button" class="close" @click="hide" aria-label="Close" v-show="!keepOpen">&times;</button>
</div>
<div class="modal-body" style="overflow:auto" tabindex="-1">
<div class="modal-body" style="overflow:auto;-webkit-overflow-scrolling:auto" tabindex="-1">
<slot></slot>
</div>
<div class="modal-footer" v-if="buttons">
@ -40,10 +40,12 @@
if(dialogStack.length > 0) {
e.stopPropagation();
e.preventDefault();
dialogStack.pop()!.isShown = false;
dialogStack[dialogStack.length - 1].hide();
}
}, true);
export let isShowing = false;
@Component
export default class Modal extends Vue {
@Prop({default: ''})
@ -72,18 +74,20 @@
if(!e.defaultPrevented) this.hideWithCheck();
}
/*tslint:disable-next-line:typedef*///https://github.com/palantir/tslint/issues/711
show(keepOpen = false): void {
this.isShown = true;
show(keepOpen: boolean = false): void {
this.keepOpen = keepOpen;
if(this.isShown) return;
this.isShown = true;
dialogStack.push(this);
this.$emit('open');
isShowing = true;
}
hide(): void {
this.isShown = false;
this.$emit('close');
dialogStack.pop();
if(dialogStack.length === 0) isShowing = false;
}
hideWithCheck(): void {

View File

@ -18,7 +18,7 @@
@Component
export default class CharacterSelect extends Vue {
@Prop({required: true, type: Number})
@Prop({required: true})
readonly value!: number;
get characters(): SelectItem[] {

View File

@ -2,11 +2,15 @@ import Vue from 'vue';
import Modal from './Modal.vue';
export default class CustomDialog extends Vue {
protected get dialog(): Modal {
return <Modal>this.$children[0];
}
show(): void {
(<Modal>this.$children[0]).show();
this.dialog.show();
}
hide(): void {
(<Modal>this.$children[0]).hide();
this.dialog.hide();
}
}

View File

@ -7,11 +7,11 @@
<i class="fa fa-cog"></i>
</div>
<ul class="nav nav-tabs" style="border-bottom:0;margin-bottom:-2px" ref="tabs">
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item" @auxclick="remove(tab)">
<li v-for="tab in tabs" :key="tab.view.id" class="nav-item" @click.middle="remove(tab)">
<a href="#" @click.prevent="show(tab)" class="nav-link"
:class="{active: tab === activeTab, hasNew: tab.hasNew && tab !== activeTab}">
<img v-if="tab.user" :src="'https://static.f-list.net/images/avatar/' + tab.user.toLowerCase() + '.png'"/>
{{tab.user || l('window.newTab')}}
<span class="d-sm-inline d-none">{{tab.user || l('window.newTab')}}</span>
<a href="#" class="btn" :aria-label="l('action.close')" style="margin-left:10px;padding:0;color:inherit"
@click.stop="remove(tab)"><i class="fa fa-times"></i>
</a>
@ -245,7 +245,7 @@
}
openMenu(): void {
electron.remote.Menu.getApplicationMenu()!.popup();
electron.remote.Menu.getApplicationMenu()!.popup({});
}
}
</script>

View File

@ -1,12 +1,12 @@
{
"name": "fchat",
"version": "3.0.3",
"version": "3.0.6",
"author": "The F-List Team",
"description": "F-List.net Chat Client",
"main": "main.js",
"license": "MIT",
"devDependencies": {
"electron": "^1.8.4"
"electron": "^2.0.2"
},
"dependencies": {
"keytar": "^4.2.1",

View File

@ -69,13 +69,14 @@ Axios.defaults.params = { __fchat: `desktop/${electron.remote.app.getVersion()}`
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: electron.remote.app.getVersion(),
dataCallback(data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}): void {
dataCallback(data: {culprit: string, exception?: {values: {stacktrace: {frames: {filename: string}[]}}[]}}): void {
data.culprit = `~${data.culprit.substr(data.culprit.lastIndexOf('/'))}`;
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
}
if(data.exception !== undefined)
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
}
}
}).addPlugin(VueRaven, Vue).install();
(<Window & {onunhandledrejection(e: PromiseRejectionEvent): void}>window).onunhandledrejection = (e: PromiseRejectionEvent) => {
@ -103,6 +104,12 @@ function openIncognito(url: string): void {
case 'ChromeHTML':
exec(`start chrome.exe -incognito ${url}`);
break;
case 'VivaldiHTM':
exec(`start vivaldi.exe -incognito ${url}`);
break;
case 'OperaStable':
exec(`start opera.exe -private ${url}`);
break;
default:
exec(`start iexplore.exe -private ${url}`);
}
@ -187,7 +194,7 @@ webContents.on('context-menu', (_, props) => {
}
}, {type: 'separator'});
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup();
if(menuTemplate.length > 0) electron.remote.Menu.buildFromTemplate(menuTemplate).popup({});
});
let dictDir = path.join(electron.remote.app.getPath('userData'), 'spellchecker');

View File

@ -7,17 +7,12 @@ 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 {
async notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): Promise<void> {
if(!this.shouldNotify(conversation)) return;
this.playSound(sound);
await this.playSound(sound);
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}>{
body,
icon: core.state.settings.showAvatars ? icon : undefined,
silent: true
});
const notification = new Notification(title, this.getOptions(conversation, body, icon));
notification.onclick = () => {
browserWindow.webContents.send('show-tab', remote.getCurrentWebContents().id);
conversation.show();

View File

@ -3,6 +3,7 @@ const ExtractTextPlugin = require('extract-text-webpack-plugin');
const fs = require('fs');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const mainConfig = {
entry: [path.join(__dirname, 'main.ts'), path.join(__dirname, 'application.json')],
@ -58,8 +59,9 @@ const mainConfig = {
test: /\.vue$/,
loader: 'vue-loader',
options: {
preserveWhitespace: false,
cssSourceMap: false
compilerOptions: {
preserveWhitespace: false
}
}
},
{
@ -76,7 +78,9 @@ const mainConfig = {
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'}
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
{test: /\.vue\.scss/, loader: ['vue-style-loader','css-loader','sass-loader']},
{test: /\.vue\.css/, loader: ['vue-style-loader','css-loader']},
]
},
node: {
@ -90,7 +94,8 @@ const mainConfig = {
tslint: path.join(__dirname, '../tslint.json'),
tsconfig: './tsconfig-renderer.json',
vue: true
})
}),
new VueLoaderPlugin()
],
resolve: {
extensions: ['.ts', '.js', '.vue', '.css'],
@ -111,13 +116,13 @@ module.exports = function(mode) {
rendererConfig.entry.chat.push(absPath);
const plugin = new ExtractTextPlugin('themes/' + theme.slice(0, -5) + '.css');
rendererConfig.plugins.push(plugin);
rendererConfig.module.rules.push({test: absPath, use: plugin.extract(cssOptions)});
rendererConfig.module.rules.unshift({test: absPath, use: plugin.extract(cssOptions)});
}
const faPath = path.join(themesDir, '../../fa.scss');
rendererConfig.entry.chat.push(faPath);
const faPlugin = new ExtractTextPlugin('./fa.css');
rendererConfig.plugins.push(faPlugin);
rendererConfig.module.rules.push({test: faPath, use: faPlugin.extract(cssOptions)});
rendererConfig.module.rules.unshift({test: faPath, use: faPlugin.extract(cssOptions)});
if(mode === 'production') {
process.env.NODE_ENV = 'production';
mainConfig.devtool = rendererConfig.devtool = 'source-map';

View File

@ -1,4 +1,4 @@
import Axios, {AxiosResponse} from 'axios';
import Axios, {AxiosError, AxiosResponse} from 'axios';
import * as qs from 'qs';
import {Connection as Interfaces, WebSocketConnection} from './interfaces';
@ -36,7 +36,9 @@ export default class Connection implements Interfaces.Connection {
try {
this.ticket = await this.ticketProvider();
} catch(e) {
if(this.reconnectTimer !== undefined) this.reconnect();
if(this.reconnectTimer !== undefined)
if((<AxiosError>e).request !== undefined) this.reconnect();
else await this.invokeHandlers('closed', false);
return this.invokeErrorHandlers(<Error>e, true);
}
try {

View File

@ -104,8 +104,9 @@ export namespace Connection {
type: 'grouprequest' | 'bugreport' | 'helpdeskticket' | 'helpdeskreply' | 'featurerequest',
name: string, id: number, title?: string
} | {type: 'trackadd' | 'trackrem' | 'friendadd' | 'friendremove' | 'friendrequest', name: string},
SFC: {action: 'confirm', moderator: string, character: string, timestamp: string, tab: string, logid: number} |
{callid: number, action: 'report', report: string, timestamp: string, character: string, tab: string, logid: number},
SFC: {action: 'confirm', moderator: string, character: string, timestamp: string, tab: string, logid: number} | {
callid: number, action: 'report', report: string, timestamp: string, character: string, tab: string, logid: number, old?: true
},
STA: {status: Character.Status, character: string, statusmsg: string},
SYS: {message: string, channel?: string},
TPN: {character: string, status: Character.TypingStatus},
@ -123,10 +124,10 @@ export namespace Connection {
readonly chat_max: number
readonly priv_max: number
readonly lfrp_max: number
//readonly cds_max: number
readonly cds_max: number
readonly lfrp_flood: number
readonly msg_flood: number
//readonly sta_flood: number
readonly sta_flood: number
readonly permissions: number
readonly icon_blacklist: ReadonlyArray<string>
}

View File

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

View File

@ -8,8 +8,8 @@ android {
applicationId "net.f_list.fchat"
minSdkVersion 19
targetSdkVersion 27
versionCode 14
versionName "3.0.3"
versionCode 17
versionName "3.0.6"
}
buildTypes {
release {

View File

@ -24,7 +24,7 @@ import java.util.*
class MainActivity : Activity() {
private lateinit var webView: WebView
private val profileRegex = Regex("^https?://(www\\.)?f-list.net/c/(.+)/?#?")
private val profileRegex = Regex("^https?://(www\\.)?f-list.net/c/([^/#]+)/?#?")
private val backgroundPlugin = Background(this)
override fun onCreate(savedInstanceState: Bundle?) {

View File

@ -4,9 +4,10 @@ buildscript {
ext.kotlin_version = '1.2.30'
repositories {
jcenter()
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.2'
classpath 'com.android.tools.build:gradle:3.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@ -17,6 +18,7 @@ buildscript {
allprojects {
repositories {
jcenter()
google()
}
}

View File

@ -43,13 +43,14 @@ const version = (<{version: string}>require('./package.json')).version; //tslint
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: `mobile-${version}`,
dataCallback: (data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
dataCallback: (data: {culprit: string, exception?: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
data.culprit = `~${data.culprit.substr(data.culprit.lastIndexOf('/'))}`;
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
}
if(data.exception !== undefined)
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
frame.filename = index !== -1 ? `~${frame.filename.substr(index)}` : frame.filename;
}
}
}).addPlugin(VueRaven, Vue).install();
(<Window & {onunhandledrejection(e: PromiseRejectionEvent): void}>window).onunhandledrejection = (e: PromiseRejectionEvent) => {

View File

@ -66,7 +66,7 @@ export class Logs implements Logging {
private async getIndex(name: string): Promise<Index> {
if(this.loadedCharacter === name) return this.loadedIndex!;
this.loadedCharacter = name;
return this.loadedIndex = await NativeLogs.loadIndex(name);
return this.loadedIndex = name === core.connection.character ? this.index : await NativeLogs.loadIndex(name);
}
async getLogs(character: string, key: string, date: Date): Promise<ReadonlyArray<Conversation.Message>> {

View File

@ -3,8 +3,9 @@ import WebKit
class IndexItem: Encodable {
let name: String
var index = [UInt16: UInt64]()
var index = NSMutableOrderedSet()
var dates = [UInt16]()
var offsets = [UInt64]()
init(_ name: String) {
self.name = name
}
@ -70,7 +71,8 @@ class Logs: NSObject, WKScriptMessageHandler {
indexItem.dates.append(date)
var o: UInt64 = 0
data.getBytes(&o, range: NSMakeRange(offset + 2, 5))
indexItem.index[date] = o
indexItem.index.add(date)
indexItem.offsets.append(o)
offset += 7
}
index[file.deletingPathExtension().lastPathComponent] = indexItem
@ -102,7 +104,7 @@ class Logs: NSObject, WKScriptMessageHandler {
if(indexItem == nil) { fm.createFile(atPath: url.path, contents: nil) }
let fd = try FileHandle(forWritingTo: url)
fd.seekToEndOfFile()
if(indexItem?.index[day] == nil) {
if(!(indexItem?.index.contains(day) ?? false)) {
let indexFile = url.appendingPathExtension("idx")
if(indexItem == nil) { fm.createFile(atPath: indexFile.path, contents: nil) }
let indexFd = try FileHandle(forWritingTo: indexFile)
@ -118,7 +120,8 @@ class Logs: NSObject, WKScriptMessageHandler {
write(indexFd.fileDescriptor, &day, 2)
var offset = fd.offsetInFile
write(indexFd.fileDescriptor, &offset, 5)
indexItem!.index[day] = offset
indexItem!.index.add(indexItem!.offsets.count)
indexItem!.offsets.append(offset)
indexItem!.dates.append(day)
}
let start = fd.offsetInFile
@ -150,25 +153,29 @@ class Logs: NSObject, WKScriptMessageHandler {
let newOffset = file.offsetInFile - UInt64(length + 2)
file.seek(toFileOffset: newOffset)
read(file.fileDescriptor, buffer, Int(length))
strings.append(deserializeMessage().0)
strings.append(try deserializeMessage(buffer, 0).0)
file.seek(toFileOffset: newOffset)
}
return "[" + strings.reversed().joined(separator: ",") + "]"
}
func getLogs(_ character: String, _ key: String, _ date: UInt16) throws -> String {
guard let offset = loadedIndex![key]?.index[date] else { return "[]" }
let index = loadedIndex![key]
guard let indexKey = index?.index.index(of: date) else { return "[]" }
let url = baseDir.appendingPathComponent("\(character)/logs/\(key)", isDirectory: false)
let file = try FileHandle(forReadingFrom: url)
let size = file.seekToEndOfFile()
file.seek(toFileOffset: offset)
let start = index!.offsets[indexKey]
let end = indexKey >= index!.offsets.count - 1 ? file.seekToEndOfFile() : index!.offsets[indexKey + 1]
file.seek(toFileOffset: start)
let length = Int(end - start)
let buffer = UnsafeMutableRawPointer.allocate(bytes: length, alignedTo: 1)
read(file.fileDescriptor, buffer, length)
var json = "["
while file.offsetInFile < size {
read(file.fileDescriptor, buffer, 51000)
let deserialized = deserializeMessage(date)
if(deserialized.1 == 0) { break }
var offset = 0
while offset < length {
let deserialized = try deserializeMessage(buffer, offset)
offset = deserialized.1 + 2
json += deserialized.0 + ","
file.seek(toFileOffset: file.offsetInFile + UInt64(deserialized.1 + 2))
}
return json + "]"
}
@ -178,14 +185,19 @@ class Logs: NSObject, WKScriptMessageHandler {
return String(data: try JSONEncoder().encode(loadedIndex), encoding: .utf8)!
}
func deserializeMessage(_ checkDate: UInt16 = 0) -> (String, Int) {
let date = buffer.load(as: UInt32.self)
if(checkDate != 0 && date / 86400 != checkDate) { return ("", 0) }
let type = buffer.load(fromByteOffset: 4, as: UInt8.self)
let senderLength = Int(buffer.load(fromByteOffset: 5, as: UInt8.self))
let sender = String(bytesNoCopy: buffer.advanced(by: 6), length: senderLength, encoding: .utf8, freeWhenDone: false)!
let textLength = Int(buffer.advanced(by: 6 + senderLength).bindMemory(to: UInt16.self, capacity: 1).pointee)
let text = String(bytesNoCopy: buffer.advanced(by: 6 + senderLength + 2), length: textLength, encoding: .utf8, freeWhenDone: false)!
return ("{\"time\":\(date),\"type\":\(type),\"sender\":\(File.escape(sender)),\"text\":\(File.escape(text))}", senderLength + textLength + 8)
func deserializeMessage(_ buffer: UnsafeMutableRawPointer, _ o: Int) throws -> (String, Int) {
var offset = o
let date = buffer.advanced(by: offset).bindMemory(to: UInt32.self, capacity: 1).pointee
let type = buffer.load(fromByteOffset: offset + 4, as: UInt8.self)
let senderLength = Int(buffer.load(fromByteOffset: offset + 5, as: UInt8.self))
guard let sender = String(bytesNoCopy: buffer.advanced(by: offset + 6), length: senderLength, encoding: .utf8, freeWhenDone: false) else {
throw NSError(domain: "Log corruption", code: 0)
}
offset += senderLength + 6
let textLength = Int(buffer.advanced(by: offset).bindMemory(to: UInt16.self, capacity: 1).pointee)
guard let text = String(bytesNoCopy: buffer.advanced(by: offset + 2), length: textLength, encoding: .utf8, freeWhenDone: false) else {
throw NSError(domain: "Log corruption", code: 0)
}
return ("{\"time\":\(date),\"type\":\(type),\"sender\":\(File.escape(sender)),\"text\":\(File.escape(text))}", offset + textLength + 2)
}
}

View File

@ -4,7 +4,7 @@ import AVFoundation
class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
var webView: WKWebView!
let profileRegex = try! NSRegularExpression(pattern: "^https?://(www\\.)?f-list.net/c/(.+)/?#?", options: [.caseInsensitive])
let profileRegex = try! NSRegularExpression(pattern: "^https?://(www\\.)?f-list.net/c/([^/#]+)/?#?", options: [.caseInsensitive])
override func loadView() {
let config = WKWebViewConfiguration()

View File

@ -15,7 +15,7 @@ document.addEventListener('notification-clicked', (e: Event) => {
});
export default class Notifications extends BaseNotifications {
notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): void {
async notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): Promise<void> {
if(!this.shouldNotify(conversation)) return;
NativeNotification.notify(core.state.settings.notifications && this.isInBackground, title, body, icon,
core.state.settings.playSound ? sound : null, conversation.key); //tslint:disable-line:no-null-keyword

View File

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

View File

@ -1,5 +1,6 @@
const path = require('path');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const config = {
entry: {
@ -25,8 +26,9 @@ const config = {
test: /\.vue$/,
loader: 'vue-loader',
options: {
preserveWhitespace: false,
cssSourceMap: false
compilerOptions: {
preserveWhitespace: false
}
}
},
{test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
@ -35,11 +37,14 @@ const config = {
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
{test: /\.scss/, use: ['css-loader', 'sass-loader']}
{test: /(?<!\.vue)\.scss/, use: ['css-loader', 'sass-loader']},
{test: /\.vue\.scss/, loader: ['vue-style-loader','css-loader','sass-loader']},
{test: /\.vue\.css/, loader: ['vue-style-loader','css-loader']},
]
},
plugins: [
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')})
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')}),
new VueLoaderPlugin()
],
resolve: {
'extensions': ['.ts', '.js', '.vue', '.scss']

View File

@ -6,18 +6,18 @@
"license": "MIT",
"devDependencies": {
"@fortawesome/fontawesome-free-webfonts": "^1.0.6",
"@types/node": "^9.6.5",
"@types/node": "^10.3.3",
"@types/sortablejs": "^1.3.31",
"axios": "^0.18.0",
"bootstrap": "^4.1.0",
"css-loader": "^0.28.11",
"date-fns": "^1.28.5",
"electron": "^1.8.4",
"electron": "^2.0.2",
"electron-builder": "^20.8.1",
"electron-log": "^2.2.9",
"electron-updater": "^2.21.4",
"extract-text-webpack-plugin": "4.0.0-beta.0",
"file-loader": "^1.1.11",
"file-loader": "^1.1.10",
"fork-ts-checker-webpack-plugin": "^0.4.1",
"lodash": "^4.16.4",
"node-sass": "^4.8.3",
@ -32,7 +32,7 @@
"typescript": "^2.8.1",
"vue": "^2.5.16",
"vue-class-component": "^6.0.0",
"vue-loader": "^14.2.2",
"vue-loader": "^15.2.4",
"vue-property-decorator": "^6.0.0",
"vue-template-compiler": "^2.5.16",
"webpack": "^4.5.0"
@ -41,6 +41,6 @@
"@types/lodash": "^4.14.107",
"keytar": "^4.2.1",
"spellchecker": "^3.4.3",
"style-loader": "^0.20.3"
"style-loader": "^0.21.0"
}
}

View File

@ -40,4 +40,15 @@
@media (min-width: breakpoint-min(sm)) {
display: none;
}
}
.bbcode-editor-controls {
display: flex;
align-items: center;
float: right;
order: 1;
justify-content: flex-end;
@media (max-width: breakpoint-max(xs)) {
flex: 1 49%;
}
}

View File

@ -241,7 +241,7 @@ $genders: (
}
}
.user-bookmark {
.user-bookmark, .message-event .user-bookmark {
color: #66CC33;
}

View File

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

View File

@ -50,6 +50,11 @@ $text-background-color-disabled: $gray-800 !default;
cursor: default;
}
.custom-select:hover {
text-decoration: none;
cursor: default;
}
select {
@extend .custom-select;
-webkit-appearance: none;

View File

@ -4,13 +4,21 @@ hr {
}
.modal-dialog.modal-wide {
width: 95%;
max-width: 95%;
}
.card-title {
font-weight: bold;
}
.nav-link {
cursor: pointer;
}
$theme-is-dark: false !default;
// HACK: Bootstrap offers no way to override these by default, and they are SUPER bright.
// The level numbers have been changed to make them work for dark themes.
@if $theme-is-dark {
@each $color, $value in $theme-colors {
@include table-row-variant($color, theme-color-level($color, 5));
}
}
@include table-row-variant(active, $table-active-bg);

View File

@ -22,9 +22,6 @@
}
}
.note-folder-create {
}
.conversation-from-me, .conversation-from-them {
margin-bottom: 5px;
max-width: percentage((($grid-columns - 4) / $grid-columns));

View File

@ -18,6 +18,7 @@
.tag-input {
background-color: $input-bg;
color: $input-color;
border: none;
width: auto;
&:focus {
@ -32,11 +33,11 @@
}
.tag-error {
border: 1px solid theme-color-border(danger);
background-color: theme-color-bg(danger);
border: 1px solid theme-color-level(danger, $alert-border-level);
background-color: theme-color-level(danger, $alert-bg-level);
.tag-input {
text-color: theme-color-level(danger, 6);
background-color: theme-color-bg(danger);
text-color: theme-color-level(danger, $alert-color-level);
background-color: theme-color-level(danger, $alert-bg-level);
}
}

View File

@ -59,4 +59,7 @@ $pagination-active-color: $link-color;
$text-background-color: $gray-100;
$text-background-color-disabled: $gray-200;
// Dark theme helpers
$theme-is-dark: true;
@import "invert";

View File

@ -57,4 +57,7 @@ $pagination-active-color: $link-color;
$text-background-color: $gray-200;
$text-background-color-disabled: $gray-100;
// Dark theme helpers
$theme-is-dark: true;
@import "invert";

View File

@ -1,9 +1,9 @@
@function lighten($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) - $amount);
@return hsla(hue($color), saturation($color), lightness($color) - $amount, alpha($color));
}
@function darken($color, $amount) {
@return hsl(hue($color), saturation($color), lightness($color) + $amount);
@return hsla(hue($color), saturation($color), lightness($color) + $amount, alpha($color));
}
@function theme-color-level($color-name: "primary", $level: 0) {
@ -12,4 +12,9 @@
$level: abs($level);
@return mix($color-base, $color, $level * $theme-color-interval);
}
}
// Alert color levels
$alert-bg-level: 7;
$alert-border-level: 6;
$alert-color-level: -8;

View File

@ -35,12 +35,12 @@ import Vue from 'vue';
import Chat from '../chat/Chat.vue';
import {init as initCore} from '../chat/core';
import l from '../chat/localize';
import Notifications from '../chat/notifications';
import VueRaven from '../chat/vue-raven';
import Socket from '../chat/WebSocket';
import Connection from '../fchat/connection';
import '../scss/fa.scss'; //tslint:disable-line:no-import-side-effect
import {Logs, SettingsStore} from './logs';
import Notifications from './notifications';
//@ts-ignore
if(typeof window.Promise !== 'function' || typeof window.Notification !== 'function') //tslint:disable-line:strict-type-predicates
@ -52,15 +52,18 @@ Axios.defaults.params = { __fchat: `web/${version}` };
if(process.env.NODE_ENV === 'production') {
Raven.config('https://a9239b17b0a14f72ba85e8729b9d1612@sentry.f-list.net/2', {
release: `web-${version}`,
dataCallback: (data: {culprit: string, exception: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
const end = data.culprit.lastIndexOf('?');
data.culprit = `~${data.culprit.substring(data.culprit.lastIndexOf('/'), end === -1 ? undefined : end)}`;
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
const endIndex = frame.filename.lastIndexOf('?');
frame.filename = `~${frame.filename.substring(index !== -1 ? index : 0, endIndex === -1 ? undefined : endIndex)}`;
}
dataCallback: (data: {culprit?: string, exception?: {values: {stacktrace: {frames: {filename: string}[]}}[]}}) => {
if(data.culprit !== undefined) {
const end = data.culprit.lastIndexOf('?');
data.culprit = `~${data.culprit.substring(data.culprit.lastIndexOf('/'), end === -1 ? undefined : end)}`;
}
if(data.exception !== undefined)
for(const ex of data.exception.values)
for(const frame of ex.stacktrace.frames) {
const index = frame.filename.lastIndexOf('/');
const endIndex = frame.filename.lastIndexOf('?');
frame.filename = `~${frame.filename.substring(index !== -1 ? index : 0, endIndex === -1 ? undefined : endIndex)}`;
}
}
}).addPlugin(VueRaven, Vue).install();
(<Window & {onunhandledrejection(e: PromiseRejectionEvent): void}>window).onunhandledrejection = (e: PromiseRejectionEvent) => {
@ -77,7 +80,7 @@ const ticketProvider = async() => {
throw new Error(data.error);
};
const connection = new Connection('F-Chat 3.0 (Web)', '3.0', Socket, chatSettings.account, ticketProvider);
const connection = new Connection('F-Chat 3.0 (Web)', version, Socket, chatSettings.account, ticketProvider);
initCore(connection, Logs, SettingsStore, Notifications);
window.addEventListener('beforeunload', (e) => {

25
webchat/notifications.ts Normal file
View File

@ -0,0 +1,25 @@
import core from '../chat/core';
import {Conversation} from '../chat/interfaces';
//tslint:disable-next-line:match-default-export-name
import BaseNotifications from '../chat/notifications';
export default class Notifications extends BaseNotifications {
async notify(conversation: Conversation, title: string, body: string, icon: string, sound: string): Promise<void> {
if(!this.shouldNotify(conversation)) return;
try {
return super.notify(conversation, title, body, icon, sound);
} catch {
(async() => { //tslint:disable-line:no-floating-promises
//tslint:disable-next-line:no-require-imports no-submodule-imports
await navigator.serviceWorker.register(<string>require('file-loader!./sw.js'));
const reg = await navigator.serviceWorker.ready;
await reg.showNotification(title, this.getOptions(conversation, body, icon));
navigator.serviceWorker.onmessage = (e) => {
const conv = core.conversations.byKey((<{key: string}>e.data).key);
if(conv !== undefined) conv.show();
window.focus();
};
})();
}
}
}

View File

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

15
webchat/sw.js Normal file
View File

@ -0,0 +1,15 @@
let client;
self.addEventListener('install', function(event) {
self.skipWaiting();
});
self.addEventListener('activate', function(event){
event.waitUntil(clients.claim());
client = clients.matchAll().then(x => client = x[0]);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
client.postMessage(event.notification.data);
});

View File

@ -1,5 +1,6 @@
const path = require('path');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const config = {
entry: __dirname + '/chat.ts',
@ -22,8 +23,9 @@ const config = {
test: /\.vue$/,
loader: 'vue-loader',
options: {
preserveWhitespace: false,
cssSourceMap: false
compilerOptions: {
preserveWhitespace: false
}
}
},
{test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
@ -31,11 +33,14 @@ const config = {
{test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
{test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'file-loader'},
{test: /\.(wav|mp3|ogg)$/, loader: 'file-loader?name=sounds/[name].[ext]'},
{test: /\.scss/, use: ['style-loader', 'css-loader', 'sass-loader']}
{test: /\.(png|html)$/, loader: 'file-loader?name=[name].[ext]'},
{test: /\.scss/, use: ['vue-style-loader', 'css-loader', 'sass-loader']},
{test: /\.css/, use: ['vue-style-loader', 'css-loader']}
]
},
plugins: [
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')})
new ForkTsCheckerWebpackPlugin({workers: 2, async: false, vue: true, tslint: path.join(__dirname, '../tslint.json')}),
new VueLoaderPlugin()
],
resolve: {
'extensions': ['.ts', '.js', '.vue', '.scss']

1582
yarn.lock

File diff suppressed because it is too large Load Diff