<template> <div style="height:100%; display: flex; position: relative;" id="chatView" @click="userMenuHandle" @contextmenu="userMenuHandle" @touchstart.passive="userMenuHandle" @touchend="userMenuHandle"> <sidebar id="sidebar" :label="l('chat.menu')" icon="fa-bars"> <img :src="characterImage(ownCharacter.name)" v-if="showAvatars" style="float:left;margin-right:5px;width:60px"/> <a target="_blank" :href="ownCharacterLink" class="btn" style="margin-right:5px">{{ownCharacter.name}}</a> <a href="#" @click.prevent="logOut()" class="btn"><i class="fas fa-sign-out-alt"></i>{{l('chat.logout')}}</a><br/> <div> {{l('chat.status')}} <a href="#" @click.prevent="showStatus()" class="btn"> <span class="fas fa-fw" :class="getStatusIcon(ownCharacter.status)"></span>{{l('status.' + ownCharacter.status)}} </a> </div> <div style="clear:both"> <a href="#" @click.prevent="showSearch()" class="btn"><span class="fas fa-search"></span> {{l('characterSearch.open')}}</a> </div> <div><a href="#" @click.prevent="showSettings()" class="btn"><span class="fas fa-cog"></span> {{l('settings.open')}}</a></div> <div><a href="#" @click.prevent="showRecent()" class="btn"><span class="fas fa-history"></span> {{l('chat.recentConversations')}}</a></div> <div class="list-group conversation-nav"> <a :class="getClasses(conversations.consoleTab)" href="#" @click.prevent="conversations.consoleTab.show()" class="list-group-item list-group-item-action"> {{conversations.consoleTab.name}} </a> </div> {{l('chat.pms')}} <div @click.prevent="showAddPmPartner()" class="pm-add"><a href="#"><span class="fas fa-plus"></span></a></div> <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" @click.middle.prevent="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='online-status' :class="getOnlineStatusIconClasses(conversation)"></span> <span style="flex:1"></span> <span class="pin fas fa-thumbtack" :class="{'active': conversation.isPinned}" @click="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> </a> </div> <a href="#" @click.prevent="showChannels()" class="btn"><span class="fas fa-list"></span> {{l('chat.channels')}}</a> <div class="list-group conversation-nav" ref="channelConversations"> <a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()" :class="getClasses(conversation)" class="list-group-item list-group-item-action item-channel" :key="conversation.key" @click.middle.prevent="conversation.close()"> <span class="name">{{conversation.name}}</span> <span> <span v-if="conversation.hasAutomatedAds()" class="fas fa-ad" :class="{'active': conversation.isSendingAutomatedAds()}" aria-label="Toggle ads" @click.stop="conversation.toggleAutomatedAds()" ></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> <div style="display:flex;flex-direction:column;flex:1;min-width:0"> <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="fas 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"/> <span class="far fa-user-circle conversation-icon" v-else></span> <div class="name">{{conversation.character.name}}</div> </a> <a v-for="conversation in conversations.channelConversations" href="#" @click.prevent="conversation.show()" :class="getClasses(conversation)" class="list-group-item list-group-item-action" :key="conversation.key"> <span class="fas fa-hashtag conversation-icon"></span> <div class="name">{{conversation.name}}</div> </a> </div> <conversation :reportDialog="$refs['reportDialog']"></conversation> </div> <user-list></user-list> <channels ref="channelsDialog"></channels> <status-switcher ref="statusDialog"></status-switcher> <character-search ref="searchDialog"></character-search> <settings ref="settingsDialog"></settings> <report-dialog ref="reportDialog"></report-dialog> <user-menu ref="userMenu" :reportDialog="$refs['reportDialog']"></user-menu> <recent-conversations ref="recentDialog"></recent-conversations> <image-preview ref="imagePreview"></image-preview> <add-pm-partner ref="addPmPartnerDialog"></add-pm-partner> <note-status></note-status> </div> </template>/me <script lang="ts"> import {Component, Hook} from '@f-list/vue-ts'; import Sortable from 'sortablejs'; import Vue from 'vue'; import {Keys} from '../keys'; import ChannelList from './ChannelList.vue'; import CharacterSearch from './CharacterSearch.vue'; import {characterImage, getKey, profileLink} from './common'; import ConversationView from './ConversationView.vue'; import core from './core'; import {Character, Connection, Conversation} from './interfaces'; import l from './localize'; import PmPartnerAdder from './PmPartnerAdder.vue'; 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 './UserView.vue'; import UserList from './UserList.vue'; import UserMenu from './UserMenu.vue'; import ImagePreview from './preview/ImagePreview.vue'; import PrivateConversation = Conversation.PrivateConversation; import * as _ from 'lodash'; import NoteStatus from '../site/NoteStatus.vue'; const unreadClasses = { [Conversation.UnreadState.None]: '', [Conversation.UnreadState.Mention]: 'list-group-item-warning', [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, sidebar: Sidebar, 'user-menu': UserMenu, 'recent-conversations': RecentConversations, 'image-preview': ImagePreview, 'add-pm-partner': PmPartnerAdder, 'note-status': NoteStatus } }) export default class ChatView extends Vue { l = l; sidebarExpanded = false; characterImage = characterImage; conversations = core.conversations; getStatusIcon = getStatusIcon; keydownListener!: (e: KeyboardEvent) => void; focusListener!: () => void; blurListener!: () => void; @Hook('mounted') mounted(): void { this.keydownListener = (e: KeyboardEvent) => this.onKeyDown(e); window.addEventListener('keydown', this.keydownListener); this.setFontSize(core.state.settings.fontSize); Sortable.create(<HTMLElement>this.$refs['privateConversations'], { animation: 50, fallbackTolerance: 5, onEnd: async(e) => { if(e.oldIndex === e.newIndex) return; return core.conversations.privateConversations[e.oldIndex!].sort(e.newIndex!); } }); Sortable.create(<HTMLElement>this.$refs['channelConversations'], { animation: 50, fallbackTolerance: 5, onEnd: async(e) => { 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.addEventListener('focus', this.focusListener = () => { core.notifications.isInBackground = false; if(idleTimer !== undefined) { clearTimeout(idleTimer); idleTimer = undefined; } if(idleStatus !== undefined) { const status = idleStatus; window.setTimeout(() => core.connection.send('STA', status), Math.max(lastUpdate + core.connection.vars.sta_flood * 1000 + 1000 - Date.now(), 0)); idleStatus = undefined; } }); window.addEventListener('blur', this.blurListener = () => { core.notifications.isInBackground = true; if(idleTimer !== undefined) clearTimeout(idleTimer); if(core.state.settings.idleTimer > 0 && core.characters.ownCharacter.status !== 'dnd') idleTimer = window.setTimeout(() => { lastUpdate = Date.now(); 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); idleTimer = undefined; } }); core.watch<number>(function(): number { return this.state.settings.fontSize; }, (value) => { this.setFontSize(value); }); } @Hook('destroyed') destroyed(): void { window.removeEventListener('keydown', this.keydownListener); window.removeEventListener('focus', this.focusListener); window.removeEventListener('blur', this.blurListener); } needsReply(conversation: Conversation): boolean { if(!core.state.settings.showNeedsReply) return false; for(let i = conversation.messages.length - 1; i >= 0; --i) { const sender = (<Partial<Conversation.ChatMessage>>conversation.messages[i]).sender; // noinspection TypeScriptValidateTypes if(sender !== undefined) return sender !== core.characters.ownCharacter; } return false; } onKeyDown(e: KeyboardEvent): void { const selected = this.conversations.selectedConversation; const pms = this.conversations.privateConversations; const channels = this.conversations.channelConversations; const console = this.conversations.consoleTab; if(getKey(e) === Keys.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(); } else { const index = channels.indexOf(<Conversation.ChannelConversation>selected); if(index === 0) if(pms.length > 0) pms[pms.length - 1].show(); else console.show(); else channels[index - 1].show(); } else if(getKey(e) === Keys.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(); } else if(Conversation.isPrivate(selected)) { const index = pms.indexOf(selected); if(index === pms.length - 1) { if(channels.length > 0) channels[0].show(); } else pms[index + 1].show(); } else { const index = channels.indexOf(<Conversation.ChannelConversation>selected); if(index < channels.length - 1) channels[index + 1].show(); else console.show(); } } setFontSize(fontSize: number): void { let overrideEl = <HTMLStyleElement | null>document.getElementById('overrideFontSize'); if(overrideEl !== null) document.body.removeChild(overrideEl); overrideEl = document.createElement('style'); overrideEl.id = 'overrideFontSize'; document.body.appendChild(overrideEl); const sheet = <CSSStyleSheet>overrideEl.sheet; sheet.insertRule(`#chatView, .btn, .form-control, .custom-select { font-size: ${fontSize}px; }`, sheet.cssRules.length); sheet.insertRule(`.form-control, select.form-control { line-height: 1.428571429 }`, sheet.cssRules.length); } getOnlineStatusIconClasses(conversation: PrivateConversation): Record<string, any> { const status = conversation.character.status; if ((conversation.typingStatus === 'typing') || (conversation.typingStatus === 'paused')) { return { fas: true, 'fa-comment-dots': (conversation.typingStatus === 'typing'), 'fa-comment': (conversation.typingStatus === 'paused') }; } const styling = { crown: { color: 'online', icon: ['fas', 'fa-crown'] }, online: { color: 'online', icon: ['fas', 'fa-circle'] }, looking: { color: 'online', icon: ['fa', 'fa-eye'] }, offline: { color: 'offline', icon: ['fas', 'fa-circle'] }, busy: { color: 'away', icon: ['fas', 'fa-circle'] }, idle: { color: 'away', icon: ['fas', 'fa-circle'] }, dnd: { color: 'away', icon: ['fas', 'fa-circle'] }, away: { color: 'away', icon: ['fas', 'fa-circle'] } }; const cls = { [styling[status].color]: true }; _.forEach( styling[status].icon, (name: string) => cls[name] = true ); return cls; } logOut(): void { if(confirm(l('chat.confirmLeave'))) core.connection.close(); } showSettings(): void { (<SettingsView>this.$refs['settingsDialog']).show(); } showSearch(): void { (<CharacterSearch>this.$refs['searchDialog']).show(); } showRecent(): void { (<RecentConversations>this.$refs['recentDialog']).show(); } showChannels(): void { (<ChannelList>this.$refs['channelsDialog']).show(); } showStatus(): void { (<StatusSwitcher>this.$refs['statusDialog']).show(); } showAddPmPartner(): void { (<PmPartnerAdder>this.$refs['addPmPartnerDialog']).show(); } userMenuHandle(e: MouseEvent | TouchEvent): void { (<UserMenu>this.$refs['userMenu']).handleEvent(e); } get showAvatars(): boolean { return core.state.settings.showAvatars; } get ownCharacter(): Character { return core.characters.ownCharacter; } get ownCharacterLink(): string { return profileLink(core.characters.ownCharacter.name); } getClasses(conversation: Conversation): string { return conversation === core.conversations.selectedConversation ? ' active' : unreadClasses[conversation.unread]; } } </script> <style lang="scss"> @import "~bootstrap/scss/functions"; @import "~bootstrap/scss/variables"; @import "~bootstrap/scss/mixins/breakpoints"; body { user-select: none; } .bbcode, .message, .profile-viewer { user-select: text; } .pm-add { font-size: 90%; float: right; margin-right: 5px; } .list-group.conversation-nav { .fas.active { color: #02a002; } margin-bottom: 10px; .list-group-item { 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; text-overflow: ellipsis; white-space: nowrap; } .fas { font-size: 16px; padding: 0 3px; &:last-child { padding-right: 0; } } &.item-private { padding-left: 0; padding-top: 0; padding-bottom: 0; .online-status { padding-left: 1px; font-size: 85%; } /*.offline,*/ /*.online,*/ /*.away {*/ /* font-size: 80%;*/ /*}*/ .offline { color: #5c5c84; } .online { color: #02a002; } .away { color: #c7894f; } .fa-comment, .fa-comment-dots { color: #cbcbe5; } /*.fa-eye {*/ /* // margin-right: 3px;*/ /*}*/ } img { height: 40px; margin: -1px 5px -1px -1px; } &:first-child img { border-top-left-radius: 4px; } &:last-child img { border-bottom-left-radius: 4px; } } .list-group-item-danger:not(.active) { color: inherit; } } #quick-switcher { margin: 0 45px 5px; overflow: auto; display: none; align-items: stretch; flex-direction: row; @media (max-width: breakpoint-max(sm)) { display: flex; } a { width: 40px; 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 { border-radius: 4px; } } &:last-child { border-radius: 0 4px 4px 0; } } img { width: 30px; } .name { overflow: hidden; white-space: nowrap; } .conversation-icon { font-size: 2em; height: 30px; } .list-group-item-danger:not(.active) { color: inherit; } } #sidebar { .body a.btn { padding: 2px 0; text-align: left; } @media (min-width: breakpoint-min(md)) { .sidebar { position: static; margin: 0; padding: 0; height: 100%; } .body { display: block; } .expander { display: none; } } } </style>