1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-04-03 20:32:10 -04:00

Add new media grid and gallery views

Fixes #199
Fixes #1342
This commit is contained in:
Zed
2026-03-15 09:29:00 +01:00
parent 91ff936cb3
commit 7ce29bd8f1
17 changed files with 767 additions and 174 deletions

View File

@@ -1,5 +1,6 @@
// @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
function insertBeforeLast(node, elem) { function insertBeforeLast(node, elem) {
node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]);
} }
@@ -8,75 +9,210 @@ function getLoadMore(doc) {
return doc.querySelector(".show-more:not(.timeline-item)"); return doc.querySelector(".show-more:not(.timeline-item)");
} }
function isDuplicate(item, itemClass) { function getHrefs(selector) {
const tweet = item.querySelector(".tweet-link"); return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href")));
if (tweet == null) return false;
const href = tweet.getAttribute("href");
return (
document.querySelector(itemClass + " .tweet-link[href='" + href + "']") !=
null
);
} }
window.onload = function () { function getTweetId(item) {
const url = window.location.pathname; const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/);
const isTweet = url.indexOf("/status/") !== -1; return m ? m[1] : "";
const containerClass = isTweet ? ".replies" : ".timeline"; }
const itemClass = containerClass + " > div:not(.top-ref)";
var html = document.querySelector("html"); function isDuplicate(item, hrefs) {
var container = document.querySelector(containerClass); return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href"));
var loading = false; }
function handleScroll(failed) { const GAP = 10;
if (loading) return;
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { class Masonry {
loading = true; constructor(container) {
var loadMore = getLoadMore(document); this.container = container;
if (loadMore == null) return; 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); // Re-sync positions whenever images finish loading and items grow taller.
url.searchParams.append("scroll", "true"); // 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()) this._rebuild();
.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)) { // Reveal all items and gallery siblings (show-more, top-ref). Idempotent.
if (item.className == "timeline-item show-more") continue; _revealAll() {
if (isDuplicate(item, itemClass)) continue; clearTimeout(this._revealTimer);
if (isTweet) container.appendChild(item); for (const item of this._items) item.classList.add("masonry-visible");
else insertBeforeLast(container, item); for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer"))
} el.classList.add("masonry-visible");
}
loading = false; // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images.
const newLoadMore = getLoadMore(doc); _pickCol() {
if (newLoadMore == null) return; return this.colHeights.reduce((min, h, i) => {
if (isTweet) container.appendChild(newLoadMore); const m = this.colHeights[min];
else insertBeforeLast(container, newLoadMore); return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min;
}) }, 0);
.catch(function (err) { }
console.warn("Something went wrong.", err);
if (failed > 3) {
loadMore.children[0].text = "Error";
return;
}
loading = false; // Position items using current column state. Updates colHeights, colCounts, container height.
handleScroll((failed || 0) + 1); _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()); window.addEventListener("scroll", () => handleScroll());
}; });
// @license-end // @license-end

View File

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

View File

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

View File

@@ -100,6 +100,13 @@ genPrefs:
autoplayGifs(checkbox, true): autoplayGifs(checkbox, true):
"Autoplay gifs" "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)": "Link replacements (blank to disable)":
replaceTwitter(input, ""): replaceTwitter(input, ""):
"Twitter -> Nitter" "Twitter -> Nitter"

View File

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

View File

@@ -12,12 +12,20 @@ export router_utils
export redis_cache, formatters, query, api export redis_cache, formatters, query, api
export profile, timeline, status 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 case tab
of "with_replies": getReplyQuery(name) of "with_replies":
of "media": getMediaQuery(name) result = getReplyQuery(name)
of "search": initQuery(params(request), name=name) of "media":
else: Query(fromUser: @[name]) 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] = template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
if cond: if cond:
@@ -121,7 +129,7 @@ proc createTimelineRouter*(cfg: Config) =
after = getCursor() after = getCursor()
names = getNames(@"name") names = getNames(@"name")
var query = request.getQuery(@"tab", @"name") var query = request.getQuery(@"tab", @"name", prefs)
if names.len != 1: if names.len != 1:
query.fromUser = names query.fromUser = names

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
padding: 8px; padding: 8px;
display: block; display: block;
font-weight: bold; font-weight: bold;
margin-bottom: 5px; margin-bottom: 4px;
box-sizing: border-box; box-sizing: border-box;
button { button {
@@ -36,7 +36,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
list-style: none; list-style: none;
margin: 0 0 5px 0; margin: 0 0 4px 0;
background-color: var(--bg_panel); background-color: var(--bg_panel);
padding: 0; padding: 0;
} }
@@ -157,3 +157,340 @@
position: relative; position: relative;
background-color: var(--bg_panel); 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;
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,19 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
li(class=query.getTabClass(tweets)): li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search" 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 = proc renderSearchTabs*(query: Query): VNode =
var q = query var q = query
buildHtml(ul(class="tab")): buildHtml(ul(class="tab")):
@@ -95,7 +108,10 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
text query.fromUser.join(" | ") text query.fromUser.join(" | ")
if query.fromUser.len > 0: 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: if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"): tdiv(class="timeline-header"):

View File

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

View File

@@ -54,6 +54,13 @@ class Timeline(object):
none = '.timeline-none' none = '.timeline-none'
protected = '.timeline-protected' protected = '.timeline-protected'
photo_rail = '.photo-rail-grid' 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): class Conversation(object):

View File

@@ -55,6 +55,28 @@ class TweetTest(BaseTestCase):
self.assert_element_absent(Timeline.older) self.assert_element_absent(Timeline.older)
self.assert_element_absent(Timeline.end) 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) #@parameterized.expand(photo_rail)
#def test_photo_rail(self, username, images): #def test_photo_rail(self, username, images):
#self.open_nitter(username) #self.open_nitter(username)