1

We have recently switched over from WebChatV3 to V4. As an ongoing effort we're porting all our custom functionality to the new client. One of those functionalities is checking URLs for a specific domain and setting the target on the a tag to "_parent".

To implement this we've added a dependency to markdown-it, since the ReactWebChat element can take it as an argument as described here: BotFramework-Webchat Middleware for renderMarkdown Instead of adding an emoji renderer, we've built a rule into it and passed it into ReactWebChat as per the example given in the answer above. That code looks like this:

export const getConfiguredMarkdownIt = () => {
    const markdownIt = new MarkdownIt.default({ html: false, xhtmlOut: true, breaks: true, linkify: true, typographer: true });
    const defaultRender = markdownIt.renderer.rules.link_open || ((tokens, idx, options, env, self) => {
        return self.renderToken(tokens, idx, options);
    });

    markdownIt.renderer.rules.link_open = (tokens, idx, options, env, self) => {
        let href = '';
        const hrefIndex = tokens[idx].attrIndex('href');
        if (hrefIndex >= 0) {
            href = tokens[idx].attrs[hrefIndex][1];
        }
        const newTarget = Helper.getTargetForUrl(href);
        const targetIndex = tokens[idx].attrIndex('target');
        if (targetIndex < 0) {
            tokens[idx].attrPush(['target', newTarget]);
        } else {
            tokens[idx].attrs[targetIndex][1] = newTarget;
        }
        const relIndex = tokens[idx].attrIndex('rel');
        const rel = 'noopener noreferrer';
        if (relIndex < 0) {
            tokens[idx].attrPush(['rel', rel]);
        } else {
            tokens[idx].attrs[relIndex][1] = rel;
        }
        console.log(tokens[idx]);
        return defaultRender(tokens, idx, options, env, self);
    };
    return markdownIt;
}

This is then used to pass into the ReactWebChat element as such (left out a lot for brevity):

import { getConfiguredMarkdownIt } from './MarkdownSetup'
const md = getConfiguredMarkdownIt();
...
<ReactWebChat renderMarkdown={ md.render.bind(md) } />   

The first message our bot returns to the user sends a URL that should be targeting "_parent". However, it turns up as "_blank" consistently while the "rel" attribute is absolutely being set through our custom method. To me this confirms that our custom rule is working but something weird is going on. I've debugged what happens and the rendered HTML, including the "target" attribute keeps the correct value for a while but eventually gets switched over to "_blank". Later messages all get their target rendered correctly, I've replaced the URL in the opening activity to one of those to see what would happen and the result is the same: "_blank".

Javascript isn't really my expertise and I have a hard time following what happens when I step through the code in chrome debug tools. But I did manage to observe the correct HTML all the way up to card-elements.ts. When I get there, at the end of the isBleedingAtBottom function, the HTML I find suddenly has "_blank" in the "target" attribute. I am at a complete loss as to why this is happening.

Is this a bug or is there something I'm missing?

Versions:

"botframework-webchat": "^4.7.0",
"markdown-it": "8.3.1",

Here's the (slightly modified) JSON of the message:

