From 9571271d83925e852237633a13fb9ae330ea9477 Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Fri, 15 Sep 2023 22:15:41 +0800
Subject: [PATCH] Experimental posting stats for non-following accounts

Also recode+redesign the multiple metadata boxes in account info
---
 src/components/account-info.css | 105 +++++++++-
 src/components/account-info.jsx | 354 +++++++++++++++++++++-----------
 2 files changed, 335 insertions(+), 124 deletions(-)

diff --git a/src/components/account-info.css b/src/components/account-info.css
index 168c4841..197a0579 100644
--- a/src/components/account-info.css
+++ b/src/components/account-info.css
@@ -139,13 +139,13 @@
   /* flex-wrap: wrap; */
   column-gap: 24px;
   row-gap: 8px;
-  opacity: 0.75;
+  /* opacity: 0.75; */
   font-size: 90%;
   background-color: var(--bg-faded-color);
   padding: 12px;
-  border-radius: 16px;
+  /* border-radius: 16px; */
   line-height: 1.25;
-  overflow-x: auto;
+  overflow-x: auto !important;
   justify-content: flex-start;
   position: relative;
 
@@ -185,11 +185,33 @@
   display: flex;
 }
 
+.account-container .account-metadata-box {
+  overflow: hidden;
+  border-radius: 16px;
+
+  & > * {
+    margin-bottom: 2px;
+    border-radius: 4px;
+    overflow: hidden;
+  }
+
+  &:has(+ .account-metadata-box) {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+  }
+
+  + .account-metadata-box {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+    border-bottom-left-radius: 16px;
+    border-bottom-right-radius: 16px;
+  }
+}
+
 .account-container .profile-metadata {
   display: flex;
   /* flex-wrap: wrap; */
   gap: 2px;
-  border-radius: 16px;
   overflow: hidden;
   overflow-x: auto;
 }
@@ -235,12 +257,11 @@
   margin: 0;
 }
 
-.account-container .common-followers p {
+.account-container .common-followers {
   font-size: 90%;
   color: var(--text-insignificant-color);
-  border-top: 1px solid var(--outline-color);
-  border-bottom: 1px solid var(--outline-color);
-  padding: 8px 0;
+  background-color: var(--bg-faded-color);
+  padding: 8px 12px;
   margin: 0;
 }
 
@@ -261,6 +282,74 @@
   opacity: 0.5;
 }
 
