mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 04:01:55 +00:00
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:
1 parent
b8a0d0fc00
commit
5219453120
17 files changed
+563
-23
No files matched your search
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,8 @@ const WEB_OPERATIONS: &[&str] = &[
|
||||
"UserTweetsAndReplies",
|
||||
"UserRepostsTimeline",
|
||||
"UserArticlesTweets",
|
||||
"UserOriginalsTimeline",
|
||||
"UserHighlightsTweets",
|
||||
"UserMedia",
|
||||
"TweetDetail",
|
||||
"TweetEditHistory",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
Vendored
+13
@@ -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())
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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: ®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}<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("</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</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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 { '?' };
|
||||
|
||||
Reference in new issue
Block a user