21

Idea of participation indicator

It would be useful for me to see a clear indication of my participation on any given question page—right at the top of the page—whether it be that I asked the question, answered the question, made a comment, edited, voted, whatever (any action taken... maybe even that I've simply viewed the page before).

Too many times now I've been surprised by the fact that I had viewed a question page before, let alone taken an action on it, but only discovered that fact by chance, because I was scrolling along, carefully re-reading parts of the page and noticed my footprints. It would be convenient to know that I've been involved in a question immediately upon loading the page (see image above); and conversely, via absence of such indication, know that I have never participated in it / even viewed it.

Additionally, through some smart UI, it might even be nice to see a chronological "history" of actions taken (like, maybe I answered, made comments, and even edited other answers).

Maybe this functionality already exists, but just needs to be turned ON in the settings?

14
  • 1
    Interesting idea, although I highly doubt SE will spend time on it. Good basis for a userscript, though :) Commented Sep 10, 2021 at 19:35
  • 7
    This would be super neat and useful, IMO, but also very expensive. Keeping a forever list of when you last visited a post and then displaying it for every single page... They do track this already (from their blog posts detailing forensic investigation of the last user hacking incident), but I don't think they currently log it forever, probably only 3 to 12 months. While you would only need to store one record per question already visited, and store only the last visited date rather than all visited dates, the size of each user's table would still grow fairly quickly... forever.
    – TylerH
    Commented Sep 10, 2021 at 19:35
  • 1
    I mean, if it's based on your last action there... there would be no extra data storage needed. Your action already has a timestamp.
    – Kevin B
    Commented Sep 10, 2021 at 19:38
  • 1
    @TylerH That sounds complicated. I'd probably look for the userid on the post and all the revisions.
    – Scratte
    Commented Sep 10, 2021 at 19:38
  • 1
    @KevinB Oh, that's true, if OP is asking for last interaction, that's different. I was thinking based on "the fact I had viewed a question" they meant simply "last visited"
    – TylerH
    Commented Sep 10, 2021 at 19:38
  • btw, I think @Scratte's right - this could be optimized down to tracking users who viewed a post in a map of <userid, timestamp> rather than tracking every single post a user visited. Commented Sep 10, 2021 at 19:39
  • 1
    @OlegValter Yeah, I suppose tracking posts rather than users would be more efficient; there are far fewer posts than there are user views of posts. This feature could also be fully available to elected moderators (along with userid and ip address) to help track voting fraud/user targeting/abuse in general.
    – TylerH
    Commented Sep 10, 2021 at 19:45
  • 1
    Yeah, this wouldn't need to track every single interaction, just the last one for currentUser() on that post. I don't know if SE itself agrees with this feature request but I like the idea, and I don't see it being an expensive call to implement. It's arguably more expensive for users to dig up their old questions and answers from 10 years ago, which we can already do from our own profile.
    – codewario
    Commented Sep 10, 2021 at 19:47
  • @TylerH we pretty much flashed out an idea for a userscript then :) I checked that the most viewed posts (non-deleted) of all time on SO has a measly 10 million views, so likely an even smaller amount of users - seems like storing a map of userid/timestamp per post isn't going to be that much of an issue in a general case Commented Sep 10, 2021 at 19:50
  • 5
    It would also be handy to see when someone was last active...
    – Kevin B
    Commented Sep 10, 2021 at 19:55
  • 1
    @KevinB like with this userscript? :) Sigh, seems like this unilaterally removed feature is not going back in any form other than in this form... Commented Sep 10, 2021 at 20:04
  • 2
    Participation seems more valuable to me than last visit. If there were a participation flag, it would be easy enough to search the page on my own. When the participation took place isn't too important to me.
    – DaveL17
    Commented Sep 10, 2021 at 21:39
  • 3
    While researching about a certain problem, I found an SO question. Then upon scrolling down, I was surprised to find out that I had answered it myself years ago. I happily copied the solution from my own answer. (this happened to me once or twice) - So, yes this indicator will probably come in handy.
    – 41686d6564
    Commented Sep 11, 2021 at 3:16
  • 2
    It'd prevent those embarrassing moments when you go to upvote a comment or answer, and then you notice that you wrote it. :)
    – PM 2Ring
    Commented Sep 11, 2021 at 4:53

