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(); +