Pārlūkot izejas kodu

Add new media grid and gallery views

Fixes #199
Fixes #1342
Zed 5 mēneši atpakaļ
vecāks
revīzija
7ce29bd8f1

+ 195 - 59
public/js/infiniteScroll.js

@@ -1,5 +1,6 @@
 // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
 // SPDX-License-Identifier: AGPL-3.0-only
+
 function insertBeforeLast(node, elem) {
   node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
 }
@@ -8,75 +9,210 @@ function getLoadMore(doc) {
   return doc.querySelector(".show-more:not(.timeline-item)");
 }
 
-function isDuplicate(item, itemClass) {
-  const tweet = item.querySelector(".tweet-link");
-  if (tweet == null) return false;
-  const href = tweet.getAttribute("href");
-  return (
-    document.querySelector(itemClass + " .tweet-link[href='" + href + "']") !=
-    null
-  );
+function getHrefs(selector) {
+  return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
+}
+
+function getTweetId(item) {
+  const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
+  return m ? m[1] : "";
+}
+
+function isDuplicate(item, hrefs) {
+  return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
+}
+
+const GAP = 10;
+
+class Masonry {
+  constructor(container) {
+    this.container = container;
+    this.colHeights = [];
+    this.colCounts = [];
+    this.colCount = 0;
+    this._lastWidth = 0;
+    this._colWidthCache = 0;
+    this._items = [];
+    this._revealTimer = null;
+    this.container.classList.add("masonry-active");
+
+    let resizeTimer;
+    window.addEventListener("resize", () => {
+      clearTimeout(resizeTimer);
+      resizeTimer = setTimeout(() => this._rebuild(), 50);
+    });
+
+    // Re-sync positions whenever images finish loading and items grow taller.
+    // Must be set up before _rebuild() so initial items get observed on first pass.
+    let syncTimer;
+    this._observer = window.ResizeObserver ? new ResizeObserver(() => {
+      clearTimeout(syncTimer);
+      syncTimer = setTimeout(() => this.syncHeights(), 100);
+    }) : null;
+
+    this._rebuild();
+  }
+
+  // Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
+  _revealAll() {
+    clearTimeout(this._revealTimer);
+    for (const item of this._items) item.classList.add("masonry-visible");
+    for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
+      el.classList.add("masonry-visible");
+  }
+
+  // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
+  _pickCol() {
+    return this.colHeights.reduce((min, h, i) => {
+      const m = this.colHeights[min];
+      return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
+    }, 0);
+  }
+
+  // Position items using current column state. Updates colHeights, colCounts, container height.
+  _position(items, heights, colWidth) {
+    for (let i = 0; i < items.length; i++) {
+      const col = this._pickCol();
+      items[i].style.left = `${col * (colWidth + GAP)}px`;
+      items[i].style.top = `${this.colHeights[col]}px`;
+      this.colHeights[col] += heights[i] + GAP;
+      this.colCounts[col]++;
+    }
+    this.container.style.height = `${Math.max(0, ...this.colHeights)}px`;
+  }
+
+  // Full reset and re-place all items.
+  _place(items, heights, n, colWidth) {
+    this.colHeights = new Array(n).fill(0);
+    this.colCounts = new Array(n).fill(0);
+    this.colCount = n;
+    this._position(items, heights, colWidth);
+  }
+
+  _rebuild() {
+    const n = Math.max(1, Math.floor(this.container.clientWidth / 350));
+    const w = this.container.clientWidth;
+    if (n === this.colCount && w === this._lastWidth) return;
+
+    const isFirst = this.colCount === 0;
+
+    if (isFirst) {
+      this._items = [...this.container.querySelectorAll(".timeline-item")];
+    }
+
+    // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings).
+    this._items.sort((a, b) => {
+      const idA = getTweetId(a), idB = getTweetId(b);
+      if (idA.length !== idB.length) return idB.length - idA.length;
+      return idB < idA ? -1 : idB > idA ? 1 : 0;
+    });
+
+    // Pre-set widths BEFORE reading heights so measurements reflect the new column width.
+    const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n);
+    for (const item of this._items) item.style.width = `${colWidth}px`;
+
+    this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth);
+    this._lastWidth = w;
+
+    if (isFirst) {
+      if (this._observer) this._items.forEach(item => this._observer.observe(item));
+      // Reveal immediately if all images are cached, else wait for syncHeights.
+      const hasUnloaded = this._items.some(item =>
+        [...item.querySelectorAll("img")].some(img => !img.complete));
+      if (hasUnloaded) {
+        this._revealTimer = setTimeout(() => this._revealAll(), 1000);
+      } else {
+        this._revealAll();
+      }
+    }
+  }
+
+  // Re-read actual heights and re-place all items. Fixes drift after images load.
+  syncHeights() {
+    this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache);
+    this._revealAll();
+  }
+
+  // Batch-add items in three phases to avoid O(N) reflows:
+  //   1. writes: set widths, append all — no reads, no reflows
+  //   2. one read: batch offsetHeight
+  //   3. writes: assign columns, set left/top
+  addAll(newItems) {
+    if (!newItems.length) return;
+    const colWidth = this._colWidthCache;
+
+    for (const item of newItems) {
+      item.style.width = `${colWidth}px`;
+      this.container.appendChild(item);
+    }
+
+    this._position(newItems, newItems.map(item => item.offsetHeight), colWidth);
+    this._items.push(...newItems);
+
+    if (this._observer) newItems.forEach(item => this._observer.observe(item));
+  }
 }
 