{
  "type": "message",
  "serviceUrl": "http://localhost:57714",
  "channelId": "emulator",
  "from": {
    "id": "63700ba0-e2ca-11ea-8243-4773a3b07af6",
    "name": "Bot",
    "role": "bot"
  },
  "conversation": {
    "id": "63727ca0-e2ca-11ea-b639-bf8d0ffe9da8|livechat"
  },
  "recipient": {
    "id": "3952a99d-87de-4b22-a1b3-04fd8c9f141b",
    "role": "user"
  },
  "locale": "en-US",
  "inputHint": "acceptingInput",
  "attachments": [
    {
      "contentType": "application/vnd.microsoft.card.hero",
      "content": {
        "text": "Go to [this page](REMOVED URL) bla bla blah. click start to continue",
        "buttons": [
          {
            "type": "imBack",
            "title": "Start",
            "value": "Start"
          }
        ]
      }
    }
  ],
  "entities": [],
  "replyToId": "654f52f0-e2ca-11ea-b639-bf8d0ffe9da8",
  "id": "688a0b90-e2ca-11ea-b639-bf8d0ffe9da8",
  "localTimestamp": "2020-08-20T11:49:15+02:00",
  "timestamp": "2020-08-20T09:49:15.336Z"
}
  • I've tried updating ```botframework-webchat``` to ```4.9.2``` and have the same result. – FelixVanLeeuwen Jul 20 '20 at 09:06
  • Linking: https://github.com/microsoft/BotFramework-WebChat/issues/3422 – Kyle Delaney Aug 18 '20 at 17:50
  • What does card-elements.ts have to do with Markdown links? If you're using Adaptive Cards then that's an incredibly huge detail to leave out. – Kyle Delaney Aug 18 '20 at 17:51
  • Hey Kyle, To figure out what was wrong I stepped through the code. I was able to read the HTML in watch and it was correct up until it stepped into that isBleedingAtBottom function in card-elements.ts – FelixVanLeeuwen Aug 19 '20 at 07:02
  • Are you using Adaptive Cards? – Kyle Delaney Aug 19 '20 at 18:03
  • The message is a Hero Card – FelixVanLeeuwen Aug 20 '20 at 09:50
  • I've added the JSON to the question – FelixVanLeeuwen Aug 20 '20 at 10:00
  • Why didn't you say the markdown was in a card? Again, that's a very important detail to leave out. Web Chat converts Bot Framework cards (like hero cards) to Adaptive Cards, and Adaptive Cards have their own rules for Markdown. You made it sound like you were just using Markdown as the text of a normal message. Anyway, since we don't have your `Helper` class that actually determines which domains to use the `_parent` target for, it would help if we could simplify this question. What happens if you just try to apply the `_parent` target to all links regardless of domain? Same problem? – Kyle Delaney Aug 20 '20 at 19:26
  • Yes, same problem – FelixVanLeeuwen Aug 24 '20 at 07:13
  • I'm working on your issue but there are still some things that are confusing to me. `MarkdownIt.default` is very unfamiliar, so I'm wondering if you could show how you're importing the markdown-it package. Also, I want to clarify that since you're saying it doesn't work for the first message, are you saying it does work for subsequent messages? And have you tested this with normal text-based messages instead of cards? Since we've established that your Helper class is irrelevant, can you edit your question to include just the relevant code in a minimal verifiable example? – Kyle Delaney Aug 25 '20 at 02:34
  • Subsequent messages do get their attributes set correctly. The first message even has its "rel" attribute set correctly. What do you mean specifically by "show how you're importing the markdown-it package"? This: `import * as MarkdownIt from 'markdown-it';`? I'll change the first message of the bot to be just a string activity and see if I get similar result. – FelixVanLeeuwen Aug 26 '20 at 07:00
  • Yes, that import statement is what I meant. I suspect it should be `import MarkdownIt from 'markdown-it';` instead. – Kyle Delaney Aug 26 '20 at 18:35
  • A regular string activity does get its target set properly. I just added it right before the prompt, same string as in the card. The card still gets the wrong target on the `a` tag. I'm going to see if we have other prompts with a URL in their text and check if they are rendered correctly. – FelixVanLeeuwen Aug 27 '20 at 13:15
  • Putting the string from the "first message" into a different prompt yields the same result. The `target` attribute is not set to the expected value but the `rel` attribute is. Being the first message indeed has nothing to do with it. – FelixVanLeeuwen Aug 27 '20 at 13:57
  • All right, well it sounds like you have your answer. All cards in Web Chat are Adaptive Cards because Web Chat converts Bot Framework cards to Adaptive Cards. So this isn't a question of what Web Chat does to the Markdown since Web Chat uses the Adaptive Cards library and those transformations are outside of Web Chat's control. You asked a "what happens" question so are you satisfied with an explanation like that or will you only accept an answer that contains a solution that allows you to do what you're trying to do? – Kyle Delaney Aug 27 '20 at 16:43
  • "What happens between using renderMarkdown and actually writing to the DOM in ReactWebChat?" was the question. What I've uncovered is not an answer to that. There's something unexpected going on between the markdown renderer and actually writing to the DOM that changes the value of my `target` attribute. – FelixVanLeeuwen Aug 28 '20 at 09:10
  • I'd like to know exactly what so I can fix my issue. – FelixVanLeeuwen Aug 28 '20 at 09:10
  • Knowing what happens is not the same thing as knowing how to fix your issue. There are many cases where an issue cannot be "fixed" even when you know what's happening. Much of what happens in Web Chat is outside of your control, and you're now talking about behavior that's several levels removed from you because it's inside a package that's not even the package you're using directly. It's all open source though, so we can dig into the problem to see what's going on. I just want to clarify that the question you meant to ask is "How do I fix this?" and not "What's going on?" – Kyle Delaney Aug 31 '20 at 17:21
  • Are you still working on this? – Kyle Delaney Sep 07 '20 at 19:20
  • Hi Kyle, You are right, I do want my issue fixed. It may have been a little presumptuous of me to think that I was doing something wrong in my approach to rendering the `target` attribute on my `a` elements. Do you believe what is happening here is expected behavior? – FelixVanLeeuwen Sep 08 '20 at 11:49
  • Is my answer acceptable? – Kyle Delaney Sep 10 '20 at 00:57
  • Hey Kyle, We'll have to pick up the bug and work on the issue next sprint. I'll let you know before October starts (I hope). Thanks for the information! – FelixVanLeeuwen Sep 10 '20 at 06:56
  • Can you go ahead and upvote and accept the answer now? – Kyle Delaney Sep 10 '20 at 22:28