1 Answer 1

8

Userscript

Inspired by this Q&A, I made a userscript that adds an activity indicator to Q&A pages and published it on Stack Apps. For now it just shows if you participated in the Q&A (asked the question, posted an answer, commented on the question or any of the answers, or were the last editor to either the question or any of the answers), but expect extended functionality in future versions.

The indicator is appended to other post stats:

post header with participation stats added

Interactive preview

You can run the preview snippet below to get a feeling on how the script works:

"use strict";
((w, d, l) => {
  const API_BASE = "https://api.stackexchange.com";
  const API_VER = 2.3;

  const delay = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms));

  const getQuestionComments = async(id, {
    site = "stackoverflow",
    page = 1,
    ...rest
  }) => {
    const url = new URL(`${API_BASE}/${API_VER}/questions/${id}/comments`);
    url.search = new URLSearchParams({
      site,
      page: page.toString(),
      ...rest,
    }).toString();
    const res = await fetch(url.toString());
    if (!res.ok)
      return [];
    const {
      items = [], has_more = false, backoff
    } = await res.json();
    if (backoff) {
      await delay(backoff * 1e3);
      return getQuestionComments(id, {
        site,
        page,
        ...rest
      });
    }
    if (has_more) {
      items.push(...(await getQuestionComments(id, {
        site,
        page: page + 1,
        ...rest,
      })));
    }
    return items;
  };

  const getAnswerComments = async(id, {
    site = "stackoverflow",
    page = 1,
    ...rest
  }) => {
    const url = new URL(`${API_BASE}/${API_VER}/answers/${id}/comments`);
    url.search = new URLSearchParams({
      site,
      page: page.toString(),
      ...rest,
    }).toString();
    const res = await fetch(url.toString());
    if (!res.ok)
      return [];
    const {
      items = [], has_more = false, backoff
    } = await res.json();
    if (backoff) {
      await delay(backoff * 1e3);
      return getAnswerComments(id, {
        site,
        page,
        ...rest
      });
    }
    if (has_more) {
      items.push(...(await getAnswerComments(id, {
        site,
        page: page + 1,
        ...rest,
      })));
    }
    return items;
  };

  const getQuestions = async(id, {
    site = "stackoverflow",
    page = 1,
    ...rest
  }) => {
    const url = new URL(`${API_BASE}/${API_VER}/questions/${id}`);
    url.search = new URLSearchParams({
      site,
      page: page.toString(),
      ...rest,
    }).toString();
    const res = await fetch(url.toString());
    if (!res.ok)
      return [];
    const {
      items = [], has_more = false, backoff
    } = await res.json();
    if (backoff) {
      await delay(backoff * 1e3);
      return getQuestions(id, {
        site,
        page,
        ...rest
      });
    }
    if (has_more) {
      items.push(...(await getQuestions(id, {
        site,
        page: page + 1,
        ...rest,
      })));
    }
    return items;
  };

  const getQuestionAnswers = async(id, {
    site = "stackoverflow",
    page = 1,
    ...rest
  }) => {
    const url = new URL(`${API_BASE}/${API_VER}/questions/${id}/answers`);
    url.search = new URLSearchParams({
      site,
      page: page.toString(),
      ...rest,
    }).toString();
    const res = await fetch(url.toString());
    if (!res.ok)
      return [];
    const {
      items = [], has_more = false, backoff
    } = await res.json();
    if (backoff) {
      await delay(backoff * 1e3);
      return getQuestionAnswers(id, {
        site,
        page,
        ...rest
      });
    }
    if (has_more) {
      items.push(...(await getQuestionAnswers(id, {
        site,
        page: page + 1,
        ...rest,
      })));
    }
    return items;
  };

  class ParticipationInfo {
    constructor(userId, questionComments, answerComments, answers, questions) {
      this.userId = userId;
      this.questionComments = questionComments;
      this.answerComments = answerComments;
      this.answers = answers;
      this.questions = questions;
    }
    get myAnswers() {
      const {
        answers,
        userId
      } = this;
      return answers.filter(({
        owner
      }) => (owner === null || owner === void 0 ? void 0 : owner.user_id) === userId);
    }
    get myQuestions() {
      const {
        questions,
        userId
      } = this;
      return questions.filter(({
        owner
      }) => (owner === null || owner === void 0 ? void 0 : owner.user_id) === userId);
    }
    get editedAnswers() {
      const {
        answers,
        userId
      } = this;
      return answers.filter(({
        last_editor
      }) => (last_editor === null || last_editor === void 0 ? void 0 : last_editor.user_id) === userId);
    }
    get editedQuestions() {
      const {
        questions,
        userId
      } = this;
      return questions.filter(({
        last_editor
      }) => (last_editor === null || last_editor === void 0 ? void 0 : last_editor.user_id) === userId);
    }
    get hasAnswers() {
      const {
        myAnswers
      } = this;
      return !!myAnswers.length;
    }
    get hasQuestions() {
      const {
        myQuestions
      } = this;
      return !!myQuestions.length;
    }
    get hasEditedAnswers() {
      const {
        editedAnswers
      } = this;
      return !!editedAnswers.length;
    }
    get hasEditedQuestions() {
      const {
        editedQuestions
      } = this;
      return !!editedQuestions.length;
    }
    get hasQuestionComments() {
      const {
        questionComments
      } = this;
      return !!questionComments.length;
    }
    get hasAnswerComments() {
      const {
        answerComments
      } = this;
      return !!answerComments.length;
    }
  }

  const addParticipationInfo = (info) => {
    const statsRow = d.querySelector("#question-header + div");
    if (!statsRow)
      return;
    const titleText = "Participated";
    const activityMap = [
      [info.hasAnswers, "A"],
      [info.hasQuestions, "Q"],
      [info.hasEditedAnswers, "EA"],
      [info.hasEditedQuestions, "EQ"],
      [info.hasAnswerComments, "AC"],
      [info.hasQuestionComments, "QC"],
    ];
    const participated = activityMap.filter(([cond]) => cond);
    const infoText = participated.map(([, l]) => l).join(" ") || "no";
    const item = d.createElement("div");
    item.id = "participated";
    item.classList.add("flex--item", "ws-nowrap", "mb8", "ml16");
    item.title = `${titleText}: ${infoText}`;
    const title = d.createElement("span");
    title.classList.add("fc-light", "mr2");
    title.textContent = titleText;
    item.append(title);
    title.after(` ${infoText} `);
    statsRow.append(item);
  };
  
  const formatDate = (timestamp) => new Date(timestamp * 1e3).toISOString().replace("T", " ").replace(/\..+/, "Z");
  
  const getDateDiff = (start, end) => Math.floor((end.valueOf() - start.valueOf()) / 864e5);

  d.addEventListener("click", async({
    target
  }) => {
    if (target.id !== "load") return;

    try {
      const { elements } = d.forms.activity;
      const userId = +elements.userId.value;
      const questionId = +elements.questionId.value;
      const site = elements.site.value;
      if (!questionId || !userId) return;
      
      const commonOpts = {
        site,
        key: "UKKfmybQ9USA0N80jdnU8w(("
      };
      const questionComments = await getQuestionComments(questionId, {
        ...commonOpts,
        filter: "!--OzlnfZUU0r",
      });
      const questions = await getQuestions(questionId, {
        ...commonOpts,
        filter: "AYfEom_eVg0.fJ-eMNhmr",
      });
      
      const preview = d.getElementById("preview");
      const missing = d.getElementById("404");
      
      if(!questions.length) {
        missing.classList.remove("hidden");
        preview.classList.add("hidden");
      }
      
      const [{ 
        creation_date,
        last_activity_date,
        title, 
        view_count 
      }] = questions;
      
      const asked = d.querySelector("#asked span:nth-child(2)");
      const acted = d.querySelector("#active span:nth-child(2)");
      const viewed = d.querySelector("#viewed span:nth-child(2)");
      const header = d.querySelector("#question-header h1");
      
      d.getElementById("participated")?.remove();
      
      const now = new Date();
      now.setHours(0);
      
      const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
      const c_date = rtf.format(getDateDiff(now, creation_date*1e3), "day");
      const a_date = rtf.format(getDateDiff(now, last_activity_date*1e3), "day");
      
      asked.textContent = c_date;
      asked.title = formatDate(creation_date);
      
      acted.textContent = a_date;
      acted.title = formatDate(last_activity_date);
      
      const vc = `${view_count} time${view_count > 1 ? "s" : ""}`;
      viewed.textContent = vc;
      viewed.title = `Viewed ${vc}`;
      
      header.textContent = title;
      
      preview.classList.remove("hidden");
      missing.classList.add("hidden");
      
      const answers = await getQuestionAnswers(questionId, {
        ...commonOpts,
        filter: "!ao-)ijIL.2UJgN",
      });
      const answerCommentsPromises = answers.map(({
        answer_id
      }) => {
        return getAnswerComments(answer_id, {
          ...commonOpts,
          filter: "!--OzlnfZUU0r",
        });
      });
      const answerComments = await Promise.all(answerCommentsPromises);
      const commentFilter = ({
        owner,
        reply_to_user
      }) => [owner === null || owner === void 0 ? void 0 : owner.user_id, reply_to_user === null || reply_to_user === void 0 ? void 0 : reply_to_user.user_id].includes(userId);
      const myQuestionComments = questionComments.filter(commentFilter);
      const myAnswerComments = answerComments
        .flat()
        .filter(commentFilter);
      const info = new ParticipationInfo(userId, myQuestionComments, myAnswerComments, answers, questions);
      console.debug(info);
      addParticipationInfo(info);
    } catch (error) {
      console.debug(`Activity Indicator error:\n${error}`);
    }

  });

})(window, document, location);
:root {
  --family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", sans-serif;
}