-window.onload = function () {
-  const url = window.location.pathname;
-  const isTweet = url.indexOf("/status/") !== -1;
+document.addEventListener("DOMContentLoaded", function () {
+  const isTweet = location.pathname.includes("/status/");
   const containerClass = isTweet ? ".replies" : ".timeline";
   const itemClass = containerClass + " > div:not(.top-ref)";
-
-  var html = document.querySelector("html");
-  var container = document.querySelector(containerClass);
-  var loading = false;
+  const html = document.documentElement;
+  const container = document.querySelector(containerClass);
+  const masonryEl = container?.querySelector(".gallery-masonry");
+  const masonry = masonryEl ? new Masonry(masonryEl) : null;
+  let loading = false;
 
   function handleScroll(failed) {
-    if (loading) return;
-
-    if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
-      loading = true;
-      var loadMore = getLoadMore(document);
-      if (loadMore == null) return;
-
-      loadMore.children[0].text = "Loading...";
-
-      var url = new URL(loadMore.children[0].href);
-      url.searchParams.append("scroll", "true");
-
-      fetch(url.toString())
-        .then(function (response) {
-          if (response.status > 299) throw "error";
-          return response.text();
-        })
-        .then(function (html) {
-          var parser = new DOMParser();
-          var doc = parser.parseFromString(html, "text/html");
-          loadMore.remove();
-
-          for (var item of doc.querySelectorAll(itemClass)) {
-            if (item.className == "timeline-item show-more") continue;
-            if (isDuplicate(item, itemClass)) continue;
-            if (isTweet) container.appendChild(item);
-            else insertBeforeLast(container, item);
-          }
+    if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
 
-          loading = false;
-          const newLoadMore = getLoadMore(doc);
-          if (newLoadMore == null) return;
-          if (isTweet) container.appendChild(newLoadMore);
-          else insertBeforeLast(container, newLoadMore);
-        })
-        .catch(function (err) {
-          console.warn("Something went wrong.", err);
-          if (failed > 3) {
-            loadMore.children[0].text = "Error";
-            return;
+    const loadMore = getLoadMore(document);
+    if (!loadMore) return;
+    loading = true;
+    loadMore.children[0].text = "Loading...";
+
+    const url = new URL(loadMore.children[0].href);
+    url.searchParams.append("scroll", "true");
+
+    fetch(url)
+      .then(r => {
+        if (r.status > 299) throw new Error("error");
+        return r.text();
+      })
+      .then(responseText => {
+        const doc = new DOMParser().parseFromString(responseText, "text/html");
+        loadMore.remove();
+
+        if (masonry) {
+          masonry.syncHeights();
+          const newMasonry = doc.querySelector(".gallery-masonry");
+          if (newMasonry) {
+            const knownHrefs = getHrefs(".gallery-masonry .tweet-link");
+            masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs)));
           }
+        } else {
+          const knownHrefs = getHrefs(`${itemClass} .tweet-link`);
+          for (const item of doc.querySelectorAll(itemClass)) {
+            if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue;
+            isTweet ? container.appendChild(item) : insertBeforeLast(container, item);
+          }
+        }
 
-          loading = false;
-          handleScroll((failed || 0) + 1);
-        });
-    }
+        loading = false;
+        const newLoadMore = getLoadMore(doc);
+        if (newLoadMore) {
+          isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
+          if (masonry) newLoadMore.classList.add("masonry-visible");
+        }
+      })
+      .catch(err => {
+        console.warn("Something went wrong.", err);
+        if (failed > 3) { loadMore.children[0].text = "Error"; return; }
+        loading = false;
+        handleScroll((failed || 0) + 1);
+      });
   }
 
   window.addEventListener("scroll", () => handleScroll());
