<template> <div @mouseover="onMouseOver" id="page" style="position:relative;padding:5px 10px 10px" :class="getThemeClass()" @auxclick.prevent @click.middle="unpinUrlPreview"> <div v-html="styling"></div> <div v-if="!characters" style="display:flex; align-items:center; justify-content:center; height: 100%;"> <div class="card bg-light" style="width: 400px;"> <div class="initializer" :class="{visible: !hasCompletedUpgrades, complete: hasCompletedUpgrades, shouldShow: shouldShowSpinner}"> <div class="title"> Getting ready, please wait... <small>You should only experience this delay once per software update</small> </div> <i class="fas fa-circle-notch fa-spin search-spinner"></i> </div> <BBCodeTester v-show="false"></BBCodeTester> <h3 class="card-header" style="margin-top:0;display:flex"> {{l('title')}} <a href="#" @click.prevent="showLogs()" class="btn" style="flex:1;text-align:right"> <span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span> </a> </h3> <div class="card-body"> <div class="alert alert-danger" v-show="error"> {{error}} </div> <div class="form-group"> <label class="control-label" for="account">{{l('login.account')}}</label> <input class="form-control" id="account" v-model="settings.account" @keypress.enter="login()" :disabled="loggingIn"/> </div> <div class="form-group"> <label class="control-label" for="password">{{l('login.password')}}</label> <input class="form-control" type="password" id="password" v-model="password" @keypress.enter="login()" :disabled="loggingIn"/> </div> <div class="form-group" v-show="showAdvanced"> <label class="control-label" for="host">{{l('login.host')}}</label> <div class="input-group"> <input class="form-control" id="host" v-model="settings.host" @keypress.enter="login()" :disabled="loggingIn"/> <div class="input-group-append"> <button class="btn btn-outline-secondary" @click="resetHost()"><span class="fas fa-undo-alt"></span> </button> </div> </div> </div> <div class="form-group"> <label for="advanced"><input type="checkbox" id="advanced" v-model="showAdvanced"/> {{l('login.advanced')}}</label> </div> <div class="form-group"> <label for="save"><input type="checkbox" id="save" v-model="saveLogin"/> {{l('login.save')}}</label> </div> <div class="form-group" style="margin:0;text-align:right"> <button class="btn btn-primary" @click="login" :disabled="loggingIn"> {{l(loggingIn ? 'login.working' : 'login.submit')}} </button> </div> </div> </div> </div> <chat v-else :ownCharacters="characters" :defaultCharacter="defaultCharacter" ref="chat"></chat> <div ref="linkPreview" class="link-preview"></div> <modal :action="l('importer.importing')" ref="importModal" :buttons="false"> <span style="white-space:pre-wrap">{{l('importer.importingNote')}}</span> <div class="progress" style="margin-top:5px"> <div class="progress-bar" :style="{width: importProgress * 100 + '%'}"></div> </div> </modal> <modal :buttons="false" ref="profileViewer" dialogClass="profile-viewer" > <character-page :authenticated="true" :oldApi="true" :name="profileName" :image-preview="true" ref="characterPage"></character-page> <template slot="title"> {{profileName}} <a class="btn" @click="openProfileInBrowser"><i class="fa fa-external-link-alt"/></a> <a class="btn" @click="openConversation"><i class="fa fa-comment"></i></a> <a class="btn" @click="reloadCharacter"><i class="fa fa-sync" /></a> <i class="fas fa-circle-notch fa-spin profileRefreshSpinner" v-show="isRefreshingProfile()"></i> <bbcode :text="profileStatus" v-show="!!profileStatus" class="status-text"></bbcode> <div class="profile-title-right"> <button class="btn" @click="prevProfile" :disabled="!prevProfileAvailable()"><i class="fas fa-arrow-left"></i></button> <button class="btn" @click="nextProfile" :disabled="!nextProfileAvailable()"><i class="fas fa-arrow-right"></i></button> </div> </template> </modal> <modal :action="l('fixLogs.action')" ref="fixLogsModal" @submit="fixLogs" buttonClass="btn-danger"> <span style="white-space:pre-wrap">{{l('fixLogs.text')}}</span> <div class="form-group"> <label class="control-label">{{l('fixLogs.character')}}</label> <select id="import" class="form-control" v-model="fixCharacter"> <option v-for="character in fixCharacters" :value="character">{{character}}</option> </select> </div> </modal> <modal :buttons="false" ref="wordDefinitionViewer" dialogClass="word-definition-viewer"> <word-definition :expression="wordDefinitionLookup" ref="wordDefinitionLookup"></word-definition> <template slot="title"> {{wordDefinitionLookup}} <a class="btn wordDefBtn dictionary" @click="openDefinitionWithDictionary"><i>D</i></a> <a class="btn wordDefBtn thesaurus" @click="openDefinitionWithThesaurus"><i>T</i></a> <a class="btn wordDefBtn urbandictionary" @click="openDefinitionWithUrbanDictionary"><i>UD</i></a> <a class="btn wordDefBtn wikipedia" @click="openDefinitionWithWikipedia"><i>W</i></a> <a class="btn" @click="openWordDefinitionInBrowser"><i class="fa fa-external-link-alt"/></a> </template> </modal> <logs ref="logsDialog"></logs> </div> </template> <script lang="ts"> import { Component, Hook, Watch } from '@f-list/vue-ts'; import Axios from 'axios'; import * as electron from 'electron'; import * as remote from '@electron/remote'; import log from 'electron-log'; //tslint:disable-line:match-default-export-name import * as fs from 'fs'; import * as path from 'path'; import * as qs from 'querystring'; import Raven from 'raven-js'; // import {promisify} from 'util'; import Vue from 'vue'; import Chat from '../chat/Chat.vue'; import {getKey, Settings} from '../chat/common'; import core /*, { init as initCore }*/ from '../chat/core'; import l from '../chat/localize'; import Logs from '../chat/Logs.vue'; import Socket from '../chat/WebSocket'; import Modal from '../components/Modal.vue'; import {SimpleCharacter} from '../interfaces'; import {Keys} from '../keys'; // import { BetterSqliteStore } from '../learn/store/better-sqlite3'; // import { Sqlite3Store } from '../learn/store/sqlite3'; import CharacterPage from '../site/character_page/character_page.vue'; import WordDefinition from '../learn/dictionary/WordDefinition.vue'; import ProfileAnalysis from '../learn/recommend/ProfileAnalysis.vue'; import {defaultHost, GeneralSettings, nativeRequire} from './common'; import { fixLogs /*SettingsStore, Logs as FSLogs*/ } from './filesystem'; import * as SlimcatImporter from './importer'; import _ from 'lodash'; import { EventBus } from '../chat/preview/event-bus'; import BBCodeTester from '../bbcode/Tester.vue'; import { BBCodeView } from '../bbcode/view'; // import ImagePreview from '../chat/preview/ImagePreview.vue'; // import Bluebird from 'bluebird'; // import Connection from '../fchat/connection'; // import Notifications from './notifications'; const webContents = remote.getCurrentWebContents(); const parent = remote.getCurrentWindow().webContents; // Allow requests to imgur.com const session = remote.session; /* tslint:disable:no-unsafe-any no-any no-unnecessary-type-assertion */ session!.defaultSession!.webRequest!.onBeforeSendHeaders( { urls: [ 'https://(api|i).imgur.com/.*', 'http://(api|i).imgur.com/.*' ] }, (details: any, callback: any) => { details.requestHeaders['Origin'] = null; details.headers['Origin'] = null; callback({requestHeaders: details.requestHeaders}); } ); log.info('init.chat.keytar.load.start'); /* tslint:disable: no-any no-unsafe-any */ //because this is hacky // const keyStore = nativeRequire< { getPassword(service: string, account: string): Promise<string> setPassword(service: string, account: string, password: string): Promise<void> deletePassword(service: string, account: string): Promise<void> findCredentials(service: string): Promise<{ account: string, password: string }> findPassword(service: string): Promise<string> [key: string]: (...args: any[]) => Promise<any> } >('keytar/build/Release/keytar.node'); // const keyStore = import('keytar'); // // for(const key in keyStore) keyStore[key] = promisify(<(...args: any[]) => any>keyStore[key].bind(keyStore, 'fchat')); //tslint:enable log.info('init.chat.keytar.load.done'); @Component({ components: { chat: Chat, modal: Modal, characterPage: CharacterPage, logs: Logs, 'word-definition': WordDefinition, BBCodeTester: BBCodeTester, bbcode: BBCodeView(core.bbCodeParser), 'profile-analysis': ProfileAnalysis } }) export default class Index extends Vue { showAdvanced = false; saveLogin = false; loggingIn = false; password = ''; character?: string; characters?: SimpleCharacter[]; error = ''; defaultCharacter?: number; l = l; settings!: GeneralSettings; hasCompletedUpgrades!: boolean; importProgress = 0; profileName = ''; profileStatus = ''; adName = ''; fixCharacters: ReadonlyArray<string> = []; fixCharacter = ''; wordDefinitionLookup = ''; shouldShowSpinner = false; profileNameHistory: string[] = []; profilePointer = 0; async startAndUpgradeCache(): Promise<void> { log.debug('init.chat.cache.start'); const timer = setTimeout( () => { this.shouldShowSpinner = true; }, 250 ); // tslint:disable-next-line no-floating-promises await core.cache.start(this.settings, this.hasCompletedUpgrades); log.debug('init.chat.cache.done'); clearTimeout(timer); parent.send('rising-upgrade-complete'); electron.ipcRenderer.send('rising-upgrade-complete'); this.hasCompletedUpgrades = true; } @Watch('profileName') onProfileNameChange(newName: string): void { if (this.profileNameHistory[this.profilePointer] !== newName) { this.profileNameHistory = _.takeRight( _.filter( _.take(this.profileNameHistory, this.profilePointer + 1), (n) => (n !== newName) ), 30 ); this.profileNameHistory.push(newName); this.profilePointer = this.profileNameHistory.length - 1; } } @Hook('mounted') onMounted(): void { log.debug('init.chat.mounted'); EventBus.$on( 'word-definition', (data: any) => { this.wordDefinitionLookup = data.lookupWord; if (!!data.lookupWord) { (<Modal>this.$refs.wordDefinitionViewer).show(); } } ); } @Hook('created') async created(): Promise<void> { await this.startAndUpgradeCache(); if(this.settings.account.length > 0) this.saveLogin = true; keyStore.getPassword('f-list.net', this.settings.account) .then((value: string) => this.password = value, (err: Error) => this.error = err.message); log.debug('init.chat.keystore.get.done'); Vue.set(core.state, 'generalSettings', this.settings); electron.ipcRenderer.on('settings', (_e: Event, settings: GeneralSettings) => { log.debug('settings.update.index'); core.state.generalSettings = this.settings = settings; }); electron.ipcRenderer.on('open-profile', (_e: Event, name: string) => { const profileViewer = <Modal>this.$refs['profileViewer']; this.openProfile(name); profileViewer.show(); }); electron.ipcRenderer.on('reopen-profile', (_e: Event) => { if ( (this.profileNameHistory.length > 0) && (this.profilePointer < this.profileNameHistory.length) && (this.profilePointer >= 0) ) { const name = this.profileNameHistory[this.profilePointer]; const profileViewer = <Modal>this.$refs['profileViewer']; if ((this.profileName === name) && (profileViewer.isShown)) { profileViewer.hide(); return; } this.openProfile(name); profileViewer.show(); } }); electron.ipcRenderer.on('fix-logs', async() => { this.fixCharacters = await core.settingsStore.getAvailableCharacters(); this.fixCharacter = this.fixCharacters[0]; (<Modal>this.$refs['fixLogsModal']).show(); }); electron.ipcRenderer.on('update-zoom', (_e, zoomLevel) => { webContents.setZoomLevel(zoomLevel); // log.info('INDEXVUE ZOOM UPDATE', zoomLevel); }); electron.ipcRenderer.on('active-tab', () => { core.cache.setTabActive(true); }); electron.ipcRenderer.on('inactive-tab', () => { core.cache.setTabActive(false); }); window.addEventListener('keydown', (e) => { const key = getKey(e); if ((key === Keys.Tab) && (e.ctrlKey) && (!e.altKey)) { parent.send(`${e.shiftKey ? 'previous' : 'switch'}-tab`, this.character); } if (((key === Keys.PageDown) || (key === Keys.PageUp)) && (e.ctrlKey) && (!e.altKey) && (!e.shiftKey)) { parent.send(`${key === Keys.PageUp ? 'previous' : 'switch'}-tab`, this.character); } }); log.debug('init.chat.listeners.done'); /*if (process.env.NODE_ENV !== 'production') { const dt = require('@vue/devtools'); dt.connect(); }*/ } async login(): Promise<void> { if(this.loggingIn) return; this.loggingIn = true; try { if(!this.saveLogin) await keyStore.deletePassword('f-list.net', this.settings.account); core.siteSession.setCredentials(this.settings.account, this.password); const data = <{ticket?: string, error: string, characters: {[key: string]: number}, default_character: number}> (await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify({ account: this.settings.account, password: this.password, no_friends: true, no_bookmarks: true, new_character_list: true }))).data; if(data.error !== '') { this.error = data.error; return; } if(this.saveLogin) { electron.ipcRenderer.send('save-login', this.settings.account, this.settings.host); await keyStore.setPassword('f-list.net', this.settings.account, this.password); } Socket.host = this.settings.host; core.connection.onEvent('connecting', async() => { if(!electron.ipcRenderer.sendSync('connect', core.connection.character) && process.env.NODE_ENV === 'production') { alert(l('login.alreadyLoggedIn')); return core.connection.close(); } parent.send('connect', webContents.id, core.connection.character); this.character = core.connection.character; if((await core.settingsStore.get('settings')) === undefined && SlimcatImporter.canImportCharacter(core.connection.character)) { if(!confirm(l('importer.importGeneral'))) return core.settingsStore.set('settings', new Settings()); (<Modal>this.$refs['importModal']).show(true); await SlimcatImporter.importCharacter(core.connection.character, (progress) => this.importProgress = progress); (<Modal>this.$refs['importModal']).hide(); } }); core.connection.onEvent('connected', () => { core.watch(() => core.conversations.hasNew, (newValue) => parent.send('has-new', webContents.id, newValue)); Raven.setUserContext({username: core.connection.character}); }); core.connection.onEvent('closed', () => { if(this.character === undefined) return; electron.ipcRenderer.send('disconnect', this.character); this.character = undefined; parent.send('disconnect', webContents.id); Raven.setUserContext(); }); core.connection.setCredentials(this.settings.account, this.password); this.characters = Object.keys(data.characters).map((name) => ({name, id: data.characters[name], deleted: false})) .sort((x, y) => x.name.localeCompare(y.name)); this.defaultCharacter = data.default_character; } catch(e) { this.error = l('login.error'); log.error('connect.error', e); if(process.env.NODE_ENV !== 'production') throw e; } finally { this.loggingIn = false; } } fixLogs(): void { if(!electron.ipcRenderer.sendSync('connect', this.fixCharacter)) return alert(l('login.alreadyLoggedIn')); try { fixLogs(this.fixCharacter); alert(l('fixLogs.success')); } catch(e) { alert(l('fixLogs.error')); throw e; } finally { electron.ipcRenderer.send('disconnect', this.fixCharacter); } } resetHost(): void { this.settings.host = defaultHost; } onMouseOver(e: MouseEvent): void { const preview = (<HTMLDivElement>this.$refs.linkPreview); if((<HTMLElement>e.target).tagName === 'A') { const target = <HTMLAnchorElement>e.target; if(target.hostname !== '') { //tslint:disable-next-line:prefer-template preview.className = 'link-preview ' + (e.clientX < window.innerWidth / 2 && e.clientY > window.innerHeight - 150 ? ' right' : ''); preview.textContent = target.href; preview.style.display = 'block'; return; } } preview.textContent = ''; preview.style.display = 'none'; } async openProfileInBrowser(): Promise<void> { await remote.shell.openExternal(`https://www.f-list.net/c/${this.profileName}`); // tslint:disable-next-line: no-any no-unsafe-any (this.$refs.profileViewer as any).hide(); } openConversation(): void { //this. // this.profileName const character = core.characters.get(this.profileName); const conversation = core.conversations.getPrivate(character); conversation.show(); // tslint:disable-next-line: no-any no-unsafe-any (this.$refs.profileViewer as any).hide(); } isRefreshingProfile(): boolean { const cp = this.$refs.characterPage as CharacterPage; return cp && cp.refreshing; } reloadCharacter(): void { // tslint:disable-next-line: no-any no-unsafe-any (this.$refs.characterPage as any).reload(); } getThemeClass(): Record<string, boolean> { // console.log('getThemeClassIndex', core.state.generalSettings?.risingDisableWindowsHighContrast); try { // Hack! if (process.platform === 'win32') { if (core.state.generalSettings?.risingDisableWindowsHighContrast) { document.querySelector('html')?.classList.add('disableWindowsHighContrast'); } else { document.querySelector('html')?.classList.remove('disableWindowsHighContrast'); } } return { [`theme-${this.settings.theme}`]: true, colorblindMode: core.state.settings.risingColorblindMode, disableWindowsHighContrast: core.state.generalSettings?.risingDisableWindowsHighContrast || false }; } catch(err) { return { [`theme-${this.settings.theme}`]: true }; } } nextProfile(): void { if (!this.nextProfileAvailable()) { return; } this.profilePointer++; this.openProfile(this.profileNameHistory[this.profilePointer]); } nextProfileAvailable(): boolean { return (this.profilePointer < this.profileNameHistory.length - 1); } prevProfile(): void { if (!this.prevProfileAvailable()) { return; } this.profilePointer--; this.openProfile(this.profileNameHistory[this.profilePointer]); } prevProfileAvailable(): boolean { return (this.profilePointer > 0); } openProfile(name: string) { this.profileName = name; const character = core.characters.get(name); this.profileStatus = character.statusText || ''; } get styling(): string { try { return `<style id="themeStyle">${fs.readFileSync(path.join(__dirname, `themes/${this.settings.theme}.css`), 'utf8').toString()}</style>`; } catch(e) { if((<Error & {code: string}>e).code === 'ENOENT' && this.settings.theme !== 'default') { this.settings.theme = 'default'; return this.styling; } throw e; } } showLogs(): void { (<Logs>this.$refs['logsDialog']).show(); } async openDefinitionWithDictionary(): Promise<void> { (this.$refs.wordDefinitionLookup as any).setMode('dictionary'); } async openDefinitionWithThesaurus(): Promise<void> { (this.$refs.wordDefinitionLookup as any).setMode('thesaurus'); } async openDefinitionWithUrbanDictionary(): Promise<void> { (this.$refs.wordDefinitionLookup as any).setMode('urbandictionary'); } async openDefinitionWithWikipedia(): Promise<void> { (this.$refs.wordDefinitionLookup as any).setMode('wikipedia'); } async openWordDefinitionInBrowser(): Promise<void> { await remote.shell.openExternal((this.$refs.wordDefinitionLookup as any).getWebUrl()); // tslint:disable-next-line: no-any no-unsafe-any (this.$refs.wordDefinitionViewer as any).hide(); } unpinUrlPreview(e: Event): void { const imagePreview = (this.$refs['chat'] as Chat)?.getChatView()?.getImagePreview(); // const imagePreview = this.$refs['imagePreview'] as ImagePreview; if ((imagePreview) && (imagePreview.isVisible()) && (imagePreview.sticky)) { e.stopPropagation(); e.preventDefault(); EventBus.$emit('imagepreview-toggle-stickyness', {force: true}); } } } </script> <style lang="scss"> html, body, #page { height: 100%; } a[href^="#"]:not([draggable]) { -webkit-user-drag: none; -webkit-app-region: no-drag; } .profileRefreshSpinner { font-size: 12pt; opacity: 0.5; } .profile-viewer { .modal-title { width: 100%; position: relative; .profile-title-right { float: right; top: -7px; right: 0; position: absolute; } .status-text { font-size: 12pt; display: block; max-height: 3em; overflow: auto; } } } .initializer { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; opacity: 0; backdrop-filter: blur(3px) grayscale(35%); &.shouldShow { transition: all 0.25s; &.visible { opacity: 1; } } &.complete { pointer-events: none !important; } i { font-size: 130pt; top: 50%; right: 50%; transform: translate(-50%, -50%); width: fit-content; } .title { position: absolute; top: 0; background: rgba(147, 255, 215, 0.6); width: 100%; text-align: center; padding-top: 20px; padding-bottom: 20px; font-weight: bold; small { display: block; opacity: 0.8; } } } .btn.wordDefBtn { background-color: red; padding: 0.2rem 0.2rem; line-height: 90%; margin-right: 0.2rem; text-align: center; i { font-style: normal !important; color: white; font-weight: bold } &.thesaurus { background-color: #F44725 } &.urbandictionary { background-color: #d96a36; i { color: #fadf4b; text-transform: lowercase; font-family: monospace; } } &.dictionary { background-color: #314ca7; } &.wikipedia { background-color: white; i { color: black; font-family: serif; } } } .modal { .word-definition-viewer { max-width: 50rem !important; width: 70% !important; min-width: 22rem !important; .modal-content { min-height: 75%; } .definition-wrapper { position: absolute; left: 0; right: 0; top: 0; bottom: 0; margin-left: 20px; margin-right: 20px; webview { height: 100%; padding-bottom: 10px; } } } } .disableWindowsHighContrast, .disableWindowsHighContrast * { forced-color-adjust: none; } </style>