From d4d4db89c9e7230a158e196afd21a22c53eec086 Mon Sep 17 00:00:00 2001
From: "Mr. Stallion" <mrstallion@nobody.nowhere.fauxemail.ext>
Date: Sun, 2 Jun 2019 18:57:32 -0500
Subject: [PATCH] Auto-posting

---
 .gitignore                    |   4 +-
 chat/ConversationSettings.vue |  24 ++++-
 chat/ConversationView.vue     | 173 +++++++++++++++++++++++++++++++++-
 chat/common.ts                |  17 ++++
 chat/conversations.ts         |  53 ++++++++++-
 chat/interfaces.ts            |  19 ++++
 6 files changed, 284 insertions(+), 6 deletions(-)

diff --git a/.gitignore b/.gitignore
index 97c08d2..6ee9746 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,6 @@ node_modules/
 /electron/app
 /electron/dist
 /mobile/www
-/webchat/dist
\ No newline at end of file
+/webchat/dist
+
+.idea/workspace.xml
diff --git a/chat/ConversationSettings.vue b/chat/ConversationSettings.vue
index 12b2726..75b628f 100644
--- a/chat/ConversationSettings.vue
+++ b/chat/ConversationSettings.vue
@@ -35,6 +35,12 @@
                 <option :value="setting.False">{{l('conversationSettings.false')}}</option>
             </select>
         </div>
+        <div class="form-group" v-for="(ad, index) in ads">
+            <label :for="'ad' + conversation.key + '-' + index" class="control-label">Channel Auto-Posting Ad #{{(index + 1)}}</label>
+            <input :id="'ad' + conversation.key + '-' + index" class="form-control" v-model="ads[index]" />
+        </div>
+        <button class="btn" @click="addAd()">Add Auto-Posting Ad</button>
+
     </modal>
 </template>
 
@@ -58,6 +64,7 @@
         highlightWords!: string;
         joinMessages!: Conversation.Setting;
         defaultHighlights!: boolean;
+        ads!: string[];
 
         load(): void {
             const settings = this.conversation.settings;
@@ -66,16 +73,29 @@
             this.highlightWords = settings.highlightWords.join(',');
             this.joinMessages = settings.joinMessages;
             this.defaultHighlights = settings.defaultHighlights;
+            this.ads = settings.adSettings.ads.slice(0);
+
+            if (this.ads.length === 0) {
+                this.ads.push('');
+            }
         }
 
         submit(): void {
             this.conversation.settings = {
                 notify: this.notify,
                 highlight: this.highlight,
-                highlightWords: this.highlightWords.split(',').map((x) => x.trim()).filter((x) => x.length),
+                highlightWords: this.highlightWords.split(',').map((x) => x.trim()).filter((x) => (x.length > 0)),
                 joinMessages: this.joinMessages,
-                defaultHighlights: this.defaultHighlights
+                defaultHighlights: this.defaultHighlights,
+                adSettings: {
+                    ads: this.ads.filter((ad: string) => (ad.length > 0))
+                }
             };
         }
+
+
+        addAd(): void {
+            this.ads.push('');
+        }
     }
 </script>
\ No newline at end of file
diff --git a/chat/ConversationView.vue b/chat/ConversationView.vue
index ad26b29..65ee938 100644
--- a/chat/ConversationView.vue
+++ b/chat/ConversationView.vue
@@ -69,6 +69,16 @@
             <a class="btn btn-sm btn-light" style="position:absolute;right:5px;top:50%;transform:translateY(-50%);line-height:0;z-index:10"
                 @click="hideSearch"><i class="fas fa-times"></i></a>
         </div>
+        <div class="auto-ads" v-show="isAutopostingAds()">
+            <h4>Auto-Posting Ads</h4>
+            <div class="update">{{adAutoPostUpdate}}</div>
+
+
+            <div v-show="adAutoPostNextAd" class="next">
+                <h5>Coming Next</h5>
+                <div>{{(adAutoPostNextAd ? adAutoPostNextAd.substr(0, 50) : '')}}...</div>
+            </div>
+        </div>
         <div class="border-top messages" :class="isChannel(conversation) ? 'messages-' + conversation.mode : undefined" ref="messages"
             @scroll="onMessagesScroll" style="flex:1;overflow:auto;margin-top:2px">
             <template v-for="message in messages">
@@ -114,6 +124,9 @@
                         <a href="#" :class="{active: conversation.isSendingAds, disabled: conversation.channel.mode != 'both'}"
                             class="nav-link" @click.prevent="setSendingAds(true)">{{adsMode}}</a>
                     </li>