-};
+});
 // @license-end

+ 8 - 8
src/api.nim

@@ -18,16 +18,16 @@ proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
   let url = apiUrl(endpoint, variables, fieldToggles)
   return ApiReq(cookie: url, oauth: url)
 
-proc mediaUrl(id: string; cursor: string): ApiReq =
+proc mediaUrl(id, cursor: string; count=20): ApiReq =
   result = ApiReq(
-    cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
-    oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
+    cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]),
+    oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count])
   )
 
 proc userTweetsUrl(id: string; cursor: string): ApiReq =
   result = ApiReq(
     # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
-    oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
+    oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"])
   )
   # might change this in the future pending testing
   result.cookie = result.oauth
@@ -36,7 +36,7 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
   let cookieVars = userTweetsAndRepliesVars % [id, cursor]
   result = ApiReq(
     cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
-    oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
+    oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
   )
 
 proc tweetDetailUrl(id: string; cursor: string): ApiReq =
@@ -73,7 +73,7 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi
     url = case kind
       of TimelineKind.tweets: userTweetsUrl(id, cursor)
       of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
-      of TimelineKind.media: mediaUrl(id, cursor)
+      of TimelineKind.media: mediaUrl(id, cursor, 100)
     js = await fetch(url)
   result = parseGraphTimeline(js, after)
 
@@ -81,7 +81,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
   if id.len == 0: return
   let
     cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
-    url = apiReq(graphListTweets, restIdVars % [id, cursor])
+    url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"])
     js = await fetch(url)
   result = parseGraphTimeline(js, after).tweets
 
@@ -205,7 +205,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
 
 proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
   if id.len == 0: return
-  let js = await fetch(mediaUrl(id, ""))
+  let js = await fetch(mediaUrl(id, "", 30))
   result = parseGraphPhotoRail(js)
 
 proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =

+ 2 - 2
src/consts.nim

