fchat-rising/chat/ConversationView.vue

361 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div style="height:100%; display:flex; flex-direction:column; flex:1; margin:0 5px; position:relative;" id="conversation">
<div style="display:flex" v-if="conversation.character" class="header">
<img :src="characterImage" style="height:60px; width:60px; margin-right: 10px;" v-if="showAvatars"/>
<div style="flex: 1; position: relative; display: flex; flex-direction: column">
<div>
<user :character="conversation.character"></user>
<logs :conversation="conversation"></logs>
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
<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>
<span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<div style="overflow: auto">
{{l('status.' + conversation.character.status)}}
<span v-show="conversation.character.statusText"> <bbcode :text="conversation.character.statusText"></bbcode></span>
</div>
</div>
</div>
<div v-else-if="conversation.channel" class="header">
<div style="display: flex; align-items: center;">
<div style="flex: 1;">
<span v-show="conversation.channel.id.substr(0, 4) !== 'adh-'" class="fa fa-star" :title="l('channel.official')"
style="margin-right:5px;vertical-align:sub"></span>
<h5 style="margin:0;display:inline;vertical-align:middle">{{conversation.name}}</h5>
<a @click="descriptionExpanded = !descriptionExpanded" class="btn">
<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>
<logs :conversation="conversation"></logs>
<a href="#" @click.prevent="$refs['settingsDialog'].show()" class="btn">
<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>
<span class="btn-text">{{l('chat.report')}}</span></a>
</div>
<ul class="nav nav-pills mode-switcher">
<li v-for="mode in modes" class="nav-item">
<a :class="{active: conversation.mode == mode, disabled: conversation.channel.mode != 'both'}"
class="nav-link" href="#" @click.prevent="setMode(mode)">{{l('channel.mode.' + mode)}}</a>
</li>
</ul>
</div>
<div style="z-index:5;position:absolute;left:0;right:0;max-height:60%;overflow:auto"
:style="'display:' + (descriptionExpanded ? 'block' : 'none')" class="bg-solid-text">
<bbcode :text="conversation.channel.description"></bbcode>
</div>
</div>
<div v-else class="header" style="display:flex;align-items:center">
<h4>{{l('chat.consoleTab')}}</h4>
<logs :conversation="conversation"></logs>
</div>
<div class="search" v-show="showSearch" style="position:relative">
<input v-model="searchInput" @keydown.esc="showSearch = false; searchInput = ''" @keypress="lastSearchInput = Date.now()"
:placeholder="l('chat.search')" ref="searchField" class="form-control"/>
<a class="btn btn-sm btn-light" style="position:absolute;right:5px;top:50%;transform:translateY(-50%);line-height:0"
@click="showSearch = false"><i class="fas fa-times"></i></a>
</div>
<div class="border-top messages" :class="'messages-' + conversation.mode" style="flex:1;overflow:auto;margin-top:2px"
ref="messages" @scroll="onMessagesScroll">
<template v-for="message in messages">
<message-view :message="message" :channel="conversation.channel" :key="message.id"
:classes="message == conversation.lastRead ? 'last-read' : ''">
</message-view>
<span v-if="message.sfc && message.sfc.action == 'report'" :key="message.id">
<a :href="'https://www.f-list.net/fchat/getLog.php?log=' + message.sfc.logid"
v-if="message.sfc.logid">{{l('events.report.viewLog')}}</a>
<span v-else>{{l('events.report.noLog')}}</span>
<span v-show="!message.sfc.confirmed">
| <a href="#" @click.prevent="acceptReport(message.sfc)">{{l('events.report.confirm')}}</a>
</span>
</span>
</template>
</div>
<div>
<span v-if="conversation.typingStatus && conversation.typingStatus !== 'clear'">
{{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 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="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">
<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)">
{{l('channel.mode.ads' + (adCountdown ? '.countdown' : ''), adCountdown)}}</a>
</li>
</ul>
</div>
</bbcode-editor>
</div>
</div>
<modal ref="helpDialog" dialogClass="modal-lg" :buttons="false" :action="l('commands.help')">
<command-help></command-help>
</modal>
<settings ref="settingsDialog" :conversation="conversation"></settings>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import {Prop, Watch} from 'vue-property-decorator';
import {EditorButton, EditorSelection} from '../bbcode/editor';
import Modal from '../components/Modal.vue';
import {Keys} from '../keys';
import {BBCodeView, Editor} from './bbcode';
import CommandHelp from './CommandHelp.vue';
import {characterImage, getByteLength, getKey} from './common';
import ConversationSettings from './ConversationSettings.vue';
import core from './core';
import {Channel, channelModes, Character, Conversation} from './interfaces';
import l from './localize';
import Logs from './Logs.vue';
import ManageChannel from './ManageChannel.vue';
import MessageView from './message_view';
import ReportDialog from './ReportDialog.vue';
import {isCommand} from './slash_commands';
import UserView from './user_view';
@Component({
components: {
user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, modal: Modal, settings: ConversationSettings,
logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp
}
})
export default class ConversationView extends Vue {
@Prop({required: true})
readonly reportDialog!: ReportDialog;
modes = channelModes;
descriptionExpanded = false;
l = l;
extraButtons: EditorButton[] = [];
getByteLength = getByteLength;
tabOptions: string[] | undefined;
tabOptionsIndex!: number;
tabOptionSelection!: EditorSelection;
showSearch = false;
searchInput = '';
search = '';
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;
}
keydownHandler!: EventListener;
created(): void {
this.extraButtons = [{
title: 'Help\n\nClick this button for a quick overview of slash commands.',
tag: '?',
icon: 'fa-question',
handler: () => (<Modal>this.$refs['helpDialog']).show()
}];
window.addEventListener('resize', this.resizeHandler);
window.addEventListener('keydown', this.keydownHandler = ((e: KeyboardEvent) => {
if(getKey(e) === Keys.KeyF && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
this.showSearch = true;
this.$nextTick(() => (<HTMLElement>this.$refs['searchField']).focus());
}
}) as EventListener);
this.searchTimer = window.setInterval(() => {
if(Date.now() - this.lastSearchInput > 500 && this.search !== this.searchInput)
this.search = this.searchInput;
}, 500);
}
destroyed(): void {
window.removeEventListener('resize', this.resizeHandler);
window.removeEventListener('keydown', this.keydownHandler);
clearInterval(this.searchTimer);
}
get conversation(): Conversation {
return core.conversations.selectedConversation;
}
get messages(): ReadonlyArray<Conversation.Message> {
return this.search !== '' ? this.conversation.messages.filter((x) => x.text.indexOf(this.search) !== -1)
: this.conversation.messages;
}
@Watch('conversation')
conversationChanged(): void {
(<Editor>this.$refs['textBox']).focus();
}
@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.messageCount = newValue.length;
}
keepScroll(): boolean {
const messageView = <HTMLElement>this.$refs['messages'];
if(messageView.scrollTop + messageView.offsetHeight >= messageView.scrollHeight - 15) {
setImmediate(() => messageView.scrollTop = messageView.scrollHeight);
return true;
}
return false;
}
onMessagesScroll(): void {
const messageView = <HTMLElement | undefined>this.$refs['messages'];
if(messageView !== undefined && messageView.scrollTop < 50) this.conversation.loadMore();
}
@Watch('conversation.errorText')
@Watch('conversation.infoText')
textChanged(newValue: string, oldValue: string): void {
if(oldValue.length === 0 && newValue.length > 0) this.keepScroll();
}
@Watch('conversation.typingStatus')
typingStatusChanged(_: string, oldValue: string): void {
if(oldValue === 'clear') this.keepScroll();
}
async onKeyDown(e: KeyboardEvent): Promise<void> {
const editor = <Editor>this.$refs['textBox'];
if(getKey(e) === Keys.Tab) {
e.preventDefault();
if(this.conversation.enteredText.length === 0 || this.isConsoleTab) return;
if(this.tabOptions === undefined) {
const selection = editor.getSelection();
if(selection.text.length === 0) {
const match = /\b[\w]+$/.exec(editor.text.substring(0, selection.end));
if(match === null) return;
selection.start = match.index < 0 ? 0 : match.index;
selection.text = editor.text.substring(selection.start, selection.end);
if(selection.text.length === 0) return;
}
const search = new RegExp(`^${selection.text.replace(/[^\w]/gi, '\\$&')}`, 'i');
const c = (<Conversation.PrivateConversation>this.conversation);
let options: ReadonlyArray<{character: Character}>;
options = Conversation.isChannel(this.conversation) ? this.conversation.channel.sortedMembers :
[{character: c.character}, {character: core.characters.ownCharacter}];
this.tabOptions = options.filter((x) => search.test(x.character.name)).map((x) => x.character.name);
this.tabOptionsIndex = 0;
this.tabOptionSelection = selection;
}
if(this.tabOptions.length > 0) {
const selection = editor.getSelection();
if(selection.end !== this.tabOptionSelection.end) return;
if(this.tabOptionsIndex >= this.tabOptions.length) this.tabOptionsIndex = 0;
const name = this.tabOptions[this.tabOptionsIndex];
const userName = (isCommand(this.conversation.enteredText) ? name : `[user]${name}[/user]`);
this.tabOptionSelection.end = this.tabOptionSelection.start + userName.length;
this.conversation.enteredText = this.conversation.enteredText.substr(0, this.tabOptionSelection.start) + userName +
this.conversation.enteredText.substr(selection.end);
++this.tabOptionsIndex;
}
} else {
if(this.tabOptions !== undefined) this.tabOptions = undefined;
if(getKey(e) === Keys.ArrowUp && this.conversation.enteredText.length === 0
&& !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey)
this.conversation.loadLastSent();
else if(getKey(e) === Keys.Enter) {
if(e.shiftKey) return;
e.preventDefault();
await this.conversation.send();
}
}
}
setMode(mode: Channel.Mode): void {
const conv = (<Conversation.ChannelConversation>this.conversation);
if(conv.channel.mode === 'both') conv.mode = mode;
}
acceptReport(sfc: {callid: number}): void {
core.connection.send('SFC', {action: 'confirm', callid: sfc.callid});
}
setSendingAds(is: boolean): void {
const conv = (<Conversation.ChannelConversation>this.conversation);
if(conv.channel.mode === 'both') {
conv.isSendingAds = is;
(<Editor>this.$refs['textBox']).focus();
}
}
get adCountdown(): string | undefined {
if(!Conversation.isChannel(this.conversation) || this.conversation.adCountdown <= 0) return;
return l('chat.adCountdown',
Math.floor(this.conversation.adCountdown / 60).toString(), (this.conversation.adCountdown % 60).toString());
}
get characterImage(): string {
return characterImage(this.conversation.name);
}
get showAvatars(): boolean {
return core.state.settings.showAvatars;
}
get isConsoleTab(): boolean {
return this.conversation === core.conversations.consoleTab;
}
get isChannelMod(): boolean {
if(core.characters.ownCharacter.isChatOp) return true;
const conv = (<Conversation.ChannelConversation>this.conversation);
const member = conv.channel.members[core.connection.character];
return member !== undefined && member.rank > Channel.Rank.Member;
}
}
</script>
<style lang="scss">
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/breakpoints";
#conversation {
.header {
@media (min-width: breakpoint-min(sm)) {
margin-right: 32px;
}
a.btn {
padding: 2px 5px;
}
}
.send-ads-switcher a {
padding: 3px 10px;
}
@media (max-width: breakpoint-max(xs)) {
.mode-switcher a {
padding: 5px 8px;
}
}
}
</style>