diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e58e1..e11314f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog ## Canary -* More conifugrable settings for F-Chat Rising +* More configurable settings for F-Chat Rising +* Hover mouse over a character name to see a character preview ## 1.3.0 diff --git a/bbcode/UrlTagView.vue b/bbcode/UrlTagView.vue index fa4465e..29f5d46 100644 --- a/bbcode/UrlTagView.vue +++ b/bbcode/UrlTagView.vue @@ -55,7 +55,6 @@ EventBus.$emit('imagepreview-show', {url: this.url}); } - toggleStickyness(): void { EventBus.$emit('imagepreview-toggle-stickyness', {url: this.url}); } diff --git a/chat/UserMenu.vue b/chat/UserMenu.vue index d2821c2..be06699 100644 --- a/chat/UserMenu.vue +++ b/chat/UserMenu.vue @@ -10,9 +10,7 @@ <bbcode id="userMenuStatus" :text="character.statusText" v-show="character.statusText" class="list-group-item" style="max-height:200px;overflow:auto;clear:both"></bbcode> - <div v-if="match" class="list-group-item menu-character-score"> - <span v-for="(score, key) in match" :class="score.getRecommendedClass()"><i :class="score.getRecommendedIcon()"></i> {{getTagDesc(key)}}</span> - </div> + <match-tags v-if="match" :match="match" class="list-group-item"></match-tags> <a tabindex="-1" :href="profileLink" target="_blank" v-if="showProfileFirst" class="list-group-item list-group-item-action"> <span class="fa fa-fw fa-user"></span>{{l('user.profile')}}</a> @@ -59,12 +57,12 @@ import core from './core'; import { Channel, Character } from './interfaces'; import l from './localize'; import ReportDialog from './ReportDialog.vue'; -import { Matcher, MatchResultScores } from '../learn/matcher'; -import { TagId } from '../learn/matcher-types'; +import { Matcher, MatchReport } from '../learn/matcher'; import _ from 'lodash'; +import MatchTags from './preview/MatchTags.vue'; @Component({ - components: {bbcode: BBCodeView(core.bbCodeParser), modal: Modal, 'ad-view': CharacterAdView} + components: {'match-tags': MatchTags, bbcode: BBCodeView(core.bbCodeParser), modal: Modal, 'ad-view': CharacterAdView} }) export default class UserMenu extends Vue { @Prop({required: true}) @@ -80,7 +78,7 @@ import _ from 'lodash'; memo = ''; memoId = 0; memoLoading = false; - match: MatchResultScores | null = null; + match: MatchReport | null = null; openConversation(jump: boolean): void { const conversation = core.conversations.getPrivate(this.character!); @@ -226,9 +224,6 @@ import _ from 'lodash'; this.showContextMenu = false; } - getTagDesc(key: any): any { - return TagId[key].toString().replace(/([A-Z])/g, ' $1').trim(); - } private async openMenu(touch: MouseEvent | Touch, character: Character, channel: Channel | undefined): Promise<void> { this.channel = channel; @@ -246,7 +241,7 @@ import _ from 'lodash'; const match = Matcher.identifyBestMatchReport(myProfile.character, theirProfile.character.character); if (_.keys(match.merged).length > 0) { - this.match = match.merged; + this.match = match; } } } @@ -272,44 +267,6 @@ import _ from 'lodash'; border-top-width: 0; z-index: -1; } - - #userMenu { - .menu-character-score { - span { - padding-left: 3px; - padding-right: 3px; - margin-bottom: 3px; - margin-right: 3px; - display: inline-block; - border: 1px solid; - border-radius: 3px; - - i { - color: white; - } - - &.match { - background-color: var(--scoreMatchBg); - border: solid 1px var(--scoreMatchFg); - } - - &.weak-match { - background-color: var(--scoreWeakMatchBg); - border: 1px solid var(--scoreWeakMatchFg); - } - - &.weak-mismatch { - background-color: var(--scoreWeakMismatchBg); - border: 1px solid var(--scoreWeakMismatchFg); - } - - &.mismatch { - background-color: var(--scoreMismatchBg); - border: 1px solid var(--scoreMismatchFg); - } - } - } - } </style> diff --git a/chat/UserView.vue b/chat/UserView.vue index 14aa25e..511b8fb 100644 --- a/chat/UserView.vue +++ b/chat/UserView.vue @@ -1,5 +1,5 @@ <!-- Linebreaks inside this template will break BBCode views --> -<template><span :class="userClass" v-bind:bbcodeTag.prop="'user'" v-bind:character.prop="character" v-bind:channel.prop="channel"><span v-if="!!statusClass" :class="statusClass"></span><span v-if="!!rankIcon" :class="rankIcon"></span>{{character.name}}<span v-if="!!matchClass" :class="matchClass">{{getMatchScoreTitle(matchScore)}}</span></span></template> +<template><span :class="userClass" v-bind:bbcodeTag.prop="'user'" v-bind:character.prop="character" v-bind:channel.prop="channel" @mouseover.prevent="show()" @mouseenter.prevent="show()" @mouseleave.prevent="dismiss()" @mouseout.prevent="dismiss()" @click.middle.prevent="toggleStickyness()" @click.right.passive="dismiss(true)" @click.left.passive="dismiss(true)"><span v-if="!!statusClass" :class="statusClass"></span><span v-if="!!rankIcon" :class="rankIcon"></span>{{character.name}}<span v-if="!!matchClass" :class="matchClass">{{getMatchScoreTitle(matchScore)}}</span></span></template> <script lang="ts"> @@ -33,6 +33,70 @@ export function getStatusIcon(status: Character.Status): string { } +export interface StatusClasses { + rankIcon: string | null; + statusClass: string | null; + matchClass: string | null; + matchScore: number | null; + userClass: string; + isBookmark: boolean; +} + +export function getStatusClasses( + character: Character, + channel: Channel | undefined, + showStatus: boolean, + showBookmark: boolean, + showMatch: boolean +): StatusClasses { + let rankIcon: string | null = null; + let statusClass = null; + let matchClass = null; + let matchScore = null; + + if(character.isChatOp) { + rankIcon = 'far fa-gem'; + } else if(channel !== undefined) { + rankIcon = (channel.owner === character.name) + ? 'fa fa-key' + : channel.opList.indexOf(character.name) !== -1 + ? (channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') + : null; + } + + if ((showStatus) || (character.status === 'crown')) + statusClass = `fa-fw ${getStatusIcon(character.status)}`; + + if ((core.state.settings.risingAdScore) && (showMatch)) { + const cache = core.cache.profileCache.getSync(character.name); + + if (cache) { + matchClass = `match-found ${Score.getClasses(cache.matchScore)}`; + matchScore = cache.matchScore; + } else { + /* tslint:disable-next-line no-floating-promises */ + core.cache.addProfile(character.name); + } + } + + const gender = character.gender !== undefined ? character.gender.toLowerCase() : 'none'; + + const isBookmark = (showBookmark) && (core.connection.isOpen) && (core.state.settings.colorBookmarks) && + ((character.isFriend) || (character.isBookmarked)); + + const userClass = `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''}`; + + return { + rankIcon, + statusClass, + matchClass, + matchScore, + userClass, + isBookmark + }; +} + + @Component({ components: { @@ -54,6 +118,9 @@ export default class UserView extends Vue { @Prop() readonly match?: boolean = false; + @Prop({default: true}) + readonly preview: boolean = true; + userClass = ''; rankIcon: string | null = null; @@ -100,8 +167,14 @@ export default class UserView extends Vue { onBeforeDestroy(): void { if (this.scoreWatcher) EventBus.$off('character-score', this.scoreWatcher); + + this.dismiss(); } + @Hook('deactivated') + deactivate(): void { + this.dismiss(); + } @Hook('beforeUpdate') onBeforeUpdate(): void { @@ -114,55 +187,13 @@ export default class UserView extends Vue { } update(): void { - this.rankIcon = null; - this.statusClass = null; - this.matchClass = null; - this.matchScore = null; + const res = getStatusClasses(this.character, this.channel, !!this.showStatus, !!this.bookmark, !!this.match); - // if (this.match) console.log('Update', this.character.name); - - if(this.character.isChatOp) { - this.rankIcon = 'far fa-gem'; - } else if(this.channel !== undefined) { - this.rankIcon = (this.channel.owner === this.character.name) - ? 'fa fa-key' - : this.channel.opList.indexOf(this.character.name) !== -1 - ? (this.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') - : null; - } - - if ((this.showStatus) || (this.character.status === 'crown')) - this.statusClass = `fa-fw ${getStatusIcon(this.character.status)}`; - - // if (this.match) console.log('Update prematch', this.character.name); - - if ((core.state.settings.risingAdScore) && (this.match)) { - const cache = core.cache.profileCache.getSync(this.character.name); - - if (cache) { - this.matchClass = `match-found ${Score.getClasses(cache.matchScore)}`; - this.matchScore = cache.matchScore; - - // console.log('Found match data', this.character.name, cache.matchScore); - } else { - // console.log('Need match data', this.character.name); - - /* tslint:disable-next-line no-floating-promises */ - core.cache.addProfile(this.character.name); - } - } - - // if (this.match) console.log('Update post match', this.character.name); - - const gender = this.character.gender !== undefined ? this.character.gender.toLowerCase() : 'none'; - - const isBookmark = (this.bookmark) && (core.connection.isOpen) && (core.state.settings.colorBookmarks) && - ((this.character.isFriend) || (this.character.isBookmarked)); - - - this.userClass = `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''}`; - - // if (this.match) console.log('Update done'); + this.rankIcon = res.rankIcon; + this.statusClass = res.statusClass; + this.matchClass = res.matchClass; + this.matchScore = res.matchScore; + this.userClass = res.userClass; } @@ -183,47 +214,37 @@ export default class UserView extends Vue { return ''; } -} -//tslint:disable-next-line:variable-name -/* const UserView = Vue.extend({ - functional: true, - render(this: void | Vue, createElement: CreateElement, context?: RenderContext): VNode { - const props = <{character: Character, channel?: Channel, showStatus?: true, bookmark?: false, match?: false}>( - context !== undefined ? context.props : (<Vue>this).$options.propsData); - const character = props.character; + getCharacterUrl(): string { + return `flist-character://${this.character.name}`; + } - let matchClasses: string | undefined; - if (props.match) { - const cache = core.cache.profileCache.getSync(character.name); - - if (cache) { - matchClasses = Score.getClasses(cache.matchScore); - } + dismiss(force: boolean = false): void { + if (!this.preview) { + return; } - let rankIcon; - if(character.isChatOp) rankIcon = 'far fa-gem'; - else if(props.channel !== undefined) - rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ? - (props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : ''; - else rankIcon = ''; - const children: (VNode | string)[] = [character.name]; - if(rankIcon !== '') children.unshift(createElement('span', {staticClass: rankIcon})); - if(props.showStatus !== undefined || character.status === 'crown') - children.unshift(createElement('span', {staticClass: `fa-fw ${getStatusIcon(character.status)}`})); - const gender = character.gender !== undefined ? character.gender.toLowerCase() : 'none'; - const isBookmark = props.bookmark !== false && core.connection.isOpen && core.state.settings.colorBookmarks && - (character.isFriend || character.isBookmarked); - return createElement('span', { - attrs: {class: `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''} ${matchClasses}`}, - domProps: {character, channel: props.channel, bbcodeTag: 'user'} - }, children); + EventBus.$emit('imagepreview-dismiss', {url: this.getCharacterUrl(), force}); } -}); -export default UserView; -*/ + + show(): void { + if (!this.preview) { + return; + } + + EventBus.$emit('imagepreview-show', {url: this.getCharacterUrl()}); + } + + + toggleStickyness(): void { + if (!this.preview) { + return; + } + + EventBus.$emit('imagepreview-toggle-stickyness', {url: this.getCharacterUrl()}); + } +} </script> diff --git a/chat/preview/CharacterPreview.vue b/chat/preview/CharacterPreview.vue new file mode 100644 index 0000000..6c693a9 --- /dev/null +++ b/chat/preview/CharacterPreview.vue @@ -0,0 +1,290 @@ +<template> + <div class="character-preview"> + <div v-if="match && character" class="row"> + <div class="col-2"> + <img :src="avatarUrl(character.character.name)" class="character-avatar"> + </div> + + <div class="col-8"> + <h1><span class="character-name" :class="(statusClasses || {}).userClass">{{ character.character.name }}</span></h1> + <h3>{{ getOnlineStatus() }}</h3> + + <div class="summary"> + <span class="uc"> + <span v-if="age" :class="byScore(TagId.Age)">{{age}}-years-old </span> + <span v-if="sexualOrientation" :class="byScore(TagId.Orientation)">{{sexualOrientation}} </span> + <span v-if="gender" :class="byScore(TagId.Gender)">{{gender}} </span> + <span v-if="species" :class="byScore(TagId.Species)">{{species}} </span> + </span> + + <span v-if="furryPref" :class="byScore(TagId.FurryPreference)"><br /><span class="uc">{{furryPref}}</span></span> + <span v-if="subDomRole" :class="byScore(TagId.SubDomRole)"><br /><span class="uc">{{subDomRole}}</span></span> + </div> + + <match-tags v-if="match" :match="match"></match-tags> + + <div v-if="latestAd"> + <h4>Latest Ad <span class="message-time">{{formatTime(latestAd.datePosted)}}</span></h4> + <bbcode :text="latestAd.message"></bbcode> + </div> + </div> + </div> + <div v-else> + Loading... + </div> + </div> +</template> + +<script lang="ts"> +import { Component, Prop } from '@f-list/vue-ts'; +import Vue from 'vue'; +import core from '../core'; +import { methods } from '../../site/character_page/data_store'; +import {Character as ComplexCharacter} from '../../site/character_page/interfaces'; +import { Matcher, MatchReport } from '../../learn/matcher'; +import { Character as CharacterStatus } from '../../fchat'; +import { getStatusClasses, StatusClasses } from '../UserView.vue'; +import * as _ from 'lodash'; +import { AdCachedPosting } from '../../learn/ad-cache'; +import {formatTime} from '../common'; +import * as Utils from '../../site/utils'; +import MatchTags from './MatchTags.vue'; +import { + furryPreferenceMapping, + Gender, + Orientation, + Species, + SubDomRole, + TagId +} from '../../learn/matcher-types'; +import { BBCodeView } from '../../bbcode/view'; + + +@Component({ + components: { + 'match-tags': MatchTags, + bbcode: BBCodeView(core.bbCodeParser) + } +}) +export default class CharacterPreview extends Vue { + @Prop + readonly id?: number; + + characterName?: string; + character?: ComplexCharacter; + match?: MatchReport; + ownCharacter?: ComplexCharacter; + onlineCharacter?: CharacterStatus; + statusClasses?: StatusClasses; + latestAd?: AdCachedPosting; + + age?: string; + sexualOrientation?: string; + species?: string; + gender?: string; + furryPref?: string; + subDomRole?: string; + + formatTime = formatTime; + readonly avatarUrl = Utils.avatarURL; + TagId = TagId; + + async load(characterName: string): Promise<void> { + if ( + (this.characterName === characterName) + && (this.match) + && (this.character) + && (this.ownCharacter) + && (this.ownCharacter.character.name === core.characters.ownProfile.character.name) + ) { + this.updateOnlineStatus(); + this.updateAdStatus(); + return; + } + + this.characterName = characterName; + + this.match = undefined; + this.character = undefined; + this.ownCharacter = core.characters.ownProfile; + + this.updateOnlineStatus(); + this.updateAdStatus(); + + this.character = await this.getCharacterData(characterName); + this.match = Matcher.identifyBestMatchReport(this.ownCharacter.character, this.character.character); + + this.updateDetails(); + } + + + updateOnlineStatus(): void { + this.onlineCharacter = core.characters.get(this.characterName!); + + if (!this.onlineCharacter) { + this.statusClasses = undefined; + return; + } + + this.statusClasses = getStatusClasses(this.onlineCharacter, undefined, true, true, false); + } + + + updateAdStatus(): void { + const cache = core.cache.adCache.get(this.characterName!); + + if ((!cache) || (cache.posts.length === 0)) { + this.latestAd = undefined; + return; + } + + this.latestAd = cache.posts[cache.posts.length - 1]; + } + + + updateDetails(): void { + if (!this.match) { + this.age = undefined; + this.species = undefined; + this.gender = undefined; + this.furryPref = undefined; + this.subDomRole = undefined; + this.sexualOrientation = undefined; + return; + } + + const a = this.match.them.yourAnalysis; + const c = this.match.them.you; + + const rawSpecies = Matcher.getTagValue(TagId.Species, c); + const rawAge = Matcher.getTagValue(TagId.Age, c); + + if ((a.species) && (!Species[a.species])) { + console.log('SPECIES', a.species, rawSpecies); + } + + this.age = a.age ? this.readable(`${a.age}`) : (rawAge && rawAge.string) || undefined; + this.species = a.species ? this.readable(Species[a.species]) : (rawSpecies && rawSpecies.string) || undefined; + this.gender = a.gender ? this.readable(Gender[a.gender]) : undefined; + this.furryPref = a.furryPreference ? this.readable(furryPreferenceMapping[a.furryPreference]) : undefined; + this.subDomRole = a.subDomRole ? this.readable(SubDomRole[a.subDomRole]) : undefined; + this.sexualOrientation = a.orientation ? this.readable(Orientation[a.orientation]) : undefined; + } + + readable(s: string): string { + return s.replace(/([A-Z])/g, ' $1').trim().toLowerCase() + .replace(/(always|usually) (submissive|dominant)/, '$2') + .replace(/bi (fe)?male preference/, 'bisexual'); + } + + byScore(_tagId: any): string { + return ''; + + // too much + // if (!this.match) { + // return ''; + // } + // + // const score = this.match.merged[tagId]; + // + // if (!score) { + // return ''; + // } + // + // return score.getRecommendedClass(); + } + + + getOnlineStatus(): string { + if (!this.onlineCharacter) { + return 'Offline'; + } + + const s = this.onlineCharacter.status as string; + + return `${s.substr(0, 1).toUpperCase()}${s.substr(1)}`; + } + + + async getCharacterData(characterName: string): Promise<ComplexCharacter> { + const cache = await core.cache.profileCache.get(characterName); + + if (cache) { + return cache.character; + } + + return methods.characterData(characterName, this.id, false); + } +} +</script> + +<style lang="scss"> + .character-preview { + padding: 10px; + background-color: var(--input-bg); + + .summary { + font-size: 125%; + + .uc { + display: inline-block; + + &::first-letter { + text-transform: capitalize; + } + } + + .match { + background-color: var(--scoreMatchBg); + border: solid 1px var(--scoreMatchFg); + } + + .weak-match { + background-color: var(--scoreWeakMatchBg); + border: 1px solid var(--scoreWeakMatchFg); + } + + .weak-mismatch { + background-color: var(--scoreWeakMismatchBg); + border: 1px solid var(--scoreWeakMismatchFg); + } + + .mismatch { + background-color: var(--scoreMismatchBg); + border: 1px solid var(--scoreMismatchFg); + } + } + + .matched-tags { + margin-top: 1rem; + } + + h1 { + line-height: 100%; + margin-bottom: 0; + font-size: 2em; + } + + h3 { + font-size: 1.1rem; + color: var(--dark); + } + + h4 { + font-size: 1rem; + margin-top: 1rem; + + .message-time { + font-size: 80%; + font-weight: normal; + color: var(--messageTimeFgColor); + margin-left: 2px; + } + } + + .character-avatar { + width: 100%; + height: auto; + } + } +</style> diff --git a/chat/preview/ImagePreview.vue b/chat/preview/ImagePreview.vue index 125ac5c..fde29a1 100644 --- a/chat/preview/ImagePreview.vue +++ b/chat/preview/ImagePreview.vue @@ -21,15 +21,20 @@ id="image-preview-ext" ref="imagePreviewExt" class="image-preview-external" - :style="externalPreviewStyle"> + :style="previewStyles.ExternalImagePreviewHelper"> </webview> <div class="image-preview-local" - :style="localPreviewStyle" + :style="previewStyles.LocalImagePreviewHelper" > </div> + <character-preview + :style="previewStyles.CharacterPreviewHelper" + ref="characterPreview" + ></character-preview> + <i id="preview-spinner" class="fas fa-circle-notch fa-spin" v-show="shouldShowSpinner"></i> <i id="preview-error" class="fas fa-times" v-show="shouldShowError"></i> </div> @@ -44,12 +49,17 @@ import {domain} from '../../bbcode/core'; import {ImageDomMutator} from './image-dom-mutator'; - import { ExternalImagePreviewHelper, LocalImagePreviewHelper } from './helper'; + import { + ExternalImagePreviewHelper, + LocalImagePreviewHelper, + PreviewManager, + CharacterPreviewHelper, RenderStyle + } from './helper'; import {Point, WebviewTag, remote} from 'electron'; import Timer = NodeJS.Timer; import IpcMessageEvent = Electron.IpcMessageEvent; - + import CharacterPreview from './CharacterPreview.vue'; const screen = remote.screen; @@ -63,15 +73,30 @@ httpStatusText: string; } - - @Component + @Component({ + components: { + 'character-preview': CharacterPreview + } + }) export default class ImagePreview extends Vue { private readonly MinTimePreviewVisible = 100; visible = false; - externalPreviewHelper = new ExternalImagePreviewHelper(this); - localPreviewHelper = new LocalImagePreviewHelper(this); + previewManager = new PreviewManager( + this, + [ + new ExternalImagePreviewHelper(this), + new LocalImagePreviewHelper(this), + new CharacterPreviewHelper(this) + // new ChannelPreviewHelper(this) + ] + ); + + // externalPreviewHelper = new ExternalImagePreviewHelper(this); + // localPreviewHelper = new LocalImagePreviewHelper(this); + // externalPreviewStyle: Record<string, any> = {}; + // localPreviewStyle: Record<string, any> = {}; url: string | null = null; domain: string | undefined; @@ -82,15 +107,11 @@ jsMutator = new ImageDomMutator(this.debug); - externalPreviewStyle: Record<string, any> = {}; - localPreviewStyle: Record<string, any> = {}; - state = 'hidden'; shouldShowSpinner = false; shouldShowError = true; - private interval: Timer | null = null; private exitInterval: Timer | null = null; @@ -100,6 +121,9 @@ private shouldDismiss = false; private visibleSince = 0; + previewStyles: Record<string, RenderStyle> = {}; + + @Hook('mounted') onMounted(): void { console.warn('Mounted ImagePreview'); @@ -299,43 +323,33 @@ reRenderStyles(): void { - // tslint:disable-next-line:no-unsafe-any - this.externalPreviewStyle = this.externalPreviewHelper.renderStyle(); - // tslint:disable-next-line:no-unsafe-any - this.localPreviewStyle = this.localPreviewHelper.renderStyle(); - - this.debugLog( - 'ImagePreview: reRenderStyles', 'external', - JSON.parse(JSON.stringify(this.externalPreviewStyle)), - 'local', JSON.parse(JSON.stringify(this.localPreviewStyle)) - ); + this.previewStyles = this.previewManager.renderStyles(); } updatePreviewSize(width: number, height: number): void { - if (!this.externalPreviewHelper.isVisible()) { - return; + const helper = this.previewManager.getVisiblePreview(); + + if ((!helper) || (!helper.reactsToSizeUpdates())) { + return; } if ((width) && (height)) { this.debugLog('ImagePreview: updatePreviewSize', width, height, width / height); - this.externalPreviewHelper.setRatio(width / height); + helper.setRatio(width / height); this.reRenderStyles(); } } hide(): void { - this.debugLog('ImagePreview: hide', this.externalPreviewHelper.isVisible(), this.localPreviewHelper.isVisible()); - this.cancelExitTimer(); this.url = null; this.visible = false; - this.localPreviewHelper.hide(); - this.externalPreviewHelper.hide(); + this.previewManager.hide(); this.exitUrl = null; this.exitInterval = null; @@ -378,7 +392,7 @@ if ((!this.hasMouseMovedSince()) && (!force)) return; - this.debugLog('ImagePreview: dismiss.exec', this.externalPreviewHelper.isVisible(), this.localPreviewHelper.isVisible(), url); + this.debugLog('ImagePreview: dismiss.exec', this.previewManager.getVisibilityStatus(), url); // This timeout makes the preview window disappear with a slight delay, which helps UX // when dealing with situations such as quickly scrolling text that moves the cursor away @@ -393,7 +407,7 @@ show(initialUrl: string): void { const url = this.jsMutator.mutateUrl(initialUrl); - this.debugLog('ImagePreview: show', this.externalPreviewHelper.isVisible(), this.localPreviewHelper.isVisible(), + this.debugLog('ImagePreview: show', this.previewManager.getVisibilityStatus(), this.visible, this.hasMouseMovedSince(), !!this.interval, this.sticky, url); // console.log('SHOW'); @@ -430,15 +444,7 @@ () => { this.debugLog('ImagePreview: show.timeout', this.url); - const isLocal = this.localPreviewHelper.match(this.domain as string); - - isLocal - ? this.localPreviewHelper.show(this.url as string) - : this.localPreviewHelper.hide(); - - this.externalPreviewHelper.match(this.domain as string) - ? this.externalPreviewHelper.show(this.url as string) - : this.externalPreviewHelper.hide(); + const helper = this.previewManager.show(this.url || undefined, this.domain); this.interval = null; this.visible = true; @@ -449,7 +455,11 @@ this.reRenderStyles(); - this.setState(isLocal ? 'loaded' : 'loading'); + if (helper) { + this.setState(helper.shouldTrackLoading() ? 'loading' : 'loaded'); + } else { + this.setState('loaded'); + } }, due ) as Timer; @@ -504,8 +514,7 @@ this.debug = !this.debug; this.jsMutator.setDebug(this.debug); - this.localPreviewHelper.setDebug(this.debug); - this.externalPreviewHelper.setDebug(this.debug); + this.previewManager.setDebug(this.debug); if (this.debug) { const webview = this.getWebview(); @@ -550,26 +559,44 @@ this.hide(); } + toggleJsMode(): void { this.runJs = !this.runJs; } - reloadUrl(): void { - if (this.externalPreviewHelper.isVisible()) { - const webview = this.getWebview(); - webview.reload(); + reloadUrl(): void { + const helper = this.previewManager.getVisiblePreview(); + + if ((!helper) || (!helper.usesWebView())) { + return; } + + // helper.reload(); + this.getWebview().reload(); } + getWebview(): WebviewTag { return this.$refs.imagePreviewExt as WebviewTag; } + getCharacterPreview(): CharacterPreview { + return this.$refs.characterPreview as CharacterPreview; + } + + reset(): void { - this.externalPreviewHelper = new ExternalImagePreviewHelper(this); - this.localPreviewHelper = new LocalImagePreviewHelper(this); + this.previewManager = new PreviewManager( + this, + [ + new ExternalImagePreviewHelper(this), + new LocalImagePreviewHelper(this), + new CharacterPreviewHelper(this) + // new ChannelPreviewHelper(this) + ] + ); this.url = null; this.domain = undefined; @@ -612,8 +639,15 @@ : false; } + testError(): boolean { - return ((this.state === 'error') && (this.externalPreviewHelper.isVisible())); + const helper = this.previewManager.getVisiblePreview(); + + if ((!helper) || (!helper.usesWebView())) { + return false; + } + + return (this.state === 'error'); } } </script> @@ -693,6 +727,7 @@ border: 1px solid rgba(255, 255, 255, 0.3); padding: 0.5rem; box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.2); + z-index: 1000; a i.fa { font-size: 1.25rem; diff --git a/chat/preview/MatchTags.vue b/chat/preview/MatchTags.vue new file mode 100644 index 0000000..db329e6 --- /dev/null +++ b/chat/preview/MatchTags.vue @@ -0,0 +1,80 @@ +<template> + <div class="matched-tags"> + <span v-for="(score, key) in merged" :class="score.getRecommendedClass()"><i :class="score.getRecommendedIcon()"></i> {{getTagDesc(key)}}</span> + </div> +</template> + +<script lang="ts"> +import { Component, Hook, Prop } from '@f-list/vue-ts'; +import Vue from 'vue'; +import { MatchReport, MatchResultScores } from '../../learn/matcher'; +import { TagId } from '../../learn/matcher-types'; + +@Component({ + components: { + + } +}) +export default class MatchTags extends Vue { + @Prop({required: true}) + readonly match!: MatchReport; + + merged!: MatchResultScores; + + + @Hook('mounted') + onMounted(): void { + this.merged = this.match.merged; + } + + + // @Watch('match', { deep: true }) + // onMatchUpdate(match: MatchReport): void { + // // console.log('ON UPDATED ETA', match); + // this.merged = match.merged; + // } + + + getTagDesc(key: any): any { + return TagId[key].toString().replace(/([A-Z])/g, ' $1').trim(); + } +} +</script> + +<style lang="scss"> +.matched-tags { + span { + padding-left: 3px; + padding-right: 3px; + margin-bottom: 3px; + margin-right: 3px; + display: inline-block; + border: 1px solid; + border-radius: 3px; + + i { + color: white; + } + + &.match { + background-color: var(--scoreMatchBg); + border: solid 1px var(--scoreMatchFg); + } + + &.weak-match { + background-color: var(--scoreWeakMatchBg); + border: 1px solid var(--scoreWeakMatchFg); + } + + &.weak-mismatch { + background-color: var(--scoreWeakMismatchBg); + border: 1px solid var(--scoreWeakMismatchFg); + } + + &.mismatch { + background-color: var(--scoreMismatchBg); + border: 1px solid var(--scoreMismatchFg); + } + } +} +</style> diff --git a/chat/preview/helper/character.ts b/chat/preview/helper/character.ts new file mode 100644 index 0000000..4ba3cba --- /dev/null +++ b/chat/preview/helper/character.ts @@ -0,0 +1,68 @@ +import { ImagePreviewHelper } from './helper'; + +export class CharacterPreviewHelper extends ImagePreviewHelper { + static readonly FLIST_CHARACTER_PROTOCOL_TESTER = /^flist-character:\/\/(.+)/; + + hide(): void { + this.visible = false; + this.url = undefined; + } + + + show(url: string | undefined): void { + this.visible = true; + this.url = url; + + if (!url) { + return; + } + + const match = url.match(CharacterPreviewHelper.FLIST_CHARACTER_PROTOCOL_TESTER); + + if (!match) { + return; + } + + const characterName = match[1]; + + // tslint:disable-next-line no-floating-promises + this.parent.getCharacterPreview().load(characterName); + } + + + setRatio(_ratio: number): void { + // do nothing + } + + + reactsToSizeUpdates(): boolean { + return false; + } + + + shouldTrackLoading(): boolean { + return false; + } + + + usesWebView(): boolean { + return false; + } + + + match(_domainName: string | undefined, url: string | undefined): boolean { + if (!url) { + return false; + } + + return CharacterPreviewHelper.FLIST_CHARACTER_PROTOCOL_TESTER.test(url); + } + + + renderStyle(): Record<string, any> { + return this.isVisible() + ? { display: 'block' } + : { display: 'none' }; + } +} + diff --git a/chat/preview/helper/external.ts b/chat/preview/helper/external.ts index 3acad17..2f92663 100644 --- a/chat/preview/helper/external.ts +++ b/chat/preview/helper/external.ts @@ -3,7 +3,7 @@ import { ImagePreviewHelper } from './helper'; import * as _ from 'lodash'; export class ExternalImagePreviewHelper extends ImagePreviewHelper { - protected lastExternalUrl: string | null = null; + protected lastExternalUrl: string | undefined = undefined; protected allowCachedUrl = true; @@ -47,6 +47,21 @@ export class ExternalImagePreviewHelper extends ImagePreviewHelper { } + reactsToSizeUpdates(): boolean { + return true; + } + + + shouldTrackLoading(): boolean { + return true; + } + + + usesWebView(): boolean { + return true; + } + + setDebug(debug: boolean): void { this.debug = debug; @@ -54,7 +69,7 @@ export class ExternalImagePreviewHelper extends ImagePreviewHelper { } - show(url: string): void { + show(url: string | undefined): void { const webview = this.parent.getWebview(); if (!this.parent) { @@ -65,6 +80,10 @@ export class ExternalImagePreviewHelper extends ImagePreviewHelper { throw new Error('Empty webview!'); } + if (!url) { + throw new Error('Empty URL!'); + } + // const oldUrl = this.url; // const oldLastExternalUrl = this.lastExternalUrl; @@ -113,8 +132,13 @@ export class ExternalImagePreviewHelper extends ImagePreviewHelper { } - match(domainName: string): boolean { - return !((domainName === 'f-list.net') || (domainName === 'static.f-list.net')); + match(domainName: string | undefined, url: string | undefined): boolean { + if ((!domainName) || (!url)) { + return false; + } + + return (ImagePreviewHelper.HTTP_TESTER.test(url)) + && (!((domainName === 'f-list.net') || (domainName === 'static.f-list.net'))); } @@ -152,6 +176,7 @@ export class ExternalImagePreviewHelper extends ImagePreviewHelper { } } + renderStyle(): Record<string, any> { return this.isVisible() ? _.merge({ display: 'flex' }, this.determineScalingRatio()) diff --git a/chat/preview/helper/helper.ts b/chat/preview/helper/helper.ts index 74de924..66f9a24 100644 --- a/chat/preview/helper/helper.ts +++ b/chat/preview/helper/helper.ts @@ -1,16 +1,23 @@ import ImagePreview from '../ImagePreview.vue'; export abstract class ImagePreviewHelper { + static readonly HTTP_TESTER = /^https?:\/\//; + protected visible = false; - protected url: string | null = 'about:blank'; + protected url: string | undefined = 'about:blank'; protected parent: ImagePreview; protected debug: boolean; - abstract show(url: string): void; + abstract show(url: string | undefined): void; abstract hide(): void; - abstract match(domainName: string): boolean; + abstract match(domainName: string | undefined, url: string | undefined): boolean; abstract renderStyle(): Record<string, any>; + abstract reactsToSizeUpdates(): boolean; + abstract setRatio(ratio: number): void; + abstract shouldTrackLoading(): boolean; + abstract usesWebView(): boolean; + constructor(parent: ImagePreview) { if (!parent) { throw new Error('Empty parent!'); @@ -24,7 +31,7 @@ export abstract class ImagePreviewHelper { return this.visible; } - getUrl(): string | null { + getUrl(): string | undefined { return this.url; } diff --git a/chat/preview/helper/index.ts b/chat/preview/helper/index.ts index e70dad5..588b76f 100644 --- a/chat/preview/helper/index.ts +++ b/chat/preview/helper/index.ts @@ -1,4 +1,6 @@ -export * from './helper'; +export * from './character'; export * from './external'; +export * from './helper'; export * from './local'; +export * from './manager'; diff --git a/chat/preview/helper/local.ts b/chat/preview/helper/local.ts index c0e4d0f..e3b2622 100644 --- a/chat/preview/helper/local.ts +++ b/chat/preview/helper/local.ts @@ -1,20 +1,46 @@ import { ImagePreviewHelper } from './helper'; + export class LocalImagePreviewHelper extends ImagePreviewHelper { hide(): void { this.visible = false; - this.url = null; + this.url = undefined; } - show(url: string): void { + show(url: string | undefined): void { this.visible = true; this.url = url; } - match(domainName: string): boolean { - return ((domainName === 'f-list.net') || (domainName === 'static.f-list.net')); + setRatio(_ratio: number): void { + // do nothing + } + + + reactsToSizeUpdates(): boolean { + return false; + } + + + shouldTrackLoading(): boolean { + return false; + } + + + usesWebView(): boolean { + return false; + } + + + match(domainName: string | undefined, url: string | undefined): boolean { + if ((!domainName) || (!url)) { + return false; + } + + return (ImagePreviewHelper.HTTP_TESTER.test(url)) + && ((domainName === 'f-list.net') || (domainName === 'static.f-list.net')); } diff --git a/chat/preview/helper/manager.ts b/chat/preview/helper/manager.ts new file mode 100644 index 0000000..b16fd47 --- /dev/null +++ b/chat/preview/helper/manager.ts @@ -0,0 +1,107 @@ +import _ from 'lodash'; +import { ImagePreviewHelper } from './helper'; +import ImagePreview from '../ImagePreview.vue'; + +export type RenderStyle = Record<string, any>; + +export interface PreviewManagerHelper { + helper: ImagePreviewHelper; + renderStyle: RenderStyle; +} + + +export class PreviewManager { + private parent: ImagePreview; + + private helpers: PreviewManagerHelper[]; + + private debugMode = false; + + constructor(parent: ImagePreview, helperInstances: ImagePreviewHelper[]) { + this.parent = parent; + this.helpers = _.map(helperInstances, (helper) => ({ helper, renderStyle: {}})); + } + + match(domain: string | undefined, url: string | undefined): PreviewManagerHelper | undefined { + return _.find(this.helpers, (h) => h.helper.match(domain, url)); + } + + matchIndex(domain: string | undefined, url: string | undefined): number { + return _.findIndex(this.helpers, (h) => h.helper.match(domain, url)); + } + + renderStyles(): Record<string, RenderStyle> { + _.each( + this.helpers, + (h) => { + h.renderStyle = h.helper.renderStyle(); + + this.debugLog('ImagePreview: pm.renderStyles()', h.helper.constructor.name, JSON.parse(JSON.stringify(h.renderStyle))); + } + ); + + return _.fromPairs( + _.map( + this.helpers, (h) => ([h.helper.constructor.name, h.renderStyle]) + ) + ); + } + + getVisiblePreview(): ImagePreviewHelper | undefined { + const found = _.find(this.helpers, (h) => h.helper.isVisible()); + + return found ? found.helper : undefined; + } + + + show(url: string | undefined, domain: string | undefined): ImagePreviewHelper | undefined { + const matchedHelper = this.match(domain, url); + + _.each( + _.filter(this.helpers, (h) => (h !== matchedHelper)), + (h) => h.helper.hide() + ); + + if (!matchedHelper) { + this.debugLog('ImagePreview: pm.show()', 'Unmatched helper', url, domain); + return undefined; + } + + matchedHelper.helper.show(url); + return matchedHelper.helper; + } + + + hide(): void { + _.each( + this.helpers, + (h) => { + this.debugLog('ImagePreview: pm.hide()', h.helper.constructor.name, h.helper.isVisible()); + h.helper.hide(); + } + ); + } + + + getVisibilityStatus(): Record<string, boolean> { + return _.fromPairs( + _.map( + this.helpers, (h) => [h.helper.constructor.name, h.helper.isVisible()] + ) + ); + } + + + setDebug(debugMode: boolean): void { + _.each(this.helpers, (h) => h.helper.setDebug(debugMode)); + + this.debugMode = debugMode; + } + + + debugLog(...messages: any[]): void { + if (this.debugMode) { + this.parent.debugLog(...messages); + } + } +} diff --git a/learn/matcher-types.ts b/learn/matcher-types.ts index f0a6630..d184aa8 100644 --- a/learn/matcher-types.ts +++ b/learn/matcher-types.ts @@ -92,6 +92,15 @@ export enum FurryPreference { FurriesPreferredHumansOk = 149 } +export const furryPreferenceMapping = { + [FurryPreference.FurriesOnly]: 'furries only', + [FurryPreference.FursAndHumans]: 'loves furries and humans', + [FurryPreference.HumansOnly]: 'humans only', + [FurryPreference.HumansPreferredFurriesOk]: 'loves humans, likes furries', + [FurryPreference.FurriesPreferredHumansOk]: 'loves furries, likes humans' +}; + + export interface GenderKinkIdMap { [key: number]: Kink } diff --git a/site/character_page/sidebar.vue b/site/character_page/sidebar.vue index 4fed9c6..a6ef1f0 100644 --- a/site/character_page/sidebar.vue +++ b/site/character_page/sidebar.vue @@ -1,13 +1,12 @@ <template> <div id="character-page-sidebar" class="card bg-light"> - <div class="card-header"> - <span class="character-name">{{ character.character.name }}</span> + <div class="card-body"> + <img :src="avatarUrl(character.character.name)" class="character-avatar" style="margin-right:10px"> + <div v-if="character.character.title" class="character-title">{{ character.character.title }}</div> <character-action-menu :character="character" @rename="showRename()" @delete="showDelete()" @block="showBlock()"></character-action-menu> - </div> - <div class="card-body"> - <img :src="avatarUrl(character.character.name)" class="character-avatar" style="margin-right:10px"> + <div v-if="authenticated" class="d-flex justify-content-between flex-wrap character-links-block"> <template v-if="character.is_self"> <a :href="editUrl" class="edit-link"><i class="fa fa-fw fa-pencil-alt"></i>Edit</a>