diff --git a/public/css/clean.css b/public/css/clean.css index b387848..a4ba558 100644 --- a/public/css/clean.css +++ b/public/css/clean.css @@ -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; diff --git a/public/js/site.js b/public/js/site.js index eb5ce50..8d8b99f 100644 --- a/public/js/site.js +++ b/public/js/site.js @@ -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"); }); diff --git a/src/api/client_endpoints.rs b/src/api/client_endpoints.rs index d177775..8ca3378 100644 --- a/src/api/client_endpoints.rs +++ b/src/api/client_endpoints.rs @@ -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 { + 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::(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 { let data = self diff --git a/src/api/endpoints.rs b/src/api/endpoints.rs index d7c313f..ce2c136 100644 --- a/src/api/endpoints.rs +++ b/src/api/endpoints.rs @@ -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 { diff --git a/src/api/operations.rs b/src/api/operations.rs index e131931..2f414b9 100644 --- a/src/api/operations.rs +++ b/src/api/operations.rs @@ -33,6 +33,8 @@ const WEB_OPERATIONS: &[&str] = &[ "UserTweetsAndReplies", "UserRepostsTimeline", "UserArticlesTweets", + "UserOriginalsTimeline", + "UserHighlightsTweets", "UserMedia", "TweetDetail", "TweetEditHistory", diff --git a/src/api/parser/entity.rs b/src/api/parser/entity.rs index c842b8c..ba42177 100644 --- a/src/api/parser/entity.rs +++ b/src/api/parser/entity.rs @@ -45,6 +45,13 @@ pub fn parse_entities_from(raw: &Entities) -> Vec { } 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), diff --git a/src/api/parser/user.rs b/src/api/parser/user.rs index bdb128f..5f197d7 100644 --- a/src/api/parser/user.rs +++ b/src/api/parser/user.rs @@ -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::() + .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); } } diff --git a/src/api/schema.rs b/src/api/schema.rs index 46c741d..eddea8e 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -603,6 +603,14 @@ pub struct UserData { pub tweet_counts: Option, pub action_counts: Option, pub pinned_items: Option, + pub highlights_info: Option, +} + +/// Whether this account has a Highlights filter on its posts. +#[derive(Deserialize, Default)] +pub struct HighlightsInfo { + pub can_highlight_tweets: Option, + pub highlighted_tweets: Option, } #[derive(Deserialize, Default)] diff --git a/src/cache/keys.rs b/src/cache/keys.rs index 5f3ee5b..1d782b0 100644 --- a/src/cache/keys.rs +++ b/src/cache/keys.rs @@ -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()) } diff --git a/src/cache/ttl.rs b/src/cache/ttl.rs index 9a8902f..54e0d30 100644 --- a/src/cache/ttl.rs +++ b/src/cache/ttl.rs @@ -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 diff --git a/src/routes/helpers.rs b/src/routes/helpers.rs index 9a98e6b..3d49264 100644 --- a/src/routes/helpers.rs +++ b/src/routes/helpers.rs @@ -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); diff --git a/src/routes/timeline.rs b/src/routes/timeline.rs index 8e6132b..6e4bc6f 100644 --- a/src/routes/timeline.rs +++ b/src/routes/timeline.rs @@ -181,6 +181,25 @@ pub struct TimelineQuery { pub view: Option, /// For AJAX infinite scroll requests - returns only tweet HTML. pub scroll: Option, + /// Posts menu: `all`, `posts`, or `highlights`. + pub filter: Option, + /// Posts menu sort: `recent` or `popular`. + pub sort: Option, +} + +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 { @@ -194,6 +213,100 @@ pub fn router() -> Router { .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 { + 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::(&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, 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() { diff --git a/src/types/user.rs b/src/types/user.rs index 48ab7c8..e0510d0 100644 --- a/src/types/user.rs +++ b/src/types/user.rs @@ -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, } diff --git a/src/utils/entity_expander.rs b/src/utils/entity_expander.rs index bab8b77..ec9552a 100644 --- a/src/utils/entity_expander.rs +++ b/src/utils/entity_expander.rs @@ -16,7 +16,20 @@ use crate::types::{Entity, EntityKind}; static MENTION_RE: LazyLock = LazyLock::new(|| Regex::new("(^|[^A-Za-z0-9_])@([A-Za-z0-9_]{1,15})").unwrap()); static HASHTAG_RE: LazyLock = - 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 = LazyLock::new(|| { + Regex::new(r"(?i)(^|[^\w&;])(\$)([a-z]{1,6}(?:[._][a-z]{1,2})?)").unwrap() +}); +static CASHTAG_BODY_RE: LazyLock = + 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#"{}"#, - 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#"{}"#, + 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@$2"#); let result = HASHTAG_RE.replace_all(&result, r#"$1$2$3"#); - 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: ®ex::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}{dollar}{ticker}"#, + url_encode(ticker), + ) + } + }) } /// HTML escape special characters. @@ -372,4 +414,41 @@ mod tests { assert!(fallback.contains(r#"@user"#)); assert!(fallback.contains("</b>")); } + + #[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")); + assert!(result.contains(r#"href="/search?q=%24BRK%2EB""#)); + assert!(result.contains(">$BRK.B")); + 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(" PageLayout<'a> { // Scripts in 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 { diff --git a/src/views/profile.rs b/src/views/profile.rs index f3a0733..6977c3c 100644 --- a/src/views/profile.rs +++ b/src/views/profile.rs @@ -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) } diff --git a/src/views/timeline.rs b/src/views/timeline.rs index 941a4e9..a52f9e4 100644 --- a/src/views/timeline.rs +++ b/src/views/timeline.rs @@ -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 { '?' };