#question-header {
  display: flex;
  flex-flow: row nowrap;
  justify-content: space-between;
}

.d-flex {
  display: flex !important;
}

.ow-break-word {
  overflow-wrap: break-word !important;
  word-wrap: break-word !important;
}

.fw-wrap {
  flex-wrap: wrap !important;
}

.ws-nowrap {
  white-space: nowrap !important;
}

.mb8 {
  margin-bottom: 8px !important;
}

.mb16 {
  margin-bottom: 16px !important;
}

.mr2 {
  margin-right: 2px !important;
}

.mr16 {
  margin-right: 16px !important;
}

.ml16 {
    margin-left: 16px !important;
}

.pb8 {
  padding-bottom: 8px !important;
}

.fl1 {
  flex: 1 auto !important;
}

.bb {
  border-bottom-style: solid !important;
  border-bottom-width: 1px !important;
}

body {
  font-family: var(--family);
  font-size: 13px;
}

h1 {
  margin: 0 0 1em;
  font-size: 1.75rem;
  line-height: 1.35;
  font-weight: normal;
  margin-bottom: 0;
}

/* end of Stack Exchange styles */

#preview {
  margin-top: 5vh;
}

.hidden {
  display: none;
}

label, input {
  display: block;
  font-family: var(--family);
}

