Override quoteState opts?

I’m trying to make anonymous posts more anonymous by overriding how quotes are displayed in categories where only anonymous posting is enabled. So instead of the default

anonymous4302934
some quoted text

what I want is

Anonymous:
some quoted text

I’m able to do this to text that gets quoted via the toolbar button, based on How to override the buildQuote function? - #7 by Canapin (I had to add a container lookup at the bottom to get it working per the workaround here: api.modifyClass sometimes(!) not working - #12 by RGJ )

I’ve dug through core files and I can’t figure out exactly where the quote is built when a quote is inserted into the composer via select and clicking the quote-button. I see in _selectionChanged() in quote-button.js that opts.username is set if the selected text is in a blockquote, but if I try modifying this to manually set opts.username, it doesn’t affect anything.

Is this the right thing to try to override/is modifyClass the correct way to approach this?

<script type="text/discourse-plugin" version="0.8">
const controller = api.container.lookup('controller:topic');
const anon_categories = [5, 6, 14, 15, 16, 17, 18]

    function buildQuote(post, contents, opts = {}) {
      if (!post || !contents) {
        return "";
      }
    
    let anon_name = ``;
    // console.log(anon_categories, controller.get("model.category_id"))
        if (anon_categories.includes(controller.get("model.category_id"))) {
            anon_name = `Anonymous`;
        } else {
            anon_name = opts.username || post.username;
        }
        
    const params = [
        anon_name,
        `post:${opts.post || post.post_number}`,
        `topic:${opts.topic || post.topic_id}`
      ];
    
      if (opts.full) params.push("full:true");
    
      return `\n[quote="${params.join(", ")}"]\n${contents.trim()}\n[/quote]\n\n`;
    }
    
    api.modifyClass('controller:composer', {
        pluginId: 'anonymize-quotes',
        actions: {
            importQuote(toolbarEvent) {
              const postStream = this.get("topic.postStream");
              let postId = this.get("model.post.id");
        
              // If there is no current post, use the first post id from the stream
              if (!postId && postStream) {
                postId = postStream.get("stream.firstObject");
              }
        
              // If we're editing a post, fetch the reply when importing a quote
              if (this.get("model.editingPost")) {
                const replyToPostNumber = this.get("model.post.reply_to_post_number");
                if (replyToPostNumber) {
                  const replyPost = postStream.posts.findBy(
                    "post_number",
                    replyToPostNumber
                  );
        
                  if (replyPost) {
                    postId = replyPost.id;
                  }
                }
              }
        
              if (postId) {
                this.set("model.loading", true);
        
                return this.store.find("post", postId).then(post => {
                  const quote = buildQuote(post, post.raw, {
                    full: true
                  });
        
                  toolbarEvent.addText(quote);
                  this.set("model.loading", false);
                });
              }
            }
        }
    });
    
    api.modifyClass('component:quote-button', {
        pluginId: 'anonymize-quotes',
        actions: {
            _selectionChanged() {
    if (this._displayFastEditInput) {
      return;
    }

    const quoteState = this.quoteState;

    const selection = window.getSelection();
    if (selection.isCollapsed) {
      if (this.visible) {
        this._hideButton();
      }
      return;
    }

    // ensure we selected content inside 1 post *only*
    let firstRange, postId;
    for (let r = 0; r < selection.rangeCount; r++) {
      const range = selection.getRangeAt(r);
      const $selectionStart = $(range.startContainer);
      const $ancestor = $(range.commonAncestorContainer);

      if ($selectionStart.closest(".cooked").length === 0) {
        return;
      }

      firstRange = firstRange || range;
      postId = postId || $ancestor.closest(".boxed, .reply").data("post-id");

      if ($ancestor.closest(".contents").length === 0 || !postId) {
        if (this.visible) {
          this._hideButton();
        }
        return;
      }
    }

    const _selectedElement = selectedElement();
    const _selectedText = selectedText();

    const $selectedElement = $(_selectedElement);
    const cooked =
      $selectedElement.find(".cooked")[0] ||
      $selectedElement.closest(".cooked")[0];

    // computing markdown takes a lot of time on long posts
    // this code attempts to compute it only when we can't fast track
    let opts = {
      full:
        selectedRange().startOffset > 0
          ? false
          : _selectedText === toMarkdown(cooked.innerHTML),
    };

    for (
      let element = _selectedElement;
      element && element.tagName !== "ARTICLE";
      element = element.parentElement
    ) {
      if (element.tagName === "ASIDE" && element.classList.contains("quote")) {
        opts.username = element.dataset.username || getQuoteTitle(element);
        opts.post = element.dataset.post;
        opts.topic = element.dataset.topic;
        break;
      }
    }
    
    opts.username = `Anonymous`

    quoteState.selected(postId, _selectedText, opts);
    this.set("visible", quoteState.buffer.length > 0);

    if (this.siteSettings.enable_fast_edit) {
      this.set(
        "_canEditPost",
        this.topic.postStream.findLoadedPost(postId)?.can_edit
      );

      if (this._canEditPost) {
        const regexp = new RegExp(regexSafeStr(quoteState.buffer), "gi");
        const matches = cooked.innerHTML.match(regexp);

        if (
          quoteState.buffer.length < 1 ||
          quoteState.buffer.includes("|") || // tables are too complex
          quoteState.buffer.match(/\n/g) || // linebreaks are too complex
          matches?.length > 1 // duplicates are too complex
        ) {
          this.set("_isFastEditable", false);
          this.set("_fastEditInitalSelection", null);
          this.set("_fastEditNewSelection", null);
        } else if (matches?.length === 1) {
          this.set("_isFastEditable", true);
          this.set("_fastEditInitalSelection", quoteState.buffer);
          this.set("_fastEditNewSelection", quoteState.buffer);
        }
      }
    }

    // avoid hard loops in quote selection unconditionally
    // this can happen if you triple click text in firefox
    if (this._prevSelection === _selectedText) {
      return;
    }

    this._prevSelection = _selectedText;

    // on Desktop, shows the button at the beginning of the selection
    // on Mobile, shows the button at the end of the selection
    const isMobileDevice = this.site.isMobileDevice;
    const { isIOS, isAndroid, isOpera } = this.capabilities;
    const showAtEnd = isMobileDevice || isIOS || isAndroid || isOpera;

    const boundaryPosition = this._getRangeBoundaryRect(firstRange, showAtEnd);

    // change the position of the button
    schedule("afterRender", () => {
      if (!this.element || this.isDestroying || this.isDestroyed) {
        return;
      }

      let top = 0;
      let left = 0;
      const pxFromSelection = 5;

      if (showAtEnd) {
        // The selection-handles on iOS have a hit area of ~50px radius
        // so we need to make sure our buttons are outside that radius
        // Apply the same logic on all mobile devices for consistency

        top = boundaryPosition.bottom + pxFromSelection;
        left = boundaryPosition.left;

        const safeRadius = 50;

        const topicArea = document
          .querySelector(".topic-area")
          .getBoundingClientRect();
        topicArea.x += document.documentElement.scrollLeft;
        topicArea.y += document.documentElement.scrollTop;

        const endHandlePosition = boundaryPosition;
        const width = this.element.clientWidth;

        const possiblePositions = [
          {
            // move to left
            top,
            left: left - width - safeRadius,
          },
          {
            // move to right
            top,
            left: left + safeRadius,
          },
          {
            // centered below end handle
            top: top + safeRadius,
            left: left - width / 2,
          },
        ];

        for (const pos of possiblePositions) {
          // Ensure buttons are entirely within the .topic-area
          pos.left = Math.max(topicArea.left, pos.left);
          pos.left = Math.min(topicArea.right - width, pos.left);

          let clearOfStartHandle = true;
          if (isAndroid) {
            // On android, the start-selection handle extends below the line, so we need to avoid it as well:
            const startHandlePosition = this._getRangeBoundaryRect(
              firstRange,
              false
            );

            clearOfStartHandle =
              pos.top - startHandlePosition.bottom >= safeRadius ||
              pos.left + width <= startHandlePosition.left - safeRadius ||
              pos.left >= startHandlePosition.left + safeRadius;
          }

          const clearOfEndHandle =
            pos.top - endHandlePosition.top >= safeRadius ||
            pos.left + width <= endHandlePosition.left - safeRadius ||
            pos.left >= endHandlePosition.left + safeRadius;

          if (clearOfStartHandle && clearOfEndHandle) {
            left = pos.left;
            top = pos.top;
            break;
          }
        }
      } else {
        // Desktop
        top =
          boundaryPosition.top - this.element.clientHeight - pxFromSelection;
        left = boundaryPosition.left;
      }

      Object.assign(this.element.style, { top: `${top}px`, left: `${left}px` });

      if (!this.animated) {
        // We only enable CSS transitions after the initial positioning
        // otherwise the button can appear to fly in from off-screen
        next(() => this.set("animated", true));
      }
    });
  }
        }
    });
    
    const composerController = api.container.lookup("controller:composer");
    const componentQuoteButton = api.container.lookup("component:quote-button");
