mirror of
https://github.com/zedeus/nitter.git
synced 2026-04-03 20:32:10 -04:00
@@ -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] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const isTweet = location.pathname.includes("/status/");
|
||||||
const containerClass = isTweet ? ".replies" : ".timeline";
|
const containerClass = isTweet ? ".replies" : ".timeline";
|
||||||
const itemClass = containerClass + " > div:not(.top-ref)";
|
const itemClass = containerClass + " > div:not(.top-ref)";
|
||||||
|
const html = document.documentElement;
|
||||||
var html = document.querySelector("html");
|
const container = document.querySelector(containerClass);
|
||||||
var container = document.querySelector(containerClass);
|
const masonryEl = container?.querySelector(".gallery-masonry");
|
||||||
var loading = false;
|
const masonry = masonryEl ? new Masonry(masonryEl) : null;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
function handleScroll(failed) {
|
function handleScroll(failed) {
|
||||||
if (loading) return;
|
if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return;
|
||||||
|
|
||||||
if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
|
const loadMore = getLoadMore(document);
|
||||||
|
if (!loadMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
var loadMore = getLoadMore(document);
|
|
||||||
if (loadMore == null) return;
|
|
||||||
|
|
||||||
loadMore.children[0].text = "Loading...";
|
loadMore.children[0].text = "Loading...";
|
||||||
|
|
||||||
var url = new URL(loadMore.children[0].href);
|
const url = new URL(loadMore.children[0].href);
|
||||||
url.searchParams.append("scroll", "true");
|
url.searchParams.append("scroll", "true");
|
||||||
|
|
||||||
fetch(url.toString())
|
fetch(url)
|
||||||
.then(function (response) {
|
.then(r => {
|
||||||
if (response.status > 299) throw "error";
|
if (r.status > 299) throw new Error("error");
|
||||||
return response.text();
|
return r.text();
|
||||||
})
|
})
|
||||||
.then(function (html) {
|
.then(responseText => {
|
||||||
var parser = new DOMParser();
|
const doc = new DOMParser().parseFromString(responseText, "text/html");
|
||||||
var doc = parser.parseFromString(html, "text/html");
|
|
||||||
loadMore.remove();
|
loadMore.remove();
|
||||||
|
|
||||||
for (var item of doc.querySelectorAll(itemClass)) {
|
if (masonry) {
|
||||||
if (item.className == "timeline-item show-more") continue;
|
masonry.syncHeights();
|
||||||
if (isDuplicate(item, itemClass)) continue;
|
const newMasonry = doc.querySelector(".gallery-masonry");
|
||||||
if (isTweet) container.appendChild(item);
|
if (newMasonry) {
|
||||||
else insertBeforeLast(container, item);
|
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;
|
loading = false;
|
||||||
const newLoadMore = getLoadMore(doc);
|
const newLoadMore = getLoadMore(doc);
|
||||||
if (newLoadMore == null) return;
|
if (newLoadMore) {
|
||||||
if (isTweet) container.appendChild(newLoadMore);
|
isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore);
|
||||||
else insertBeforeLast(container, newLoadMore);
|
if (masonry) newLoadMore.classList.add("masonry-visible");
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
console.warn("Something went wrong.", err);
|
|
||||||
if (failed > 3) {
|
|
||||||
loadMore.children[0].text = "Error";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn("Something went wrong.", err);
|
||||||
|
if (failed > 3) { loadMore.children[0].text = "Error"; return; }
|
||||||
loading = false;
|
loading = false;
|
||||||
handleScroll((failed || 0) + 1);
|
handleScroll((failed || 0) + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("scroll", () => handleScroll());
|
window.addEventListener("scroll", () => handleScroll());
|
||||||
};
|
});
|
||||||
// @license-end
|
// @license-end
|
||||||
|
|||||||
16
src/api.nim
16
src/api.nim
@@ -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.} =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,9 +99,13 @@ 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:
|
||||||
|
params.add "view=" & encodeUrl(query.view)
|
||||||
|
|
||||||
|
if query.kind in {tweets, users}:
|
||||||
|
params.add &"f={query.kind}"
|
||||||
if query.text.len > 0:
|
if query.text.len > 0:
|
||||||
params.add "q=" & encodeUrl(query.text)
|
params.add "q=" & encodeUrl(query.text)
|
||||||
for f in query.filters:
|
for f in query.filters:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
@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);
|
||||||
@@ -58,7 +58,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 700px) {
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -72,6 +91,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tabs.media-only {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
width: 100vw !important;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-tab {
|
.profile-tab {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: unset;
|
max-width: unset;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -102,12 +102,17 @@ 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)
|
||||||
|
|
||||||
|
if not isGalleryView:
|
||||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
tdiv(class=("profile-tab" & sticky)):
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
renderUserCard(profile.user, prefs)
|
renderUserCard(profile.user, prefs)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
if query.kind != media or query.view != "gallery":
|
||||||
renderProfileTabs(query, query.fromUser.join(","))
|
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"):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
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:
|
else:
|
||||||
renderThread(thread, prefs, path)
|
for thread in filtered:
|
||||||
|
if thread.len == 1: renderTweet(thread[0], 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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user