reactjsbotframeworkweb-chatmarkdown-it

What happens between using renderMarkdown and actually writing to the DOM in ReactWebChat?


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"
}

Solution

  • 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.