From a0d2037007e7d61c9d61f81ce85ea59303d80998 Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Thu, 11 Apr 2024 17:18:17 +0800
Subject: [PATCH] Early implementation of media-first UI experience

---
 src/app.css                 |  28 ++
 src/components/status.css   | 228 ++++++++++++-
 src/components/status.jsx   | 623 +++++++++++++++++++++++-------------
 src/components/timeline.jsx |  28 +-
 src/utils/store-utils.js    |   5 +
 5 files changed, 682 insertions(+), 230 deletions(-)

diff --git a/src/app.css b/src/app.css
index 01e0a0dc..ecd56adc 100644
--- a/src/app.css
+++ b/src/app.css
@@ -301,6 +301,34 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
     }
   }
 }
+
+.deck-container-media-first {
+  .timeline {
+    > li:not(.timeline-item-carousel, .timeline-item-container) {
+      &:has(.status-media-first) {
+        width: fit-content;
+        background-color: transparent !important;
+        border: 0 !important;
+        box-shadow: none !important;
+        max-width: min(480px, 100%);
+        margin-inline: auto !important;
+
+        &:has(.skeleton) {
+          width: 100%;
+        }
+      }
+
+      &:has(.media[data-orientation='landscape']) {
+        max-width: 100%;
+      }
+    }
+
+    .status-link:has(.status-media-first):hover {
+      background-color: transparent;
+    }
+  }
+}
+
 .timeline.grow {
   /* min-height: 100vh;
   min-height: 100dvh; */
diff --git a/src/components/status.css b/src/components/status.css
index 64b90399..eea4c280 100644
--- a/src/components/status.css
+++ b/src/components/status.css
@@ -618,6 +618,7 @@
   ~ *:not(
       .content.truncated,
       .media-container,
+      .media-first-container,
       .card,
       .media-figure-multiple,
       .spoiler-media-button
@@ -638,6 +639,7 @@
 
   ~ *:not(
       .media-container,
+      .media-first-container,
       .card,
       .media-figure-multiple,
       .spoiler-media-button
@@ -708,11 +710,12 @@
     }
   }
 
-  ~ :is(.media-container, .media-figure-multiple) .media {
+  ~ :is(.media-container, .media-first-container, .media-figure-multiple)
+    .media {
     background-image: radial-gradient(
       circle at 50% 50%,
       var(--average-color, var(--bg-faded-color)),
-      var(--bg-color) 20em
+      var(--bg-color) 25em
     );
 
     > *:not(.media-play, .alt-badge) {
@@ -1316,6 +1319,227 @@ body:has(#modal-container .carousel) .status .media img:hover {
   background-blend-mode: multiply;
 }
 
+.status.skeleton .media-first-container {
+  min-height: 3em;
+  background-color: var(--outline-color);
+}
+
+.status-media-first {
+  .meta-name {
+    opacity: 0.65;
+    transition: opacity 0.5s ease-in-out;
+
+    b + i {
+      opacity: 0;
+      transition: opacity 0.5s ease-in-out;
+    }
+  }
+  :is(:hover, :focus) > & .meta-name {
+    opacity: 1;
+    b + i {
+      opacity: 0.5;
+    }
+  }
+
+  .media-first-spoiler-content {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 100%;
+    transition: opacity 0.5s ease-in-out;
+    opacity: 0.5;
+  }
+  &:hover .media-first-spoiler-content {
+    opacity: 1;
+  }
+
+  .media-first-spoiler-button {
+    display: inline-flex !important;
+  }
+  .media-first-container {
+    margin-top: 8px;
+    display: flex;
+    max-height: 80vh;
+    overflow-x: auto;
+    overflow-y: hidden;
+    scroll-snap-type: x mandatory;
+    scroll-behavior: smooth;
+    user-select: none;
+    margin-inline: -16px;
+    position: relative;
+    scrollbar-width: none;
+    /* border: var(--hairline-width) solid var(--outline-color);
+    border-inline-width: 0;
+    background-color: var(--bg-faded-color); */
+
+    @media (min-width: 40em) {
+      margin-inline: 0;
+      /* border-radius: 4px; */
+      border-inline-width: var(--hairline-width);
+    }
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+
+    > .media-first-item {
+      scroll-snap-align: center;
+      scroll-snap-stop: always;
+      flex-shrink: 0;
+      display: flex;
+      width: 100%;
+      align-items: center;
+      justify-content: center;
+
+      &:not(:only-child) {
+        background-color: var(--bg-blur-color);
+        box-shadow: inset 0 0 0 var(--hairline-width) var(--outline-color);
+      }
+
+      .media {
+        /* background-color: var(--average-color, var(--bg-faded-color)); */
+        width: var(--width);
+        max-width: 100%;
+        max-height: 100%;
+        min-height: var(--min-dimension);
+        /* max-height: min(var(--height), 80vh); */
+
+        &:active {
+          transform: none;
+        }
+
+        img,
+        video {
+          object-fit: scale-down;
+          animation: none;
+
+          &:not([data-loaded='true']) {
+            background-color: var(--bg-color);
+          }
+        }
+      }
+    }
+
+    .media-carousel-controls {
+      flex-shrink: 0;
+      width: 100%;
+      position: sticky;
+      right: 0;
+      left: 0;
+      pointer-events: none;
+      display: flex;
+      justify-content: space-between;
+    }
+
+    .carousel-indexer {
+      z-index: 1;
+      position: absolute;
+      top: 8px;
+      right: 8px;
+      color: var(--media-fg-color);
+      background-color: var(--media-bg-color);
+      padding: 2px 8px;
+      border-radius: 16px;
+      font-size: 0.8em;
+      font-variant-numeric: tabular-nums;
+      opacity: 0.6;
+      transition: opacity 1.5s ease-in-out;
+      border: var(--hairline-width) solid var(--media-outline-color);
+    }
+
+    .media-carousel-button {
+      display: flex;
+      flex-shrink: 0;
+      padding-inline: 8px;
+      margin-block: 3em;
+      pointer-events: auto;
+      cursor: pointer;
+      align-items: center;
+      justify-content: center;
+    }
+    .carousel-button {
+      @media (pointer: coarse) {
+        display: none;
+      }
+
+      + .carousel-button {
+        left: auto;
+        right: 8px;
+      }
+    }
+
+    @media (hover: hover) and (pointer: fine) {
+      .carousel-button {
+        filter: opacity(0);
+      }
+      &:hover .carousel-button {
+        filter: opacity(1);
+      }
+    }
+  }
+  :is(:hover, :focus) > & .carousel-indexer {
+    opacity: 0;
+  }
+
+  .media-carousel-dots {
+    pointer-events: none;
+    display: flex;
+    gap: 5px;
+    justify-content: center;
+    margin-top: 8px;
+    padding: 8px;
+
+    .carousel-dot {
+      display: inline-block;
+      width: 5px;
+      height: 5px;
+      border-radius: 50%;
+      background-color: var(--text-color);
+      transition: all 0.3s ease-in-out;
+      opacity: 0.3;
+
+      &.active {
+        opacity: 1;
+        background-color: var(--text-color);
+        transform: scale(1.5);
+      }
+    }
+  }
+
+  .media-first-content {
+    margin-top: 8px;
+    height: 1.75em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 0.9em;
+    mask-image: linear-gradient(to bottom, black 1.5em, transparent 1.75em);
+    opacity: 0.5;
+    transition: opacity 0.5s ease-in-out;
+
+    @media (min-width: 40em) {
+      margin-inline: 16px;
+    }
+
+    * {
+      text-align: center;
+      /* Brute force ellipsis */
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap !important;
+      pointer-events: none;
+    }
+
+    a {
+      filter: grayscale(0.5);
+    }
+  }
+
+  :is(:hover, :focus) > & .media-first-content {
+    opacity: 1;
+  }
+}
+
 .status:not(.large) .hashtag-stuffing {
   opacity: 0.75;
   transition: opacity 0.2s ease-in-out;
diff --git a/src/components/status.jsx b/src/components/status.jsx
index 2d739824..9c09314b 100644
--- a/src/components/status.jsx
+++ b/src/components/status.jsx
@@ -169,15 +169,19 @@ function Status({
   allowContextMenu,
   showActionsBar,
   showReplyParent,
+  mediaFirst,
 }) {
   if (skeleton) {
     return (
-      <div class="status skeleton">
-        <Avatar size="xxl" />
+      <div class={`status skeleton ${mediaFirst ? 'status-media-first' : ''}`}>
+        {!mediaFirst && <Avatar size="xxl" />}
         <div class="container">
-          <div class="meta">███ ████████</div>
+          <div class="meta">
+            {(size === 's' || mediaFirst) && <Avatar size="m" />} ███ ████████
+          </div>
           <div class="content-container">
-            <div class="content">
+            {mediaFirst && <div class="media-first-container" />}
+            <div class={`content ${mediaFirst ? 'media-first-content' : ''}`}>
               <p>████ ████████</p>
             </div>
           </div>
@@ -247,6 +251,10 @@ function Status({
     emojiReactions,
   } = status;
 
+  // if (!mediaAttachments?.length) mediaFirst = false;
+  const hasMediaAttachments = !!mediaAttachments?.length;
+  if (mediaFirst && hasMediaAttachments) size = 's';
+
   const currentAccount = useMemo(() => {
     return store.session.get('currentAccount');
   }, []);
@@ -354,6 +362,7 @@ function Status({
             size={size}
             contentTextWeight={contentTextWeight}
             readOnly={readOnly}
+            mediaFirst={mediaFirst}
           />
         </div>
       );
@@ -378,6 +387,7 @@ function Status({
           contentTextWeight={contentTextWeight}
           readOnly={readOnly}
           enableCommentHint
+          mediaFirst={mediaFirst}
         />
       </div>
     );
@@ -411,6 +421,7 @@ function Status({
           contentTextWeight={contentTextWeight}
           readOnly={readOnly}
           enableCommentHint
+          mediaFirst={mediaFirst}
         />
       </div>
     );
@@ -848,56 +859,62 @@ function Status({
           </MenuItem>
         </>
       )}
-      {(enableTranslate || !language || differentLanguage) && <MenuDivider />}
-      {enableTranslate ? (
-        <div class={supportsTTS ? 'menu-horizontal' : ''}>
-          <MenuItem
-            disabled={forceTranslate}
-            onClick={() => {
-              setForceTranslate(true);
-            }}
-          >
-            <Icon icon="translate" />
-            <span>Translate</span>
-          </MenuItem>
-          {supportsTTS && (
-            <MenuItem
-              onClick={() => {
-                const postText = getPostText(status);
-                if (postText) {
-                  speak(postText, language);
-                }
-              }}
-            >
-              <Icon icon="speak" />
-              <span>Speak</span>
-            </MenuItem>
+      {!mediaFirst && (
+        <>
+          {(enableTranslate || !language || differentLanguage) && (
+            <MenuDivider />
           )}
-        </div>
-      ) : (
-        (!language || differentLanguage) && (
-          <div class={supportsTTS ? 'menu-horizontal' : ''}>
-            <MenuLink
-              to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
-            >
-              <Icon icon="translate" />
-              <span>Translate</span>
-            </MenuLink>
-            {supportsTTS && (
+          {enableTranslate ? (
+            <div class={supportsTTS ? 'menu-horizontal' : ''}>
               <MenuItem
+                disabled={forceTranslate}
                 onClick={() => {
-                  const postText = getPostText(status);
-                  if (postText) {
-                    speak(postText, language);
-                  }
+                  setForceTranslate(true);
                 }}
               >
-                <Icon icon="speak" />
-                <span>Speak</span>
+                <Icon icon="translate" />
+                <span>Translate</span>
               </MenuItem>
-            )}
-          </div>
-        )
+              {supportsTTS && (
+                <MenuItem
+                  onClick={() => {
+                    const postText = getPostText(status);
+                    if (postText) {
+                      speak(postText, language);
+                    }
+                  }}
+                >
+                  <Icon icon="speak" />
+                  <span>Speak</span>
+                </MenuItem>
+              )}
+            </div>
+          ) : (
+            (!language || differentLanguage) && (
+              <div class={supportsTTS ? 'menu-horizontal' : ''}>
+                <MenuLink
+                  to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
+                >
+                  <Icon icon="translate" />
+                  <span>Translate</span>
+                </MenuLink>
+                {supportsTTS && (
+                  <MenuItem
+                    onClick={() => {
+                      const postText = getPostText(status);
+                      if (postText) {
+                        speak(postText, language);
+                      }
+                    }}
+                  >
+                    <Icon icon="speak" />
+                    <span>Speak</span>
+                  </MenuItem>
+                )}
+              </div>
+            )
+          )}
+        </>
       )}
       {((!isSizeLarge && sameInstance) ||
         enableTranslate ||
@@ -1384,7 +1401,7 @@ function Status({
           }[size]
         } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
           isContextMenuOpen ? 'status-menu-open' : ''
-        }`}
+        } ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`}
         onMouseEnter={debugHover}
         onContextMenu={(e) => {
           if (!showContextMenu) return;
@@ -1712,188 +1729,253 @@ function Status({
               }
             }
           >
-            {!!spoilerText && (
+            {mediaFirst && hasMediaAttachments ? (
               <>
-                <div
-                  class="content spoiler-content"
-                  lang={language}
-                  dir="auto"
-                  ref={spoilerContentRef}
-                  data-read-more={readMoreText}
-                >
-                  <p>
-                    <EmojiText text={spoilerText} emojis={emojis} />
-                  </p>
-                </div>
-                {readingExpandSpoilers || previewMode ? (
-                  <div class="spoiler-divider">
-                    <Icon icon="eye-open" /> Content warning
+                {(!!spoilerText || !!sensitive) && !readingExpandSpoilers && (
+                  <>
+                    {!!spoilerText && (
+                      <span
+                        class="spoiler-content media-first-spoiler-content"
+                        lang={language}
+                        dir="auto"
+                        ref={spoilerContentRef}
+                        data-read-more={readMoreText}
+                      >
+                        <EmojiText text={spoilerText} emojis={emojis} />{' '}
+                      </span>
+                    )}
+                    <button
+                      class={`light spoiler-button media-first-spoiler-button ${
+                        showSpoiler ? 'spoiling' : ''
+                      }`}
+                      type="button"
+                      onClick={(e) => {
+                        e.preventDefault();
+                        e.stopPropagation();
+                        if (showSpoiler) {
+                          delete states.spoilers[id];
+                          if (!readingExpandSpoilers) {
+                            delete states.spoilersMedia[id];
+                          }
+                        } else {
+                          states.spoilers[id] = true;
+                          if (!readingExpandSpoilers) {
+                            states.spoilersMedia[id] = true;
+                          }
+                        }
+                      }}
+                    >
+                      <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
+                      {showSpoiler ? 'Show less' : 'Show content'}
+                    </button>
+                  </>
+                )}
+                <MediaFirstContainer
+                  mediaAttachments={mediaAttachments}
+                  language={language}
+                  postID={id}
+                  instance={instance}
+                />
+                {!!content && (
+                  <div class="media-first-content content" ref={contentRef}>
+                    <PostContent
+                      post={status}
+                      instance={instance}
+                      previewMode={previewMode}
+                    />
                   </div>
-                ) : (
-                  <button
-                    class={`light spoiler-button ${
-                      showSpoiler ? 'spoiling' : ''
-                    }`}
-                    type="button"
-                    onClick={(e) => {
-                      e.preventDefault();
-                      e.stopPropagation();
-                      if (showSpoiler) {
-                        delete states.spoilers[id];
-                        if (!readingExpandSpoilers) {
-                          delete states.spoilersMedia[id];
-                        }
-                      } else {
-                        states.spoilers[id] = true;
-                        if (!readingExpandSpoilers) {
-                          states.spoilersMedia[id] = true;
-                        }
-                      }
-                    }}
-                  >
-                    <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
-                    {showSpoiler ? 'Show less' : 'Show content'}
-                  </button>
                 )}
               </>
-            )}
-            {!!content && (
-              <div
-                class="content"
-                ref={contentRef}
-                data-read-more={readMoreText}
-              >
-                <PostContent
-                  post={status}
-                  instance={instance}
-                  previewMode={previewMode}
-                />
-                <QuoteStatuses id={id} instance={instance} level={quoted} />
-              </div>
-            )}
-            {!!poll && (
-              <Poll
-                lang={language}
-                poll={poll}
-                readOnly={readOnly || !sameInstance || !authenticated}
-                onUpdate={(newPoll) => {
-                  states.statuses[sKey].poll = newPoll;
-                }}
-                refresh={() => {
-                  return masto.v1.polls
-                    .$select(poll.id)
-                    .fetch()
-                    .then((pollResponse) => {
-                      states.statuses[sKey].poll = pollResponse;
-                    })
-                    .catch((e) => {}); // Silently fail
-                }}
-                votePoll={(choices) => {
-                  return masto.v1.polls
-                    .$select(poll.id)
-                    .votes.create({
-                      choices,
-                    })
-                    .then((pollResponse) => {
-                      states.statuses[sKey].poll = pollResponse;
-                    })
-                    .catch((e) => {}); // Silently fail
-                }}
-              />
-            )}
-            {(((enableTranslate || inlineTranslate) &&
-              !!content.trim() &&
-              !!getHTMLText(emojifyText(content, emojis)) &&
-              differentLanguage) ||
-              forceTranslate) && (
-              <TranslationBlock
-                forceTranslate={forceTranslate || inlineTranslate}
-                mini={!isSizeLarge && !withinContext}
-                sourceLanguage={language}
-                text={getPostText(status)}
-              />
-            )}
-            {!previewMode &&
-              sensitive &&
-              !!mediaAttachments.length &&
-              readingExpandMedia !== 'show_all' && (
-                <button
-                  class={`plain spoiler-media-button ${
-                    showSpoilerMedia ? 'spoiling' : ''
-                  }`}
-                  type="button"
-                  hidden={!readingExpandSpoilers && !!spoilerText}
-                  onClick={(e) => {
-                    e.preventDefault();
-                    e.stopPropagation();
-                    if (showSpoilerMedia) {
-                      delete states.spoilersMedia[id];
-                    } else {
-                      states.spoilersMedia[id] = true;
-                    }
-                  }}
-                >
-                  <Icon icon={showSpoilerMedia ? 'eye-open' : 'eye-close'} />{' '}
-                  {showSpoilerMedia ? 'Show less' : 'Show media'}
-                </button>
-              )}
-            {!!mediaAttachments.length && (
-              <MultipleMediaFigure
-                lang={language}
-                enabled={showMultipleMediaCaptions}
-                captionChildren={captionChildren}
-              >
-                <div
-                  ref={mediaContainerRef}
-                  class={`media-container media-eq${mediaAttachments.length} ${
-                    mediaAttachments.length > 2 ? 'media-gt2' : ''
-                  } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
-                >
-                  {displayedMediaAttachments.map((media, i) => (
-                    <Media
-                      key={media.id}
-                      media={media}
-                      autoAnimate={isSizeLarge}
-                      showCaption={mediaAttachments.length === 1}
-                      allowLongerCaption={
-                        !content && mediaAttachments.length === 1
-                      }
+            ) : (
+              <>
+                {!!spoilerText && (
+                  <>
+                    <div
+                      class="content spoiler-content"
                       lang={language}
-                      altIndex={
-                        showMultipleMediaCaptions &&
-                        !!media.description &&
-                        i + 1
-                      }
-                      to={`/${instance}/s/${id}?${
-                        withinContext ? 'media' : 'media-only'
-                      }=${i + 1}`}
-                      onClick={
-                        onMediaClick
-                          ? (e) => {
-                              onMediaClick(e, i, media, status);
+                      dir="auto"
+                      ref={spoilerContentRef}
+                      data-read-more={readMoreText}
+                    >
+                      <p>
+                        <EmojiText text={spoilerText} emojis={emojis} />
+                      </p>
+                    </div>
+                    {readingExpandSpoilers || previewMode ? (
+                      <div class="spoiler-divider">
+                        <Icon icon="eye-open" /> Content warning
+                      </div>
+                    ) : (
+                      <button
+                        class={`light spoiler-button ${
+                          showSpoiler ? 'spoiling' : ''
+                        }`}
+                        type="button"
+                        onClick={(e) => {
+                          e.preventDefault();
+                          e.stopPropagation();
+                          if (showSpoiler) {
+                            delete states.spoilers[id];
+                            if (!readingExpandSpoilers) {
+                              delete states.spoilersMedia[id];
                             }
-                          : undefined
-                      }
+                          } else {
+                            states.spoilers[id] = true;
+                            if (!readingExpandSpoilers) {
+                              states.spoilersMedia[id] = true;
+                            }
+                          }
+                        }}
+                      >
+                        <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
+                        {showSpoiler ? 'Show less' : 'Show content'}
+                      </button>
+                    )}
+                  </>
+                )}
+                {!!content && (
+                  <div
+                    class="content"
+                    ref={contentRef}
+                    data-read-more={readMoreText}
+                  >
+                    <PostContent
+                      post={status}
+                      instance={instance}
+                      previewMode={previewMode}
                     />
-                  ))}
-                </div>
-              </MultipleMediaFigure>
+                    <QuoteStatuses id={id} instance={instance} level={quoted} />
+                  </div>
+                )}
+                {!!poll && (
+                  <Poll
+                    lang={language}
+                    poll={poll}
+                    readOnly={readOnly || !sameInstance || !authenticated}
+                    onUpdate={(newPoll) => {
+                      states.statuses[sKey].poll = newPoll;
+                    }}
+                    refresh={() => {
+                      return masto.v1.polls
+                        .$select(poll.id)
+                        .fetch()
+                        .then((pollResponse) => {
+                          states.statuses[sKey].poll = pollResponse;
+                        })
+                        .catch((e) => {}); // Silently fail
+                    }}
+                    votePoll={(choices) => {
+                      return masto.v1.polls
+                        .$select(poll.id)
+                        .votes.create({
+                          choices,
+                        })
+                        .then((pollResponse) => {
+                          states.statuses[sKey].poll = pollResponse;
+                        })
+                        .catch((e) => {}); // Silently fail
+                    }}
+                  />
+                )}
+                {(((enableTranslate || inlineTranslate) &&
+                  !!content.trim() &&
+                  !!getHTMLText(emojifyText(content, emojis)) &&
+                  differentLanguage) ||
+                  forceTranslate) && (
+                  <TranslationBlock
+                    forceTranslate={forceTranslate || inlineTranslate}
+                    mini={!isSizeLarge && !withinContext}
+                    sourceLanguage={language}
+                    text={getPostText(status)}
+                  />
+                )}
+                {!previewMode &&
+                  sensitive &&
+                  !!mediaAttachments.length &&
+                  readingExpandMedia !== 'show_all' && (
+                    <button
+                      class={`plain spoiler-media-button ${
+                        showSpoilerMedia ? 'spoiling' : ''
+                      }`}
+                      type="button"
+                      hidden={!readingExpandSpoilers && !!spoilerText}
+                      onClick={(e) => {
+                        e.preventDefault();
+                        e.stopPropagation();
+                        if (showSpoilerMedia) {
+                          delete states.spoilersMedia[id];
+                        } else {
+                          states.spoilersMedia[id] = true;
+                        }
+                      }}
+                    >
+                      <Icon
+                        icon={showSpoilerMedia ? 'eye-open' : 'eye-close'}
+                      />{' '}
+                      {showSpoilerMedia ? 'Show less' : 'Show media'}
+                    </button>
+                  )}
+                {!!mediaAttachments.length && (
+                  <MultipleMediaFigure
+                    lang={language}
+                    enabled={showMultipleMediaCaptions}
+                    captionChildren={captionChildren}
+                  >
+                    <div
+                      ref={mediaContainerRef}
+                      class={`media-container media-eq${
+                        mediaAttachments.length
+                      } ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${
+                        mediaAttachments.length > 4 ? 'media-gt4' : ''
+                      }`}
+                    >
+                      {displayedMediaAttachments.map((media, i) => (
+                        <Media
+                          key={media.id}
+                          media={media}
+                          autoAnimate={isSizeLarge}
+                          showCaption={mediaAttachments.length === 1}
+                          allowLongerCaption={
+                            !content && mediaAttachments.length === 1
+                          }
+                          lang={language}
+                          altIndex={
+                            showMultipleMediaCaptions &&
+                            !!media.description &&
+                            i + 1
+                          }
+                          to={`/${instance}/s/${id}?${
+                            withinContext ? 'media' : 'media-only'
+                          }=${i + 1}`}
+                          onClick={
+                            onMediaClick
+                              ? (e) => {
+                                  onMediaClick(e, i, media, status);
+                                }
+                              : undefined
+                          }
+                        />
+                      ))}
+                    </div>
+                  </MultipleMediaFigure>
+                )}
+                {!!card &&
+                  /^https/i.test(card?.url) &&
+                  !sensitive &&
+                  !spoilerText &&
+                  !poll &&
+                  !mediaAttachments.length &&
+                  !snapStates.statusQuotes[sKey] && (
+                    <Card
+                      card={card}
+                      selfReferential={
+                        card?.url === status.url || card?.url === status.uri
+                      }
+                      instance={currentInstance}
+                    />
+                  )}
+              </>
             )}
-            {!!card &&
-              /^https/i.test(card?.url) &&
-              !sensitive &&
-              !spoilerText &&
-              !poll &&
-              !mediaAttachments.length &&
-              !snapStates.statusQuotes[sKey] && (
-                <Card
-                  card={card}
-                  selfReferential={
-                    card?.url === status.url || card?.url === status.uri
-                  }
-                  instance={currentInstance}
-                />
-              )}
           </div>
           {!isSizeLarge && showCommentCount && (
             <div class="content-comment-hint insignificant">
@@ -2171,6 +2253,101 @@ function MultipleMediaFigure(props) {
   );
 }
 
+function MediaFirstContainer(props) {
+  const { mediaAttachments, language, postID, instance } = props;
+  const moreThanOne = mediaAttachments.length > 1;
+
+  const carouselRef = useRef();
+  const [currentIndex, setCurrentIndex] = useState(0);
+
+  useEffect(() => {
+    let handleScroll = () => {
+      const { clientWidth, scrollLeft } = carouselRef.current;
+      const index = Math.round(scrollLeft / clientWidth);
+      setCurrentIndex(index);
+    };
+    if (carouselRef.current) {
+      carouselRef.current.addEventListener('scroll', handleScroll, {
+        passive: true,
+      });
+    }
+    return () => {
+      if (carouselRef.current) {
+        carouselRef.current.removeEventListener('scroll', handleScroll);
+      }
+    };
+  }, []);
+
+  return (
+    <>
+      <div class="media-first-container" ref={carouselRef}>
+        {mediaAttachments.map((media, i) => (
+          <div class="media-first-item" key={media.id}>
+            <Media
+              media={media}
+              lang={language}
+              to={`/${instance}/s/${postID}?media-only=${i + 1}`}
+            />
+          </div>
+        ))}
+        {moreThanOne && (
+          <div class="media-carousel-controls">
+            <div class="carousel-indexer">
+              {currentIndex + 1}/{mediaAttachments.length}
+            </div>
+            <label class="media-carousel-button">
+              <button
+                type="button"
+                class="carousel-button"
+                hidden={currentIndex === 0}
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+                  carouselRef.current.focus();
+                  carouselRef.current.scrollTo({
+                    left: carouselRef.current.clientWidth * (currentIndex - 1),
+                    behavior: 'smooth',
+                  });
+                }}
+              >
+                <Icon icon="arrow-left" />
+              </button>
+            </label>
+            <label class="media-carousel-button">
+              <button
+                type="button"
+                class="carousel-button"
+                hidden={currentIndex === mediaAttachments.length - 1}
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+                  carouselRef.current.focus();
+                  carouselRef.current.scrollTo({
+                    left: carouselRef.current.clientWidth * (currentIndex + 1),
+                    behavior: 'smooth',
+                  });
+                }}
+              >
+                <Icon icon="arrow-right" />
+              </button>
+            </label>
+          </div>
+        )}
+      </div>
+      {moreThanOne && (
+        <div class="media-carousel-dots">
+          {mediaAttachments.map((media, i) => (
+            <span
+              key={media.id}
+              class={`carousel-dot ${i === currentIndex ? 'active' : ''}`}
+            />
+          ))}
+        </div>
+      )}
+    </>
+  );
+}
+
 function Card({ card, selfReferential, instance }) {
   const snapStates = useSnapshot(states);
   const {
diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx
index 1a878dc9..6fe1273c 100644
--- a/src/components/timeline.jsx
+++ b/src/components/timeline.jsx
@@ -1,5 +1,11 @@
 import { memo } from 'preact/compat';
-import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'preact/hooks';
 import { useHotkeys } from 'react-hotkeys-hook';
 import { InView } from 'react-intersection-observer';
 import { useDebouncedCallback } from 'use-debounce';
@@ -9,6 +15,7 @@ import FilterContext from '../utils/filter-context';
 import { filteredItems, isFiltered } from '../utils/filters';
 import states, { statusKey } from '../utils/states';
 import statusPeek from '../utils/status-peek';
+import { isMediaFirstInstance } from '../utils/store-utils';
 import { groupBoosts, groupContext } from '../utils/timeline-utils';
 import useInterval from '../utils/useInterval';
 import usePageVisibility from '../utils/usePageVisibility';
@@ -59,6 +66,8 @@ function Timeline({
 
   console.debug('RENDER Timeline', id, refresh);
 
+  const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
+
   const allowGrouping = view !== 'media';
   const loadItems = useDebouncedCallback(
     (firstLoad) => {
@@ -355,7 +364,9 @@ function Timeline({
     <FilterContext.Provider value={filterContext}>
       <div
         id={`${id}-page`}
-        class="deck-container"
+        class={`deck-container ${
+          mediaFirst ? 'deck-container-media-first' : ''
+        }`}
         ref={(node) => {
           scrollableRef.current = node;
           jRef.current = node;
@@ -432,6 +443,7 @@ function Timeline({
                     view={view}
                     showFollowedTags={showFollowedTags}
                     showReplyParent={showReplyParent}
+                    mediaFirst={mediaFirst}
                   />
                 ))}
                 {showMore &&
@@ -443,14 +455,14 @@ function Timeline({
                           height: '20vh',
                         }}
                       >
-                        <Status skeleton />
+                        <Status skeleton mediaFirst={mediaFirst} />
                       </li>
                       <li
                         style={{
                           height: '25vh',
                         }}
                       >
-                        <Status skeleton />
+                        <Status skeleton mediaFirst={mediaFirst} />
                       </li>
                     </>
                   ))}
@@ -490,7 +502,7 @@ function Timeline({
                   />
                 ) : (
                   <li key={i}>
-                    <Status skeleton />
+                    <Status skeleton mediaFirst={mediaFirst} />
                   </li>
                 ),
               )}
@@ -525,6 +537,7 @@ const TimelineItem = memo(
     view,
     showFollowedTags,
     showReplyParent,
+    mediaFirst,
   }) => {
     console.debug('RENDER TimelineItem', status.id);
     const { id: statusID, reblog, items, type, _pinned } = status;
@@ -533,6 +546,7 @@ const TimelineItem = memo(
     const url = instance
       ? `/${instance}/s/${actualStatusID}`
       : `/s/${actualStatusID}`;
+
     if (items) {
       const fItems = filteredItems(items, filterContext);
       let title = '';
@@ -585,6 +599,7 @@ const TimelineItem = memo(
                           contentTextWeight
                           enableCommentHint
                           // allowFilters={allowFilters}
+                          mediaFirst={mediaFirst}
                         />
                       ) : (
                         <Status
@@ -594,6 +609,7 @@ const TimelineItem = memo(
                           contentTextWeight
                           enableCommentHint
                           // allowFilters={allowFilters}
+                          mediaFirst={mediaFirst}
                         />
                       )}
                     </Link>
@@ -689,6 +705,7 @@ const TimelineItem = memo(
               showFollowedTags={showFollowedTags}
               showReplyParent={showReplyParent}
               // allowFilters={allowFilters}
+              mediaFirst={mediaFirst}
             />
           ) : (
             <Status
@@ -698,6 +715,7 @@ const TimelineItem = memo(
               showFollowedTags={showFollowedTags}
               showReplyParent={showReplyParent}
               // allowFilters={allowFilters}
+              mediaFirst={mediaFirst}
             />
           )}
         </Link>
diff --git a/src/utils/store-utils.js b/src/utils/store-utils.js
index ed92b7d6..aff33e4a 100644
--- a/src/utils/store-utils.js
+++ b/src/utils/store-utils.js
@@ -126,3 +126,8 @@ export function getCurrentInstanceConfiguration() {
   const instance = getCurrentInstance();
   return getInstanceConfiguration(instance);
 }
+
+export function isMediaFirstInstance() {
+  const instance = getCurrentInstance();
+  return /pixelfed/i.test(instance?.version);
+}