Add the profile posts menu and stop treating prices as tickers.

Hide Articles unless that account has some, and leave amounts like $250,000 as plain text.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
4cuckandCursor committed 2026-10-25 21:04:19 +00:00
1 parent b8a0d0fc00
commit 5219453120
17 files changed
+563 -23

No files matched your search

+63
View File
@@ -487,6 +487,69 @@ main .container {
overflow: visible;
box-shadow: none;
}
.timeline-container > .tab {
position: relative;
z-index: 8;
}
.posts-filter { position: relative; height: 100%; }
.posts-filter[open] { z-index: 9; }
.posts-filter > summary {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 100%;
padding: 8px 0;
list-style: none;
cursor: pointer;
color: var(--tab_selected);
font-weight: 700;
border-bottom: 0.1rem solid var(--tab_selected);
}
.posts-filter > summary::-webkit-details-marker { display: none; }
.posts-filter-chevron {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
}
.posts-filter-menu {
position: absolute;
z-index: 40;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: max-content;
padding: 4px;
background: var(--bg_panel);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 10px 28px var(--shadow);
}
.posts-filter-menu a {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px;
border-radius: 8px;
color: var(--text);
font-weight: 650;
white-space: nowrap;
}
.posts-filter-menu a:hover { background: var(--bg_hover); }
.posts-filter-menu a.on::after { content: "✓"; color: var(--accent); }
.posts-filter-label {
padding: 7px 10px 1px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
text-align: center;
}
@media (max-width: 700px) {
.posts-filter-menu { left: 0; transform: none; }
}
.tab-item a {
padding: 8px 0;
font-size: inherit;
+2 -2
View File
@@ -3,12 +3,12 @@
document.addEventListener("click", async (event) => {
const target = event.target;
const accountDetails = target instanceof Element
? target.closest(".tweet-account-trigger, .account-context-trigger")
? target.closest(".tweet-account-trigger, .account-context-trigger, .posts-filter")
: null;
if (!accountDetails) {
document
.querySelectorAll(".tweet-account-trigger[open], .account-context-trigger[open]")
.querySelectorAll(".tweet-account-trigger[open], .account-context-trigger[open], .posts-filter[open]")
.forEach((details) => {
details.removeAttribute("open");
});
+35
View File
@@ -450,6 +450,41 @@ impl ApiClient {
parser::parse_timeline(&data)
}
/// Posts menu: All (optionally Popular), original Posts, or Highlights.
pub async fn get_filtered_posts(
&self,
user_id: &str,
cursor: Option<&str>,
filter: &str,
popular: bool,
) -> Result<Timeline> {
let (endpoint, variables, features, toggles) = match filter {
"posts" => (
endpoints::GRAPH_USER_ORIGINALS,
endpoints::user_originals_vars(user_id, cursor, popular),
endpoints::USER_REPOSTS_FEATURES,
endpoints::USER_REPOSTS_FIELD_TOGGLES,
),
"highlights" => (
endpoints::GRAPH_USER_HIGHLIGHTS,
endpoints::user_highlights_vars(user_id, cursor, popular),
endpoints::USER_REPOSTS_FEATURES,
endpoints::USER_REPOSTS_FIELD_TOGGLES,
),
_ if popular => (
endpoints::GRAPH_USER_TWEETS,
endpoints::user_tweets_sorted_vars(user_id, cursor, true),
endpoints::GQL_FEATURES,
endpoints::USER_TWEETS_FIELD_TOGGLES,
),
_ => return self.get_user_tweets(user_id, cursor).await,
};
let data = self
.graphql_request::<UserTimelineData>(endpoint, &variables, features, Some(toggles))
.await?;
parser::parse_timeline(&data)
}
/// Profile Articles tab (`UserArticlesTweets`).
pub async fn get_user_articles(&self, user_id: &str, cursor: Option<&str>) -> Result<Timeline> {
let data = self
+40 -2
View File
@@ -50,6 +50,10 @@ pub const GRAPH_USER_REPOSTS: &str = "xqXxsWU5PFYzr90dfab6rg/UserRepostsTimeline
/// Web query for a profile's Articles tab, from a logged-in Chrome capture of
/// `/prestonjbyrne/articles`.
pub const GRAPH_USER_ARTICLES: &str = "px0ZuoJAreztxT8_nJr9Xg/UserArticlesTweets";
/// Original posts only. The Posts item in the profile posts menu.
pub const GRAPH_USER_ORIGINALS: &str = "GMxl7_rXoC4G0o-kiH0oLA/UserOriginalsTimeline";
/// Highlighted posts. Shown in that menu only when the account has some.
pub const GRAPH_USER_HIGHLIGHTS: &str = "2vCerAWrlID9W2wM0pmuaA/UserHighlightsTweets";
pub const GRAPH_USER_TWEETS_AND_REPLIES_V2: &str =
"BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2";
pub const GRAPH_TWEET_EDIT_HISTORY: &str = "upS9teTSG45aljmP9oTuXA/TweetEditHistory";
@@ -131,11 +135,45 @@ pub fn tweet_detail_vars(focal_tweet_id: &str, cursor: Option<&str>, ranking_mod
}
pub fn user_tweets_vars(user_id: &str, cursor: Option<&str>) -> String {
vars(json!({
user_tweets_sorted_vars(user_id, cursor, false)
}
/// `UserTweets`, with Popular sort when requested.
pub fn user_tweets_sorted_vars(user_id: &str, cursor: Option<&str>, popular: bool) -> String {
let mut value = json!({
"userId": user_id, "cursor": cursor, "count": 20,
"includePromotedContent": false,
"withQuickPromoteEligibilityTweetFields": true, "withVoice": true,
}))
});
if popular {
value["sortByMostLiked"] = json!(true);
}
vars(value)
}
/// Original posts (`UserOriginalsTimeline`), as the profile Posts filter sends them.
pub fn user_originals_vars(user_id: &str, cursor: Option<&str>, popular: bool) -> String {
let mut value = json!({
"userId": user_id, "cursor": cursor, "count": 20,
"includePromotedContent": true,
"withQuickPromoteEligibilityTweetFields": true, "withVoice": true,
});
if popular {
value["sortByMostLiked"] = json!(true);
}
vars(value)
}
/// Highlighted posts. `sortByMostLiked` is the Popular item in the same menu.
pub fn user_highlights_vars(user_id: &str, cursor: Option<&str>, popular: bool) -> String {
let mut value = json!({
"userId": user_id, "cursor": cursor, "count": 20,
"includePromotedContent": true, "withVoice": true,
});
if popular {
value["sortByMostLiked"] = json!(true);
}
vars(value)
}
pub fn user_reposts_vars(user_id: &str, cursor: Option<&str>) -> String {
+2
View File
@@ -33,6 +33,8 @@ const WEB_OPERATIONS: &[&str] = &[
"UserTweetsAndReplies",
"UserRepostsTimeline",
"UserArticlesTweets",
"UserOriginalsTimeline",
"UserHighlightsTweets",
"UserMedia",
"TweetDetail",
"TweetEditHistory",
+7
View File
@@ -45,6 +45,13 @@ pub fn parse_entities_from(raw: &Entities) -> Vec<Entity> {
}
for symbol in &raw.symbols {
// Prices (`$250`, `$25,000`) are not tickers. Skip them so the digits
// stay in the surrounding text instead of becoming a search link.
if let Some(text) = symbol.text.as_deref()
&& !crate::utils::entity_expander::is_cashtag(text)
{
continue;
}
let (start, end) = indices(&symbol.indices);
entities.push(Entity {
indices: (start, end),
+37
View File
@@ -114,6 +114,16 @@ impl TryFrom<&UserData> for User {
.or(legacy.protected)
.unwrap_or(false);
let suspended = raw.unavailable_message;
let has_highlights = raw.highlights_info.as_ref().is_some_and(|info| {
info.can_highlight_tweets.unwrap_or(false)
&& info
.highlighted_tweets
.as_deref()
.unwrap_or("0")
.parse::<i64>()
.unwrap_or(0)
> 0
});
let is_blue = raw.is_blue_verified.unwrap_or(false);
// Check verified_type first -- Business/Government accounts also have
@@ -237,6 +247,7 @@ impl TryFrom<&UserData> for User {
verified_type,
protected,
suspended,
has_highlights,
join_date,
})
}
@@ -285,5 +296,31 @@ mod tests {
assert_eq!(user.location, "Pale Blue Dot");
assert_eq!(user.verified_type, VerifiedType::Government);
assert!(user.join_date.is_some());
assert!(!user.has_highlights);
}
#[test]
fn highlights_require_a_count() {
let raw: UserData = serde_json::from_str(
r#"{
"__typename": "User",
"rest_id": "1",
"core": {"name": "NFL", "screen_name": "NFL"},
"highlights_info": {"can_highlight_tweets": true, "highlighted_tweets": "187"}
}"#,
)
.unwrap();
assert!(User::try_from(&raw).unwrap().has_highlights);
let empty: UserData = serde_json::from_str(
r#"{
"__typename": "User",
"rest_id": "2",
"core": {"name": "Quiet", "screen_name": "quiet"},
"highlights_info": {"can_highlight_tweets": true, "highlighted_tweets": "0"}
}"#,
)
.unwrap();
assert!(!User::try_from(&empty).unwrap().has_highlights);
}
}
+8
View File
@@ -603,6 +603,14 @@ pub struct UserData {
pub tweet_counts: Option<TweetCounts>,
pub action_counts: Option<ActionCounts>,
pub pinned_items: Option<PinnedItems>,
pub highlights_info: Option<HighlightsInfo>,
}
/// Whether this account has a Highlights filter on its posts.
#[derive(Deserialize, Default)]
pub struct HighlightsInfo {
pub can_highlight_tweets: Option<bool>,
pub highlighted_tweets: Option<String>,
}
#[derive(Deserialize, Default)]
+13
View File
@@ -15,6 +15,19 @@ pub fn account_context(username: &str) -> String {
format!("ac:{}", username.to_lowercase())
}
pub fn articles_flag(user_id: &str) -> String {
format!("af:{user_id}")
}
/// A filtered posts page: All, original posts, or highlights, recent or popular.
pub fn filtered_posts(username: &str, filter: &str, sort: &str, cursor: Option<&str>) -> String {
let name = username.to_lowercase();
match cursor {
Some(cursor) => format!("fp:{name}:{filter}:{sort}:{cursor}"),
None => format!("fp:{name}:{filter}:{sort}"),
}
}
pub fn timeline(username: &str, kind: &str) -> String {
format!("tl:{}:{kind}", username.to_lowercase())
}
+5
View File
@@ -37,6 +37,11 @@ pub const EXPLORE_CURSOR: u64 = 3 * 60;
pub const EXPLORE_CURSOR_STALE: u64 = 15 * 60;
/// Whether a profile has any articles. Publishing one shows the tab within an hour.
pub const ARTICLES_FLAG: u64 = 60 * 60;
/// Keep a known answer through a rate-limit blip.
pub const ARTICLES_FLAG_STALE: u64 = 6 * 60 * 60;
/// Long TTL for immutable mappings like user ID -> username.
pub const USER_ID_MAPPING: u64 = 24 * 60 * 60; // 1 day
+25
View File
@@ -84,6 +84,31 @@ pub fn remember_user_id(state: &AppState, user: &User) {
);
}
/// Whether this account has published an article.
///
/// X does not put that on the profile record, so the articles timeline is
/// checked once and remembered. A failed check keeps the tab, so a rate limit
/// cannot hide articles that are there.
pub async fn user_has_articles(state: &AppState, user_id: &str) -> bool {
if user_id.is_empty() {
return false;
}
let key = cache_keys::articles_flag(user_id);
if let Some(Hit::Fresh(has) | Hit::Stale(has)) = state.cache.lookup(&key) {
return has;
}
match state.api.get_user_articles(user_id, None).await {
Ok(timeline) => {
let has = timeline.content.iter().flatten().next().is_some();
state
.cache
.set_swr(&key, &has, ttl::ARTICLES_FLAG, ttl::ARTICLES_FLAG_STALE);
has
},
Err(_) => true,
}
}
/// Store a full user record plus id mappings.
pub fn store_user(state: &AppState, user: &User) {
remember_user_id(state, user);
+162
View File
@@ -181,6 +181,25 @@ pub struct TimelineQuery {
pub view: Option<String>,
/// For AJAX infinite scroll requests - returns only tweet HTML.
pub scroll: Option<String>,
/// Posts menu: `all`, `posts`, or `highlights`.
pub filter: Option<String>,
/// Posts menu sort: `recent` or `popular`.
pub sort: Option<String>,
}
fn posts_filter(raw: Option<&str>) -> &'static str {
match raw.unwrap_or("").trim() {
"posts" | "originals" => "posts",
"highlights" | "highlight" => "highlights",
_ => "all",
}
}
fn posts_sort(raw: Option<&str>) -> &'static str {
match raw.unwrap_or("").trim() {
"popular" | "likes" => "popular",
_ => "recent",
}
}
pub fn router() -> Router<AppState> {
@@ -194,6 +213,100 @@ pub fn router() -> Router<AppState> {
.route("/{username}/search", get(user_search))
}
async fn filtered_posts(
state: &AppState,
prefs: &Prefs,
username: &str,
cursor: Option<&str>,
filter: &'static str,
sort: &'static str,
is_scroll: bool,
) -> Result<Response> {
let user = get_cached_user(state, username).await?;
let popular = sort == "popular";
let cache_key = cache_keys::filtered_posts(username, filter, sort, cursor);
let timeline_result = if let Some(Hit::Fresh(hit) | Hit::Stale(hit)) =
state.cache.lookup::<Timeline>(&cache_key)
{
Ok(hit)
} else {
let result = state
.api
.get_filtered_posts(&user.id, cursor, filter, popular)
.await;
if let Ok(ref data) = result {
state
.cache
.set_swr(&cache_key, data, ttl::DEFAULT, ttl::DEFAULT_STALE);
}
result
};
match timeline_result {
Ok(data) => {
let (mut tweets, next_cursor) = extract_timeline(data);
helpers::enrich_tweet_groups(state, &mut tweets).await;
for group in &tweets {
helpers::remember_tweets(state, group);
}
let base_url = timeline::posts_href(&user.username, filter, sort);
if is_scroll {
let content = timeline::render_timeline_with_prefs(
&tweets,
&state.config,
next_cursor.as_deref(),
Some(&base_url),
prefs,
None,
);
return Ok(Html(content.into_string()).into_response());
}
let (photo_rail, show_articles) = tokio::join!(
helpers::photo_rail(state, username, &user.id),
helpers::user_has_articles(state, &user.id),
);
let newer = cursor.is_some().then_some(base_url.as_str());
let timeline_content = html! {
(timeline::render_timeline_with_prefs(
&tweets,
&state.config,
next_cursor.as_deref(),
Some(&base_url),
prefs,
newer,
))
};
let menu = timeline::PostsMenu {
filter,
sort,
show_highlights: user.has_highlights || filter == "highlights",
show_articles,
};
let content = profile::render_profile_page(
&user,
&photo_rail,
&state.config,
prefs,
TimelineKind::Tweets,
&timeline_content,
"",
&menu,
);
let title = match filter {
"highlights" => format!("Highlights from @{}", user.username),
"posts" => format!("Posts from @{}", user.username),
_ => format!("{} (@{})", user.fullname, user.username),
};
let markup = layout::PageLayout::new(&state.config, &title, content)
.prefs(prefs)
.referer(&base_url)
.render();
Ok(Html(markup.into_string()).into_response())
},
Err(err) => Ok(helpers::api_error(&state.config, &err)),
}
}
async fn user_timeline(
State(state): State<AppState>,
jar: CookieJar,
@@ -224,9 +337,23 @@ async fn user_timeline(
// Check if this is an AJAX scroll request
let is_scroll = query.scroll.as_ref().is_some_and(|val| val == "true");
let filter = posts_filter(query.filter.as_deref());
let sort = posts_sort(query.sort.as_deref());
// Extract prefs from cookies
let prefs = Prefs::from_cookies(&jar, &state.config);
if filter != "all" || sort != "recent" {
return filtered_posts(
&state,
&prefs,
&username,
query.cursor.as_deref(),
filter,
sort,
is_scroll,
)
.await;
}
let profile_result = if let Some(cursor) = query.cursor.as_deref() {
load_profile_cursor(&state, &username, cursor).await
@@ -283,12 +410,20 @@ async fn user_timeline(
helpers::prefetch_profiles(&state, first);
}
let show_articles = helpers::user_has_articles(&state, &profile_data.user.id).await;
let menu = timeline::PostsMenu {
filter: "all",
sort: "recent",
show_highlights: profile_data.user.has_highlights,
show_articles,
};
let content = profile::render_profile_with_prefs(
&profile_data,
&state.config,
tab,
Some(&prefs),
newer.as_deref(),
&menu,
);
let markup = layout::PageLayout::new(&state.config, &title, content)
.description(&desc)
@@ -425,6 +560,24 @@ async fn user_tab_handler(
if tab == TimelineKind::Media { view } else { "" },
))
};
let show_articles = if tab == TimelineKind::Articles {
let has = tweets.iter().flatten().next().is_some();
state.cache.set_swr(
&cache_keys::articles_flag(&user.id),
&has,
ttl::ARTICLES_FLAG,
ttl::ARTICLES_FLAG_STALE,
);
has
} else {
helpers::user_has_articles(state, &user.id).await
};
let menu = timeline::PostsMenu {
filter: "all",
sort: "recent",
show_highlights: user.has_highlights,
show_articles,
};
let content = profile::render_profile_page(
&user,
&photo_rail,
@@ -433,6 +586,7 @@ async fn user_tab_handler(
tab,
&timeline_content,
view,
&menu,
);
let title = format!("{title_prefix} @{}", user.username);
@@ -584,6 +738,13 @@ async fn user_search(
}
(timeline::render_timeline_with_prefs(&tweets, &state.config, cursor.as_deref(), Some(&base_url), &prefs, newer))
};
let show_articles = helpers::user_has_articles(&state, &user.id).await;
let menu = timeline::PostsMenu {
filter: "all",
sort: "recent",
show_highlights: user.has_highlights,
show_articles,
};
let content = profile::render_profile_page(
&user,
&photo_rail,
@@ -592,6 +753,7 @@ async fn user_search(
TimelineKind::Search,
&timeline_content,
"",
&menu,
);
let title = if search_query.is_empty() {
+2
View File
@@ -37,6 +37,8 @@ pub struct User {
pub verified_type: VerifiedType,
pub protected: bool,
pub suspended: bool,
/// X reported at least one highlighted post, so the posts menu can offer it.
pub has_highlights: bool,
#[serde(with = "time::serde::timestamp::option")]
pub join_date: Option<time::OffsetDateTime>,
}
+88 -9
View File
@@ -16,7 +16,20 @@ use crate::types::{Entity, EntityKind};
static MENTION_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new("(^|[^A-Za-z0-9_])@([A-Za-z0-9_]{1,15})").unwrap());
static HASHTAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(^|[^\w&;])([#$])(\w+)").unwrap());
LazyLock::new(|| Regex::new(r"(^|[^\w&;])(#)(\w+)").unwrap());
/// A cashtag is `$` plus a ticker (`$TSLA`, `$BRK.B`), never a dollar amount.
/// The regex crate has no lookahead, so a following digit or letter is rejected
/// in `link_cashtags` (`$AAPL123` and `$ABCDEFG` stay plain text).
static CASHTAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(^|[^\w&;])(\$)([a-z]{1,6}(?:[._][a-z]{1,2})?)").unwrap()
});
static CASHTAG_BODY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^[a-z]{1,6}(?:[._][a-z]{1,2})?$").unwrap());
/// True when `name` is a stock ticker, not digits from a price like `$250`.
pub fn is_cashtag(name: &str) -> bool {
CASHTAG_BODY_RE.is_match(name.trim_start_matches('$'))
}
/// Convert character index to byte index.
/// Twitter entities use character (code point) indices, not byte indices.
@@ -119,13 +132,19 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool)
},
EntityKind::Symbol => {
let symbol_name = entity_text.trim_start_matches('$');
let root = if link_to_x { "https://x.com" } else { "" };
let _ = write!(
result,
r#"<a href="{root}/search?q=%24{}">{}</a>"#,
url_encode(symbol_name),
entity_text,
);
// X sometimes hands back `$250` inside `$250,000`. That is a price,
// not a ticker, so leave it as text.
if !is_cashtag(symbol_name) {
result.push_str(&entity_text);
} else {
let root = if link_to_x { "https://x.com" } else { "" };
let _ = write!(
result,
r#"<a href="{root}/search?q=%24{}">{}</a>"#,
url_encode(symbol_name),
entity_text,
);
}
},
EntityKind::Url => {
let display = if link_to_x {
@@ -165,7 +184,30 @@ pub fn expand_with_regex(text: &str) -> String {
let escaped = escape_tags(text);
let result = MENTION_RE.replace_all(&escaped, r#"$1<a href="/$2">@$2</a>"#);
let result = HASHTAG_RE.replace_all(&result, r#"$1<a href="/search?q=%23$3">$2$3</a>"#);
result.into_owned()
link_cashtags(&result).into_owned()
}
/// Link `$TSLA`-style tickers. A match followed by another letter, digit, or
/// underscore is a longer token (or a price) and is left alone.
fn link_cashtags(text: &str) -> Cow<'_, str> {
CASHTAG_RE.replace_all(text, |caps: &regex::Captures| {
let end = caps.get(0).map(|m| m.end()).unwrap_or(0);
let ticker = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let continues = text[end..]
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
if continues || !is_cashtag(ticker) {
caps.get(0).map(|m| m.as_str()).unwrap_or("").to_owned()
} else {
let before = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let dollar = caps.get(2).map(|m| m.as_str()).unwrap_or("$");
format!(
r#"{before}<a href="/search?q=%24{}">{dollar}{ticker}</a>"#,
url_encode(ticker),
)
}
})
}
/// HTML escape special characters.
@@ -372,4 +414,41 @@ mod tests {
assert!(fallback.contains(r#"<a href="/user">@user</a>"#));
assert!(fallback.contains("&lt;/b&gt;"));
}
#[test]
fn dollar_amounts_are_not_cashtags() {
let text = "give me $25,000 for fun (the $250,000 my X video made) and $5";
let result = expand_with_regex(text);
assert_eq!(result, text);
assert_eq!(
expand_with_regex("$250000 and $AAPL123 and $ABCDEFG"),
"$250000 and $AAPL123 and $ABCDEFG"
);
assert!(!is_cashtag("250"));
assert!(!is_cashtag("$25,000"));
}
#[test]
fn ticker_cashtag_still_links() {
let result = expand_with_regex("buying $TSLA and $BRK.B today");
assert!(result.contains(r#"href="/search?q=%24TSLA""#));
assert!(result.contains(">$TSLA</a>"));
assert!(result.contains(r#"href="/search?q=%24BRK%2EB""#));
assert!(result.contains(">$BRK.B</a>"));
assert!(is_cashtag("TSLA"));
assert!(is_cashtag("BRK.B"));
}
#[test]
fn numeric_symbol_entity_stays_text() {
let text = "the $250,000 my X video made";
let entities = vec![Entity {
indices: (4, 8),
kind: EntityKind::Symbol,
..Default::default()
}];
let result = expand_entities(text, &entities);
assert_eq!(result, text);
assert!(!result.contains("<a"));
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ use regex::Regex;
use crate::{config::Config, types::Prefs};
pub const STYLE_CSS: &str = "/css/style.css?teapawt11";
pub const CLEAN_CSS: &str = "/css/clean.css?teapawt37";
pub const CLEAN_CSS: &str = "/css/clean.css?teapawt40";
pub const FONTELLO_CSS: &str = "/css/fontello.css";
/// Builder for rendering a full page layout.
@@ -255,7 +255,7 @@ impl<'a> PageLayout<'a> {
// Scripts in <head> with defer
script src="/js/htmx.min.js" defer="" {}
script src="/js/webmcp.js" defer="" {}
script src="/js/site.js?teapawt=1" defer="" {}
script src="/js/site.js?teapawt=2" defer="" {}
script src="/js/threadContext.js" defer="" {}
script src="/js/imageLightbox.js" defer="" {}
@if infinite_scroll {
+7 -3
View File
@@ -1,6 +1,8 @@
use maud::{Markup, PreEscaped, html};
use super::timeline::{render_timeline_tabs, render_timeline_with_pinned_and_prefs, tab_to_kind};
use super::timeline::{
render_timeline_tabs, render_timeline_with_pinned_and_prefs, tab_to_kind, PostsMenu,
};
use crate::{
config::Config,
types::{GalleryPhoto, Prefs, Profile, TimelineKind, User},
@@ -21,6 +23,7 @@ pub fn render_profile_with_prefs(
tab: &str,
prefs: Option<&Prefs>,
newer_url: Option<&str>,
menu: &PostsMenu<'_>,
) -> Markup {
let groups = &profile.tweets.content;
let cursor = profile.tweets.bottom.as_deref();
@@ -79,7 +82,7 @@ pub fn render_profile_with_prefs(
} @else {
// Timeline container - main content area
div class="timeline-container" {
(render_timeline_tabs(timeline_kind, &profile.user.username))
(render_timeline_tabs(timeline_kind, &profile.user.username, menu))
(render_timeline_with_pinned_and_prefs(groups, config, cursor, Some(&base_url), pinned, prefs, newer_url))
}
}
@@ -99,6 +102,7 @@ pub fn render_profile_page(
tab: TimelineKind,
timeline_content: &Markup,
view: &str,
menu: &PostsMenu<'_>,
) -> Markup {
let hide_banner = prefs.hide_banner;
let sticky = prefs.sticky_profile;
@@ -140,7 +144,7 @@ pub fn render_profile_page(
div class=(container_class) {
@if !is_gallery {
(render_timeline_tabs(tab, &user.username))
(render_timeline_tabs(tab, &user.username, menu))
}
(timeline_content)
}
+65 -5
View File
@@ -278,12 +278,47 @@ fn render_timeline_items(
}
}
/// Which posts slice and sort the profile menu is showing.
#[derive(Clone, Copy)]
pub struct PostsMenu<'a> {
pub filter: &'a str,
pub sort: &'a str,
pub show_highlights: bool,
pub show_articles: bool,
}
/// Profile path for a posts filter. Defaults stay on `/{username}`.
pub fn posts_href(username: &str, filter: &str, sort: &str) -> String {
let mut query = Vec::new();
if filter != "all" {
query.push(format!("filter={filter}"));
}
if sort != "recent" {
query.push(format!("sort={sort}"));
}
if query.is_empty() {
format!("/{username}")
} else {
format!("/{username}?{}", query.join("&"))
}
}
/// Render timeline with tabs (tweets, replies, media).
pub fn render_timeline_tabs(active_tab: TimelineKind, username: &str) -> Markup {
pub fn render_timeline_tabs(active_tab: TimelineKind, username: &str, menu: &PostsMenu<'_>) -> Markup {
let on_posts = active_tab == TimelineKind::Tweets;
let posts_label = match menu.filter {
"posts" if on_posts => "Posts",
"highlights" if on_posts => "Highlights",
_ => "Tweets",
};
html! {
ul class="tab" {
li class=(if active_tab == TimelineKind::Tweets { "tab-item active" } else { "tab-item" }) {
a href=(format!("/{username}")) { "Tweets" }
li class=(if on_posts { "tab-item active" } else { "tab-item" }) {
@if on_posts {
(render_posts_menu(username, posts_label, menu))
} @else {
a href=(format!("/{username}")) { "Tweets" }
}
}
li class=(if active_tab == TimelineKind::Replies { "tab-item active wide" } else { "tab-item wide" }) {
a href=(format!("/{username}/with_replies")) { "Tweets & Replies" }
@@ -291,8 +326,10 @@ pub fn render_timeline_tabs(active_tab: TimelineKind, username: &str) -> Markup
li class=(if active_tab == TimelineKind::Reposts { "tab-item active" } else { "tab-item" }) {
a href=(format!("/{username}/reposts")) { "Reposts" }
}
li class=(if active_tab == TimelineKind::Articles { "tab-item active" } else { "tab-item" }) {
a href=(format!("/{username}/articles")) { "Articles" }
@if menu.show_articles || active_tab == TimelineKind::Articles {
li class=(if active_tab == TimelineKind::Articles { "tab-item active" } else { "tab-item" }) {
a href=(format!("/{username}/articles")) { "Articles" }
}
}
li class=(if active_tab == TimelineKind::Media { "tab-item active" } else { "tab-item" }) {
a href=(format!("/{username}/media")) { "Media" }
@@ -304,6 +341,29 @@ pub fn render_timeline_tabs(active_tab: TimelineKind, username: &str) -> Markup
}
}
fn render_posts_menu(username: &str, label: &str, menu: &PostsMenu<'_>) -> Markup {
let recent = posts_href(username, menu.filter, "recent");
let popular = posts_href(username, menu.filter, "popular");
html! {
details class="posts-filter" {
summary {
span { (label) }
span class="posts-filter-chevron" aria-hidden="true" {}
}
div class="posts-filter-menu" {
a class=(if menu.filter == "all" { "on" } else { "" }) href=(posts_href(username, "all", menu.sort)) { "All" }
a class=(if menu.filter == "posts" { "on" } else { "" }) href=(posts_href(username, "posts", menu.sort)) { "Posts" }
@if menu.show_highlights || menu.filter == "highlights" {
a class=(if menu.filter == "highlights" { "on" } else { "" }) href=(posts_href(username, "highlights", menu.sort)) { "Highlights" }
}
div class="posts-filter-label" { "Sort by" }
a class=(if menu.sort == "recent" { "on" } else { "" }) href=(recent) { "Most recent" }
a class=(if menu.sort == "popular" { "on" } else { "" }) href=(popular) { "Popular" }
}
}
}
}
/// Timeline / Grid / Gallery switcher for media tabs.
pub fn render_media_view_tabs(base: &str, current: &str) -> Markup {
let sep = if base.contains('?') { '&' } else { '?' };