label {
  margin-bottom: 0.5vh;
  font-weight: 600;
}

input {
  margin-bottom: 2vh;
  padding: 1vh 1vw 1vh 0;
  border-width: 0 0 1px 0;
}

button {
  margin: 1vh 0;
  padding: 1vh 1vw;
  background: none;
  border-radius: 1vh;
  border-width: 1px;
}

button:hover {
  color: grey;
  border-color: grey;
}
<form id="activity">
  <label>Site Hostname</label>
  <input id="site" type="text" placeholder="meta.stackoverflow" value="meta.stackoverflow" />

  <label>User Id</label>
  <input id="userId" type="text" placeholder="2370483" value="2370483" />

  <label>Question Id</label>
  <input id="questionId" type="text" placeholder="411441" value="411441" />

  <button id="load" type="button">Load</button>
</form>

<div id="404" class="hidden">
  <p>Question with this id is not found</p>
</div>

<div id="preview" class="hidden">

  <div id="question-header" class="d-flex sm:fd-column">
    <h1 class="ow-break-word mb8 flex--item fl1"></h1>
  </div>

  <div class="d-flex fw-wrap pb8 mb16 bb">
    <div id="asked" class="flex--item ws-nowrap mr16 mb8">
      <span class="mr2">Asked</span>
      <span></span>
    </div>
    <div id="active" class="flex--item ws-nowrap mr16 mb8">
      <span class="mr2">Active</span>
      <span></span>
    </div>
    <div id="viewed" class="flex--item ws-nowrap mb8" title="">
      <span class="mr2">Viewed</span>
      <span></span>
    </div>
  </div>

</div>

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .