From 6183acc30a13140115e38d5efa926c9032b0b1c7 Mon Sep 17 00:00:00 2001
From: "Mr. Stallion" <mrstallion@nobody.nowhere.fauxdomain.ext>
Date: Mon, 15 Jul 2019 11:59:16 -0500
Subject: [PATCH] Charater rating badge on private chats

---
 chat/AdView.vue              |   5 +-
 chat/CharacterSearch.vue     |   2 +-
 chat/ChatView.vue            |  21 +++-
 chat/ConversationView.vue    |  55 ++++++++-
 chat/PmPartnerAdder.vue      |  40 +++++++
 chat/RecentConversations.vue |   2 +-
 chat/StatusSwitcher.vue      |   2 +-
 chat/UserChannelList.vue     |  76 ++++++++++++
 chat/UserList.vue            |   2 +-
 chat/UserMenu.vue            |  31 ++++-
 chat/UserView.vue            | 219 +++++++++++++++++++++++++++++++++++
 chat/bbcode.ts               |   2 +-
 chat/event-bus.ts            |   1 +
 chat/message_view.ts         |   2 +-
 chat/user_view.ts            |  58 ----------
 learn/cache-manager.ts       |  16 ++-
 learn/profile-cache.ts       |  11 ++
 readme.md                    |   9 +-
 18 files changed, 475 insertions(+), 79 deletions(-)
 create mode 100644 chat/PmPartnerAdder.vue
 create mode 100644 chat/UserChannelList.vue
 create mode 100644 chat/UserView.vue
 delete mode 100644 chat/user_view.ts

diff --git a/chat/AdView.vue b/chat/AdView.vue
index ab4ff11..4b5db58 100644
--- a/chat/AdView.vue
+++ b/chat/AdView.vue
@@ -1,7 +1,7 @@
 <template>
    <modal :buttons="false" ref="dialog" @open="onOpen" @close="onClose" style="width:98%" dialogClass="ads-dialog">
         <template slot="title">
-            Channel Ads for {{character.name}}
+            Channel Ads for <user :character="character">{{character.name}}</user>
         </template>
 
        <div class="row ad-viewer" ref="pageBody">
@@ -25,9 +25,10 @@ import { Character } from '../fchat/interfaces';
 import { AdCachedPosting } from '../learn/ad-cache';
 import core from './core';
 import {formatTime} from './common';
+import UserView from './UserView.vue';
 
 @Component({
-    components: {modal: Modal}
+    components: {modal: Modal, user: UserView}
 })
 export default class AdView extends CustomDialog {
     @Prop({required: true})
diff --git a/chat/CharacterSearch.vue b/chat/CharacterSearch.vue
index 303785b..7b3f29b 100644
--- a/chat/CharacterSearch.vue
+++ b/chat/CharacterSearch.vue
@@ -36,7 +36,7 @@
     import core from './core';
     import {Character, Connection} from './interfaces';
     import l from './localize';
-    import UserView from './user_view';
+    import UserView from './UserView.vue';
 
     type Options = {
         kinks: Kink[],
diff --git a/chat/ChatView.vue b/chat/ChatView.vue
index 722d75c..ef041ab 100644
--- a/chat/ChatView.vue
+++ b/chat/ChatView.vue
@@ -25,7 +25,11 @@
                     {{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"
@@ -91,6 +95,7 @@
         <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>
     </div>
 </template>
 
@@ -107,12 +112,13 @@
     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 './user_view';
+    import {getStatusIcon} from './UserView.vue';
     import UserList from './UserList.vue';
     import UserMenu from './UserMenu.vue';
     import ImagePreview from './ImagePreview.vue';
@@ -128,7 +134,8 @@
             '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
+            'image-preview': ImagePreview,
+            'add-pm-partner': PmPartnerAdder
         }
     })
     export default class ChatView extends Vue {
@@ -294,6 +301,10 @@
             (<StatusSwitcher>this.$refs['statusDialog']).show();
         }
 
+        showAddPmPartner(): void {
+            (<PmPartnerAdder>this.$refs['addPmPartnerDialog']).show();
+        }
+
         userMenuHandle(e: MouseEvent | TouchEvent): void {
             (<UserMenu>this.$refs['userMenu']).handleEvent(e);
         }
@@ -329,6 +340,12 @@
         user-select: text;
     }
 