</script>
2 Likes

In case someone else comes across this, I found how to override the quoteState opts in quote-button.js insertQuote. Full code below, which will replace the username in quotes in specified categories with both the toolbar quote and the selection quote button.

<script type="text/discourse-plugin" version="0.8">
const controller = api.container.lookup('controller:topic');
const anon_categories = [5, 6, 14, 15, 16, 17, 18]

    function buildQuote(post, contents, opts = {}) {
      if (!post || !contents) {
        return "";
      }
    
        let anon_name = ``;
        // console.log(anon_categories, controller.get("model.category_id"))
        if (anon_categories.includes(controller.get("model.category_id"))) {
            anon_name = `Anonymous`;
        } else {
            anon_name = opts.username || post.username;
        }
        
        const params = [
            anon_name,
            `post:${opts.post || post.post_number}`,
            `topic:${opts.topic || post.topic_id}`
          ];
    
        if (opts.full) params.push("full:true");
    
        return `\n[quote="${params.join(", ")}"]\n${contents.trim()}\n[/quote]\n\n`;
    }
    
    api.modifyClass('controller:composer', {
        pluginId: 'anonymize-quotes',
        actions: {
            importQuote(toolbarEvent) {
              const postStream = this.get("topic.postStream");
              let postId = this.get("model.post.id");
        
              // If there is no current post, use the first post id from the stream
              if (!postId && postStream) {
                postId = postStream.get("stream.firstObject");
              }
        
              // If we're editing a post, fetch the reply when importing a quote
              if (this.get("model.editingPost")) {
                const replyToPostNumber = this.get("model.post.reply_to_post_number");
                if (replyToPostNumber) {
                  const replyPost = postStream.posts.findBy(
                    "post_number",
                    replyToPostNumber
                  );
        
                  if (replyPost) {
                    postId = replyPost.id;
                  }
                }
              }
        
              if (postId) {
                this.set("model.loading", true);
        
                return this.store.find("post", postId).then(post => {
                  const quote = buildQuote(post, post.raw, {
                    full: true
                  });
        
                  toolbarEvent.addText(quote);
                  this.set("model.loading", false);
                });
              }
            }
        }
    });
    
    api.modifyClass("component:quote-button", {
        pluginId: 'anonymize-quotes',
        actions: {
            insertQuote() {
                let anon_name = ``;
                // console.log(anon_categories, controller.get("model.category_id"))
                if (anon_categories.includes(controller.get("model.category_id"))) {
                    anon_name = `Anonymous`;
                } else {
                    anon_name = opts.username || post.username;
                }
                this.attrs.quoteState.value.opts.username = anon_name;
                this.attrs.selectText().then(() => this._hideButton());
                console.log(anon_name, this.attrs.quoteState.value.opts)
              }
        }
    });
    
    const composerController = api.container.lookup("controller:composer");
    const quoteButton = api.container.lookup("component:quote-button");
</script>