From 0107dd60a10b444b32b384e5db8be5737a32a10c Mon Sep 17 00:00:00 2001
From: "Mr. Stallion" <mrstallion@nobody.nowhere.fauxemail.ext>
Date: Fri, 7 Jun 2019 21:26:01 -0500
Subject: [PATCH] URL previews

---
 bbcode/core.ts            |  62 ++++++++++++------
 bbcode/parser.ts          |   4 ++
 chat/ConversationView.vue |   6 +-
 chat/ImagePreview.vue     | 132 ++++++++++++++++++++++++++++++++++++++
 chat/UrlTagView.vue       |  58 +++++++++++++++++
 chat/bbcode.ts            |  24 ++++++-
 chat/event-bus.ts         |   3 +
 7 files changed, 268 insertions(+), 21 deletions(-)
 create mode 100644 chat/ImagePreview.vue
 create mode 100644 chat/UrlTagView.vue
 create mode 100644 chat/event-bus.ts

diff --git a/bbcode/core.ts b/bbcode/core.ts
index 766a7be..a0fd2b5 100644
--- a/bbcode/core.ts
+++ b/bbcode/core.ts
@@ -4,7 +4,7 @@ const urlFormat = '((?:https?|ftps?|irc)://[^\\s/$.?#"\']+\\.[^\\s"]+)';
 export const findUrlRegex = new RegExp(`(\\[url[=\\]]\\s*)?${urlFormat}`, 'gi');
 export const urlRegex = new RegExp(`^${urlFormat}$`);
 