+                    <li class="nav-item">
+                        <a href="#" :class="{active: conversation.adState.active}" class="nav-link" @click="autoPostAds()">Auto-Post Ads</a>
+                    </li>
                 </ul>
                 <div class="btn btn-sm btn-primary" v-show="!settings.enterSend" @click="sendButton">{{l('chat.send')}}</div>
             </div>
@@ -133,7 +146,7 @@
     import {Keys} from '../keys';
     import {BBCodeView, Editor} from './bbcode';
     import CommandHelp from './CommandHelp.vue';
-    import {characterImage, getByteLength, getKey} from './common';
+    import { AdState, characterImage, getByteLength, getKey } from "./common";
     import ConversationSettings from './ConversationSettings.vue';
     import core from './core';
     import {Channel, channelModes, Character, Conversation, Settings} from './interfaces';
@@ -177,6 +190,9 @@
         ignoreScroll = false;
         adCountdown = 0;
         adsMode = l('channel.mode.ads');
+        autoPostingUpdater = 0;
+        adAutoPostUpdate: string|null = null;
+        adAutoPostNextAd: string|null = null;
         isChannel = Conversation.isChannel;
         isPrivate = Conversation.isPrivate;
 
@@ -219,6 +235,8 @@
                     this.adCountdown = window.setInterval(setAdCountdown, 1000);
                 setAdCountdown();
             });
+
+            this.$watch('conversation.adState.active', () => (this.refreshAutoPostingTimer()));
         }
 
         @Hook('destroyed')
@@ -227,6 +245,8 @@
             window.removeEventListener('keydown', this.keydownHandler);
             window.removeEventListener('keypress', this.keypressHandler);
             clearInterval(this.searchTimer);
+            clearInterval(this.autoPostingUpdater);
+            clearInterval(this.adCountdown);
         }
 
         hideSearch(): void {
@@ -377,6 +397,124 @@
             (<ManageChannel>this.$refs['manageDialog']).show();
         }
 
+
+        isAutopostingAds(): boolean {
+            return this.conversation.adState.active;
+        }
+
+
+        clearAutoPostAds(): void {
+            if (this.conversation.adState.interval) {
+                clearTimeout(this.conversation.adState.interval);
+            }
+
+            this.conversation.adState = new AdState();
+        }
+
+
+        autoPostAds(): void {
+            if(this.isAutopostingAds()) {
+                this.clearAutoPostAds();
+                this.refreshAutoPostingTimer();
+                return;
+            }
+
+            const conversation = this.conversation;
+
+            /**** Do not use 'this' keyword below this line, it will operate differently than you expect ****/
+
+            const chanConv = (<Conversation.ChannelConversation>conversation);
+
+            const adState = conversation.adState;
+            const initialWait = Math.max(0, chanConv.nextAd - Date.now()) * 1.1;
+
+            adState.adIndex = 0;
+
+            const sendNextPost = async () => {
+                const ads = conversation.settings.adSettings.ads;
+                const index = (adState.adIndex || 0);
+
+                if ((ads.length === 0) || ((adState.expireDue) && (adState.expireDue.getTime() < Date.now()))) {
+                    conversation.adState = new AdState();
+                    return;
+                }
+
+                const msg = ads[index % ads.length];
+
+                await chanConv.sendAd(msg);
+
+                const nextInMs = Math.max(0, (chanConv.nextAd - Date.now())) * 1.1;
+
+                adState.adIndex = index + 1;
+                adState.nextPostDue = new Date(Date.now() + nextInMs);
+
+                adState.interval = setTimeout(
+                    async () => {
+                        await sendNextPost();
+                    },
+                    nextInMs
+                );
+            };
+
+
+            adState.active = true;
+            adState.nextPostDue = new Date(Date.now() + initialWait);
+            adState.expireDue = new Date(Date.now() + 2 * 60 * 60 * 1000);
+
+
+            adState.interval = setTimeout(
+                async () => {
+                    adState.firstPost = new Date();
+
+                    await sendNextPost();
+                },
+                initialWait
+            );
+
+            this.refreshAutoPostingTimer();
+        }
+
+
+        refreshAutoPostingTimer() {
+            if (this.autoPostingUpdater) {
+                window.clearInterval(this.autoPostingUpdater);
+            }
+
+            if(this.isAutopostingAds() === false) {
+                this.adAutoPostUpdate = null;
+                this.adAutoPostNextAd = null;
+                return;
+            }
+
+            const updateAutoPostingState = () => {
+                const adState = this.conversation.adState;
+                const ads = this.conversation.settings.adSettings.ads;
+
+                if(ads.length > 0) {
+                    this.adAutoPostNextAd = ads[(adState.adIndex || 0) % ads.length];
+
+                    const diff = ((adState.nextPostDue || new Date()).getTime() - Date.now()) / 1000;
+                    const expDiff = ((adState.expireDue || new Date()).getTime() - Date.now()) / 1000;
+
+                    if((adState.nextPostDue) && (!adState.firstPost)) {
+                        this.adAutoPostUpdate = `Posting beings in ${Math.floor(diff / 60)}m ${Math.floor(diff % 60)}s`;
+                    } else {
+                        this.adAutoPostUpdate = `Next ad in ${Math.floor(diff / 60)}m ${Math.floor(diff % 60)}s`;
+                    }
+
+                    this.adAutoPostUpdate += `, auto-posting expires in ${Math.floor(expDiff / 60)}m ${Math.floor(expDiff % 60)}s`;
+                } else {
+                    this.adAutoPostNextAd = null;
+                    this.adAutoPostUpdate = 'No ads have been set up -- auto-posting will be cancelled.';
+                }
+            };
+
+            this.autoPostingUpdater = window.setInterval(updateAutoPostingState, 1000);
+
+            updateAutoPostingState();
+        }
+
+
         hasSFC(message: Conversation.Message): message is Conversation.SFCMessage {
             return (<Partial<Conversation.SFCMessage>>message).sfc !== undefined;
         }