+@keyframes swoosh-bg-image {
+  0% {
+    background-position: -320px 0;
+    opacity: 0.25;
+  }
+  100% {
+    background-position: 0 0;
+    opacity: 1;
+  }
+}
+.account-container .posting-stats {
+  font-size: 90%;
+  color: var(--text-insignificant-color);
+  background-color: var(--bg-faded-color);
+  padding: 8px 12px;
+  --size: 8px;
+  --original-color: var(--link-color);
+
+  .posting-stats-bar {
+    height: var(--size);
+    border-radius: var(--size);
+    overflow: hidden;
+    margin: 8px 0;
+    box-shadow: inset 0 0 0 1px var(--outline-color),
+      inset 0 0 0 1.5px var(--bg-blur-color);
+    background-color: var(--bg-color);
+    background-repeat: no-repeat;
+    animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
+    background-image: linear-gradient(
+      to right,
+      var(--original-color) 0%,
+      var(--original-color) var(--originals-percentage),
+      var(--reply-to-color) var(--originals-percentage),
+      var(--reply-to-color) var(--replies-percentage),
+      var(--reblog-color) var(--replies-percentage),
+      var(--reblog-color) 100%
+    );
+  }
+
+  .posting-stats-legends {
+    font-size: 12px;
+    text-transform: uppercase;
+  }
+
+  .posting-stats-legend-item {
+    display: inline-block;
+    width: var(--size);
+    height: var(--size);
+    border-radius: var(--size);
+    background-color: var(--text-insignificant-color);
+    vertical-align: middle;
+    margin: 0 4px 2px;
+    /* border: 1px solid var(--outline-color); */
+    box-shadow: inset 0 0 0 1px var(--outline-color),
+      inset 0 0 0 1.5px var(--bg-blur-color);
+
+    &.posting-stats-legend-item-originals {
+      background-color: var(--original-color);
+    }
+    &.posting-stats-legend-item-replies {
+      background-color: var(--reply-to-color);
+    }
+    &.posting-stats-legend-item-boosts {
+      background-color: var(--reblog-color);
+    }
+  }
+}
+
 @keyframes shine {
   0% {
     left: -100%;
diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx
index 9a9ddb82..f7b04756 100644
--- a/src/components/account-info.jsx
+++ b/src/components/account-info.jsx
@@ -357,94 +357,99 @@ function AccountInfo({
                   __html: enhanceContent(note, { emojis }),
                 }}
               />
-              {fields?.length > 0 && (
-                <div class="profile-metadata">
-                  {fields.map(({ name, value, verifiedAt }, i) => (
-                    <div
-                      class={`profile-field ${
-                        verifiedAt ? 'profile-verified' : ''
-                      }`}
-                      key={name + i}
-                    >
-                      <b>
-                        <EmojiText text={name} emojis={emojis} />{' '}
-                        {!!verifiedAt && <Icon icon="check-circle" size="s" />}
-                      </b>
-                      <p
-                        dangerouslySetInnerHTML={{
-                          __html: enhanceContent(value, { emojis }),
-                        }}
-                      />
-                    </div>
-                  ))}
-                </div>
-              )}
-              <p class="stats">
-                <LinkOrDiv
-                  tabIndex={0}
-                  to={accountLink}
-                  onClick={() => {
-                    states.showAccount = false;
-                    states.showGenericAccounts = {
-                      heading: 'Followers',
-                      fetchAccounts: fetchFollowers,
-                    };
-                  }}
-                >
-                  <span title={followersCount}>
-                    {shortenNumber(followersCount)}
-                  </span>{' '}
-                  Followers
-                </LinkOrDiv>
-                <LinkOrDiv
-                  class="insignificant"
-                  tabIndex={0}
-                  to={accountLink}
-                  onClick={() => {
-                    states.showAccount = false;
-                    states.showGenericAccounts = {
-                      heading: 'Following',
-                      fetchAccounts: fetchFollowing,
-                    };
-                  }}
-                >
-                  <span title={followingCount}>
-                    {shortenNumber(followingCount)}
-                  </span>{' '}
-                  Following
-                  <br />
-                </LinkOrDiv>
-                <LinkOrDiv
-                  class="insignificant"
-                  to={accountLink}
-                  onClick={
-                    standalone
-                      ? undefined
-                      : () => {
-                          hideAllModals();
-                        }
-                  }
-                >
-                  <span title={statusesCount}>
-                    {shortenNumber(statusesCount)}
-                  </span>{' '}
-                  Posts
-                </LinkOrDiv>
-                {!!createdAt && (
-                  <div class="insignificant">
-                    Joined{' '}
-                    <time datetime={createdAt}>
-                      {niceDateTime(createdAt, {
-                        hideTime: true,
-                      })}
-                    </time>
+              <div class="account-metadata-box">
+                {fields?.length > 0 && (
+                  <div class="profile-metadata">
+                    {fields.map(({ name, value, verifiedAt }, i) => (
+                      <div
+                        class={`profile-field ${
+                          verifiedAt ? 'profile-verified' : ''
+                        }`}
+                        key={name + i}
+                      >
+                        <b>
+                          <EmojiText text={name} emojis={emojis} />{' '}
+                          {!!verifiedAt && (
+                            <Icon icon="check-circle" size="s" />
+                          )}
+                        </b>
+                        <p
+                          dangerouslySetInnerHTML={{
+                            __html: enhanceContent(value, { emojis }),
+                          }}
+                        />
+                      </div>
+                    ))}
                   </div>
                 )}
-              </p>
+                <div class="stats">
+                  <LinkOrDiv
+                    tabIndex={0}
+                    to={accountLink}
+                    onClick={() => {
+                      states.showAccount = false;
+                      states.showGenericAccounts = {
+                        heading: 'Followers',
+                        fetchAccounts: fetchFollowers,
+                      };
+                    }}
+                  >
+                    <span title={followersCount}>
+                      {shortenNumber(followersCount)}
+                    </span>{' '}
+                    Followers
+                  </LinkOrDiv>
+                  <LinkOrDiv
+                    class="insignificant"
+                    tabIndex={0}
+                    to={accountLink}
+                    onClick={() => {
+                      states.showAccount = false;
+                      states.showGenericAccounts = {
+                        heading: 'Following',
+                        fetchAccounts: fetchFollowing,
+                      };
+                    }}
+                  >
+                    <span title={followingCount}>
+                      {shortenNumber(followingCount)}
+                    </span>{' '}
+                    Following
+                    <br />
+                  </LinkOrDiv>
+                  <LinkOrDiv
+                    class="insignificant"
+                    to={accountLink}
+                    onClick={
+                      standalone
+                        ? undefined
+                        : () => {
+                            hideAllModals();
+                          }
+                    }
+                  >
+                    <span title={statusesCount}>
+                      {shortenNumber(statusesCount)}
+                    </span>{' '}
+                    Posts
+                  </LinkOrDiv>
+                  {!!createdAt && (
+                    <div class="insignificant">
+                      Joined{' '}
+                      <time datetime={createdAt}>
+                        {niceDateTime(createdAt, {
+                          hideTime: true,
+                        })}
+                      </time>
+                    </div>
+                  )}
+                </div>
+              </div>
               <RelatedActions
                 info={info}
                 instance={instance}
                 authenticated={authenticated}
+                standalone={standalone}
               />
             </main>
           </>
@@ -454,7 +459,9 @@ function AccountInfo({
   );
 }
 
-function RelatedActions({ info, instance, authenticated }) {
+const FAMILIAR_FOLLOWERS_LIMIT = 10;
+
+function RelatedActions({ info, instance, authenticated, standalone }) {
   if (!info) return null;
   const {
     masto: currentMasto,
@@ -466,6 +473,7 @@ function RelatedActions({ info, instance, authenticated }) {
   const [relationshipUIState, setRelationshipUIState] = useState('default');
   const [relationship, setRelationship] = useState(null);
   const [familiarFollowers, setFamiliarFollowers] = useState([]);
+  const [postingStats, setPostingStats] = useState();
 
   const { id, acct, url, username, locked, lastStatusAt, note, fields } = info;
   const accountID = useRef(id);
@@ -526,12 +534,11 @@ function RelatedActions({ info, instance, authenticated }) {
 
         setRelationshipUIState('loading');
         setFamiliarFollowers([]);
+        setPostingStats(null);
 
         const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([
           currentID,
         ]);
-        const fetchFamiliarFollowers =
-          currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
 
         try {
           const relationships = await fetchRelationships;
@@ -542,9 +549,55 @@ function RelatedActions({ info, instance, authenticated }) {
 
             if (!relationship.following) {
               try {
+                const fetchFamiliarFollowers =
+                  currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
+                const fetchStatuses = currentMasto.v1.accounts
+                  .listStatuses(currentID, {
+                    limit: 20,
+                  })
+                  .next();
+
                 const followers = await fetchFamiliarFollowers;
                 console.log('fetched familiar followers', followers);
-                setFamiliarFollowers(followers[0].accounts.slice(0, 10));
+                setFamiliarFollowers(followers[0].accounts);
+
+                if (standalone) return;
+
+                const { value: statuses } = await fetchStatuses;
+                console.log('fetched statuses', statuses);
+                const stats = {
+                  total: statuses.length,
+                  originals: 0,
+                  replies: 0,
+                  boosts: 0,
+                };
+                // Categories statuses by type
+                // - Original posts (not replies to others)
+                // - Threads (self-replies + 1st original post)
+                // - Boosts (reblogs)
+                // - Replies (not-self replies)
+                statuses.forEach((status) => {
+                  if (status.reblog) {
+                    stats.boosts++;
+                  } else if (
+                    status.inReplyToAccountId !== currentID &&
+                    !!status.inReplyToId
+                  ) {
+                    stats.replies++;
+                  } else {
+                    stats.originals++;
+                  }
+                });
+
+                // Count days since last post
+                stats.daysSinceLastPost = Math.ceil(
+                  (Date.now() -
+                    new Date(statuses[statuses.length - 1].createdAt)) /
+                    86400000,
+                );
+
+                console.log('posting stats', stats);
+                setPostingStats(stats);
               } catch (e) {
                 console.error(e);
               }
@@ -571,40 +624,109 @@ function RelatedActions({ info, instance, authenticated }) {
   const [showTranslatedBio, setShowTranslatedBio] = useState(false);
   const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
 
+  const hasFamiliarFollowers = familiarFollowers?.length > 0;
+  const hasPostingStats = postingStats?.total >= 3;
+
   return (
     <>
-      <div
-        class="common-followers shazam-container no-animation"
-        hidden={!familiarFollowers?.length}
-      >
-        <div class="shazam-container-inner">
-          <p>
-            Followed by{' '}
-            <span class="ib">
-              {familiarFollowers.map((follower) => (
-                <a
-                  href={follower.url}
-                  rel="noopener noreferrer"
-                  onClick={(e) => {
-                    e.preventDefault();
-                    states.showAccount = {
-                      account: follower,
-                      instance,
-                    };
-                  }}
-                >
-                  <Avatar
-                    url={follower.avatarStatic}
-                    size="l"
-                    alt={`${follower.displayName} @${follower.acct}`}
-                    squircle={follower?.bot}
+      {(hasFamiliarFollowers || hasPostingStats) && (
+        <div class="account-metadata-box">
+          {hasFamiliarFollowers && (
+            <div class="shazam-container">
+              <div class="shazam-container-inner">
+                <p class="common-followers">
+                  Followed by{' '}
+                  <span class="ib">
+                    {familiarFollowers
+                      .slice(0, FAMILIAR_FOLLOWERS_LIMIT)
+                      .map((follower) => (
+                        <a
+                          href={follower.url}
+                          rel="noopener noreferrer"
+                          onClick={(e) => {
+                            e.preventDefault();
+                            states.showAccount = {
+                              account: follower,
+                              instance,
+                            };
+                          }}
+                        >
+                          <Avatar
+                            url={follower.avatarStatic}
+                            size="l"
+                            alt={`${follower.displayName} @${follower.acct}`}
+                            squircle={follower?.bot}
+                          />
+                        </a>
+                      ))}
+                    {familiarFollowers.length > FAMILIAR_FOLLOWERS_LIMIT && (
+                      <button
+                        type="button"
+                        class="small plain4"
+                        onClick={() => {
+                          states.showGenericAccounts = {
+                            heading: 'Followed by',
+                            accounts: familiarFollowers,
+                          };
+                        }}
+                      >
+                        +{familiarFollowers.length - FAMILIAR_FOLLOWERS_LIMIT}
+                        <Icon icon="chevron-down" size="s" />
+                      </button>
+                    )}
+                  </span>
+                </p>
+              </div>
+            </div>
+          )}
+          {hasPostingStats && (
+            <div class="shazam-container">
+              <div class="shazam-container-inner">
+                <div class="posting-stats">
+                  <div>
+                    {postingStats.daysSinceLastPost < 365
+                      ? `Last ${postingStats.total} posts in the past 
+                    ${postingStats.daysSinceLastPost} day${
+                          postingStats.daysSinceLastPost > 1 ? 's' : ''
+                        }`
+                      : `
+                     Last ${postingStats.total} posts in the past year(s)
+                    `}
+                  </div>
+                  <div
+                    class="posting-stats-bar"
+                    style={{
+                      // [originals | replies | boosts]
+                      '--originals-percentage': `${
+                        (postingStats.originals / postingStats.total) * 100
+                      }%`,
+                      '--replies-percentage': `${
+                        ((postingStats.originals + postingStats.replies) /
+                          postingStats.total) *
+                        100
+                      }%`,
+                    }}
                   />
-                </a>
-              ))}
-            </span>
-          </p>
+                  <div class="posting-stats-legends">
+                    <span class="ib">
+                      <span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
+                      Original
+                    </span>{' '}
+                    <span class="ib">
+                      <span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
+                      Replies
+                    </span>{' '}
+                    <span class="ib">
+                      <span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
+                      Boosts
+                    </span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          )}
         </div>
-      </div>
+      )}
       <p class="actions">
         <span>
           {followedBy ? (