1 Answers1

1

Because Web Chat converts all cards to Adaptive Cards, you will need to solve this issue using Adaptive Cards. You can see here that the Adaptive Cards SDK that Web Chat is using converts all anchors to "_blank" after Markdown is applied.

let anchors = element.getElementsByTagName("a");

for (let i = 0; i < anchors.length; i++) {
    let anchor = <HTMLAnchorElement>anchors[i];
    anchor.classList.add(hostConfig.makeCssClassName("ac-anchor"));
    anchor.target = "_blank";
    anchor.onclick = (e) => {
        if (raiseAnchorClickedEvent(this, e.target as HTMLAnchorElement)) {
            e.preventDefault();
            e.cancelBubble = true;
        }
    }
}

I think you have a few options for how to force the link to open with "_parent" instead.

Option 1: Intercept the click event

There's some reading you'll need to do about how to implement custom handlers for your Adaptive Cards renderer in Web Chat: BotFramework-WebChat - Adaptive Card

The first thing to understand is that Web Chat uses the Adaptive Cards JavaScript SDK, available as an npm package. Web Chat mostly uses the out-of-the-box rendering functionality of the SDK, but one important thing it changes is how actions are handled. Without providing a customized handler, submit actions wouldn't be sent to the bot.

adaptiveCard.onExecuteAction = handleExecuteAction;

This is how applications are supposed to use Adaptive Cards. While most of the functionality is handled on the SDK side, there are a few things the application needs to do to make Adaptive Cards work for that specific app. While you can see Web Chat assigning a function to the onExecuteAction "event" property of a specific Adaptive Card instance, there is also a static counterpart of onExecuteAction that could be accessed like this:

AdaptiveCard.onExecuteAction = handleExecuteAction;

Using the static event will apply a handler for all Adaptive Cards instead of just one, but it will be overridden by any handlers applied to specific instances. The reason I'm telling you this is because there are many more static events, and there are a few in particular that will be useful for your situation:

static onAnchorClicked: (element: CardElement, anchor: HTMLAnchorElement) => boolean = null;
static onExecuteAction: (action: Action) => void = null;
static onElementVisibilityChanged: (element: CardElement) => void = null;
static onImageLoaded: (image: Image) => void = null;
static onInlineCardExpanded: (action: ShowCardAction, isExpanded: boolean) => void = null;
static onInputValueChanged: (input: Input) => void = null;
static onParseElement: (element: CardElement, json: any, errors?: Array<HostConfig.IValidationError>) => void = null;
static onParseAction: (element: Action, json: any, errors?: Array<HostConfig.IValidationError>) => void = null;
static onParseError: (error: HostConfig.IValidationError) => void = null;
static onProcessMarkdown: (text: string, result: IMarkdownProcessingResult) => void = null;

You might have guessed that the event we want is onAnchorClicked, and we can use it like this:

adaptiveCardsPackage.AdaptiveCard.onAnchorClicked = (element, anchor) => {
  console.log('anchor clicked', anchor);
  
  // Since it looks like you only want to use _parent for certain links
  // you can put that logic here
  window.open(anchor.href, '_parent', 'noreferrer');
  
  // Returning true will prevent the default behavior
  return true;
}

Option 2: Create a custom element

If you really want to make sure the anchor tag looks the way you want when inspecting the HTML and you don't want to open the link with JavaScript then you will need to create your own element type because we can see that text blocks and text runs don't allow you to do what you're trying to do. If you create your own type of text-based element like a text block then you can override internalRender and apply the Markdown however you like without changing the target to _blank. Please refer to the docs for more information about this option. Note that you will need to use an Adaptive Card explicitly in this case to use your custom element because Web Chat won't know to put the custom element in the Adaptive Card if you give it a hero card.

Kyle Delaney
  • 11,616
  • 6
  • 39
  • 66
  • 1
    Hey Kyle, Sorry for the late response. Other work items swamped us the last few week. Today I finally got to trying out your suggestions. I chose to go with option 1 first, the `a` tag does not need to look "correct" in the inspect element pane. Instead of opening a window through javascript, I simply set the target on the anchor to the result of my logic and return false to let the default behavior handle the rest. – FelixVanLeeuwen Oct 06 '20 at 09:56