@@ -421,6 +559,39 @@
             padding: 3px 10px;
         }
 
+        .auto-ads {
+            background-color: rgba(255, 128, 32, 0.8);
+            padding-left: 10px;
+            padding-right: 10px;
+            padding-top: 5px;
+            padding-bottom: 5px;
+            margin: 0;
+
+            h4 {
+                font-size: 1.1rem;
+                margin: 0;
+                line-height: 100%;
+            }
+
+            .update {
+                color: rgba(255, 255, 255, 0.6);
+                font-size: 13px;
+            }
+
+            .next {
+                margin-top: 0.5rem;
+                color: rgba(255, 255, 255, 0.4);
+                font-size: 11px;
+
+                h5 {
+                    font-size: 0.8rem;
+                    margin: 0;
+                    line-height: 100%;
+                }
+            }
+
+        }
+
         @media (max-width: breakpoint-max(sm)) {
             .mode-switcher a {
                 padding: 5px 8px;
diff --git a/chat/common.ts b/chat/common.ts
index 851056c..385acb3 100644
--- a/chat/common.ts
+++ b/chat/common.ts
@@ -44,12 +44,29 @@ export class Settings implements ISettings {
     bbCodeBar = true;
 }
 
+
+export class AdSettings implements Conversation.AdSettings {
+    ads: string[] = [];
+}
+
+
+export class AdState implements Conversation.AdState {
+    active = false;
+    firstPost?: Date = undefined;
+    nextPostDue?: Date = undefined;
+    interval?: any = undefined;
+    adIndex?: number = undefined;
+    expireDue?: Date = undefined;
+}
+
+
 export class ConversationSettings implements Conversation.Settings {
     notify = Conversation.Setting.Default;
     highlight = Conversation.Setting.Default;
     highlightWords: string[] = [];
     joinMessages = Conversation.Setting.Default;
     defaultHighlights = true;
+    adSettings: Conversation.AdSettings = { ads: [] };
 }
 
 function pad(num: number): string | number {
diff --git a/chat/conversations.ts b/chat/conversations.ts
index 9043138..74a23bf 100644
--- a/chat/conversations.ts
+++ b/chat/conversations.ts
@@ -1,6 +1,6 @@
 import {queuedJoin} from '../fchat/channels';
 import {decodeHTML} from '../fchat/common';
-import {characterImage, ConversationSettings, EventMessage, Message, messageToString} from './common';
+import { AdState, characterImage, ConversationSettings, EventMessage, Message, messageToString } from './common';
 import core from './core';
 import {Channel, Character, Conversation as Interfaces} from './interfaces';
 import l from './localize';
@@ -30,6 +30,7 @@ abstract class Conversation implements Interfaces.Conversation {
     infoText = '';
     abstract readonly maxMessageLength: number | undefined;
     _settings: Interfaces.Settings | undefined;
+    _adState: Interfaces.AdState | undefined;
     protected abstract context: CommandContext;
     protected maxMessages = 50;
     protected allMessages: Interfaces.Message[] = [];
@@ -49,6 +50,17 @@ abstract class Conversation implements Interfaces.Conversation {
         state.setSettings(this.key, value); //tslint:disable-line:no-floating-promises
     }
 
+    get adState(): Interfaces.AdState {
+        //tslint:disable-next-line:strict-boolean-expressions
+        return this._adState || (this._adState = state.adStates[this.key] || new AdState());
+    }
+
+    set adState(value: Interfaces.AdState) {
+        this._adState = value;
+        state.setAdState(this.key, value); //tslint:disable-line:no-floating-promises
+    }
+
+
     get isPinned(): boolean {
         return this._isPinned;
     }
@@ -65,6 +77,7 @@ abstract class Conversation implements Interfaces.Conversation {
 
     async send(): Promise<void> {
         if(this.enteredText.length === 0) return;
+
         if(isCommand(this.enteredText)) {
             const parsed = parseCommand(this.enteredText, this.context);
             if(typeof parsed === 'string') this.errorText = parsed;
@@ -186,6 +199,13 @@ class PrivateConversation extends Conversation implements Interfaces.PrivateConv
             this.errorText = l('chat.errorIgnored', this.character.name);
             return;
         }
+
+        if(this.adState.active) {
+            this.errorText = 'Cannot send ads manually while ad auto-posting is active';
+            return;
+        }
+
+
         core.connection.send('PRI', {recipient: this.name, message: this.enteredText});
         const message = createMessage(MessageType.Message, core.characters.ownCharacter, this.enteredText);
         this.safeAddMessage(message);
@@ -310,7 +330,17 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
 
     protected async doSend(): Promise<void> {
         const isAd = this.isSendingAds;
-        if(isAd && Date.now() < this.nextAd) return;
+
+        if(this.adState.active) {
+            this.errorText = 'Cannot post ads manually while ad auto-posting is active';
+            return;
+        }
+
+        if(isAd && Date.now() < this.nextAd) {
+            this.errorText = 'You must wait at least ten minutes between ad posts on this channel';
+            return;
+        }
+
         core.connection.send(isAd ? 'LRP' : 'MSG', {channel: this.channel.id, message: this.enteredText});
         await this.addMessage(
             createMessage(isAd ? MessageType.Ad : MessageType.Message, core.characters.ownCharacter, this.enteredText, new Date()));
@@ -318,6 +348,18 @@ class ChannelConversation extends Conversation implements Interfaces.ChannelConv
             this.nextAd = Date.now() + core.connection.vars.lfrp_flood * 1000;
         else this.clearText();
     }
+
+
+    async sendAd(text: string): Promise<void> {
+        if (text.length < 1)
+            return;
+
+        await this.addMessage(
+            createMessage(MessageType.Ad, core.characters.ownCharacter, text, new Date())
+        );
+
+        this.nextAd = Date.now() + core.connection.vars.lfrp_flood * 1000;
+    }
 }
 
 class ConsoleConversation extends Conversation {
@@ -357,6 +399,7 @@ class State implements Interfaces.State {
     recentChannels: Interfaces.RecentChannelConversation[] = [];
     pinned!: {channels: string[], private: string[]};
     settings!: {[key: string]: Interfaces.Settings};
+    adStates: {[key: string]: Interfaces.AdState} = {};
     modes!: {[key: string]: Channel.Mode | undefined};
     windowFocused = document.hasFocus();
 
@@ -401,6 +444,12 @@ class State implements Interfaces.State {
         await core.settingsStore.set('conversationSettings', this.settings);
     }
 
+
+    setAdState(key: string, value: Interfaces.AdState): void {
+        this.adStates[key] = value;
+    }
+
+
     show(conversation: Conversation): void {
         this.selectedConversation.onHide();
         conversation.unread = Interfaces.UnreadState.None;
diff --git a/chat/interfaces.ts b/chat/interfaces.ts
index 1ab53be..fb161d8 100644
--- a/chat/interfaces.ts
+++ b/chat/interfaces.ts
@@ -62,6 +62,7 @@ export namespace Conversation {
         mode: Channel.Mode
         readonly nextAd: number
         isSendingAds: boolean
+        sendAd(text: string): Promise<void>
     }
 
     export function isPrivate(conversation: Conversation): conversation is PrivateConversation {
@@ -94,8 +95,25 @@ export namespace Conversation {
         readonly highlightWords: ReadonlyArray<string>;
         readonly joinMessages: Setting;
         readonly defaultHighlights: boolean;
+        readonly adSettings: AdSettings;
     }
 
+
+    export interface AdSettings {
+        readonly ads: string[];
+    }
+
+
+    export interface AdState {
+        active: boolean;
+        firstPost?: Date;
+        nextPostDue?: Date;
+        expireDue?: Date;
+        interval?: any;
+        adIndex?: number;
+    }
+
+
     export const enum UnreadState { None, Unread, Mention }
 
     export interface Conversation {
@@ -109,6 +127,7 @@ export namespace Conversation {
         readonly key: string
         readonly unread: UnreadState
         settings: Settings
+        adState: AdState
         send(): Promise<void>
         clear(): void
         loadLastSent(): void