From b0129c75cb9ff17d0821fd8bff6c000df0c282cc Mon Sep 17 00:00:00 2001 From: "Mr. Stallion" Date: Sat, 6 Jul 2019 20:37:15 -0500 Subject: [PATCH] Highlight ad matches --- chat/ConversationView.vue | 47 ++++++ chat/ImagePreview.vue | 28 +++- chat/common.ts | 2 + chat/conversations.ts | 16 +- chat/core.ts | 8 + chat/event-bus.ts | 28 ++++ chat/image-preview-mutator.ts | 12 +- chat/interfaces.ts | 2 + chat/message_view.ts | 43 ++++- chat/profile_api.ts | 8 +- fchat/characters.ts | 25 ++- fchat/interfaces.ts | 4 + learn/ad-cache.ts | 62 +++++++ learn/cache-manager.ts | 218 +++++++++++++++++++++++++ learn/cache.ts | 29 ++++ learn/channel-conversation-cache.ts | 32 ++++ learn/character-profiler.ts | 96 +++++++++++ learn/personal-profiler.ts | 0 learn/profile-cache.ts | 71 ++++++++ readme.md | 2 + site/character_page/character_page.vue | 35 +++- site/character_page/interfaces.ts | 2 +- site/character_page/matcher.ts | 61 ++++++- 23 files changed, 806 insertions(+), 25 deletions(-) create mode 100644 learn/ad-cache.ts create mode 100644 learn/cache-manager.ts create mode 100644 learn/cache.ts create mode 100644 learn/channel-conversation-cache.ts create mode 100644 learn/character-profiler.ts create mode 100644 learn/personal-profiler.ts create mode 100644 learn/profile-cache.ts diff --git a/chat/ConversationView.vue b/chat/ConversationView.vue index 8a12ec7..0bdcc20 100644 --- a/chat/ConversationView.vue +++ b/chat/ConversationView.vue @@ -592,4 +592,51 @@ flex-basis: 100%; } } + + + + .message.message-score { + padding-left: 5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + &.match { + border-left: 12px solid #027b02; + background-color: rgba(1, 76, 1, 0.45); + } + + &.weak-match { + border-left: 12px solid #015a01; + background-color: rgba(0, 58, 0, 0.35); + } + + &.weak-mismatch { + background-color: rgba(208, 188, 0, 0.0); + border-left: 12px solid rgb(138, 123, 0); + + .bbcode { + filter: grayscale(0.7); + } + + .bbcode, + .user-view, + .message-time { + opacity: 0.4; + } + } + + &.mismatch { + border-left: 12px solid #841a1a; + + .bbcode { + filter: grayscale(0.8); + } + + .bbcode, + .user-view, + .message-time { + opacity: 0.3; + } + } + } + \ No newline at end of file diff --git a/chat/ImagePreview.vue b/chat/ImagePreview.vue index 7220c42..57ffef9 100644 --- a/chat/ImagePreview.vue +++ b/chat/ImagePreview.vue @@ -95,7 +95,7 @@ } ); - const webview = this.$refs.imagePreviewExt as WebviewTag; + const webview = this.getWebview(); webview.addEventListener( 'dom-ready', @@ -179,7 +179,7 @@ this.visible = false; if (this.externalUrlVisible) { - const webview = this.$refs.imagePreviewExt as WebviewTag; + const webview = this.getWebview(); webview.executeJavaScript(this.jsMutator.getHideMutator()); } @@ -263,10 +263,21 @@ this.internalUrlVisible = isInternal; this.externalUrlVisible = !isInternal; - if (isInternal) + if (isInternal) { this.internalUrl = this.url; - else + } else { + const webview = this.getWebview(); + + try { + if (webview.getURL() === this.url) { + webview.executeJavaScript(this.jsMutator.getReShowMutator()); + } + } catch (err) { + console.log('Webview reuse error', err); + } + this.externalUrl = this.url; + } this.visible = true; this.visibleSince = Date.now(); @@ -328,7 +339,7 @@ this.jsMutator.setDebug(this.debug); if (this.debug) { - const webview = this.$refs.imagePreviewExt as WebviewTag; + const webview = this.getWebview(); webview.openDevTools(); } @@ -347,11 +358,16 @@ reloadUrl(): void { if (this.externalUrlVisible) { - const webview = this.$refs.imagePreviewExt as WebviewTag; + const webview = this.getWebview(); webview.reload(); } } + + + getWebview(): WebviewTag { + return this.$refs.imagePreviewExt as WebviewTag; + } } diff --git a/chat/common.ts b/chat/common.ts index 402ce43..1674b03 100644 --- a/chat/common.ts +++ b/chat/common.ts @@ -94,6 +94,8 @@ export class Message implements Conversation.ChatMessage { readonly id = ++messageId; isHighlight = false; + score?: number; + constructor(readonly type: Conversation.Message.Type, readonly sender: Character, readonly text: string, readonly time: Date = new Date()) { if(Conversation.Message.Type[type] === undefined) throw new Error('Unknown type'); //tslint:disable-line diff --git a/chat/conversations.ts b/chat/conversations.ts index a47686a..74fc1fe 100644 --- a/chat/conversations.ts +++ b/chat/conversations.ts @@ -7,6 +7,7 @@ import {Channel, Character, Conversation as Interfaces} from './interfaces'; import l from './localize'; import {CommandContext, isAction, isCommand, isWarn, parse as parseCommand} from './slash_commands'; import MessageType = Interfaces.Message.Type; +import {EventBus} from '../chat/event-bus'; function createMessage(this: void, type: MessageType, sender: Character, text: string, time?: Date): Message { if(type === MessageType.Message && isAction(text)) { @@ -549,6 +550,7 @@ export default function(this: void): Interfaces.State { const char = core.characters.get(data.character); if(char.isIgnored) return connection.send('IGN', {action: 'notify', character: data.character}); const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time); + EventBus.$emit('private-message', { message }); const conv = state.getPrivate(char); await conv.addMessage(message); }); @@ -558,6 +560,7 @@ export default function(this: void): Interfaces.State { if(conversation === undefined) return core.channels.leave(data.channel); if(char.isIgnored && !isOp(conversation)) return; const message = createMessage(MessageType.Message, char, decodeHTML(data.message), time); + EventBus.$emit('channel-message', { message, channel: conversation }); await conversation.addMessage(message); const words = conversation.settings.highlightWords.slice(); @@ -586,7 +589,18 @@ export default function(this: void): Interfaces.State { const conv = state.channelMap[data.channel.toLowerCase()]; if(conv === undefined) return core.channels.leave(data.channel); if((char.isIgnored || core.state.hiddenUsers.indexOf(char.name) !== -1) && !isOp(conv)) return; - await conv.addMessage(new Message(MessageType.Ad, char, decodeHTML(data.message), time)); + const msg = new Message(MessageType.Ad, char, decodeHTML(data.message), time); + + if (core.characters.ownProfile) { + const p = core.cache.profileCache.get(char.name); + + if (p) { + msg.score = p.matchScore; + } + } + + EventBus.$emit('channel-ad', { message: msg, channel: conv }); + await conv.addMessage(msg); }); connection.onMessage('RLL', async(data, time) => { const sender = core.characters.get(data.character); diff --git a/chat/core.ts b/chat/core.ts index 13a581b..4b3827b 100644 --- a/chat/core.ts +++ b/chat/core.ts @@ -1,4 +1,5 @@ import Vue, {WatchHandler} from 'vue'; +import { CacheManager } from '../learn/cache-manager'; import BBCodeParser from './bbcode'; import {Settings as SettingsImpl} from './common'; import {Channel, Character, Connection, Conversation, Logs, Notifications, Settings, State as StateInterface} from './interfaces'; @@ -60,6 +61,7 @@ const data = { channels: undefined, characters: undefined, notifications: undefined, + cache: undefined, register(this: void | never, module: 'characters' | 'conversations' | 'channels', subState: Channel.State | Character.State | Conversation.State): void { Vue.set(vue, module, subState); @@ -85,6 +87,10 @@ export function init(this: void, connection: Connection, logsClass: new() => Log data.logs = new logsClass(); data.settingsStore = new settingsClass(); data.notifications = new notificationsClass(); + data.cache = new CacheManager(); + + data.cache.start(); + connection.onEvent('connecting', async() => { await data.reloadSettings(); data.bbCodeParser = createBBCodeParser(); @@ -101,6 +107,8 @@ export interface Core { readonly channels: Channel.State readonly bbCodeParser: BBCodeParser readonly notifications: Notifications + readonly cache: CacheManager + register(module: 'conversations', state: Conversation.State): void register(module: 'channels', state: Channel.State): void register(module: 'characters', state: Character.State): void diff --git a/chat/event-bus.ts b/chat/event-bus.ts index 29a1426..5142b74 100644 --- a/chat/event-bus.ts +++ b/chat/event-bus.ts @@ -1,9 +1,37 @@ import Vue from 'vue'; +import { Character } from '../site/character_page/interfaces'; +import { Message } from './common'; +import { Conversation } from './interfaces'; +import ChannelConversation = Conversation.ChannelConversation; + +/** + * 'imagepreview-dismiss': {url: string} + * 'imagepreview-show': {url: string} + * 'imagepreview-toggle-stickyness': {url: string} + * 'character-data': {character: Character} + * 'private-message': {message: Message} + * 'channel-ad': {message: Message, channel: Conversation} + * 'channel-message': {message: Message, channel: Conversation} + */ + export interface EventBusEvent { // tslint:disable: no-any [key: string]: any; } +export interface ChannelMessageEvent extends EventBusEvent { + message: Message; + channel: ChannelConversation; +} + +// tslint:disable-next-line no-empty-interface +export interface ChannelAdEvent extends ChannelMessageEvent {} + +export interface CharacterDataEvent { + character: Character; +} + + export const EventBus = new Vue(); diff --git a/chat/image-preview-mutator.ts b/chat/image-preview-mutator.ts index 840c201..167f866 100644 --- a/chat/image-preview-mutator.ts +++ b/chat/image-preview-mutator.ts @@ -168,7 +168,7 @@ export class ImagePreviewMutator { let removeList = []; const safeIds = ['flistWrapper', 'flistError', 'flistHider']; - body.childNodes.forEach((el) => ((safeIds.indexOf(el.id) < 0) ? removeList.push(el) : true) + body.childNodes.forEach((el) => ((safeIds.indexOf(el.id) < 0) ? removeList.push(el) : true)); ${skipElementRemove ? '' : 'removeList.forEach((el) => el.remove());'} removeList = []; @@ -255,4 +255,14 @@ export class ImagePreviewMutator { "> `); } + + getReShowMutator(): string { + return this.wrapJs( + ` + const el = document.querySelector('#flistHider'); + + if (el) { el.remove(); } + ` + ); + } } diff --git a/chat/interfaces.ts b/chat/interfaces.ts index e3bc650..d16c2f9 100644 --- a/chat/interfaces.ts +++ b/chat/interfaces.ts @@ -13,6 +13,8 @@ export namespace Conversation { readonly type: Message.Type readonly text: string readonly time: Date + + score?: number; } export interface EventMessage extends BaseMessage { diff --git a/chat/message_view.ts b/chat/message_view.ts index add358d..c14fb0c 100644 --- a/chat/message_view.ts +++ b/chat/message_view.ts @@ -1,6 +1,7 @@ -import {Component, Prop} from '@f-list/vue-ts'; +import { Component, Prop, Watch } from '@f-list/vue-ts'; import {CreateElement, default as Vue, VNode, VNodeChildrenArrayContents} from 'vue'; import {Channel} from '../fchat'; +import { Score, Scoring } from '../site/character_page/matcher'; import {BBCodeView} from './bbcode'; import {formatTime} from './common'; import core from './core'; @@ -21,7 +22,8 @@ const userPostfix: {[key: number]: string | undefined} = { /*tslint:disable-next-line:prefer-template*///unreasonable here let classes = `message message-${Conversation.Message.Type[message.type].toLowerCase()}` + (separators ? ' message-block' : '') + (message.type !== Conversation.Message.Type.Event && message.sender.name === core.connection.character ? ' message-own' : '') + - ((this.classes !== undefined) ? ` ${this.classes}` : ''); + ((this.classes !== undefined) ? ` ${this.classes}` : '') + + ` ${this.scoreClasses}`; if(message.type !== Conversation.Message.Type.Event) { children.push((message.type === Conversation.Message.Type.Action) ? '*' : '', createElement(UserView, {props: {character: message.sender, channel: this.channel}}), @@ -54,4 +56,41 @@ export default class MessageView extends Vue { readonly channel?: Channel; @Prop readonly logs?: true; + + scoreClasses = this.getMessageScoreClasses(this.message); + + @Watch('message.score') + scoreUpdate(): void { + console.log('Message score update', this.message.score, this.message.text); + + this.scoreClasses = this.getMessageScoreClasses(this.message); + + this.$forceUpdate(); + } + + + getMessageScoreClasses(message: Conversation.Message): string { + if ((!('score' in message)) || (message.score === undefined) || (message.score === 0)) { + return ''; + } + + console.log('Score was', message.score); + + return `message-score ${Score.getClasses(message.score as Scoring)}`; + + // const baseClass = message.score > 0 ? 'message-score-positive' : 'message-score-negative'; + // + // const score = Math.abs(message.score); + // + // let scoreStrength = 'message-score-normal'; + // + // if (score > 3) { + // scoreStrength = 'message-score-high'; + // } else if (score > 1.5) { + // scoreStrength = 'message-score-medium'; + // } + // + // return `message-score ${baseClass} ${scoreStrength}`; + } + } \ No newline at end of file diff --git a/chat/profile_api.ts b/chat/profile_api.ts index abd6abe..b663839 100644 --- a/chat/profile_api.ts +++ b/chat/profile_api.ts @@ -29,7 +29,10 @@ const parserSettings = { inlineDisplayMode: InlineDisplayMode.DISPLAY_ALL }; -async function characterData(name: string | undefined): Promise { + +// tslint:disable-next-line: ban-ts-ignore +// @ts-ignore +async function characterData(name: string | undefined, id: number = -1, skipEvent: boolean = false): Promise { const data = await core.connection.queryApi { self_staff: false }; - EventBus.$emit('character-data', charData); + if (!skipEvent) + EventBus.$emit('character-data', { character: charData }); return charData; } diff --git a/fchat/characters.ts b/fchat/characters.ts index eafc0c8..8abb9df 100644 --- a/fchat/characters.ts +++ b/fchat/characters.ts @@ -1,5 +1,8 @@ +import core from '../chat/core'; +import { methods } from '../site/character_page/data_store'; import {decodeHTML} from './common'; import {Character as Interfaces, Connection} from './interfaces'; +import { Character as CharacterProfile } from '../site/character_page/interfaces'; class Character implements Interfaces.Character { gender: Interfaces.Gender = 'None'; @@ -16,7 +19,10 @@ class Character implements Interfaces.Character { class State implements Interfaces.State { characters: {[key: string]: Character | undefined} = {}; + ownCharacter: Character = undefined; /*tslint:disable-line:no-any*///hack + ownProfile: CharacterProfile = undefined; /*tslint:disable-line:no-any*///hack + friends: Character[] = []; bookmarks: Character[] = []; ignoreList: string[] = []; @@ -49,6 +55,12 @@ class State implements Interfaces.State { character.status = status; character.statusText = decodeHTML(text); } + + async resolveOwnProfile(): Promise { + await methods.fieldsGet(); + + this.ownProfile = await methods.characterData(this.ownCharacter.name, -1, false); + } } let state: State; @@ -108,9 +120,18 @@ export default function(this: void, connection: Connection): Interfaces.State { connection.onMessage('FLN', (data) => { state.setStatus(state.get(data.character), 'offline', ''); }); - connection.onMessage('NLN', (data) => { + connection.onMessage('NLN', async(data) => { const character = state.get(data.identity); - if(data.identity === connection.character) state.ownCharacter = character; + + if(data.identity === connection.character) { + state.ownCharacter = character; + + await state.resolveOwnProfile(); + + // tslint:disable-next-line no-unnecessary-type-assertion + core.cache.setProfile(state.ownProfile as CharacterProfile); + } + character.name = data.identity; character.gender = data.gender; state.setStatus(character, data.status, ''); diff --git a/fchat/interfaces.ts b/fchat/interfaces.ts index d6e3cf4..c7b6d43 100644 --- a/fchat/interfaces.ts +++ b/fchat/interfaces.ts @@ -1,3 +1,5 @@ +import { Character as CharacterProfile } from '../site/character_page/interfaces'; + //tslint:disable:no-shadowed-variable export namespace Connection { export type ClientCommands = { @@ -165,6 +167,8 @@ export namespace Character { readonly friendList: ReadonlyArray readonly bookmarkList: ReadonlyArray + readonly ownProfile: CharacterProfile; + get(name: string): Character } diff --git a/learn/ad-cache.ts b/learn/ad-cache.ts new file mode 100644 index 0000000..fe9fc73 --- /dev/null +++ b/learn/ad-cache.ts @@ -0,0 +1,62 @@ +import { Cache } from './cache'; + +export interface AdCachedPosting { + channelName: string; + datePosted: Date; + message: string; +} + +export interface AdPosting extends AdCachedPosting { + name: string; +} + +export class AdCacheRecord { + protected name: string; + protected posts: AdCachedPosting[] = []; + + constructor(name: string, posting?: AdPosting) { + this.name = name; + + if (posting) + this.add(posting); + } + + add(ad: AdPosting): void { + this.posts.push( + { + channelName: ad.channelName, + datePosted: ad.datePosted, + message: ad.message + } + ); + } + + + count(): number { + return this.posts.length; + } + + + getDateLastPosted(): Date | null { + if (this.posts.length === 0) + return null; + + return this.posts[this.posts.length - 1].datePosted; + } +} + + +export class AdCache extends Cache { + register(ad: AdPosting): void { + const k = Cache.nameKey(ad.name); + + if (k in this.cache) { + const adh = this.cache[k]; + + adh.add(ad); + return; + } + + this.cache[k] = new AdCacheRecord(name, ad) as RecordType; + } +} diff --git a/learn/cache-manager.ts b/learn/cache-manager.ts new file mode 100644 index 0000000..0c18262 --- /dev/null +++ b/learn/cache-manager.ts @@ -0,0 +1,218 @@ +import * as _ from 'lodash'; +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 { Gender } from '../site/character_page/matcher'; +import { AdCache } from './ad-cache'; +import { ChannelConversationCache } from './channel-conversation-cache'; +import { CharacterProfiler } from './character-profiler'; +import { ProfileCache } from './profile-cache'; +import Timer = NodeJS.Timer; +import ChannelConversation = Conversation.ChannelConversation; +import Message = Conversation.Message; + +export interface ProfileCacheQueueEntry { + name: string; + key: string; + added: Date; + gender?: Gender; + score: number; +} + + +export class CacheManager { + static readonly PROFILE_QUERY_DELAY = 1000; //1 * 1000; + + adCache: AdCache = new AdCache(); + profileCache: ProfileCache = new ProfileCache(); + channelConversationCache: ChannelConversationCache = new ChannelConversationCache(); + + protected queue: ProfileCacheQueueEntry[] = []; + + protected profileTimer: Timer | null = null; + protected characterProfiler: CharacterProfiler | undefined; + + + queueForFetching(name: string): void { + const key = ProfileCache.nameKey(name); + + if (this.profileCache.has(key)) + return; + + if (!!_.find(this.queue, (q: ProfileCacheQueueEntry) => (q.key === key))) + return; + + const entry: ProfileCacheQueueEntry = { + name, + key, + added: new Date(), + score: 0 + }; + + this.queue.push(entry); + } + + async fetchProfile(name: string): Promise { + try { + await methods.fieldsGet(); + + const c = await methods.characterData(name, -1, true); + + const r = this.profileCache.register(c); + + this.updateAdScoringForProfile(c, r.matchScore); + } catch (err) { + console.error('Failed to fetch profile for cache', name, err); + } + } + + + updateAdScoringForProfile(c: Character, score: number): void { + _.each( + core.conversations.channelConversations, + (ch: ChannelConversation) => { + _.each( + ch.messages, (m: Conversation.Message) => { + if ((m.type === Message.Type.Ad) && (m.sender) && (m.sender.name === c.character.name)) { + console.log('Update score', score, ch.name, m.sender.name, m.text, m.id); + + m.score = score; + } + } + ); + } + ); + } + + + addProfile(character: string | Character): void { + if (typeof character === 'string') { + // console.log('Learn discover', character); + + this.queueForFetching(character); + return; + } + + this.profileCache.register(character); + } + + + /* + * Preference in order (plan): + * + has messaged me + * + bookmarked / friend + * + * + genders I like + * + looking + * + online + * + * - busy + * - DND + * - away + */ + consumeNextInQueue(): ProfileCacheQueueEntry | null { + if (this.queue.length === 0) { + return null; + } + + // re-score + _.each(this.queue, (e: ProfileCacheQueueEntry) => this.calculateScore(e)); + + this.queue = _.sortBy(this.queue, 'score'); + + return this.queue.pop() as ProfileCacheQueueEntry; + } + + calculateScore(e: ProfileCacheQueueEntry): number { + return this.characterProfiler ? this.characterProfiler.calculateInterestScoreForQueueEntry(e) : 0; + } + + start(): void { + this.stop(); + + EventBus.$on( + 'character-data', + (data: CharacterDataEvent) => { + this.addProfile(data.character); + } + ); + + EventBus.$on( + 'channel-message', + (data: ChannelMessageEvent) => { + const message = data.message; + const channel = data.channel; + + this.channelConversationCache.register( + { + name: message.sender.name, + channelName : channel.name, + datePosted: message.time, + message: message.text + } + ); + + this.addProfile(message.sender.name); + } + ); + + EventBus.$on( + 'channel-ad', + (data: ChannelAdEvent) => { + const message = data.message; + const channel = data.channel; + + this.adCache.register( + { + name: message.sender.name, + channelName : channel.name, + datePosted: message.time, + message: message.text + } + ); + + this.addProfile(message.sender.name); + } + ); + + // EventBus.$on( + // 'private-message', + // (data: any) => {} + // ); + + + const scheduleNextFetch = () => { + this.profileTimer = setTimeout( + async() => { + const next = this.consumeNextInQueue(); + + if (next) { + // console.log('Learn fetch', next.name, next.score); + await this.fetchProfile(next.name); + } + + scheduleNextFetch(); + }, + CacheManager.PROFILE_QUERY_DELAY + ); + }; + + scheduleNextFetch(); + } + + stop(): void { + if (this.profileTimer) { + clearTimeout(this.profileTimer); + this.profileTimer = null; + } + + // should do some $off here + } + + + setProfile(c: Character): void { + this.characterProfiler = new CharacterProfiler(c, this.adCache); + } +} diff --git a/learn/cache.ts b/learn/cache.ts new file mode 100644 index 0000000..c364474 --- /dev/null +++ b/learn/cache.ts @@ -0,0 +1,29 @@ +interface CacheCollection { + [key: string]: RecordType +} + + +export abstract class Cache { + protected cache: CacheCollection = {}; + + get(name: string): RecordType | null { + const key = Cache.nameKey(name); + + if (key in this.cache) { + return this.cache[key]; + } + + return null; + } + + // tslint:disable-next-line: no-any + abstract register(record: any): void; + + has(name: string): boolean { + return (name in this.cache); + } + + static nameKey(name: string): string { + return name.toLowerCase(); + } +} diff --git a/learn/channel-conversation-cache.ts b/learn/channel-conversation-cache.ts new file mode 100644 index 0000000..8f8a19c --- /dev/null +++ b/learn/channel-conversation-cache.ts @@ -0,0 +1,32 @@ +import { Cache } from './cache'; +import { AdCachedPosting, AdCacheRecord, AdCache } from './ad-cache'; + +export interface ChannelCachedPosting extends AdCachedPosting { + channelName: string; + datePosted: Date; + message: string; +} + +export interface ChannelPosting extends ChannelCachedPosting { + name: string; +} + +export class ChannelCacheRecord extends AdCacheRecord {} + + +export class ChannelConversationCache extends AdCache { + + register(ad: ChannelPosting): void { + const k = Cache.nameKey(ad.name); + + if (k in this.cache) { + const adh = this.cache[k]; + + adh.add(ad); + return; + } + + this.cache[k] = new ChannelCacheRecord(name, ad); + } + +} diff --git a/learn/character-profiler.ts b/learn/character-profiler.ts new file mode 100644 index 0000000..9f9f63a --- /dev/null +++ b/learn/character-profiler.ts @@ -0,0 +1,96 @@ +import core from '../chat/core'; +import { Character as CharacterFChatInf } from '../fchat'; +import { Character } from '../site/character_page/interfaces'; +import { Matcher } from '../site/character_page/matcher'; +import { AdCache } from './ad-cache'; +import { ProfileCacheQueueEntry } from './cache-manager'; + + +export class CharacterProfiler { + static readonly ADVERTISEMENT_RECENT_RANGE = 22 * 60 * 1000; + static readonly ADVERTISEMENT_POTENTIAL_RAGE = 50 * 60 * 1000; + + protected adCache: AdCache; + protected me: Character; + + constructor(me: Character, adCache: AdCache) { + this.me = me; + this.adCache = adCache; + } + + calculateInterestScoreForQueueEntry(entry: ProfileCacheQueueEntry): number { + const c = core.characters.get(entry.name); + + if (!c) + return 0; + + const genderScore = this.getInterestScoreForGender(this.me, c); + const statusScore = this.getInterestScoreForStatus(c); + const adScore = (genderScore > 0) ? this.getLastAdvertisementStatus(c) : 0; + const friendlyScore = this.getInterestScoreForFriendlies(c); + + // tslint:disable-next-line: number-literal-format binary-expression-operand-order + return ((1.0 * genderScore) + (1.0 * statusScore) + (1.0 * adScore) + (1.0 * friendlyScore)); + } + + + getInterestScoreForFriendlies(c: CharacterFChatInf.Character): number { + if(c.isFriend) + return 1; + + if(c.isBookmarked) + return 0.5; + + if(c.isIgnored) + return -1; + + return 0; + } + + + getInterestScoreForGender(me: Character, c: CharacterFChatInf.Character): number { + const g = Matcher.strToGender(c.gender); + + if (g === null) { + return 0; + } + + const score = Matcher.scoreOrientationByGender(me.character, g); + + return score.score; + } + + + getInterestScoreForStatus(c: CharacterFChatInf.Character): number { + if ((c.status === 'offline') || (c.status === 'away') || (c.status === 'busy') || (c.status === 'dnd')) + return -0.5; + + if (c.status === 'looking') + return 0.5; + + return 0; + } + + + getLastAdvertisementStatus(c: CharacterFChatInf.Character): number { + const ads = this.adCache.get(c.name); + + if (!ads) + return 0; + + const lastPost = ads.getDateLastPosted(); + + if (lastPost === null) + return 0; + + const delta = Date.now() - lastPost.getTime(); + + if (delta < CharacterProfiler.ADVERTISEMENT_RECENT_RANGE) + return 1; + + if (delta < CharacterProfiler.ADVERTISEMENT_POTENTIAL_RAGE) + return 0.5; + + return -0.5; // has been advertising, but not recently, so likely busy + } +} \ No newline at end of file diff --git a/learn/personal-profiler.ts b/learn/personal-profiler.ts new file mode 100644 index 0000000..e69de29 diff --git a/learn/profile-cache.ts b/learn/profile-cache.ts new file mode 100644 index 0000000..1a0655b --- /dev/null +++ b/learn/profile-cache.ts @@ -0,0 +1,71 @@ +import * as _ from 'lodash'; + +import core from '../chat/core'; +import { Character } from '../site/character_page/interfaces'; +import { Matcher, Score, Scoring } from '../site/character_page/matcher'; +import { Cache } from './cache'; + +export interface CharacterCacheRecord { + character: Character; + lastFetched: Date; + added: Date; + matchScore: number; +} + +export class ProfileCache extends Cache { + register(c: Character): CharacterCacheRecord { + const k = Cache.nameKey(c.character.name); + const score = ProfileCache.score(c); + + if (k in this.cache) { + const rExisting = this.cache[k]; + + rExisting.character = c; + rExisting.lastFetched = new Date(); + rExisting.matchScore = score; + + return rExisting; + } + + const rNew = { + character: c, + lastFetched: new Date(), + added: new Date(), + matchScore: score + }; + + this.cache[k] = rNew; + + return rNew; + } + + + static score(c: Character): number { + const you = core.characters.ownProfile; + const m = Matcher.generateReport(you.character, c.character); + + // let mul = Math.sign(Math.min(m.you.total, m.them.total)); + + // if (mul === 0) + // mul = 0.5; + + // const score = Math.min(m.them.total, m.you.total); // mul * (Math.abs(m.you.total) + Math.abs(m.them.total)); + + const finalScore = _.reduce( + _.concat(_.values(m.them.scores), _.values(m.you.scores)), + (accum: Scoring | null, score: Score) => { + if (accum === null) { + return (score.score !== Scoring.NEUTRAL) ? score.score : null; + } + + return (score.score === Scoring.NEUTRAL) ? accum : Math.min(accum, score.score); + }, + null + ); + + // console.log('Profile score', c.character.name, score, m.you.total, m.them.total, + // m.you.total + m.them.total, m.you.total * m.them.total); + + return (finalScore === null) ? Scoring.NEUTRAL : finalScore; + } +} diff --git a/readme.md b/readme.md index 111474a..d72809b 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,8 @@ This repository contains a modified version of the mainline F-Chat 3.0 client. * Improvements to log browsing * Highlight ads from characters most interesting to you * Fix broken BBCode, such as `[big]` in character profiles +* Ad cache, so you can find your chat partners ads easily (channel names too) +* Which channels my chart partner is on? # F-List Exported diff --git a/site/character_page/character_page.vue b/site/character_page/character_page.vue index 456f958..ede50db 100644 --- a/site/character_page/character_page.vue +++ b/site/character_page/character_page.vue @@ -90,6 +90,8 @@ import MatchReportView from './match-report.vue'; + const CHARACTER_CACHE_EXPIRE = 4 * 60 * 60 * 1000; + interface ShowableVueTab extends Vue { show?(): void } @@ -252,26 +254,49 @@ protected async loadSelfCharacter(): Promise { // console.log('SELF'); - const ownChar = core.characters.ownCharacter; + // const ownChar = core.characters.ownCharacter; - this.selfCharacter = await methods.characterData(ownChar.name, -1); + // this.selfCharacter = await methods.characterData(ownChar.name, -1); + this.selfCharacter = core.characters.ownProfile; // console.log('SELF LOADED'); this.updateMatches(); } + private async fetchCharacter(): Promise { + if (!this.name) { + throw new Error('A man must have a name'); + } + + // tslint:disable-next-line: await-promise + const cachedCharacter = await core.cache.profileCache.get(this.name); + + if (cachedCharacter) { + if (Date.now() - cachedCharacter.lastFetched.getTime() <= CHARACTER_CACHE_EXPIRE) { + return cachedCharacter.character; + } + } + + return methods.characterData(this.name, this.characterid, false); + } + private async _getCharacter(): Promise { this.character = undefined; this.friendCount = null; this.groupCount = null; this.guestbookPostCount = null; - this.character = await methods.characterData(this.name, this.characterid); + if (!this.name) { + return; + } + + this.character = await this.fetchCharacter(); + standardParser.allowInlines = true; standardParser.inlines = this.character.character.inlines; - console.log('LoadChar', this.name, this.character); + // console.log('LoadChar', this.name, this.character); this.updateMatches(); @@ -294,7 +319,7 @@ this.characterMatch = Matcher.generateReport(this.selfCharacter.character, this.character.character); - console.log('Match', this.selfCharacter.character.name, this.character.character.name, this.characterMatch); + // console.log('Match', this.selfCharacter.character.name, this.character.character.name, this.characterMatch); } } diff --git a/site/character_page/interfaces.ts b/site/character_page/interfaces.ts index a53f5ef..26536c8 100644 --- a/site/character_page/interfaces.ts +++ b/site/character_page/interfaces.ts @@ -22,7 +22,7 @@ export interface StoreMethods { characterBlock?(id: number, block: boolean, reason?: string): Promise characterCustomKinkAdd(id: number, name: string, description: string, choice: KinkChoice): Promise - characterData(name: string | undefined, id: number | undefined): Promise + characterData(name: string | undefined, id: number | undefined, skipEvent: boolean | undefined): Promise characterDelete(id: number): Promise characterDuplicate(id: number, name: string): Promise characterFriends(id: number): Promise diff --git a/site/character_page/matcher.ts b/site/character_page/matcher.ts index d37c552..518068b 100644 --- a/site/character_page/matcher.ts +++ b/site/character_page/matcher.ts @@ -178,6 +178,22 @@ const speciesMapping: SpeciesMap = { [Species.Minotaur]: ['minotaur'] }; + +interface FchatGenderMap { + [key: string]: Gender; +} + +const fchatGenderMap: FchatGenderMap = { + None: Gender.None, + Male: Gender.Male, + Female: Gender.Female, + Shemale: Gender.Shemale, + Herm: Gender.Herm, + 'Male-Herm': Gender.MaleHerm, + 'Cunt-boy': Gender.Cuntboy, + Transgender: Gender.Transgender +}; + interface KinkPreferenceMap { [key: string]: KinkPreference; } @@ -214,6 +230,7 @@ export interface MatchResult { them: Character, scores: MatchResultScores; info: MatchResultCharacterInfo; + total: number } export enum Scoring { @@ -249,7 +266,11 @@ export class Score { } getRecommendedClass(): string { - return scoreClasses[this.score]; + return Score.getClasses(this.score); + } + + static getClasses(score: Scoring): string { + return scoreClasses[score]; } } @@ -279,10 +300,12 @@ export class Matcher { }; } + match(): MatchResult { - return { + const data = { you: this.you, them: this.them, + total: 0, scores: { [TagId.Orientation]: this.resolveOrientationScore(), @@ -297,7 +320,15 @@ export class Matcher { gender: Matcher.getTagValueList(TagId.Gender, this.you), orientation: Matcher.getTagValueList(TagId.Orientation, this.you) } - }; + }; + + data.total = _.reduce( + data.scores, + (accum: number, s: Score) => (accum + s.score), + 0 + ); + + return data; } private resolveOrientationScore(): Score { @@ -313,9 +344,17 @@ export class Matcher { // Question: If someone identifies themselves as 'straight cuntboy', how should they be matched? like a straight female? + return Matcher.scoreOrientationByGender(you, theirGender); + } + + + static scoreOrientationByGender(you: Character, theirGender: Gender): Score { + const yourGender = Matcher.getTagValueList(TagId.Gender, you); + const yourOrientation = Matcher.getTagValueList(TagId.Orientation, you); + // CIS // tslint:disable-next-line curly - if (Matcher.isCisGender(yourGender)) { + if ((yourGender !== null) && (Matcher.isCisGender(yourGender))) { if (yourGender === theirGender) { // same sex CIS if (yourOrientation === Orientation.Straight) @@ -359,7 +398,6 @@ export class Matcher { } } - // Can't do anything with Gender.None return new Score(Scoring.NEUTRAL); } @@ -662,4 +700,17 @@ export class Matcher { // tslint:disable-next-line: strict-type-predicates return (foundSpeciesId === null) ? null : parseInt(foundSpeciesId, 10); } + + + static strToGender(fchatGenderStr: string | undefined): Gender | null { + if (fchatGenderStr === undefined) { + return null; + } + + if (fchatGenderStr in fchatGenderMap) { + return fchatGenderMap[fchatGenderStr]; + } + + return null; + } }