-function domain(url: string): string | undefined {
+export function domain(url: string): string | undefined {
     const pieces = urlRegex.exec(url);
     if(pieces === null) return;
     const match = pieces[1].match(/(?:(https?|ftps?|irc):)?\/\/(?:www.)?([^\/]+)/);
@@ -17,6 +17,39 @@ function fixURL(url: string): string {
     return url.replace(/ /g, '%20');
 }
 
+export function analyzeUrlTag(parser: BBCodeParser, param: string, content: string): {success: boolean, url?: string, domain?: string, textContent: string} {
+    let url: string | undefined, textContent: string = content;
+    let success = true;
+
+    if(param.length > 0) {
+        url = param.trim();
+        if(content.length === 0) textContent = param;
+    } else if(content.length > 0) url = content;
+    else {
+        parser.warning('url tag contains no url.');
+        textContent = '';
+        success = false;
+    }
+
+    if((success) && (url)) {
+        // This fixes problems where content based urls are marked as invalid if they contain spaces.
+        url = fixURL(url);
+
+        if (!urlRegex.test(url)) {
+            textContent = `[BAD URL] ${url}`;
+            success = false;
+        }
+    }
+
+    return {
+        success,
+        url,
+        textContent,
+        domain: url ? domain(url) : undefined
+    };
+}
+
+
 export class CoreBBCodeParser extends BBCodeParser {
     /*tslint:disable-next-line:typedef*///https://github.com/palantir/tslint/issues/711
     constructor(public makeLinksClickable = true) {
@@ -40,41 +73,32 @@ export class CoreBBCodeParser extends BBCodeParser {
             return el;
         }));
         this.addTag(new BBCodeTextTag('url', (parser, parent, param, content) => {
+            const tagData = analyzeUrlTag(parser, param, content);
             const element = parser.createElement('span');
+
             parent.appendChild(element);
 
-            let url: string, display: string = content;
-            if(param.length > 0) {
-                url = param.trim();
-                if(content.length === 0) display = param;
-            } else if(content.length > 0) url = content;
-            else {
-                parser.warning('url tag contains no url.');
-                element.textContent = '';
+            if (!tagData.success) {
+                element.textContent = tagData.textContent;
                 return;
             }
 
-            // This fixes problems where content based urls are marked as invalid if they contain spaces.
-            url = fixURL(url);
-            if(!urlRegex.test(url)) {
-                element.textContent = `[BAD URL] ${url}`;
-                return;
-            }
             const fa = parser.createElement('i');
             fa.className = 'fa fa-link';
             element.appendChild(fa);
             const a = parser.createElement('a');
-            a.href = url;
+            a.href = tagData.url as string;
             a.rel = 'nofollow noreferrer noopener';
             a.target = '_blank';
             a.className = 'user-link';
-            a.title = url;
-            a.textContent = display;
+            a.title = tagData.url as string;
+            a.textContent = tagData.textContent;
             element.appendChild(a);
             const span = document.createElement('span');
             span.className = 'link-domain bbcode-pseudo';
-            span.textContent = ` [${domain(url)}]`;
+            span.textContent = ` [${tagData.domain}]`;
             element.appendChild(span);
+
             return element;
         }));
     }
diff --git a/bbcode/parser.ts b/bbcode/parser.ts
index fe9a4ee..59aad3d 100644
--- a/bbcode/parser.ts
+++ b/bbcode/parser.ts
@@ -92,6 +92,10 @@ export class BBCodeParser {
         this._tags[impl.tag] = impl;
     }
 
+    getTag(tag: string): BBCodeTag | undefined {
+        return this._tags[tag];
+    }
+
     removeTag(tag: string): void {
         delete this._tags[tag];
     }
diff --git a/chat/ConversationView.vue b/chat/ConversationView.vue
index 1f9172e..bc03297 100644
--- a/chat/ConversationView.vue
+++ b/chat/ConversationView.vue
@@ -136,6 +136,7 @@
         <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>
+        <image-preview ref="imagePreview"></image-preview>
     </div>
 </template>
 
@@ -150,6 +151,7 @@
     import { characterImage, getByteLength, getKey } from "./common";
     import ConversationSettings from './ConversationSettings.vue';
     import core from './core';
+    import ImagePreview from './ImagePreview.vue';
     import {Channel, channelModes, Character, Conversation, Settings} from './interfaces';
     import l from './localize';
     import Logs from './Logs.vue';
@@ -162,7 +164,8 @@
     @Component({
         components: {
             user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, settings: ConversationSettings,
-            logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp
+            logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp,
+            'image-preview': ImagePreview
         }
     })
     export default class ConversationView extends Vue {
@@ -471,6 +474,7 @@
             updateAutoPostingState();
         }
 
+
         hasSFC(message: Conversation.Message): message is Conversation.SFCMessage {
             return (<Partial<Conversation.SFCMessage>>message).sfc !== undefined;
         }
diff --git a/chat/ImagePreview.vue b/chat/ImagePreview.vue
new file mode 100644
index 0000000..d65b187
--- /dev/null
+++ b/chat/ImagePreview.vue
@@ -0,0 +1,132 @@
+<template>
+    <div class="image-preview-wrapper" v-if="isVisible()">
+        <webview class="image-preview-external" :src="isExternalUrl() ? url : null" :style="{display: isExternalUrl() ? 'flex' : 'none'}"></webview>
+        <div
+            class="image-preview-local"
+            :style="{backgroundImage: `url(${url})`, display: isInternalUrl() ? 'block' : 'none'}"
+        >
+        </div>
+    </div>
+</template>
+
+<script lang="ts">
+    import {Component, Hook} from '@f-list/vue-ts';
+    import Vue from 'vue';
+    import {EventBus} from './event-bus';
+    import {domain } from '../bbcode/core';
+
+    @Component
+    export default class ImagePreview extends Vue {
+        public visible: boolean = false;
+        public url: string|null = null;
+        public domain: string|undefined;
+
+        private interval: any = null;
+
+
+        @Hook('mounted')
+        onMounted() {
+            EventBus.$on(
+                'imagepreview-dismiss',
+                (eventData: any) => {
+                    console.log('DIMSMISS');
+                    this.dismiss(eventData.url);
+                }
+            );
+
+            EventBus.$on(
+                'imagepreview-show',
+                (eventData: any) => {
+                    console.log('SHOW');
+                    this.show(eventData.url);
+                }
+            );
+        }
+
+
+        dismiss(url: string) {
+            if (this.url !== url) {
+                // simply ignore
+                return;
+            }
+
+            this.url = null;
+            this.visible = false;
+
+            this.cancelTimer();
+        }
+
+
+        show(url: string) {
+            this.url = url;
+            this.domain = domain(url);
+
+            this.cancelTimer();
+
+            this.interval = setTimeout(
+                () => {
+                    this.visible = true;
+                },
+                100
+            );
+        }
+
+
+        cancelTimer() {
+            if (this.interval) {
+                clearTimeout(this.interval);
+            }
+
+            this.interval = null;
+        }
+
+
+        isVisible() {
+            return this.visible;
+        }
+
+
+        getUrl() {
+            return this.url;
+        }
+
+
+        isExternalUrl() {
+            return !((this.domain === 'f-list.net') || (this.domain === 'static.f-list.net'));
+        }
+
+
+        isInternalUrl() {
+            return !this.isExternalUrl();
+        }
+    }
+</script>
+
+
+<style lang="scss">
+    @import "~bootstrap/scss/functions";
+    @import "~bootstrap/scss/variables";
+    @import "~bootstrap/scss/mixins/breakpoints";
+
+    .image-preview-external {
+        position: absolute;
+        width: 50%;
+        height: 50%;
+        top: 0;
+        left: 0;
+        pointer-events: none;
+
+    }
+
+    .image-preview-local {
+        position: absolute;
+        width: 50%;
+        height: 50%;
+        top: 0;
+        left: 0;
+        pointer-events: none;
+        background-size: contain;
+        background-position: top left;
+        background-repeat: no-repeat;
+    }
+</style>
diff --git a/chat/UrlTagView.vue b/chat/UrlTagView.vue
new file mode 100644
index 0000000..dd5a0e1
--- /dev/null
+++ b/chat/UrlTagView.vue
@@ -0,0 +1,58 @@
+<template>
+    <span
+        @mouseover="show()"
+        @mouseleave="dismiss()"
+    >
+        <i class="fa fa-link"></i>
+        <a
+            :href="url"
+            rel="nofollow noreferrer noopener"
+            target="_blank"
+            class="user-link"
+            :title="url"
+        >{{text}}</a>
+        <span
+            class="link-domain bbcode-pseudo"
+        > [{{domain}}]</span>
+    </span>
+</template>
+
+<script lang="ts">
+    import {Component, Hook, Prop} from '@f-list/vue-ts';
+    import Vue from 'vue';
+    import {EventBus} from './event-bus';
+    // import core from './core';
+
+    @Component
+    export default class UrlTagView extends Vue {
+        @Prop({required: true})
+        readonly url!: string;
+
+        @Prop({required: true})
+        readonly text!: string;
+
+        @Prop({required: true})
+        readonly domain!: string;
+
+        @Prop()
+        hover!: boolean = false;
+
+        @Hook("beforeDestroy")
+        beforeDestroy() {
+            this.dismiss();
+        }
+
+        @Hook("deactivated")
+        deactivate() {
+            this.dismiss();
+        }
+
+        dismiss() {
+            EventBus.$emit('imagepreview-dismiss', {url: this.url});
+        }
+
+        show() {
+            EventBus.$emit('imagepreview-show', {url: this.url});
+        }
+    }
+</script>
diff --git a/chat/bbcode.ts b/chat/bbcode.ts
index a80665c..02d8050 100644
--- a/chat/bbcode.ts
+++ b/chat/bbcode.ts
@@ -1,5 +1,5 @@
 import Vue, {Component, CreateElement, RenderContext, VNode} from 'vue';
-import {CoreBBCodeParser} from '../bbcode/core';
+import { CoreBBCodeParser, analyzeUrlTag } from '../bbcode/core';
 //tslint:disable-next-line:match-default-export-name
 import BaseEditor from '../bbcode/Editor.vue';
 import {BBCodeTextTag} from '../bbcode/parser';
@@ -7,6 +7,7 @@ import ChannelView from './ChannelTagView.vue';
 import {characterImage} from './common';
 import core from './core';
 import {Character} from './interfaces';
+import UrlView from './UrlTagView.vue';
 import UserView from './user_view';
 
 export const BBCodeView: Component = {
@@ -100,6 +101,27 @@ export default class BBCodeParser extends CoreBBCodeParser {
             this.cleanup.push(view);
             return root;
         }));
+
+        this.addTag(new BBCodeTextTag(
+            'url',
+            (parser, parent, _, content) => {
+                const tagData = analyzeUrlTag(parser, _, content);
+
+                const root = parser.createElement('span');
+                // const el = parser.createElement('span');
+                parent.appendChild(root);
+                // root.appendChild(el);
+
+                if (!tagData.success) {
+                    root.textContent = tagData.textContent;
+                    return;
+                }
+
+                const view = new UrlView({el: root, propsData: {url: tagData.url, text: tagData.textContent, domain: tagData.domain}});
+                this.cleanup.push(view);
+
+                return root;
+            }));
     }
 
     parseEverything(input: string): BBCodeElement {
diff --git a/chat/event-bus.ts b/chat/event-bus.ts
new file mode 100644
index 0000000..5b3e40d
--- /dev/null
+++ b/chat/event-bus.ts
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+export const EventBus = new Vue();
+