+    .pm-add {
+        font-size: 90%;
+        float: right;
+        margin-right: 5px;
+    }
+
     .list-group.conversation-nav {
         margin-bottom: 10px;
         .list-group-item {
diff --git a/chat/ConversationView.vue b/chat/ConversationView.vue
index a945409..5846c82 100644
--- a/chat/ConversationView.vue
+++ b/chat/ConversationView.vue
@@ -4,7 +4,7 @@
             <img :src="characterImage" style="height:60px;width:60px;margin-right:10px" v-if="settings.showAvatars"/>
             <div style="flex:1;position:relative;display:flex;flex-direction:column">
                 <div>
-                    <user :character="conversation.character"></user>
+                    <user :character="conversation.character" :match="true"></user>
                     <a href="#" @click.prevent="showLogs()" class="btn">
                         <span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
                     </a>
@@ -17,6 +17,10 @@
                     <a href="#" @click.prevent="showAds()" class="btn">
                         <span class="fa fa-ad"></span><span class="btn-text">Ads</span>
                     </a>
+
+                    <a href="#" @click.prevent="showChannels()" class="btn">
+                        <span class="fa fa-tv"></span><span class="btn-text">Channels</span>
+                    </a>
                 </div>
                 <div style="overflow:auto;max-height:50px">
                     {{l('status.' + conversation.character.status)}}
@@ -140,7 +144,8 @@
         <settings ref="settingsDialog" :conversation="conversation"></settings>
         <logs ref="logsDialog" :conversation="conversation"></logs>
         <manage-channel ref="manageDialog" v-if="isChannel(conversation)" :channel="conversation.channel"></manage-channel>
-        <ad-view ref="adViewer" v-if="isPrivate(conversation)" :character="conversation.character"></ad-view>
+        <ad-view ref="adViewer" v-if="isPrivate(conversation) && conversation.character" :character="conversation.character"></ad-view>
+        <channel-list ref="channelList" v-if="isPrivate(conversation)" :character="conversation.character"></channel-list>
     </div>
 </template>
 
@@ -163,13 +168,14 @@
     import MessageView from './message_view';
     import ReportDialog from './ReportDialog.vue';
     import {isCommand} from './slash_commands';
-    import UserView from './user_view';
+    import UserView from './UserView.vue';
+    import UserChannelList from './UserChannelList.vue';
 
     @Component({
         components: {
             user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, settings: ConversationSettings,
             logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp,
-            'ad-view': AdView
+            'ad-view': AdView, 'channel-list': UserChannelList
         }
     })
     export default class ConversationView extends Vue {
@@ -411,6 +417,10 @@
             (<AdView>this.$refs['adViewer']).show();
         }
 
+        showChannels(): void {
+            (<UserChannelList>this.$refs['channelList']).show();
+        }
+
 
         isAutopostingAds(): boolean {
             return this.conversation.adManager.isActive();
@@ -635,6 +645,43 @@
         }
     }
 
+
+    .user-view {
+        .match-found {
+            margin-left: 3px;
+            padding-left: 2px;
+            padding-right: 2px;
+            border-radius: 3px;
+            color: rgba(255, 255, 255, 0.8);
+            font-size: 75%;
+            padding-top: 0;
+            padding-bottom: 0;
+            text-align: center;
+            display: inline-block;
+            text-transform: uppercase;
+
+            &.match {
+                background-color: rgb(0, 142, 0);
+                border: solid 1px rgb(0, 113, 0);
+            }
+
+            &.weak-match {
+                background-color: rgb(0, 80, 0);
+                border: 1px solid rgb(0, 64, 0);
+            }
+
+            &.weak-mismatch {
+                background-color: rgb(152, 134, 0);
+                border: 1px solid rgb(142, 126, 0);
+            }
+
+            &.mismatch {
+                background-color: rgb(171, 0, 0);
+                border: 1px solid rgb(128, 0, 0);
+            }
+        }
+    }
+
     .message {
         &.message-event {
             font-size: 85%;
diff --git a/chat/PmPartnerAdder.vue b/chat/PmPartnerAdder.vue
new file mode 100644
index 0000000..c6aacc7
--- /dev/null
+++ b/chat/PmPartnerAdder.vue
@@ -0,0 +1,40 @@
+<template>
+   <modal action="Open Conversation" ref="dialog" @submit="submit" style="width:98%" dialogClass="ads-dialog" buttonText="Open">
+        <div>
+            <input type="text" id="name" v-model="name" placeholder="Name" />
+            <div class="error" v-if="error">{{error}}</div>
+        </div>
+
+   </modal>
+</template>
+
+
+<script lang="ts">
+import { Component } from '@f-list/vue-ts';
+import CustomDialog from '../components/custom_dialog';
+import Modal from '../components/Modal.vue';
+import core from './core';
+
+@Component({
+    components: {modal: Modal}
+})
+export default class PmPartnerAdder extends CustomDialog {
+    name = '';
+    error: string | null = null;
+
+    submit(): void {
+        const c = core.characters.get(this.name);
+
+        if (c) {
+            const conversation = core.conversations.getPrivate(c);
+
+            conversation.show();
+
+            this.name = '';
+            this.error = '';
+        } else {
+            this.error = `Unknown character '${this.name}'`;
+        }
+    }
+}
+</script>
diff --git a/chat/RecentConversations.vue b/chat/RecentConversations.vue
index f6f2bb3..be98bc3 100644
--- a/chat/RecentConversations.vue
+++ b/chat/RecentConversations.vue
@@ -24,7 +24,7 @@
     import core from './core';
     import {Character, Conversation} from './interfaces';
     import l from './localize';
-    import UserView from './user_view';
+    import UserView from './UserView.vue';
 
     @Component({
         components: {'user-view': UserView, 'channel-view': ChannelView, modal: Modal, tabs: Tabs}
diff --git a/chat/StatusSwitcher.vue b/chat/StatusSwitcher.vue
index 9bed284..d36d53e 100644
--- a/chat/StatusSwitcher.vue
+++ b/chat/StatusSwitcher.vue
@@ -30,7 +30,7 @@
     import core from './core';
     import {Character, userStatuses} from './interfaces';
     import l from './localize';
-    import {getStatusIcon} from './user_view';
+    import {getStatusIcon} from './UserView.vue';
 
     @Component({
         components: {modal: Modal, editor: Editor, dropdown: Dropdown}
diff --git a/chat/UserChannelList.vue b/chat/UserChannelList.vue
new file mode 100644
index 0000000..d0c2ea6
--- /dev/null
+++ b/chat/UserChannelList.vue
@@ -0,0 +1,76 @@
+<template>
+   <modal :buttons="false" ref="dialog" style="width:98%" dialogClass="">
+        <template slot="title">
+            Channels for <user :character="character">{{character.name}}</user>
+        </template>
+
+       <div class="user-channel-list" ref="pageBody">
+            <template v-for="channel in channels">
+                <h3><a href="#" @click.prevent="jumpToChannel(channel)">#{{channel.name}}</a></h3>
+            </template>
+        </div>
+
+   </modal>
+</template>
+
+
+<script lang="ts">
+
+import * as _ from 'lodash';
+import { Component, Hook, Prop, Watch } from '@f-list/vue-ts';
+import CustomDialog from '../components/custom_dialog';
+import Modal from '../components/Modal.vue';
+import { Character } from '../fchat/interfaces';
+import core from './core';
+import { Conversation } from './interfaces';
+import UserView from './UserView.vue';
+import ChannelConversation = Conversation.ChannelConversation;
+
+@Component({
+    components: {modal: Modal, user: UserView}
+})
+export default class UserChannelList extends CustomDialog {
+    @Prop({required: true})
+    readonly character!: Character;
+
+    channels: ChannelConversation[] = [];
+
+    @Watch('character')
+    onNameUpdate(): void {
+        this.update();
+    }
+
+
+    @Hook('mounted')
+    onMounted(): void {
+        this.update();
+    }
+
+    update(): void {
+        if (!this.character) {
+            this.channels = [];
+            return;
+        }
+
+        this.channels = _.sortBy(
+            _.filter(
+                core.conversations.channelConversations,
+                (cc: ChannelConversation) => !!cc.channel.members[this.character.name]
+            ),
+            'name'
+        );
+    }
+
+    jumpToChannel(channel: ChannelConversation): void {
+        channel.show();
+    }
+
+}
+</script>
+
+
+<style lang="scss">
+    .user-channel-list h3 {
+        font-size: 120%;
+    }
+</style>
\ No newline at end of file
diff --git a/chat/UserList.vue b/chat/UserList.vue
index f52b652..9ecd8cd 100644
--- a/chat/UserList.vue
+++ b/chat/UserList.vue
@@ -36,7 +36,7 @@
     import {Channel, Character, Conversation} from './interfaces';
     import l from './localize';
     import Sidebar from './Sidebar.vue';
-    import UserView from './user_view';
+    import UserView from './UserView.vue';
 
     @Component({
         components: {user: UserView, sidebar: Sidebar, tabs: Tabs}
diff --git a/chat/UserMenu.vue b/chat/UserMenu.vue
index 8d2bd5d..8cd7d09 100644
--- a/chat/UserMenu.vue
+++ b/chat/UserMenu.vue
@@ -21,6 +21,9 @@
                 <span class="far fa-fw fa-sticky-note"></span>{{l('user.memo')}}</a>
             <a tabindex="-1" href="#" @click.prevent="setBookmarked()" class="list-group-item list-group-item-action">
                 <span class="far fa-fw fa-bookmark"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}</a>
+            <a tabindex="-1" href="#" @click.prevent="showAdLogs()" class="list-group-item list-group-item-action" :class="{ disabled: !hasAdLogs()}">
+                <span class="far fa-fw fa-ad"></span>Show ad log
+            </a>
             <a tabindex="-1" href="#" @click.prevent="setHidden()" class="list-group-item list-group-item-action" v-show="!isChatOp">
                 <span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}</a>
             <a tabindex="-1" href="#" @click.prevent="report()" class="list-group-item list-group-item-action" style="border-top-width:1px">
@@ -36,6 +39,7 @@
             <div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div>
             <textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea>
         </modal>
+        <ad-view ref="adViewDialog" :character="character" v-if="character"></ad-view>
     </div>
 </template>
 
@@ -43,6 +47,7 @@
     import {Component, Prop} from '@f-list/vue-ts';
     import Vue from 'vue';
     import Modal from '../components/Modal.vue';
+    import AdView from './AdView.vue';
     import {BBCodeView} from './bbcode';
     import {characterImage, errorToString, getByteLength, profileLink} from './common';
     import core from './core';
@@ -51,7 +56,7 @@
     import ReportDialog from './ReportDialog.vue';
 
     @Component({
-        components: {bbcode: BBCodeView, modal: Modal}
+        components: {bbcode: BBCodeView, modal: Modal, 'ad-view': AdView}
     })
     export default class UserMenu extends Vue {
         @Prop({required: true})
@@ -120,6 +125,30 @@
                 .catch((e: object) => alert(errorToString(e)));
         }
 
+
+        showAdLogs(): void {
+            if (!this.hasAdLogs()) {
+                return;
+            }
+
+            (<AdView>this.$refs['adViewDialog']).show();
+        }
+
+
+        hasAdLogs(): boolean {
+            if (!this.character) {
+                return false;
+            }
+
+            const cache = core.cache.adCache.get(this.character.name);
+
+            if (!cache) {
+                return false;
+            }
+
+            return (cache.count() > 0);
+        }
+
         get isChannelMod(): boolean {
             if(this.channel === undefined) return false;
             if(core.characters.ownCharacter.isChatOp) return true;
diff --git a/chat/UserView.vue b/chat/UserView.vue
new file mode 100644
index 0000000..e21c29a
--- /dev/null
+++ b/chat/UserView.vue
@@ -0,0 +1,219 @@
+<!-- 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>
+
+
+<script lang="ts">
+import { Component, Hook, Prop } from '@f-list/vue-ts';
+import Vue from 'vue';
+import {Channel, Character} from '../fchat';
+import { Score, Scoring } from '../learn/matcher';
+import core from './core';
+import { EventBus } from './event-bus';
+
+
+export function getStatusIcon(status: Character.Status): string {
+    switch(status) {
+        case 'online':
+            return 'far fa-user';
+        case 'looking':
+            return 'fa fa-eye';
+        case 'dnd':
+            return 'fa fa-minus-circle';
+        case 'offline':
+            return 'fa fa-ban';
+        case 'away':
+            return 'far fa-circle';
+        case 'busy':
+            return 'fa fa-cog';
+        case 'idle':
+            return 'far fa-clock';
+        case 'crown':
+            return 'fa fa-birthday-cake';
+    }
+}
+
+
+@Component({
+    components: {
+
+    }
+})
+export default class UserView extends Vue {
+    @Prop({required: true})
+    readonly character!: Character;
+
+    @Prop()
+    readonly channel?: Channel;
+
+    @Prop()
+    readonly showStatus?: boolean = true;
+
+    @Prop()
+    readonly bookmark?: boolean = false;
+
+    @Prop()
+    readonly match?: boolean = false;
+
+    userClass = '';
+
+    rankIcon: string | null = null;
+    statusClass: string | null = null;
+    matchClass: string | null = null;
+    matchScore: number | null = null;
+
+    // tslint:disable-next-line no-any
+    scoreWatcher: ((event: any) => void) | null = null;
+
+    @Hook('mounted')
+    onMounted(): void {
+        this.update();
+
+        if ((this.match) && (!this.matchClass)) {
+            if (this.scoreWatcher) {
+                EventBus.$off('character-score', this.scoreWatcher);
+            }
+
+            // tslint:disable-next-line no-unsafe-any no-any
+            this.scoreWatcher = (event: any): void => {
+                // tslint:disable-next-line no-unsafe-any no-any
+                if ((event.character) && (event.character.name === this.character.name)) {
+                    this.update();
+
+                    if (this.scoreWatcher) {
+                        EventBus.$off('character-score', this.scoreWatcher);
+
+                        delete this.scoreWatcher;
+                    }
+                }
+            };
+
+            EventBus.$on(
+                'character-score',
+                this.scoreWatcher
+            );
+        }
+    }
+
+    @Hook('beforeDestroy')
+    onBeforeDestroy(): void {
+        if (this.scoreWatcher)
+            EventBus.$off('character-score', this.scoreWatcher);
+    }
+
+
+    @Hook('beforeUpdate')
+    onBeforeUpdate(): void {
+        this.update();
+    }
+
+
+    update(): void {
+        this.rankIcon = null;
+        this.statusClass = null;
+        this.matchClass = null;
+
+        if (this.match) console.log('Update');
+
+        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');
+
+        if (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;
+            } else {
+                core.cache.addProfile(this.character.name);
+            }
+        }
+
+        if (this.match) console.log('Update post match');
+
+        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');
+    }
+
+
+    getMatchScoreTitle(score: number | null): string {
+        switch (score) {
+            case Scoring.MATCH:
+                return 'Great';
+
+            case Scoring.WEAK_MATCH:
+                return 'Good';
+
+            case Scoring.WEAK_MISMATCH:
+                return 'Maybe';
+
+            case Scoring.MISMATCH:
+                return 'No';
+        }
+
+        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;
+
+        let matchClasses: string | undefined;
+
+        if (props.match) {
+            const cache = core.cache.profileCache.getSync(character.name);
+
+            if (cache) {
+                matchClasses = Score.getClasses(cache.matchScore);
+            }
+        }
+
+        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);
+    }
+});
+
+export default UserView;
+*/
+
+</script>
+
diff --git a/chat/bbcode.ts b/chat/bbcode.ts
index 19121bb..17e95f3 100644
--- a/chat/bbcode.ts
+++ b/chat/bbcode.ts
@@ -8,7 +8,7 @@ import {characterImage} from './common';
 import core from './core';
 import {Character} from './interfaces';
 import {default as UrlView} from '../bbcode/UrlTagView.vue';
-import UserView from './user_view';
+import UserView from './UserView.vue';
 
 export const BBCodeView: Component = {
     functional: true,
diff --git a/chat/event-bus.ts b/chat/event-bus.ts
index f9087f9..0eea540 100644
--- a/chat/event-bus.ts
+++ b/chat/event-bus.ts
@@ -9,6 +9,7 @@ import ChannelConversation = Conversation.ChannelConversation;
  * 'imagepreview-show': {url: string}
  * 'imagepreview-toggle-stickyness': {url: string}
  * 'character-data': {character: Character}
+ * 'character-score': {character: Character, score: number}
  * 'private-message': {message: Message}
  * 'channel-ad': {message: Message, channel: Conversation, profile: ComplexCharacter | undefined}
  * 'channel-message': {message: Message, channel: Conversation}
diff --git a/chat/message_view.ts b/chat/message_view.ts
index 6bfee6b..0d47550 100644
--- a/chat/message_view.ts
+++ b/chat/message_view.ts
@@ -6,7 +6,7 @@ import {BBCodeView} from './bbcode';
 import {formatTime} from './common';
 import core from './core';
 import {Conversation} from './interfaces';
-import UserView from './user_view';
+import UserView from './UserView.vue';
 
 const userPostfix: {[key: number]: string | undefined} = {
     [Conversation.Message.Type.Message]: ': ',
diff --git a/chat/user_view.ts b/chat/user_view.ts
deleted file mode 100644
index 75941db..0000000
--- a/chat/user_view.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-//  TODO convert this to single-file once Vue supports it for functional components.
-//template:
-//<span class="gender" :class="genderClass" @click="click" @contextmenu.prevent="showMenu" style="cursor:pointer;" ref="main"><span
-//class="fa" :class="statusIcon"></span> <span class="fa" :class="rankIcon"></span>{{character.name}}</span>
-
-import Vue, {CreateElement, RenderContext, VNode} from 'vue';
-import {Channel, Character} from '../fchat';
-import core from './core';
-
-export function getStatusIcon(status: Character.Status): string {
-    switch(status) {
-        case 'online':
-            return 'far fa-user';
-        case 'looking':
-            return 'fa fa-eye';
-        case 'dnd':
-            return 'fa fa-minus-circle';
-        case 'offline':
-            return 'fa fa-ban';
-        case 'away':
-            return 'far fa-circle';
-        case 'busy':
-            return 'fa fa-cog';
-        case 'idle':
-            return 'far fa-clock';
-        case 'crown':
-            return 'fa fa-birthday-cake';
-    }
-}
-
-//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}>(
-            context !== undefined ? context.props : (<Vue>this).$options.propsData);
-        const character = props.character;
-        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' : ''}`},
-            domProps: {character, channel: props.channel, bbcodeTag: 'user'}
-        }, children);
-    }
-});
-
-export default UserView;
\ No newline at end of file
diff --git a/learn/cache-manager.ts b/learn/cache-manager.ts
index e5d3482..06fc6aa 100644
--- a/learn/cache-manager.ts
+++ b/learn/cache-manager.ts
@@ -3,7 +3,7 @@ import core from '../chat/core';
 import { ChannelAdEvent, ChannelMessageEvent, CharacterDataEvent, EventBus } from '../chat/event-bus';
 import { Conversation } from '../chat/interfaces';
 import { methods } from '../site/character_page/data_store';
-import { Character } from '../site/character_page/interfaces';
+import { Character as ComplexCharacter } from '../site/character_page/interfaces';
 import { Gender } from './matcher';
 import { AdCache } from './ad-cache';
 import { ChannelConversationCache } from './channel-conversation-cache';
@@ -74,7 +74,15 @@ export class CacheManager {
     }
 
 
-    updateAdScoringForProfile(c: Character, score: number): void {
+    updateAdScoringForProfile(c: ComplexCharacter, score: number): void {
+        EventBus.$emit(
+            'character-score',
+            {
+                character: c,
+                score
+            }
+        );
+
         _.each(
             core.conversations.channelConversations,
             (ch: ChannelConversation) => {
@@ -92,7 +100,7 @@ export class CacheManager {
     }
 
 
-    async addProfile(character: string | Character): Promise<void> {
+    async addProfile(character: string | ComplexCharacter): Promise<void> {
         if (typeof character === 'string') {
             // console.log('Learn discover', character);
 
@@ -237,7 +245,7 @@ export class CacheManager {
     }
 
 
-    setProfile(c: Character): void {
+    setProfile(c: ComplexCharacter): void {
         this.characterProfiler = new CharacterProfiler(c, this.adCache);
     }
 }
diff --git a/learn/profile-cache.ts b/learn/profile-cache.ts
index 0c3681e..cbbcefb 100644
--- a/learn/profile-cache.ts
+++ b/learn/profile-cache.ts
@@ -30,6 +30,17 @@ export class ProfileCache extends AsyncCache<CharacterCacheRecord> {
     }
 
 
+    getSync(name: string): CharacterCacheRecord | null {
+        const key = AsyncCache.nameKey(name);
+
+        if (key in this.cache) {
+            return this.cache[key];
+        }
+
+        return null;
+    }
+
+
     async get(name: string, skipStore: boolean = false): Promise<CharacterCacheRecord | null> {
         const key = AsyncCache.nameKey(name);
 
diff --git a/readme.md b/readme.md
index 29d66c7..77ab7d5 100644
--- a/readme.md
+++ b/readme.md
@@ -6,8 +6,8 @@ This repository contains a modified version of the mainline F-Chat 3.0 client.
 ## Key Differences
 
 *   Ads view
-    *    Highlight ads from characters most interesting to you / hide ads from characters clearly incompatible
-    *    View recent ads from a character on any channel to which you subscribe
+    *    Highlight ads from characters most interesting to you
+    *    View a character's recent ads
 *   Ad auto-posting
     *    Manage channel's ad settings via "Tab Settings"
     *    Automatically re-post ads every 11-18 minutes (randomized) for up to 180 minutes
@@ -35,6 +35,11 @@ This repository contains a modified version of the mainline F-Chat 3.0 client.
 *   Improvements to log browsing
 *   Fix broken BBCode, such as `[big]` in character profiles
 *   Which channels my chart partner is on?
+*   Reposition ad settings and toggle
+*   Cache image list, guestbook pages
+*   Bug: Invalid Ticket
+*   Bug: Posting on the same second
+*   Bug: Images tab count is off
 
 
 # F-List Exported