@@ -138,12 +138,12 @@ const
 
   restIdVars* = """{
   "rest_id": "$1", $2
-  "count": 20
+  "count": $3
 }"""
 
   userMediaVars* = """{
   "userId": "$1", $2
-  "count": 20,
+  "count": $3,
   "includePromotedContent": false,
   "withClientEventToken": false,
   "withBirdwatchNotes": false,

+ 7 - 0
src/prefs_impl.nim

@@ -100,6 +100,13 @@ genPrefs:
     autoplayGifs(checkbox, true):
       "Autoplay gifs"
 
+    compactGallery(checkbox, false):
+      "Compact media gallery (no profile info or text)"
+
+    mediaView(select, "Timeline"):
+      "Default media view"
+      options: @["Timeline", "Grid", "Gallery"]
+
   "Link replacements (blank to disable)":
     replaceTwitter(input, ""):
       "Twitter -> Nitter"

+ 23 - 18
src/query.nim

@@ -20,6 +20,7 @@ template `@`(param: string): untyped =
 proc initQuery*(pms: Table[string, string]; name=""): Query =
   result = Query(
     kind: parseEnum[QueryKind](@"f", tweets),
+    view: @"view",
     text: @"q",
     filters: validFilters.filterIt("f-" & it in pms),
     excludes: validFilters.filterIt("e-" & it in pms),
@@ -98,24 +99,28 @@ proc genQueryParam*(query: Query; maxId=""): string =
     result &= " max_id:" & maxId
 
 proc genQueryUrl*(query: Query): string =
-  if query.kind notin {tweets, users}: return
-
-  var params = @[&"f={query.kind}"]
-  if query.text.len > 0:
-    params.add "q=" & encodeUrl(query.text)
-  for f in query.filters:
-    params.add &"f-{f}=on"
-  for e in query.excludes:
-    params.add &"e-{e}=on"
-  for i in query.includes.filterIt(it != "nativeretweets"):
-    params.add &"i-{i}=on"
-
-  if query.since.len > 0:
-    params.add "since=" & query.since
-  if query.until.len > 0:
-    params.add "until=" & query.until
-  if query.minLikes.len > 0:
-    params.add "min_faves=" & query.minLikes
+  var params: seq[string]
+
+  if query.view.len > 0:
+    params.add "view=" & encodeUrl(query.view)
+
+  if query.kind in {tweets, users}:
+    params.add &"f={query.kind}"
+    if query.text.len > 0:
+      params.add "q=" & encodeUrl(query.text)
+    for f in query.filters:
+      params.add &"f-{f}=on"
+    for e in query.excludes:
+      params.add &"e-{e}=on"
+    for i in query.includes.filterIt(it != "nativeretweets"):
+      params.add &"i-{i}=on"
+
+    if query.since.len > 0:
+      params.add "since=" & query.since
+    if query.until.len > 0:
+      params.add "until=" & query.until
+    if query.minLikes.len > 0:
+      params.add "min_faves=" & query.minLikes
 
   if params.len > 0:
     result &= params.join("&")

+ 14 - 6
src/routes/timeline.nim

@@ -12,12 +12,20 @@ export router_utils
 export redis_cache, formatters, query, api
 export profile, timeline, status
 
-proc getQuery*(request: Request; tab, name: string): Query =
+proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
+  let view = request.params.getOrDefault("view")
   case tab
-  of "with_replies": getReplyQuery(name)
-  of "media": getMediaQuery(name)
-  of "search": initQuery(params(request), name=name)
-  else: Query(fromUser: @[name])
+  of "with_replies":
+    result = getReplyQuery(name)
+  of "media":
+    result = getMediaQuery(name)
+    result.view =
+      if view in ["timeline", "grid", "gallery"]: view
+      else: prefs.mediaView.toLowerAscii
+  of "search":
+    result = initQuery(params(request), name=name)
+  else:
+    result = Query(fromUser: @[name])
 
 template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
   if cond:
@@ -121,7 +129,7 @@ proc createTimelineRouter*(cfg: Config) =
         after = getCursor()
         names = getNames(@"name")
 
-      var query = request.getQuery(@"tab", @"name")
+      var query = request.getQuery(@"tab", @"name", prefs)
       if names.len != 1:
         query.fromUser = names
 

+ 1 - 0
src/sass/inputs.scss

@@ -179,6 +179,7 @@ input::-webkit-datetime-edit-year-field:focus {
     -moz-appearance: none;
     -webkit-appearance: none;
     appearance: none;
+    min-width: 100px;
   }
 
   input[type="text"],

+ 90 - 61
src/sass/profile/_base.scss

@@ -1,87 +1,116 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
 
-@import 'card';
-@import 'photo-rail';
+@import "card";
+@import "photo-rail";
 
 .profile-tabs {
-    @include panel(auto, 900px);
+  @include panel(auto, 900px);
 
-    .timeline-container {
-        float: right;
-        width: 68% !important;
-        max-width: unset;
-    }
+  .timeline-container {
+    float: right;
+    width: 68% !important;
+    max-width: unset;
+  }
 }
 
 .profile-banner {
-    margin-bottom: 4px;
-    background-color: var(--bg_panel);
-
-    a {
-        display: block;
-        position: relative;
-        padding: 33.34% 0 0 0;
-    }
-
-    img {
-        max-width: 100%;
-        position: absolute;
-        top: 0;
-    }
+  margin-bottom: 4px;
+  background-color: var(--bg_panel);
+
+  a {
+    display: block;
+    position: relative;
+    padding: 33.34% 0 0 0;
+  }
+
+  img {
+    max-width: 100%;
+    position: absolute;
+    top: 0;
+  }
 }
 
 .profile-tab {
-    padding: 0 4px 0 0;
-    box-sizing: border-box;
-    display: inline-block;
-    font-size: 14px;
-    text-align: left;
-    vertical-align: top;
-    max-width: 32%;
-    top: 0;
-
-    body.fixed-nav & {
-        top: 50px;
-    }
+  padding: 0 4px 0 0;
+  box-sizing: border-box;
+  display: inline-block;
+  font-size: 14px;
+  text-align: left;
+  vertical-align: top;
+  max-width: 32%;
+  top: 0;
+
+  body.fixed-nav & {
+    top: 50px;
+  }
 }
 
 .profile-result {
-    min-height: 54px;
+  min-height: 54px;
 
-    .username {
-        margin: 0 !important;
-    }
+  .username {
+    margin: 0 !important;
+  }
 
-    .tweet-header {
-        margin-bottom: unset;
-    }
+  .tweet-header {
+    margin-bottom: unset;
+  }
 }
 
-@media(max-width: 700px) {
-    .profile-tabs {
-        width: 100vw;
-        max-width: 600px;
+.profile-tabs.media-only {
+  max-width: none;
+  width: 100%;
+
+  .timeline-container {
+    float: none;
+    width: 100% !important;
+    max-width: none;
+    padding: 0 10px;
+    box-sizing: border-box;
+  }
+
+  .timeline-container > .tab {
+    max-width: 900px;
+    margin-left: auto;
+    margin-right: auto;
+  }
+}
 
-        .timeline-container {
-            width: 100% !important;
+@media (max-width: 700px) {
+  .profile-tabs {
+    width: 100vw;
+    max-width: 600px;
 
-            .tab-item wide {
-                flex-grow: 1.4;
-            }
-        }
+    .timeline-container {
+      width: 100% !important;
+
+      .tab-item wide {
+        flex-grow: 1.4;
+      }
     }
+  }
+
+  .profile-tabs.media-only {
+    width: 100%;
+    max-width: none;
 
-    .profile-tab {
-        width: 100%;
-        max-width: unset;
-        position: initial !important;
-        padding: 0;
+    .timeline-container {
+      width: 100vw !important;
+      padding: 0;
     }
+  }
+
+  .profile-tab {
+    width: 100%;
+    max-width: unset;
+    position: initial !important;
+    padding: 0;
+  }
 }
 
 @media (min-height: 900px) {
-    .profile-tab.sticky {
-        position: sticky;
-    }
+  .profile-tab.sticky {
+    position: sticky;
+  }
 }

+ 339 - 2
src/sass/timeline.scss

@@ -15,7 +15,7 @@
   padding: 8px;
   display: block;
   font-weight: bold;
-  margin-bottom: 5px;
+  margin-bottom: 4px;
   box-sizing: border-box;
 
   button {
@@ -36,7 +36,7 @@
   display: flex;
   flex-wrap: wrap;
   list-style: none;
-  margin: 0 0 5px 0;
+  margin: 0 0 4px 0;
   background-color: var(--bg_panel);
   padding: 0;
 }
@@ -157,3 +157,340 @@
   position: relative;
   background-color: var(--bg_panel);
 }
+
+.timeline.media-grid-view,
+.timeline.media-gallery-view {
+  > div:not(:first-child) {
+    border-top: none;
+  }
+
+  .timeline-item::before {
+    display: none;
+  }
+}
+
+.timeline.media-grid-view,
+.timeline.media-gallery-view .gallery-masonry.compact {
+  .tweet-header,
+  .replying-to,
+  .retweet-header,
+  .pinned,
+  .tweet-stats,
+  .attribution,
+  .poll,
+  .quote,
+  .community-note,
+  .media-tag-block,
+  .tweet-content,
+  .card-content {
+    display: none;
+  }
+
+  .card {
+    margin: unset;
+
+    .card-container {
+      border: unset;
+      border-radius: unset;
+
+      .card-image-container {
+        width: 100%;
+        min-height: 100%;
+      }
+
+      .card-content-container {
+        display: none;
+      }
+    }
+  }
+}
+
+.timeline.media-grid-view {
+  display: grid;
+  gap: 4px;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+
+  > div:not(:first-child) {
+    margin-top: 0;
+  }
+
+  .timeline-item {
+    padding: 0;
+  }
+
+  .tweet-link {
+    z-index: 1000;
+
+    &:hover {
+      background-color: unset;
+    }
+  }
+
+  > .show-more,
+  > .top-ref,
+  > .timeline-footer,
+  > .timeline-header {
+    grid-column: 1 / -1;
+  }
+
+  .tweet-body {
+    height: 100%;
+    margin-left: 0;
+    padding: 0;
+    position: relative;
+    aspect-ratio: 1/1;
+  }
+
+  .gallery-row + .gallery-row {
+    margin-top: 0.25em !important;
+  }
+
+  .attachments {
+    background-color: var(--darkest_grey);
+    border-radius: 0;
+    margin: 0;
+    max-height: none;
+  }
+
+  .attachments,
+  .gallery-row,
+  .still-image {
+    height: 100%;
+    width: 100%;
+  }
+
+  .still-image img,
+  .attachment > video,
+  .attachment > img {
+    object-fit: cover;
+    height: 100%;
+    width: 100%;
+  }
+
+  .attachment {
+    display: flex;
+    align-items: center;
+  }
+
+  .gallery-video {
+    height: 100%;
+  }
+
+  .media-gif {
+    display: flex;
+  }
+
+  .timeline-item:hover {
+    opacity: 0.85;
+  }
+
+  .alt-text {
+    display: none;
+  }
+}
+
+.timeline.media-gallery-view {
+  .gallery-masonry {
+    margin: 10px 0;
+    column-gap: 10px;
+    column-width: 350px;
+
+    &.masonry-active {
+      column-width: unset;
+      column-gap: unset;
+      position: relative;
+
+      .timeline-item {
+        animation: none;
+        position: absolute;
+        box-sizing: border-box;
+        margin-bottom: 0;
+      }
+    }
+
+    &.compact {
+      .tweet-body {
+        padding: 0;
+
+        > .attachments {
+          margin: 0;
+        }
+      }
+
+      .card-image-container img {
+        max-height: unset;
+      }
+    }
+  }
+
+  @keyframes masonry-init {
+    to {
+      opacity: 1;
+      pointer-events: auto;
+    }
+  }
+
+  // Start hidden. CSS animation reveals after a delay as a no-JS fallback.
+  // With JS, masonry-active cancels the animation and masonry-visible reveals.
+  .gallery-masonry .timeline-item,
+  > .show-more,
+  > .top-ref,
+  > .timeline-footer {
+    opacity: 0;
+    pointer-events: none;
+    animation: masonry-init 0.2s 0.3s forwards;
+  }
+
+  .gallery-masonry.masonry-active .timeline-item.masonry-visible,
+  > .show-more.masonry-visible,
+  > .top-ref.masonry-visible,
+  > .timeline-footer.masonry-visible {
+    opacity: 1;
+    pointer-events: auto;
+    transition: opacity 0.15s ease;
+    animation: none;
+  }
+
+  .timeline-item {
+    margin-bottom: 10px;
+    break-inside: avoid;
+    flex-direction: column;
+    padding: 0;
+  }
+
+  > .show-more,
+  > .top-ref,
+  > .timeline-footer,
+  > .timeline-header {
+    margin-left: auto;
+    margin-right: auto;
+    max-width: 900px;
+  }
+
+  > .show-more {
+    padding: 0;
+    margin-top: 8px;
+    background-color: unset;
+  }
+
+  .tweet-content {
+    margin: 3px 0;
+  }
+
+  .tweet-body {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    margin-left: 0;
+    padding: 10px;
+
+    > .attachments {
+      align-self: stretch;
+      border-radius: 0;
+      margin: -10px -10px 10px;
+      max-height: none;
+      order: -1;
+      width: auto;
+      background-color: var(--bg_elements);
+
+      .gallery-row {
+        max-height: none;
+        max-width: none;
+        align-items: center;
+      }
+
+      .still-image img,
+      .attachment > video,
+      .attachment > img {
+        max-height: none;
+        width: 100%;
+      }
+
+      .attachment:last-child {
+        max-height: none;
+      }
+
+      .card-container {
+        border: unset;
+        border-radius: unset;
+      }
+    }
+
+    .tweet-stat {
+      padding-top: unset;
+    }
+
+    .quote {
+      margin-bottom: 5px;
+      margin-top: 5px;
+    }
+
+    .replying-to {
+      margin: 0;
+    }
+  }
+
+  .tweet-header {
+    align-items: flex-start;
+    display: flex;
+    gap: 0.75em;
+    margin-bottom: 0;
+
+    .tweet-avatar {
+      img {
+        float: none;
+        height: 42px;
+        margin: 0;
+        width: 42px;
+      }
+    }
+
+    .tweet-name-row {
+      flex: 1;
+    }
+
+    .fullname-and-username {
+      flex-wrap: wrap;
+    }
+
+    .fullname {
+      max-width: calc(100% - 18px);
+    }
+
+    .verified-icon {
+      margin-left: 4px;
+      margin-top: 1px;
+    }
+
+    .username {
+      display: block;
+      flex-basis: 100%;
+      margin-left: 0;
+    }
+  }
+}
+
+@media (max-width: 900px) {
+  .timeline.media-grid-view {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 520px) {
+  .timeline.media-grid-view {
+    grid-template-columns: 1fr;
+  }
+
+  .timeline.media-gallery-view {
+    padding: 8px 0;
+
+    .gallery-masonry {
+      columns: 1;
+      column-gap: 0;
+
+      &.masonry-active {
+        columns: unset;
+      }
+    }
+  }
+}

+ 3 - 0
src/sass/tweet/_base.scss

@@ -101,6 +101,7 @@
 .avatar {
   &.round {
     border-radius: 50%;
+    user-select: none;
     -webkit-user-select: none;
   }
 
@@ -204,6 +205,7 @@
 
 .tweet-stats {
   margin-bottom: -3px;
+  user-select: none;
   -webkit-user-select: none;
 }
 
@@ -236,6 +238,7 @@
   left: 0;
   top: 0;
   position: absolute;
+  user-select: none;
   -webkit-user-select: none;
 
   &:hover {

+ 1 - 0
src/types.nim

@@ -123,6 +123,7 @@ type
 
   Query* = object
     kind*: QueryKind
+    view*: string
     text*: string
     filters*: seq[string]
     includes*: seq[string]

+ 1 - 1
src/views/general.nim

@@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
   let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
 
   buildHtml(head):
-    link(rel="stylesheet", type="text/css", href="/css/style.css?v=29")
+    link(rel="stylesheet", type="text/css", href="/css/style.css?v=30")
     link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4")
 
     if theme.len > 0:

+ 12 - 7
src/views/profile.nim

@@ -102,17 +102,22 @@ proc renderProtected(username: string): VNode =
 
 proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
   profile.tweets.query.fromUser = @[profile.user.username]
+  let
+    isGalleryView = profile.tweets.query.kind == media and
+      profile.tweets.query.view == "gallery"
+    viewClass = if isGalleryView: " media-only" else: ""
 
-  buildHtml(tdiv(class="profile-tabs")):
-    if not prefs.hideBanner:
+  buildHtml(tdiv(class=("profile-tabs" & viewClass))):
+    if not isGalleryView and not prefs.hideBanner:
       tdiv(class="profile-banner"):
         renderBanner(profile.user.banner)
 
-    let sticky = if prefs.stickyProfile: " sticky" else: ""
-    tdiv(class=("profile-tab" & sticky)):
-      renderUserCard(profile.user, prefs)
-      if profile.photoRail.len > 0:
-        renderPhotoRail(profile)
+    if not isGalleryView:
+      let sticky = if prefs.stickyProfile: " sticky" else: ""
+      tdiv(class=("profile-tab" & sticky)):
+        renderUserCard(profile.user, prefs)
+        if profile.photoRail.len > 0:
+          renderPhotoRail(profile)
 
     if profile.user.protected:
       renderProtected(profile.user.username)

+ 17 - 1
src/views/search.nim

@@ -39,6 +39,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
     li(class=query.getTabClass(tweets)):
       a(href=(link & "/search")): text "Search"
 
+proc renderMediaViewTabs*(query: Query; username: string): VNode =
+  let currentView = if query.view.len > 0: query.view else: "timeline"
+  let base = "/" & username & "/media?view="
+  func cls(view: string): string =
+    if currentView == view: "tab-item active" else: "tab-item"
+  buildHtml(ul(class="tab media-view-tabs")):
+    li(class=cls("timeline")):
+      a(href=(base & "timeline")): text "Timeline"
+    li(class=cls("grid")):
+      a(href=(base & "grid")): text "Grid"
+    li(class=cls("gallery")):
+      a(href=(base & "gallery")): text "Gallery"
+
 proc renderSearchTabs*(query: Query): VNode =
   var q = query
   buildHtml(ul(class="tab")):
@@ -95,7 +108,10 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
         text query.fromUser.join(" | ")
 
     if query.fromUser.len > 0:
-      renderProfileTabs(query, query.fromUser.join(","))
+      if query.kind != media or query.view != "gallery":
+        renderProfileTabs(query, query.fromUser.join(","))
+      if query.kind == media and query.fromUser.len == 1:
+        renderMediaViewTabs(query, query.fromUser[0])
 
     if query.fromUser.len == 0 or query.kind == tweets:
       tdiv(class="timeline-header"):

+ 34 - 18
src/views/timeline.nim

@@ -5,6 +5,15 @@ import karax/[karaxdsl, vdom]
 import ".."/[types, query, formatters]
 import tweet, renderutils
 
+proc timelineViewClass(query: Query): string =
+  if query.kind != media:
+    return "timeline"
+
+  case query.view
+  of "grid": "timeline media-grid-view"
+  of "gallery": "timeline media-gallery-view"
+  else: "timeline"
+
 proc getQuery(query: Query): string =
   if query.kind != posts:
     result = genQueryUrl(query)
@@ -105,9 +114,22 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
     else:
       renderNoMore()
 
+proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] =
+  var retweets: seq[int64]
+  for thread in threads:
+    if thread.len == 1:
+      let tweet = thread[0]
+      let retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
+      if retweetId in retweets or tweet.id in retweets or
+         tweet.pinned and prefs.hidePins:
+        continue
+      if retweetId != 0 and tweet.retweet.isSome:
+        retweets &= retweetId
+    result.add(thread)
+
 proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
                            pinned=none(Tweet)): VNode =
-  buildHtml(tdiv(class="timeline")):
+  buildHtml(tdiv(class=results.query.timelineViewClass)):
     if not results.beginning:
       renderNewer(results.query, parseUri(path).path)
 
@@ -121,23 +143,17 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
       else:
         renderNoneFound()
     else:
-      var retweets: seq[int64]
-
-      for thread in results.content:
-        if thread.len == 1:
-          let
-            tweet = thread[0]
-            retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
-
-          if retweetId in retweets or tweet.id in retweets or
-             tweet.pinned and prefs.hidePins:
-            continue
-
-          if retweetId != 0 and tweet.retweet.isSome:
-            retweets &= retweetId
-          renderTweet(tweet, prefs, path)
-        else:
-          renderThread(thread, prefs, path)
+      let filtered = filterThreads(results.content, prefs)
+
+      if results.query.view == "gallery":
+        tdiv(class=if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"):
+          for thread in filtered:
+            if thread.len == 1: renderTweet(thread[0], prefs, path)
+            else: renderThread(thread, prefs, path)
+      else:
+        for thread in filtered:
+          if thread.len == 1: renderTweet(thread[0], prefs, path)
+          else: renderThread(thread, prefs, path)
 
       var cursor = getSearchMaxId(results, path)
       if cursor.len > 0:

+ 7 - 0
tests/base.py

@@ -54,6 +54,13 @@ class Timeline(object):
     none = '.timeline-none'
     protected = '.timeline-protected'
     photo_rail = '.photo-rail-grid'
+    media_view_tabs = '.media-view-tabs'
+    media_view_timeline = '.media-view-tabs a[href$="media?view=timeline"]'
+    media_view_grid = '.media-view-tabs a[href$="media?view=grid"]'
+    media_view_gallery = '.media-view-tabs a[href$="media?view=gallery"]'
+    media_view_active = '.media-view-tabs .tab-item.active a'
+    grid_view = '.timeline.media-grid-view'
+    gallery_view = '.timeline.media-gallery-view'
 
 
 class Conversation(object):

+ 22 - 0
tests/test_timeline.py

@@ -55,6 +55,28 @@ class TweetTest(BaseTestCase):
         self.assert_element_absent(Timeline.older)
         self.assert_element_absent(Timeline.end)
 
+    def test_media_view_tabs(self):
+        self.open_nitter('mobile_test/media')
+        self.assert_element_present(Timeline.media_view_tabs)
+        self.assert_text('Timeline', Timeline.media_view_timeline)
+        self.assert_text('Grid', Timeline.media_view_grid)
+        self.assert_text('Gallery', Timeline.media_view_gallery)
+        self.assert_text('Timeline', Timeline.media_view_active)
+
+    def test_media_view_grid_tab(self):
+        self.open_nitter('mobile_test/media?view=grid')
+        self.assert_element_present(Timeline.grid_view)
+        self.assert_text('Grid', Timeline.media_view_active)
+
+    def test_media_view_gallery_tab(self):
+        self.open_nitter('mobile_test/media?view=gallery')
+        self.assert_element_present(Timeline.gallery_view)
+        self.assert_text('Gallery', Timeline.media_view_active)
+
+    def test_media_view_tabs_not_on_posts(self):
+        self.open_nitter('mobile_test')
+        self.assert_element_absent(Timeline.media_view_tabs)
+
     #@parameterized.expand(photo_rail)
     #def test_photo_rail(self, username, images):
         #self.open_nitter(username)