diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index a385edf..1ea35d1 100644 --- a/public/js/infiniteScroll.js +++ b/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"))); } -window.onload = function () { - const url = window.location.pathname; - const isTweet = url.indexOf("/status/") !== -1; - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; +function getTweetId(item) { + const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/); + return m ? m[1] : ""; +} - var html = document.querySelector("html"); - var container = document.querySelector(containerClass); - var loading = false; +function isDuplicate(item, hrefs) { + return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href")); +} - function handleScroll(failed) { - if (loading) return; +const GAP = 10; - if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { - loading = true; - var loadMore = getLoadMore(document); - if (loadMore == null) return; +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"); - loadMore.children[0].text = "Loading..."; + let resizeTimer; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => this._rebuild(), 50); + }); - var url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); + // 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; - 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(); + this._rebuild(); + } - 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); - } + // 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"); + } - 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; - } + // 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); + } - loading = false; - handleScroll((failed || 0) + 1); - }); + // 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)); + } +} + +document.addEventListener("DOMContentLoaded", function () { + const isTweet = location.pathname.includes("/status/"); + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; + 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 || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) 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; + 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 diff --git a/src/api.nim b/src/api.nim index 68e8eec..9e496a4 100644 --- a/src/api.nim +++ b/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.} = diff --git a/src/consts.nim b/src/consts.nim index 17827f2..cea9cc8 100644 --- a/src/consts.nim +++ b/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, diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 047caef..8ad2413 100644 --- a/src/prefs_impl.nim +++ b/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" diff --git a/src/query.nim b/src/query.nim index 4d2ef78..38fe6f4 100644 --- a/src/query.nim +++ b/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: seq[string] - 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.view.len > 0: + params.add "view=" & encodeUrl(query.view) - 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 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("&") diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 39552fd..8a11e0e 100644 --- a/src/routes/timeline.nim +++ b/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 diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index c69711a..2b6016f 100644 --- a/src/sass/inputs.scss +++ b/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"], diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 3abc736..4daddf6 100644 --- a/src/sass/profile/_base.scss +++ b/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); + margin-bottom: 4px; + background-color: var(--bg_panel); - a { - display: block; - position: relative; - padding: 33.34% 0 0 0; - } + a { + display: block; + position: relative; + padding: 33.34% 0 0 0; + } - img { - max-width: 100%; - position: absolute; - top: 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; + 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; - } + 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 { - width: 100% !important; + .timeline-container { + float: none; + width: 100% !important; + max-width: none; + padding: 0 10px; + box-sizing: border-box; + } - .tab-item wide { - flex-grow: 1.4; - } - } + .timeline-container > .tab { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } +} + +@media (max-width: 700px) { + .profile-tabs { + width: 100vw; + max-width: 600px; + + .timeline-container { + width: 100% !important; + + .tab-item wide { + flex-grow: 1.4; + } } + } - .profile-tab { - width: 100%; - max-width: unset; - position: initial !important; - padding: 0; + .profile-tabs.media-only { + width: 100%; + max-width: none; + + .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; + } } diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index eeb794a..192073f 100644 --- a/src/sass/timeline.scss +++ b/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; + } + } + } +} diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index b9d64bc..8019522 100644 --- a/src/sass/tweet/_base.scss +++ b/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 { diff --git a/src/types.nim b/src/types.nim index 3f655b6..4f52886 100644 --- a/src/types.nim +++ b/src/types.nim @@ -123,6 +123,7 @@ type Query* = object kind*: QueryKind + view*: string text*: string filters*: seq[string] includes*: seq[string] diff --git a/src/views/general.nim b/src/views/general.nim index 9671c73..ac6c6e2 100644 --- a/src/views/general.nim +++ b/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: diff --git a/src/views/profile.nim b/src/views/profile.nim index ee3f71d..83d300a 100644 --- a/src/views/profile.nim +++ b/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) diff --git a/src/views/search.nim b/src/views/search.nim index a43008f..c79f3fe 100644 --- a/src/views/search.nim +++ b/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"): diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 2d7537f..f4899c8 100644 --- a/src/views/timeline.nim +++ b/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] + let filtered = filterThreads(results.content, prefs) - 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) + 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: diff --git a/tests/base.py b/tests/base.py index 81371d3..6d795e1 100644 --- a/tests/base.py +++ b/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): diff --git a/tests/test_timeline.py b/tests/test_timeline.py index 919aa70..65c598f 100644 --- a/tests/test_timeline.py +++ b/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)