"
@@ -42,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = now.int64
newest = 0'i64
average = 0'i64
+ oauthTotal, cookieTotal = 0
+ oauthLimited, cookieLimited = 0
for session in sessionPool:
let created = snowflakeToEpoch(session.id)
@@ -51,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = created
average += created
+ case session.kind
+ of oauth: inc oauthTotal
+ of cookie: inc cookieTotal
+
if session.limited:
limited.incl session.id
+ case session.kind
+ of oauth: inc oauthLimited
+ of cookie: inc cookieLimited
for api in session.apis.keys:
let
@@ -76,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode =
"sessions": %*{
"total": sessionPool.len,
"limited": limited.card,
+ "oauth": %*{"total": oauthTotal, "limited": oauthLimited},
+ "cookie": %*{"total": cookieTotal, "limited": cookieLimited},
"oldest": $fromUnix(oldest),
"newest": $fromUnix(newest),
"average": $fromUnix(average)
@@ -92,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode =
for session in sessionPool:
let sessionJson = %*{
+ "kind": $session.kind,
"apis": newJObject(),
"pending": session.pending,
}
@@ -122,11 +142,12 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available")
-proc isLimited(session: Session; api: Api): bool =
+proc isLimited(session: Session; req: ApiReq): bool =
if session.isNil:
return true
- if session.limited and api != Api.userTweets:
+ let api = req.endpoint(session)
+ if session.limited and api != graphUserTweetsV2:
if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false
log "resetting limit: ", session.pretty
@@ -140,8 +161,8 @@ proc isLimited(session: Session; api: Api): bool =
else:
return false
-proc isReady(session: Session; api: Api): bool =
- not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
+proc isReady(session: Session; req: ApiReq): bool =
+ not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
proc invalidate*(session: var Session) =
if session.isNil: return
@@ -156,24 +177,29 @@ proc release*(session: Session) =
if session.isNil: return
dec session.pending
-proc getSession*(api: Api): Future[Session] {.async.} =
+proc getSession*(req: ApiReq): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len:
- if result.isReady(api): break
+ if result.isReady(req): break
result = sessionPool.sample()
- if not result.isNil and result.isReady(api):
+ if not result.isNil and result.isReady(req):
inc result.pending
else:
- log "no sessions available for API: ", api
+ if result.isNil:
+ log "no sessions available for API: ", req.cookie.endpoint
+ else:
+ log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty
raise noSessionsError()
-proc setLimited*(session: Session; api: Api) =
+proc setLimited*(session: Session; req: ApiReq) =
+ let api = req.endpoint(session)
session.limited = true
session.limitedAt = epochTime().int
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
-proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
+proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
# avoid undefined behavior in race conditions
+ let api = req.endpoint(session)
if api in session.apis:
let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
diff --git a/src/config.nim b/src/config.nim
index 1b05ffe..b46a979 100644
--- a/src/config.nim
+++ b/src/config.nim
@@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
proc getConfig*(path: string): (Config, parseCfg.Config) =
var cfg = loadConfig(path)
+ let masterRss = cfg.get("Config", "enableRSS", true)
+
let conf = Config(
# Server
address: cfg.get("Server", "address", "0.0.0.0"),
@@ -37,10 +39,20 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
base64Media: cfg.get("Config", "base64Media", false),
minTokens: cfg.get("Config", "tokenCount", 10),
- enableRss: cfg.get("Config", "enableRSS", true),
+ enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
+ enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
+ enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
+ enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true),
+ enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
+ enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
- proxyAuth: cfg.get("Config", "proxyAuth", "")
+ proxyAuth: cfg.get("Config", "proxyAuth", ""),
+ apiProxy: cfg.get("Config", "apiProxy", ""),
+ disableTid: cfg.get("Config", "disableTid", false),
+ maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
+ maxRetries: cfg.get("Config", "maxRetries", 1),
+ retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
)
return (conf, cfg)
diff --git a/src/consts.nim b/src/consts.nim
index 792a519..c88ae68 100644
--- a/src/consts.nim
+++ b/src/consts.nim
@@ -1,116 +1,108 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import uri, strutils
+import strutils
const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
+ bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
+ bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
- gql = parseUri("https://api.x.com") / "graphql"
+ graphUser* = "Gb-d6r0vxPOADdG62OEBpQ/UserByScreenName"
+ graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
+ graphUserById* = "xvmVfRLmnr1alc5f2dib0Q/UserByRestId"
+ graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2"
+ graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
+ graphUserTweets* = "SXVCYB8XHSS25nzIljNtZA/UserTweets"
+ graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies"
+ graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia"
+ graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
+ graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets"
+ graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery"
+ graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
+ graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail"
+ graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
+ graphTweetEditHistory* = "1izbuOcH_QpuMcyCxOXkAg/TweetEditHistory"
+ graphSearchTimeline* = "hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline"
- graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
- graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
- graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
- graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
- graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
- graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
- graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
- graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
- graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
- graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
- graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
- graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
- graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
- graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
- graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
- graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
+ graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId"
+ graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug"
+ graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers"
+ graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
+ graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery"
+
+ graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery"
+ graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline"
+ graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline"
+ graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query"
+ graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query"
+ graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline"
+
+ graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId"
+ graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds"
+
+ graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery"
+ graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById"
+ restLiveStream* = "1.1/live_video_stream/status/"
+
+ graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers"
+ graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following"
gqlFeatures* = """{
- "android_ad_formats_media_component_render_overlay_enabled": false,
- "android_graphql_skip_api_media_color_palette": false,
- "android_professional_link_spotlight_display_enabled": false,
- "blue_business_profile_image_shape_enabled": false,
- "commerce_android_shop_module_enabled": false,
- "creator_subscriptions_subscription_count_enabled": false,
- "creator_subscriptions_tweet_preview_api_enabled": true,
- "freedom_of_speech_not_reach_fetch_enabled": true,
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
- "hidden_profile_likes_enabled": false,
- "highlights_tweets_tab_ui_enabled": false,
- "interactive_text_enabled": false,
- "longform_notetweets_consumption_enabled": true,
- "longform_notetweets_inline_media_enabled": true,
- "longform_notetweets_rich_text_read_enabled": true,
- "longform_notetweets_richtext_consumption_enabled": true,
- "mobile_app_spotlight_module_enabled": false,
- "responsive_web_edit_tweet_api_enabled": true,
- "responsive_web_enhance_cards_enabled": false,
- "responsive_web_graphql_exclude_directive_enabled": true,
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
- "responsive_web_graphql_timeline_navigation_enabled": true,
- "responsive_web_media_download_video_enabled": false,
- "responsive_web_text_conversations_enabled": false,
- "responsive_web_twitter_article_tweet_consumption_enabled": true,
- "unified_cards_destination_url_params_enabled": false,
- "responsive_web_twitter_blue_verified_badge_is_enabled": true,
- "rweb_lists_timeline_redesign_enabled": true,
- "spaces_2022_h2_clipping": true,
- "spaces_2022_h2_spaces_communities": true,
- "standardized_nudges_misinfo": true,
- "subscriptions_verification_info_enabled": true,
- "subscriptions_verification_info_reason_enabled": true,
- "subscriptions_verification_info_verified_since_enabled": true,
- "super_follow_badge_privacy_enabled": false,
- "super_follow_exclusive_tweet_notifications_enabled": false,
- "super_follow_tweet_api_enabled": false,
- "super_follow_user_api_enabled": false,
- "tweet_awards_web_tipping_enabled": false,
- "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
- "tweetypie_unmention_optimization_enabled": false,
- "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
+ "rweb_video_screen_enabled": false,
+ "rweb_cashtags_enabled": true,
+ "profile_label_improvements_pcf_label_in_post_enabled": true,
+ "responsive_web_profile_redirect_enabled": false,
+ "rweb_tipjar_consumption_enabled": false,
"verified_phone_label_enabled": false,
- "vibe_api_enabled": false,
- "view_counts_everywhere_api_enabled": true,
+ "creator_subscriptions_tweet_preview_api_enabled": true,
+ "responsive_web_graphql_timeline_navigation_enabled": true,
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
- "responsive_web_jetfuel_frame": true,
+ "c9s_tweet_anatomy_moderator_badge_enabled": true,
+ "c9s_list_members_action_api_enabled": false,
+ "c9s_superc9s_indication_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
+ "responsive_web_grok_analyze_post_followups_enabled": true,
+ "rweb_cashtags_composer_attachment_enabled": true,
+ "responsive_web_jetfuel_frame": true,
+ "responsive_web_grok_share_attachment_enabled": true,
+ "responsive_web_grok_annotations_enabled": true,
+ "articles_preview_enabled": true,
+ "responsive_web_edit_tweet_api_enabled": true,
+ "rweb_conversational_replies_downvote_enabled": false,
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
+ "view_counts_everywhere_api_enabled": true,
+ "longform_notetweets_consumption_enabled": true,
+ "responsive_web_twitter_article_tweet_consumption_enabled": true,
+ "content_disclosure_indicator_enabled": true,
+ "content_disclosure_ai_generated_indicator_enabled": true,
+ "responsive_web_grok_show_grok_translated_post": true,
+ "responsive_web_grok_analysis_button_from_backend": true,
+ "post_ctas_fetch_enabled": true,
+ "freedom_of_speech_not_reach_fetch_enabled": true,
+ "standardized_nudges_misinfo": true,
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
+ "longform_notetweets_rich_text_read_enabled": true,
+ "longform_notetweets_inline_media_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
- "rweb_tipjar_consumption_enabled": true,
- "profile_label_improvements_pcf_label_in_post_enabled": true,
- "creator_subscriptions_quote_tweet_preview_enabled": false,
- "c9s_tweet_anatomy_moderator_badge_enabled": true,
- "responsive_web_grok_analyze_post_followups_enabled": true,
- "rweb_video_timestamps_enabled": false,
- "responsive_web_grok_share_attachment_enabled": true,
- "articles_preview_enabled": true,
- "immersive_video_status_linkable_timestamps": false,
- "articles_api_enabled": false,
- "responsive_web_grok_analysis_button_from_backend": true,
- "rweb_video_screen_enabled": false,
- "payments_enabled": false,
- "responsive_web_profile_redirect_enabled": false,
- "responsive_web_grok_show_grok_translated_post": false,
- "responsive_web_grok_community_note_auto_translation_is_enabled": false,
- "profile_label_improvements_pcf_label_in_profile_enabled": false,
- "grok_android_analyze_trend_fetch_enabled": false,
- "grok_translations_community_note_auto_translation_is_enabled": false,
- "grok_translations_post_auto_translation_is_enabled": false,
- "grok_translations_community_note_translation_is_enabled": false,
- "grok_translations_timeline_user_bio_auto_translation_is_enabled": false
+ "responsive_web_grok_community_note_auto_translation_is_enabled": true,
+ "responsive_web_enhance_cards_enabled": false
}""".replace(" ", "").replace("\n", "")
- tweetVariables* = """{
+ tweetVars* = """{
"postId": "$1",
$2
+ "ranking_mode": "$3",
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
- "withBirdwatchNotes": false,
+ "withBirdwatchNotes": true,
"withVoice": false,
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
- tweetDetailVariables* = """{
+ tweetDetailVars* = """{
"focalTweetId": "$1",
$2
"referrer": "profile",
@@ -123,21 +115,26 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- restIdVariables* = """{
- "rest_id": "$1", $2
- "count": 20
-}"""
+ tweetEditHistoryVars* = """{
+ "tweetId": "$1",
+ "withQuickPromoteEligibilityTweetFields": true
+}""".replace(" ", "").replace("\n", "")
- userMediaVariables* = """{
+ restIdVars* = """{
+ "rest_id": "$1", $2
+ "count": $3
+}""".replace(" ", "").replace("\n", "")
+
+ userMediaVars* = """{
"userId": "$1", $2
- "count": 20,
+ "count": $3,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- userTweetsVariables* = """{
+ userTweetsVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -145,7 +142,7 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- userTweetsAndRepliesVariables* = """{
+ userTweetsAndRepliesVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -153,5 +150,70 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- fieldToggles* = """{"withArticlePlainText":false}"""
+ userArticlesVars* = """{
+ "userId": "$1", $2
+ "count": 20,
+ "includePromotedContent": false,
+ "withVoice": true
+}""".replace(" ", "").replace("\n", "")
+
+ articleVars* = """{
+ "tweetId": "$1",
+ "includePromotedContent": false,
+ "withBirdwatchNotes": true,
+ "withVoice": true,
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ articleBatchVars* = """{
+ "tweetIds": $1,
+ "includePromotedContent": false,
+ "withBirdwatchNotes": true,
+ "withVoice": true,
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}"""
+
+ tweetByRestIdVars* = """{
+ "tweetId": "$1",
+ "includePromotedContent": false,
+ "withBirdwatchNotes": false,
+ "withVoice": false,
+ "withCommunity": false
+}""".replace(" ", "").replace("\n", "")
+
+ userByRestIdVars* = """{
+ "userId": "$1",
+ "withSafetyModeUserFields": true
+}""".replace(" ", "").replace("\n", "")
+
+ communityTweetsVars* = """{
+ "communityId": "$1", $2
+ "count": 20,
+ "displayLocation": "Community",
+ "rankingMode": "$3",
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ communityMediaVars* = """{
+ "communityId": "$1", $2
+ "count": 20,
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ communityMembersVars* = """{
+ "communityId": "$1",
+ "cursor": $2
+}""".replace(" ", "").replace("\n", "")
+
+ communityHashtagsVars* = """{
+ "communityId": "$1", $2
+ "count": 20,
+ "hashtags": ["$3"],
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
+ userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim
index 40986f5..e22a51f 100644
--- a/src/experimental/parser.nim
+++ b/src/experimental/parser.nim
@@ -1,2 +1,2 @@
-import parser/[user, graphql]
-export user, graphql
+import parser/[user, graphql, article]
+export user, graphql, article
diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim
new file mode 100644
index 0000000..ae5de7c
--- /dev/null
+++ b/src/experimental/parser/article.nim
@@ -0,0 +1,88 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import std/[strutils, tables, times, options]
+import jsony
+import utils, graphql, ../types/article
+from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia,
+ User, TweetStats
+
+proc parseGraphArticle*(json: string): Article =
+ if json.len == 0 or json[0] != '{':
+ return
+
+ var raw: GraphArticle
+ try:
+ raw = json.fromJson(GraphArticle)
+ except CatchableError:
+ return
+
+ let
+ tweet = raw.data.tweetResult.result
+ article = tweet.article.articleResults.result
+
+ if article.title.len == 0:
+ return
+
+ let publishedAt = article.metadata.firstPublishedAtSecs
+ var articleTime: DateTime
+ if publishedAt > 0:
+ articleTime = publishedAt.int64.fromUnix.utc
+ elif tweet.legacy.createdAt.len > 0:
+ articleTime = parseTwitterDate(tweet.legacy.createdAt)
+
+ result = Article(
+ title: article.title,
+ coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl),
+ time: articleTime,
+ user: parseUserResult(tweet.core.userResults.result),
+ )
+
+ result.stats = TweetStats(
+ replies: tweet.legacy.replyCount,
+ retweets: tweet.legacy.retweetCount,
+ likes: tweet.legacy.favoriteCount,
+ )
+ if tweet.views.count.len > 0:
+ try: result.stats.views = parseInt(tweet.views.count)
+ except ValueError: discard
+
+ for blk in article.contentState.blocks:
+ result.paragraphs.add ArticleParagraph(
+ text: blk.text,
+ kind: blk.blockKind,
+ inlineStyles: blk.inlineStyleRanges,
+ entityRanges: blk.entityRanges,
+ )
+
+ for entry in article.contentState.entityMap:
+ let key = try: parseInt(entry.key) except ValueError: continue
+ var entity = ArticleEntity(kind: entry.value.entityKind)
+ case entity.kind
+ of "LINK": entity.url = entry.value.data.url
+ of "MEDIA":
+ for mi in entry.value.data.mediaItems:
+ entity.mediaIds.add mi.mediaId
+ entity.caption = entry.value.data.caption
+ of "TWEET": entity.tweetId = entry.value.data.tweetId
+ of "MARKDOWN": entity.markdown = entry.value.data.markdown
+ else: discard
+ result.entities[key] = entity
+
+ for me in article.mediaEntities:
+ let typeName = me.mediaInfo.typeName
+ var media = ArticleMedia(kind: typeName)
+ if me.mediaInfo.videoInfo.isSome:
+ let variants = me.mediaInfo.videoInfo.get.variants
+ case typeName
+ of "ApiGif":
+ if variants.len > 0:
+ media.url = variants[0].url
+ of "ApiVideo":
+ var bestBitrate = -1
+ for v in variants:
+ if v.bitrate > bestBitrate:
+ bestBitrate = v.bitrate
+ media.url = v.url
+ else: discard
+ elif typeName == "ApiImage":
+ media.url = getImageUrl(me.mediaInfo.originalImgUrl)
+ result.media[me.mediaId] = media
diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim
index 045a5d6..85be202 100644
--- a/src/experimental/parser/graphql.nim
+++ b/src/experimental/parser/graphql.nim
@@ -1,6 +1,6 @@
import options, strutils
import jsony
-import user, ../types/[graphuser, graphlistmembers]
+import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User =
@@ -10,27 +10,61 @@ proc parseUserResult*(userResult: UserResult): User =
result.verifiedType = blue
if result.username.len == 0 and userResult.core.screenName.len > 0:
+ # Modern UserByRestId/UserByScreenName shape: `legacy` is empty and the data
+ # lives in typed sub-objects (core/relationship_counts/tweet_counts/etc.).
result.id = userResult.restId
result.username = userResult.core.screenName
result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
+ if userResult.banner.imageUrl.len > 0:
+ result.banner = userResult.banner.imageUrl & "/1500x500"
+
+ if userResult.privacy.isSome:
+ result.protected = userResult.privacy.get.protected
+
+ if userResult.location.isSome:
+ result.location = userResult.location.get.location
+
+ if userResult.core.createdAt.len > 0:
+ result.joinDate = parseTwitterDate(userResult.core.createdAt)
+
if userResult.verification.isSome:
let v = userResult.verification.get
if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType
+ if userResult.relationshipCounts.isSome:
+ let rc = userResult.relationshipCounts.get
+ result.followers = rc.followers
+ result.following = rc.following
+
+ if userResult.tweetCounts.isSome:
+ let tc = userResult.tweetCounts.get
+ result.tweets = tc.tweets
+ result.media = tc.mediaTweets
+
+ if userResult.actionCounts.isSome:
+ result.likes = userResult.actionCounts.get.favoritesCount
+
if userResult.profileBio.isSome:
- result.bio = userResult.profileBio.get.description
+ let bio = userResult.profileBio.get
+ result.bio = bio.description
+ result.expandUserEntities(bio.entities)
proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
return
- let raw = json.fromJson(GraphUser)
- let userResult = raw.data.userResult.result
+ let
+ raw = json.fromJson(GraphUser)
+ userResult =
+ if raw.data.userResult.isSome: raw.data.userResult.get.result
+ elif raw.data.user.isSome: raw.data.user.get.result
+ else: UserResult()
- if userResult.unavailableReason.get("") == "Suspended":
+ if userResult.unavailableReason.get("") == "Suspended" or
+ userResult.reason.get("") == "Suspended":
return User(suspended: true)
result = parseUserResult(userResult)
@@ -53,3 +87,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value
+
+proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
+ result = Result[User](
+ beginning: cursor.len == 0,
+ query: Query(kind: kind)
+ )
+
+ if json.len == 0 or json[0] != '{':
+ return
+
+ let raw = json.fromJson(GraphFollowers)
+ for instruction in raw.data.user.result.timeline.timeline.instructions:
+ if instruction.kind == "TimelineAddEntries":
+ for entry in instruction.entries:
+ case entry.content.entryType
+ of TimelineTimelineItem:
+ let userResult = entry.content.itemContent.userResults.result
+ if userResult.restId.len > 0:
+ result.content.add parseUserResult(userResult)
+ of TimelineTimelineCursor:
+ if entry.content.cursorType == "Bottom":
+ result.bottom = entry.content.value
diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim
index 45e6e1d..db2c98d 100644
--- a/src/experimental/parser/slices.nim
+++ b/src/experimental/parser/slices.nim
@@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let
name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a]
- result.add a(symbol & name, href = "/search?q=%23" & name)
+ result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl:
diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim
new file mode 100644
index 0000000..28fccea
--- /dev/null
+++ b/src/experimental/parser/tid.nim
@@ -0,0 +1,8 @@
+import jsony
+import ../types/tid
+export TidPair
+
+proc parseTidPairs*(raw: string): seq[TidPair] =
+ result = raw.fromJson(seq[TidPair])
+ if result.len == 0:
+ raise newException(ValueError, "Parsing pairs failed: " & raw)
diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim
index 498757a..866973c 100644
--- a/src/experimental/parser/user.nim
+++ b/src/experimental/parser/user.nim
@@ -9,12 +9,10 @@ let
unReplace = "$1@$2"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)"""
- htReplace = "$1$2$3"
+ htReplace = "$1$2$3"
-proc expandUserEntities(user: var User; raw: RawUser) =
- let
- orig = user.bio.toRunes
- ent = raw.entities
+proc expandUserEntities*(user: var User; ent: Entities) =
+ let orig = user.bio.toRunes
if ent.url.urls.len > 0:
user.website = ent.url.urls[0].expandedUrl
@@ -58,15 +56,17 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount,
verifiedType: raw.verifiedType,
protected: raw.protected,
- joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
)
+ if raw.createdAt.len > 0:
+ result.joinDate = parseTwitterDate(raw.createdAt)
+
if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
- result.expandUserEntities(raw)
+ result.expandUserEntities(raw.entities)
proc parseHook*(s: string; i: var int; v: var User) =
var u: RawUser
diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim
new file mode 100644
index 0000000..946f8c3
--- /dev/null
+++ b/src/experimental/types/article.nim
@@ -0,0 +1,79 @@
+import std/options
+import graphuser
+from ../../types import ArticleStyle, ArticleEntityRange
+
+type
+ GraphArticle* = object
+ data*: tuple[tweetResult: tuple[result: TweetResultNode]]
+
+ TweetResultNode* = object
+ article*: tuple[articleResults: tuple[result: ArticleResultNode]]
+ legacy*: TweetLegacy
+ core*: tuple[userResults: UserData]
+ views*: tuple[count: string]
+
+ TweetLegacy* = object
+ createdAt*: string
+ replyCount*: int
+ retweetCount*: int
+ favoriteCount*: int
+
+ ArticleResultNode* = object
+ title*: string
+ coverMedia*: tuple[mediaInfo: MediaInfoNode]
+ contentState*: ContentState
+ metadata*: tuple[firstPublishedAtSecs: int]
+ mediaEntities*: seq[RawMediaEntity]
+
+ ContentState* = object
+ blocks*: seq[ContentBlock]
+ entityMap*: seq[EntityMapEntry]
+
+ ContentBlock* = object
+ text*: string
+ blockKind*: string
+ inlineStyleRanges*: seq[ArticleStyle]
+ entityRanges*: seq[ArticleEntityRange]
+
+ EntityMapEntry* = object
+ key*: string
+ value*: EntityMapValue
+
+ EntityMapValue* = object
+ entityKind*: string
+ data*: EntityDataNode
+
+ EntityDataNode* = object
+ url*: string
+ mediaItems*: seq[tuple[mediaId: string]]
+ tweetId*: string
+ markdown*: string
+ caption*: string
+
+ RawMediaEntity* = object
+ mediaId*: string
+ mediaInfo*: MediaInfoNode
+
+ MediaInfoNode* = object
+ typeName*: string
+ originalImgUrl*: string
+ videoInfo*: Option[VideoInfoNode]
+
+ VideoInfoNode* = object
+ variants*: seq[VideoVariant]
+
+ VideoVariant* = object
+ url*: string
+ bitrate*: int
+
+proc renameHook*(v: var ContentBlock; fieldName: var string) =
+ if fieldName == "type":
+ fieldName = "blockKind"
+
+proc renameHook*(v: var EntityMapValue; fieldName: var string) =
+ if fieldName == "type":
+ fieldName = "entityKind"
+
+proc renameHook*(v: var MediaInfoNode; fieldName: var string) =
+ if fieldName == "__typename":
+ fieldName = "typeName"
diff --git a/src/experimental/types/graphfollowers.nim b/src/experimental/types/graphfollowers.nim
new file mode 100644
index 0000000..ba9210b
--- /dev/null
+++ b/src/experimental/types/graphfollowers.nim
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import graphlistmembers
+
+type
+ GraphFollowers* = object
+ data*: tuple[user: UserWrapper]
+
+ UserWrapper = object
+ result*: UserResultWrapper
+
+ UserResultWrapper = object
+ timeline*: tuple[timeline: graphlistmembers.Timeline]
+
+# Hook to normalize snake_case field from API to camelCase used by shared types
+proc renameHook*(v: var Content; fieldName: var string) =
+ if fieldName == "user_results":
+ fieldName = "userResults"
diff --git a/src/experimental/types/graphlistmembers.nim b/src/experimental/types/graphlistmembers.nim
index 4cb3757..9e520d8 100644
--- a/src/experimental/types/graphlistmembers.nim
+++ b/src/experimental/types/graphlistmembers.nim
@@ -7,10 +7,10 @@ type
List = object
membersTimeline*: tuple[timeline: Timeline]
- Timeline = object
+ Timeline* = object
instructions*: seq[Instruction]
- Instruction = object
+ Instruction* = object
kind*: string
entries*: seq[tuple[content: Content]]
@@ -18,7 +18,7 @@ type
TimelineTimelineItem
TimelineTimelineCursor
- Content = object
+ Content* = object
case entryType*: ContentEntryType
of TimelineTimelineItem:
itemContent*: tuple[userResults: UserData]
diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim
index d732b4e..ec41f89 100644
--- a/src/experimental/types/graphuser.nim
+++ b/src/experimental/types/graphuser.nim
@@ -1,9 +1,10 @@
import options, strutils
from ../../types import User, VerifiedType
+import user as userType # Entities, for modern profile_bio parsing
type
GraphUser* = object
- data*: tuple[userResult: UserData]
+ data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object
result*: UserResult
@@ -15,6 +16,7 @@ type
UserBio* = object
description*: string
+ entities*: Entities
UserAvatar* = object
imageUrl*: string
@@ -22,15 +24,41 @@ type
Verification* = object
verifiedType*: VerifiedType
+ Location* = object
+ location*: string
+
+ Privacy* = object
+ protected*: bool
+
+ # Modern UserByRestId/UserByScreenName shape moves the counts out of `legacy`
+ # into these typed sub-objects.
+ RelationshipCounts* = object
+ followers*: int
+ following*: int
+
+ TweetCounts* = object
+ tweets*: int
+ mediaTweets*: int
+
+ ActionCounts* = object
+ favoritesCount*: int
+
UserResult* = object
legacy*: User
restId*: string
isBlueVerified*: bool
- unavailableReason*: Option[string]
core*: UserCore
avatar*: UserAvatar
+ banner*: UserAvatar
+ unavailableReason*: Option[string]
+ reason*: Option[string]
+ privacy*: Option[Privacy]
profileBio*: Option[UserBio]
verification*: Option[Verification]
+ location*: Option[Location]
+ relationshipCounts*: Option[RelationshipCounts]
+ tweetCounts*: Option[TweetCounts]
+ actionCounts*: Option[ActionCounts]
proc enumHook*(s: string; v: var VerifiedType) =
v = try:
diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim
new file mode 100644
index 0000000..ad036d9
--- /dev/null
+++ b/src/experimental/types/tid.nim
@@ -0,0 +1,4 @@
+type
+ TidPair* = object
+ animationKey*: string
+ verification*: string
diff --git a/src/formatters.nim b/src/formatters.nim
index cafaa4f..958e518 100644
--- a/src/formatters.nim
+++ b/src/formatters.nim
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
+import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
import std/[enumerate, re]
import types, utils, query
const
cards = "cards.twitter.com/cards"
tco = "https://t.co"
- twitter = parseUri("https://twitter.com")
+ twitter = parseUri("https://x.com")
let
twRegex = re"(?<=(? 0 and "youtu" in result:
- result = result.replace(ytRegex, prefs.replaceYouTube)
+ let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
+ result = result.replace(ytRegex, youtubeHost)
if prefs.replaceTwitter.len > 0:
+ let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result:
- result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
+ result = result.replace(tco, https & twitterHost & "/t.co")
if "x.com" in result:
- result = result.replace(xRegex, prefs.replaceTwitter)
+ result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a(
- prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
+ twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result:
- result = result.replace(cards, prefs.replaceTwitter & "/cards")
- result = result.replace(twRegex, prefs.replaceTwitter)
+ result = result.replace(cards, twitterHost & "/cards")
+ result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a(
- prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
+ twitterHost & "$2", href = https & twitterHost & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
- result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
- result = result.replace(rdRegex, prefs.replaceReddit)
- if prefs.replaceReddit in result and "/gallery/" in result:
+ let redditHost = strip(prefs.replaceReddit, chars={'/'})
+ result = result.replace(rdShortRegex, redditHost & "/comments/")
+ result = result.replace(rdRegex, redditHost)
+ if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result:
@@ -88,7 +91,17 @@ proc getM3u8Url*(content: string): string =
if re.find(content, m3u8Regex, matches) != -1:
result = matches[0]
-proc proxifyVideo*(manifest: string; proxy: bool): string =
+proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
+ let (baseUrl, basePath) =
+ if manifestUrl.len > 0:
+ let
+ u = parseUri(manifestUrl)
+ origin = u.scheme & "://" & u.hostname
+ idx = manifestUrl.rfind('/')
+ dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
+ (origin, dirPath)
+ else:
+ ("https://video.twimg.com", "")
var replacements: seq[(string, string)]
for line in manifest.splitLines:
let url =
@@ -96,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line
- if url.startsWith('/'):
- let path = "https://video.twimg.com" & url
- replacements.add (url, if proxy: path.getVidUrl else: path)
+ let resolved =
+ if url.startsWith('/'): baseUrl & url
+ elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
+ not url.startsWith("http") and ('.' in url): basePath & url
+ else: ""
+ if resolved.len > 0:
+ replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
return manifest.multiReplace(replacements)
proc getUserPic*(userPic: string; style=""): string =
@@ -123,25 +140,30 @@ proc pageDesc*(user: User): string =
"The latest tweets from " & user.fullname
proc getJoinDate*(user: User): string =
+ if user.joinDate.year == 0: return ""
user.joinDate.format("'Joined' MMMM YYYY")
proc getJoinDateFull*(user: User): string =
+ if user.joinDate.year == 0: return ""
user.joinDate.format("h:mm tt - d MMM YYYY")
proc getTime*(tweet: Tweet): string =
+ if tweet.time.year == 0: return ""
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
proc getRfc822Time*(tweet: Tweet): string =
+ if tweet.time.year == 0: return ""
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
-proc getShortTime*(tweet: Tweet): string =
+proc getShortTime*(time: DateTime): string =
+ if time.year == 0: return ""
let now = now()
- let since = now - tweet.time
+ let since = now - time
- if now.year != tweet.time.year:
- result = tweet.time.format("d MMM yyyy")
+ if now.year != time.year:
+ result = time.format("d MMM yyyy")
elif since.inDays >= 1:
- result = tweet.time.format("MMM d")
+ result = time.format("MMM d")
elif since.inHours >= 1:
result = $since.inHours & "h"
elif since.inMinutes >= 1:
@@ -151,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string =
else:
result = "now"
+proc getShortTime*(tweet: Tweet): string =
+ getShortTime(tweet.time)
+
+proc getDuration*(ms: int): string =
+ let
+ sec = int(round(ms / 1000))
+ min = floorDiv(sec, 60)
+ hour = floorDiv(min, 60)
+ if hour > 0:
+ &"{hour}:{min mod 60:02}:{sec mod 60:02}"
+ else:
+ &"{min mod 60}:{sec mod 60:02}"
+
+proc getDuration*(video: Video): string =
+ getDuration(video.durationMs)
+
+proc getLink*(id: int64; username="i"; focus=true): string =
+ var username = username
+ if username.len == 0:
+ username = "i"
+ result = &"/{username}/status/{id}"
+ if focus: result &= "#m"
+
proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return
var username = tweet.user.username
- if username.len == 0:
- username = "i"
- result = &"/{username}/status/{tweet.id}"
- if focus: result &= "#m"
+ return getLink(tweet.id, username, focus)
proc getTwitterLink*(path: string; params: Table[string, string]): string =
var
@@ -185,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
proc getLocation*(u: User | Tweet): (string, string) =
if "://" in u.location: return (u.location, "")
let loc = u.location.split(":")
- let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
+ let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
(loc[0], url)
proc getSuspended*(username: string): string =
diff --git a/src/http_pool.nim b/src/http_pool.nim
index 664e9a6..2553dd9 100644
--- a/src/http_pool.nim
+++ b/src/http_pool.nim
@@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) =
proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient =
if pool.conns.len == 0:
- result = newAsyncHttpClient(headers=heads, proxy=proxy)
+ result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy)
else:
result = pool.conns.pop()
result.headers = heads
diff --git a/src/nitter.nim b/src/nitter.nim
index f81dc1c..a76dda6 100644
--- a/src/nitter.nim
+++ b/src/nitter.nim
@@ -2,15 +2,15 @@
import asyncdispatch, strformat, logging
from net import Port
from htmlgen import a
-from os import getEnv
+from os import getEnv, normalizedPath
import jester
-import types, config, prefs, formatters, redis_cache, http_pool, auth
+import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about]
import routes/[
- preferences, timeline, status, media, search, rss, list, debug,
- unsupported, embed, resolver, router_utils]
+ preferences, timeline, status, media, search, rss, list, community, debug,
+ unsupported, embed, resolver, broadcast, space, article, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -34,39 +34,62 @@ stdout.flushFile
updateDefaultPrefs(fullCfg)
setCacheTimes(cfg)
setHmacKey(cfg.hmacKey)
+if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey":
+ stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " &
+ "set a unique random value to stop media URL signatures being forgeable.\n"
+ stderr.flushFile
setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth)
+setApiProxy(cfg.apiProxy)
+setDisableTid(cfg.disableTid)
+setMaxConcurrentReqs(cfg.maxConcurrentReqs)
+setMaxRetries(cfg.maxRetries)
+setRetryDelayMs(cfg.retryDelayMs)
initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile
+createArticleRouter(cfg)
createUnsupportedRouter(cfg)
createResolverRouter(cfg)
createPrefRouter(cfg)
createTimelineRouter(cfg)
createListRouter(cfg)
+createCommunityRouter(cfg)
createStatusRouter(cfg)
createSearchRouter(cfg)
createMediaRouter(cfg)
createEmbedRouter(cfg)
createRssRouter(cfg)
+createBroadcastRouter(cfg)
+createSpaceRouter(cfg)
createDebugRouter(cfg)
settings:
port = Port(cfg.port)
- staticDir = cfg.staticDir
+ staticDir = normalizedPath(cfg.staticDir)
bindAddr = cfg.address
reusePort = true
+ maxBody = 64 * 1024
routes:
+ before:
+ # Reject malformed paths
+ if request.path.len == 0 or request.path[0] != '/':
+ halt Http400
+
+ # skip all file URLs (except Twitter widget compatibility)
+ cond "." notin request.path or request.path == "/embed/Tweet.html"
+ applyUrlPrefs()
+
get "/":
- resp renderMain(renderSearch(), request, cfg, themePrefs())
+ resp renderMain(renderSearch(), request, cfg, requestPrefs())
get "/about":
- resp renderMain(renderAbout(), request, cfg, themePrefs())
+ resp renderMain(renderAbout(), request, cfg, requestPrefs())
get "/explore":
redirect("/about")
@@ -77,7 +100,7 @@ routes:
get "/i/redirect":
let url = decodeUrl(@"url")
if url.len == 0: resp Http404
- redirect(replaceUrls(url, cookiePrefs()))
+ redirect(replaceUrls(url, requestPrefs()))
error Http404:
resp Http404, showError("Page not found", cfg)
@@ -102,14 +125,18 @@ routes:
resp Http429, showError(
&"Instance has no auth tokens, or is fully rate limited.
Use {link} or try again later.", cfg)
+ extend articleRoute, ""
extend rss, ""
extend status, ""
extend search, ""
extend timeline, ""
extend media, ""
extend list, ""
+ extend community, ""
extend preferences, ""
extend resolver, ""
extend embed, ""
+ extend broadcastRoute, ""
+ extend spaceRoute, ""
extend debug, ""
extend unsupported, ""
diff --git a/src/parser.nim b/src/parser.nim
index 700e896..eebca2d 100644
--- a/src/parser.nim
+++ b/src/parser.nim
@@ -1,10 +1,20 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, options, times, math, tables
+import strutils, options, times, math, tables, uri
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/unifiedcard
-proc parseGraphTweet(js: JsonNode): Tweet
+proc parseGraphTweet*(js: JsonNode): Tweet
+
+proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
+ try: parseEnum[VerifiedType](s)
+ except ValueError: current
+
+proc parseCommunityNote(js: JsonNode): string =
+ let subtitle = js{"subtitle"}
+ result = subtitle{"text"}.getStr
+ with entities, subtitle{"entities"}:
+ result = expandBirdwatchEntities(result, entities)
proc parseUser(js: JsonNode; id=""): User =
if js.isNull: return
@@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt,
- protected: js{"protected"}.getBool,
+ protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
joinDate: js{"created_at"}.getTime
)
@@ -29,17 +39,17 @@ proc parseUser(js: JsonNode; id=""): User =
result.verifiedType = blue
with verifiedType, js{"verified_type"}:
- result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
+ result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"}
if user.isNull:
- user = ? js{"user_results", "result"}
+ user = js{"user_results", "result"}
if user.isNull:
- if js{"core"}.notNull and js{"legacy"}.notNull:
+ if js{"core"}.notNull:
user = js
else:
return
@@ -51,15 +61,164 @@ proc parseGraphUser(js: JsonNode): User =
# fallback to support UserMedia/recent GraphQL updates
if result.username.len == 0:
+ result.id = user{"rest_id"}.getStr
result.username = user{"core", "screen_name"}.getStr
result.fullname = user{"core", "name"}.getStr
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
- if user{"is_blue_verified"}.getBool(false):
+ if user{"is_blue_verified"}.getBool(
+ user{"verification", "is_blue_verified"}.getBool(false)):
result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}:
- result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
+ result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
+
+proc parseAboutAccount*(js: JsonNode): AccountInfo =
+ if js.isNull: return
+
+ let user = ? js{"data", "user_result_by_screen_name", "result"}
+
+ if user{"unavailable_reason"}.getStr == "Suspended":
+ result.suspended = true
+ return
+
+ result = AccountInfo(
+ username: user{"core", "screen_name"}.getStr,
+ fullname: user{"core", "name"}.getStr,
+ joinDate: user{"core", "created_at"}.getTime,
+ userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
+ affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
+ )
+
+ if user{"is_blue_verified"}.getBool(false):
+ result.verifiedType = blue
+ with verifiedType, user{"verification", "verified_type"}:
+ result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
+
+ with about, user{"about_profile"}:
+ result.basedIn = about{"account_based_in"}.getStr
+ result.source = about{"source"}.getStr
+ result.affiliateUsername = about{"affiliate_username"}.getStr
+
+ try:
+ result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
+ except ValueError:
+ discard
+
+ with lastChange, about{"username_changes", "last_changed_at_msec"}:
+ result.lastUsernameChange = lastChange.getTimeFromMsStr
+
+ with info, user{"verification_info"}:
+ result.isIdentityVerified = info{"is_identity_verified"}.getBool
+ with reason, info{"reason"}:
+ result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
+ with since, reason{"verified_since_msec"}:
+ result.verifiedSince = since.getTimeFromMsStr
+
+proc parseBroadcastInfo*(js: JsonNode): Broadcast =
+ let bc = ? js{"data", "broadcast"}
+ result = Broadcast(
+ id: bc{"broadcast_id"}.getStr,
+ title: bc{"status"}.getStr,
+ state: bc{"state"}.getStr.toUpperAscii,
+ thumb: bc{"image_url"}.getStr,
+ mediaKey: bc{"media_key"}.getStr,
+ totalWatched: bc{"total_watched"}.getInt,
+ startTime: bc{"start_time"}.getTimeFromMs,
+ endTime: bc{"end_time"}.getTimeFromMs,
+ replayStart: bc{"edited_replay", "start_time"}.getInt,
+ availableForReplay: bc{"available_for_replay"}.getBool,
+ user: parseGraphUser(bc)
+ )
+
+proc parseSpaceParticipant(js: JsonNode): SpaceParticipant =
+ result = SpaceParticipant(
+ userId: js{"user_results", "rest_id"}.getStr,
+ username: js{"twitter_screen_name"}.getStr,
+ displayName: js{"display_name"}.getStr,
+ avatarUrl: js{"avatar_url"}.getStr,
+ isVerified: js{"is_verified"}.getBool or
+ js{"user_results", "result", "is_blue_verified"}.getBool
+ )
+
+proc parseAudioSpace*(js: JsonNode): AudioSpace =
+ let space = ? js{"data", "audioSpace"}
+ let meta = space{"metadata"}
+
+ result = AudioSpace(
+ id: meta{"rest_id"}.getStr,
+ title: meta{"title"}.getStr,
+ state: meta{"state"}.getStr.toUpperAscii,
+ mediaKey: meta{"media_key"}.getStr,
+ totalLiveListeners: meta{"total_live_listeners"}.getInt,
+ totalReplayWatched: meta{"total_replay_watched"}.getInt,
+ availableForReplay: meta{"is_space_available_for_replay"}.getBool
+ )
+
+ let startedAt = meta{"started_at"}.getInt(0)
+ if startedAt > 0:
+ result.startTime = fromUnix(startedAt div 1000).utc()
+
+ let endedAtStr = meta{"ended_at"}.getStr
+ if endedAtStr.len > 0:
+ try:
+ let endedAt = parseBiggestInt(endedAtStr)
+ if endedAt > 0:
+ result.endTime = fromUnix(endedAt div 1000).utc()
+ except ValueError:
+ discard
+
+ result.creator = parseGraphUser(meta{"creator_results", "result"})
+
+ for admin in space{"participants", "admins"}:
+ result.admins.add parseSpaceParticipant(admin)
+
+ for speaker in space{"participants", "speakers"}:
+ result.speakers.add parseSpaceParticipant(speaker)
+
+proc parseGraphCommunity*(js: JsonNode): Community =
+ if js.isNull: return
+ let c = ? js{"data", "communityResults", "result"}
+
+ result = Community(
+ id: c{"rest_id"}.getStr(c{"id_str"}.getStr),
+ name: c{"name"}.getStr,
+ description: c{"description"}.getStr,
+ memberCount: c{"member_count"}.getInt,
+ joinPolicy: c{"join_policy"}.getStr,
+ category: c{"primary_community_topic", "topic_name"}.getStr,
+ banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr,
+ creator: parseGraphUser(c{"creator_results", "result"}),
+ )
+
+ let createdMs = c{"created_at"}.getInt(0)
+ if createdMs > 0:
+ result.createdAt = fromUnix(createdMs div 1000).utc()
+
+ for rule in c{"rules"}:
+ result.rules.add CommunityRule(
+ name: rule{"name"}.getStr,
+ description: rule{"description"}.getStr
+ )
+
+ for item in c{"trending_hashtags_slice", "items"}:
+ let tag = item{"hashtag"}.getStr
+ if tag.len > 0:
+ result.hashtags.add tag
+
+proc parseListObject(js: JsonNode; owner: User): List =
+ List(
+ id: js{"id_str"}.getStr,
+ name: js{"name"}.getStr,
+ username: owner.username,
+ userId: owner.id,
+ description: js{"description"}.getStr,
+ members: js{"member_count"}.getInt,
+ banner: select(
+ js{"custom_banner_media", "media_info", "original_img_url"},
+ js{"default_banner_media", "media_info", "original_img_url"}
+ ).getImageStr
+ )
proc parseGraphList*(js: JsonNode): List =
if js.isNull: return
@@ -70,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List =
if list.isNull:
return
- result = List(
- id: list{"id_str"}.getStr,
- name: list{"name"}.getStr,
- username: list{"user_results", "result", "legacy", "screen_name"}.getStr,
- userId: list{"user_results", "result", "rest_id"}.getStr,
- description: list{"description"}.getStr,
- members: list{"member_count"}.getInt,
- banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr
+ result = parseListObject(list, parseGraphUser(list))
+
+proc parseGraphSearchList(js: JsonNode): ListSearchResult =
+ let owner = parseGraphUser(js)
+ result = ListSearchResult(
+ list: parseListObject(js, owner),
+ owner: owner,
+ followersContext: js{"followers_context"}.getStr
)
+ for url in js{"facepile_urls"}:
+ result.facepiles.add url.getStr
proc parsePoll(js: JsonNode): Poll =
let vals = js{"binding_values"}
@@ -134,60 +295,85 @@ proc parseVideo(js: JsonNode): Video =
result.variants = parseVideoVariants(js{"video_info", "variants"})
+proc addMedia(media: var MediaEntities; photo: Photo) =
+ media.add Media(kind: photoMedia, photo: photo)
+
+proc addMedia(media: var MediaEntities; video: Video) =
+ media.add Media(kind: videoMedia, video: video)
+
+proc addMedia(media: var MediaEntities; gif: Gif) =
+ media.add Media(kind: gifMedia, gif: gif)
+
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia:
case m.getTypeName:
of "photo":
- result.photos.add m{"media_url_https"}.getImageStr
+ result.media.addMedia(Photo(
+ url: m{"media_url_https"}.getImageStr,
+ altText: m{"ext_alt_text"}.getStr
+ ))
of "video":
- result.video = some(parseVideo(m))
+ result.media.addMedia(parseVideo(m))
with user, m{"additional_media_info", "source_user"}:
if user{"id"}.getInt > 0:
result.attribution = some(parseUser(user))
else:
result.attribution = some(parseGraphUser(user))
+ # Set attribution link from expanded_url (strip /video/N suffix)
+ let expanded = m{"expanded_url"}.getStr
+ if expanded.len > 0:
+ result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
of "animated_gif":
- result.gif = some Gif(
+ result.media.addMedia(Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
- thumb: m{"media_url_https"}.getImageStr
- )
+ thumb: m{"media_url_https"}.getImageStr,
+ altText: m{"ext_alt_text"}.getStr
+ ))
else: discard
- with url, m{"url"}:
- if result.text.endsWith(url.getStr):
- result.text.removeSuffix(url.getStr)
- result.text = result.text.strip()
-
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}:
+ var parsedMedia: MediaEntities
for mediaEntity in mediaEntities:
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
case mediaInfo.getTypeName
of "ApiImage":
- result.photos.add mediaInfo{"original_img_url"}.getImageStr
+ parsedMedia.addMedia(Photo(
+ url: mediaInfo{"original_img_url"}.getImageStr,
+ altText: mediaInfo{"alt_text"}.getStr
+ ))
of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
- result.video = some Video(
+ parsedMedia.addMedia(Video(
available: status.getStr == "Available",
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
+ title: mediaInfo{"alt_text"}.getStr,
durationMs: mediaInfo{"duration_millis"}.getInt,
variants: parseVideoVariants(mediaInfo{"variants"})
- )
+ ))
+
+ # Parse source user for video attribution
+ with sourceUser, mediaEntity{"source_user_results", "result"}:
+ if result.attribution.isNone:
+ let expanded = mediaEntity{"expanded_url"}.getStr
+ if expanded.len > 0:
+ result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
+ result.attribution = some(User(
+ id: sourceUser{"rest_id"}.getStr,
+ fullname: sourceUser{"core", "name"}.getStr,
+ userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "")
+ ))
of "ApiGif":
- result.gif = some Gif(
+ parsedMedia.addMedia(Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
- thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
- )
+ thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
+ altText: mediaInfo{"alt_text"}.getStr
+ ))
else: discard
- # Remove media URLs from text
- with mediaList, js{"legacy", "entities", "media"}:
- for url in mediaList:
- let expandedUrl = url{"expanded_url"}.getStr
- if result.text.endsWith(expandedUrl):
- result.text.removeSuffix(expandedUrl)
- result.text = result.text.strip()
+ if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
+ result.media = parsedMedia
proc parsePromoVideo(js: JsonNode): Video =
result = Video(
@@ -210,14 +396,23 @@ proc parsePromoVideo(js: JsonNode): Video =
result.variants.add variant
proc parseBroadcast(js: JsonNode): Card =
- let image = js{"broadcast_thumbnail_large"}.getImageVal
+ let
+ image = js{"broadcast_thumbnail_large"}.getImageVal
+ broadcastUrl = js{"broadcast_url"}.getStrVal
+ broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
+ streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
result = Card(
kind: broadcast,
- url: js{"broadcast_url"}.getStrVal,
+ url: "/i/broadcasts/" & broadcastId,
title: js{"broadcaster_display_name"}.getStrVal,
text: js{"broadcast_title"}.getStrVal,
image: image,
- video: some Video(thumb: image)
+ video: some Video(
+ thumb: image,
+ available: true,
+ playbackType: m3u8,
+ variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
+ )
)
proc parseCard(js: JsonNode; urls: JsonNode): Card =
@@ -256,7 +451,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url = vals{"player_url"}.getStrVal
if "youtube.com" in result.url:
result.url = result.url.replace("/embed/", "/watch?v=")
- of audiospace, unknown:
+ of audiospace:
+ let spaceId = vals{"id"}.getStrVal
+ if spaceId.len > 0:
+ result.url = "/i/spaces/" & spaceId
+ result.title = "Twitter Space"
+ result.text = "Click to view Space"
+ of unknown:
result.title = "This card type is not supported."
else: discard
@@ -267,7 +468,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls:
if u{"url"}.getStr == result.url:
- result.url = u{"expanded_url"}.getStr
+ result.url = u.getExpandedUrl(result.url)
break
if kind in {videoDirectMessage, imageDirectMessage}:
@@ -277,8 +478,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url.len == 0 or result.url.startsWith("card://"):
result.url = getPicUrl(result.image)
-proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
- if js.isNull: return
+proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
+ replyId: int64 = 0; hasArticle = false): Tweet =
+ if js.isNull: return Tweet()
let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime
@@ -301,6 +503,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
)
)
+ if result.replyId == 0:
+ result.replyId = replyId
+
# fix for pinned threads
if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId
@@ -318,13 +523,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
# graphql
with rt, js{"retweeted_status_result", "result"}:
# needed due to weird edgecase where the actual tweet data isn't included
- if "legacy" in rt:
+ if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt)
return
with reposts, js{"repostedStatusResults"}:
with rt, reposts{"result"}:
- if "legacy" in rt:
+ if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt)
return
@@ -332,15 +537,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr
if "poll" in name:
if "image" in name:
- result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
+ result.media.addMedia(Photo(
+ url: jsCard{"binding_values", "image_large"}.getImageVal
+ ))
result.poll = some parsePoll(jsCard)
elif name == "amplify":
- result.video = some(parsePromoVideo(jsCard{"binding_values"}))
- else:
+ result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
+ elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"entities", "urls"})
- result.expandTweetEntities(js)
+ result.expandTweetEntities(js, hasArticle)
parseLegacyMediaEntities(js, result)
with jsWithheld, js{"withheld_in_countries"}:
@@ -357,7 +564,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.")
result.available = false
-proc parseGraphTweet(js: JsonNode): Tweet =
+proc parseGraphTweet*(js: JsonNode): Tweet =
if js.kind == JNull:
return Tweet()
@@ -375,7 +582,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
else:
discard
- if not js.hasKey("legacy"):
+ if "legacy" notin js and "rest_id" notin js:
return Tweet()
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
@@ -394,12 +601,80 @@ proc parseGraphTweet(js: JsonNode): Tweet =
"binding_values": %bindingObj
}
- result = parseTweet(js{"legacy"}, jsCard)
- result.id = js{"rest_id"}.getId
+ var replyId: int64 = 0
+ with restId, js{"reply_to_results", "rest_id"}:
+ replyId = restId.getId
+
+ let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
+
+ if "details" in js:
+ result = Tweet(
+ id: js{"rest_id"}.getId,
+ available: true,
+ text: js{"details", "full_text"}.getStr,
+ time: js{"details", "created_at_ms"}.getTimeFromMs,
+ replyId: js{"reply_to_results", "rest_id"}.getId,
+ isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
+ isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
+ stats: TweetStats(
+ replies: js{"counts", "reply_count"}.getInt,
+ retweets: js{"counts", "retweet_count"}.getInt,
+ likes: js{"counts", "favorite_count"}.getInt,
+ )
+ )
+
+ if jsCard.kind != JNull:
+ let name = jsCard{"name"}.getStr
+ if "poll" in name:
+ if "image" in name:
+ result.media.addMedia(Photo(
+ url: jsCard{"binding_values", "image_large"}.getImageVal
+ ))
+
+ result.poll = some parsePoll(jsCard)
+ elif name == "amplify":
+ result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
+ elif name.len > 0 and jsCard{"binding_values"}.notNull:
+ result.card = some parseCard(jsCard, js{"url_entities"})
+
+ parseMediaEntities(js, result)
+ if result.attribution.isNone:
+ parseLegacyMediaEntities(js{"legacy"}, result)
+
+ result.expandTweetEntitiesV2(js, hasArticle)
+
+ # Strip video source URL from text (for videos from other tweets)
+ with mediaEntities, js{"media_entities"}:
+ for m in mediaEntities:
+ if "source_status_id_str" in m:
+ let mediaUrl = m{"url"}.getStr
+ if mediaUrl.len > 0:
+ let idx = result.text.rfind(mediaUrl)
+ if idx >= 0:
+ result.text = result.text[0 ..< idx].strip()
+ break
+ else:
+ result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle)
+ result.id = js{"rest_id"}.getId
+
+ with artNode, js{"article", "article_results", "result"}:
+ let artTitle = artNode{"title"}.getStr
+ if artTitle.len > 0:
+ result.articlePreview = some ArticlePreview(
+ title: artTitle,
+ previewText: artNode{"preview_text"}.getStr,
+ coverImage: select(
+ artNode{"cover_media_results", "result", "media_info", "original_img_url"},
+ artNode{"cover_media", "media_info", "original_img_url"}
+ ).getImageStr,
+ tweetId: result.id
+ )
+
result.user = parseGraphUser(js{"core"})
- if result.replyId == 0:
- result.replyId = js{"reply_to_results", "rest_id"}.getId
+ if result.reply.len == 0:
+ with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
+ result.reply = @[replyTo.getStr]
with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt
@@ -409,21 +684,58 @@ proc parseGraphTweet(js: JsonNode): Tweet =
parseMediaEntities(js, result)
- if result.quote.isSome:
- result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
+ # Hide card if it's redundant with attribution (same video shown via embed)
+ if result.attribution.isSome and result.card.isSome:
+ let cardUri = get(result.card).url.parseUri
+ if cardUri.isTwitterUrl:
+ let cardPath = cardUri.path.replace("/video/1", "")
+ if cardPath.len > 0 and cardPath == result.attributionLink:
+ get(result.card).kind = hidden
- with quoted, js{"quotedPostResults", "result"}:
+ # Handle retweets - check both legacy and top-level paths
+ with reposts, js{"legacy", "repostedStatusResults"}:
+ with rt, reposts{"result"}:
+ if "legacy" in rt or "rest_id" in rt:
+ result.retweet = some parseGraphTweet(rt)
+
+ with quoted, js{"quoted_status_result", "result"}:
result.quote = some(parseGraphTweet(quoted))
+ with quoted, js{"quotedPostResults"}:
+ if "result" in quoted:
+ result.quote = some(parseGraphTweet(quoted{"result"}))
+ else:
+ result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
+
+ with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
+ for id in ids:
+ result.history.add parseBiggestInt(id.getStr)
+
+ with birdwatch, js{"birdwatch_pivot"}:
+ result.note = parseCommunityNote(birdwatch)
+
+proc getConvSection(js: JsonNode): string =
+ let details = select(
+ js{"item", "client_event_info", "details"},
+ js{"item", "clientEventInfo", "details"}
+ )
+ select(
+ details{"conversation_details", "conversation_section"},
+ details{"conversationDetails", "conversationSection"}
+ ).getStr
+
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
+ var checkedSection = false
for t in ? js{"content", "items"}:
let entryId = t.getEntryId
- if "cursor-showmore" in entryId:
- let cursor = t{"item", "content", "value"}
- result.thread.cursor = cursor.getStr
- result.thread.hasMore = true
- elif "tweet" in entryId and "promoted" notin entryId:
- with tweet, t.getTweetResult("item"):
+ if "tweet-" in entryId and "promoted" notin entryId:
+ if not checkedSection:
+ checkedSection = true
+ if getConvSection(t) == "RelatedTweet":
+ result.thread.related = true
+
+ let tweet = t.getTweetResult("item")
+ if tweet.notNull:
result.thread.content.add parseGraphTweet(tweet)
let tweetDisplayType = select(
@@ -432,11 +744,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
)
if tweetDisplayType.getStr == "SelfThread":
result.self = true
+ else:
+ result.thread.content.add Tweet(id: entryId.getId)
+ elif "cursor-showmore" in entryId:
+ let cursor = t{"item", "content", "value"}
+ result.thread.cursor = cursor.getStr
+ result.thread.hasMore = true
proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}:
result = parseGraphTweet(tweet)
+proc parseTweetByRestId*(js: JsonNode): Tweet =
+ with tweet, js{"data", "tweetResult", "result"}:
+ result = parseGraphTweet(tweet)
+
+proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] =
+ let results = js{"data", "tweetResult"}
+ if results.kind != JArray: return
+ for item in results:
+ let tweet = item{"result"}
+ if tweet.isNull: continue
+ let t = parseGraphTweet(tweet)
+ if t != nil:
+ result.add t
+
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result = Conversation(replies: Result[Chain](beginning: true))
@@ -452,7 +784,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e.getEntryId
- if entryId.startsWith("tweet"):
+ if entryId.startsWith("tweet-"):
let tweetResult = getTweetResult(e)
if tweetResult.notNull:
let tweet = parseGraphTweet(tweetResult)
@@ -460,11 +792,14 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if not tweet.available:
tweet.id = entryId.getId
- if $tweet.id == tweetId:
+ if entryId.endsWith(tweetId):
result.tweet = tweet
else:
result.before.content.add tweet
- elif entryId.startsWith("conversationthread"):
+ elif not entryId.endsWith(tweetId):
+ result.before.content.add Tweet(id: entryId.getId)
+ elif entryId.startsWith("conversationthread") or
+ entryId.startsWith("tweetdetailrelatedtweets"):
let (thread, self) = parseGraphThread(e)
if self:
result.after = thread
@@ -485,25 +820,60 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result.before.content.add tweet
elif entryId.startsWith("cursor-bottom"):
var cursorValue = select(
+ e{"content", "value"},
e{"content", "content", "value"},
e{"content", "itemContent", "value"}
)
result.replies.bottom = cursorValue.getStr
+proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory =
+ let instructions = ? js{
+ "data", "tweet_result_by_rest_id", "result",
+ "edit_history_timeline", "timeline", "instructions"
+ }
+ if instructions.len == 0:
+ return
+
+ for i in instructions:
+ if i.getTypeName == "TimelineAddEntries":
+ for e in i{"entries"}:
+ let entryId = e.getEntryId
+ if entryId == "latestTweet":
+ with item, e{"content", "items"}[0]:
+ let tweetResult = item.getTweetResult("item")
+ if tweetResult.notNull:
+ result.latest = parseGraphTweet(tweetResult)
+ elif entryId == "staleTweets":
+ for item in e{"content", "items"}:
+ let tweetResult = item.getTweetResult("item")
+ if tweetResult.notNull:
+ result.history.add parseGraphTweet(tweetResult)
+
+iterator extractTweetsFromModuleItems(items: JsonNode): Tweet =
+ for item in items:
+ with tweetResult, item.getTweetResult("item"):
+ let tweet = parseGraphTweet(tweetResult)
+ if not tweet.available:
+ tweet.id = item.getEntryId.getId
+ yield tweet
+
+iterator extractListsFromItems(items: JsonNode): ListSearchResult =
+ for item in items:
+ with listJs, item{"item", "itemContent", "list"}:
+ let r = parseGraphSearchList(listJs)
+ if r.list.id.len > 0:
+ yield r
+
proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] =
with tweetResult, getTweetResult(e):
- var tweet = parseGraphTweet(tweetResult)
+ let tweet = parseGraphTweet(tweetResult)
if not tweet.available:
tweet.id = e.getEntryId.getId
result.add tweet
return
- for item in e{"content", "items"}:
- with tweetResult, item.getTweetResult("item"):
- var tweet = parseGraphTweet(tweetResult)
- if not tweet.available:
- tweet.id = item.getEntryId.getId
- result.add tweet
+ for tweet in extractTweetsFromModuleItems(e{"content", "items"}):
+ result.add tweet
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0))
@@ -518,12 +888,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
for i in instructions:
if i{"moduleItems"}.notNull:
- for item in i{"moduleItems"}:
- with tweetResult, item.getTweetResult("item"):
- let tweet = parseGraphTweet(tweetResult)
- if not tweet.available:
- tweet.id = item.getEntryId.getId
- result.tweets.content.add tweet
+ for tweet in extractTweetsFromModuleItems(i{"moduleItems"}):
+ result.tweets.content.add tweet
continue
if i{"entries"}.notNull:
@@ -558,18 +924,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
for i in instructions:
if i{"moduleItems"}.notNull:
- for item in i{"moduleItems"}:
- with tweetResult, item.getTweetResult("item"):
- let t = parseGraphTweet(tweetResult)
- if not t.available:
- t.id = item.getEntryId.getId
+ for t in extractTweetsFromModuleItems(i{"moduleItems"}):
+ let photo = extractGalleryPhoto(t)
+ if photo.url.len > 0:
+ result.add photo
- let photo = extractGalleryPhoto(t)
- if photo.url.len > 0:
- result.add photo
-
- if result.len == 16:
- return
+ if result.len == 16:
+ return
continue
if i.getTypeName != "TimelineAddEntries":
@@ -586,7 +947,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
if result.len == 16:
return
-proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
+proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0)
let instructions = select(
@@ -602,19 +963,73 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
for e in instruction{"entries"}:
let entryId = e.getEntryId
when T is Tweets:
- if entryId.startsWith("tweet"):
- with tweetRes, getTweetResult(e):
- let tweet = parseGraphTweet(tweetRes)
- if not tweet.available:
- tweet.id = entryId.getId
+ if entryId.startsWith("tweet") or entryId.startsWith("search-grid"):
+ for tweet in extractTweetsFromEntry(e):
result.content.add tweet
elif T is User:
if entryId.startsWith("user"):
with userRes, e{"content", "itemContent"}:
result.content.add parseGraphUser(userRes)
+ elif T is ListSearchResult:
+ if entryId.startsWith("list-search"):
+ for list in extractListsFromItems(e{"content", "items"}):
+ result.content.add list
if entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr
+ elif typ == "TimelineAddToModule":
+ when T is Tweets:
+ for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}):
+ result.content.add tweet
+ elif T is ListSearchResult:
+ for list in extractListsFromItems(instruction{"moduleItems"}):
+ result.content.add list
elif typ == "TimelineReplaceEntry":
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
result.bottom = instruction{"entry", "content", "value"}.getStr
+
+proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline =
+ result = Timeline(beginning: after.len == 0)
+
+ let communityResult = js{"data", "communityResults", "result"}
+ let instructions = ? select(
+ communityResult{"ranked_community_timeline", "timeline", "instructions"},
+ communityResult{"community_media_timeline", "timeline", "instructions"},
+ communityResult{"community_filtered_timeline", "timeline", "instructions"}
+ )
+ if instructions.len == 0:
+ return
+
+ for i in instructions:
+ if i{"entries"}.notNull:
+ for e in i{"entries"}:
+ let entryId = e.getEntryId
+ if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or
+ entryId.startsWith("communities-grid"):
+ for tweet in extractTweetsFromEntry(e):
+ result.content.add tweet
+ elif entryId.startsWith("cursor-bottom"):
+ result.bottom = e{"content", "value"}.getStr
+
+ if after.len == 0 and i.getTypeName == "TimelinePinEntry":
+ var tweets = extractTweetsFromEntry(i{"entry"})
+ for tweet in tweets.mitems:
+ tweet.pinned = true
+ if tweets.len > 0:
+ result.content.insert(tweets, 0)
+
+proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] =
+ result = Result[User](beginning: after.len == 0)
+
+ let r = js{"data", "communityResults", "result"}
+ let slice = if not r{"members_slice"}.isNull: r{"members_slice"}
+ else: r{"moderators_slice"}
+ for item in slice{"items_results"}:
+ let user = parseGraphUser(item{"result"})
+ if user.username.len > 0:
+ result.content.add user
+
+ let cursor = slice{"slice_info", "next_cursor"}.getStr
+ if cursor.len > 0:
+ result.bottom = cursor
+
diff --git a/src/parserutils.nim b/src/parserutils.nim
index 72c50e1..4b8e0b4 100644
--- a/src/parserutils.nim
+++ b/src/parserutils.nim
@@ -17,7 +17,7 @@ let
unReplace = "$1@$2"
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
- htReplace = "$1$2$3"
+ htReplace = "$1$2$3"
type
ReplaceSliceKind = enum
@@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string =
template getEntryId*(e: JsonNode): string =
e{"entryId"}.getStr(e{"entry_id"}.getStr)
-
template parseTime(time: string; f: static string; flen: int): DateTime =
if time.len != flen: return
parse(time, f, utc())
@@ -89,11 +88,19 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
let seconds = ms div 1000
return fromUnix(seconds).utc()
+proc getTimeFromMsStr*(js: JsonNode): DateTime =
+ var ms: int64
+ try: ms = parseBiggestInt(js.getStr("0"))
+ except ValueError: return
+ if ms == 0: return
+ let seconds = ms div 1000
+ return fromUnix(seconds).utc()
+
proc getId*(id: string): int64 {.inline.} =
let start = id.rfind("-")
- if start < 0:
- return parseBiggestInt(id)
- return parseBiggestInt(id[start + 1 ..< id.len])
+ try:
+ parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len])
+ except ValueError: 0'i64
proc getId*(js: JsonNode): int64 {.inline.} =
case js.kind
@@ -112,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
template getImageVal*(js: JsonNode): string =
js{"image_value", "url"}.getImageStr
+template getExpandedUrl*(js: JsonNode; fallback=""): string =
+ js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
+
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
result = js{"website_url"}.getStrVal
if kind == promoVideoConvo:
@@ -175,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] =
result = js["indices"][0].getInt ..< js["indices"][1].getInt
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
- textLen: int; hideTwitter = false) =
+ textLen: int; hideTwitter = false;
+ hideArticle = false) =
let
- url = js["expanded_url"].getStr
+ url = js.getExpandedUrl
slice = js.extractSlice
- if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
+ if hideArticle and url.isTwitterUrl and "/article/" in url:
+ if slice.a < textLen:
+ result.add ReplaceSlice(kind: rkRemove, slice: slice)
+ elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
if slice.a < textLen:
result.add ReplaceSlice(kind: rkRemove, slice: slice)
else:
@@ -192,28 +206,41 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
textSlice: Slice[int]): string =
+ let
+ runeLen = runes.len
+ safeStart = max(0, textSlice.a)
+ safeEnd = min(runeLen, textSlice.b)
+
+ var validRepls: seq[ReplaceSlice]
+ for rep in repls:
+ if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
+ validRepls.add rep
+
template extractLowerBound(i: int; idx): int =
- if i > 0: repls[idx].slice.b.succ else: textSlice.a
+ if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
result = newStringOfCap(runes.len)
- for i, rep in repls:
- result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
+ for i, rep in validRepls:
+ let lower = extractLowerBound(i, i - 1)
+ if lower < rep.slice.a:
+ result.add $runes[lower ..< rep.slice.a]
case rep.kind
of rkHashtag:
- let
- name = $runes[rep.slice.a.succ .. rep.slice.b]
- symbol = $runes[rep.slice.a]
- result.add a(symbol & name, href = "/search?q=%23" & name)
+ if rep.slice.a.succ <= rep.slice.b:
+ let
+ name = $runes[rep.slice.a.succ .. rep.slice.b]
+ symbol = $runes[rep.slice.a]
+ result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention:
- result.add a($runes[rep.slice], href = rep.url, title = rep.display)
+ result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display))
of rkUrl:
- result.add a(rep.display, href = rep.url)
+ result.add a(escape(rep.display), href = rep.url)
of rkRemove:
discard
- let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
- if rest.a <= rest.b:
+ let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
+ if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
result.add $runes[rest]
proc deduplicate(s: var seq[ReplaceSlice]) =
@@ -238,7 +265,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"}
with urls, ent{"url", "urls"}:
- user.website = urls[0]{"expanded_url"}.getStr
+ user.website = urls[0].getExpandedUrl
var replacements = newSeq[ReplaceSlice]()
@@ -254,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
.replacef(htRegex, htReplace)
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
- replyTo=""; hasRedundantLink=false) =
+ replyTo=""; hasRedundantLink=false; hasArticle=false) =
let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]()
@@ -265,10 +292,11 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
if urlStr.len == 0 or urlStr notin text:
continue
- replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
+ replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
+ hideArticle = hasArticle)
if hasCard and u{"url"}.getStr == get(tweet.card).url:
- get(tweet.card).url = u{"expanded_url"}.getStr
+ get(tweet.card).url = u.getExpandedUrl
with media, entities{"media"}:
for m in media:
@@ -302,7 +330,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
-proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
+proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) =
let
entities = ? js{"entities"}
textRange = js{"display_text_range"}
@@ -316,23 +344,99 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
replyTo = reply.getStr
tweet.reply.add replyTo
- tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
+ tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
+ hasQuote or hasJobCard, hasArticle)
+
+proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
+ hasRedundantLink=false; hasArticle=false) =
+ let hasCard = tweet.card.isSome
+
+ var replacements = newSeq[ReplaceSlice]()
+
+ with urls, js{"url_entities"}:
+ for u in urls:
+ let urlStr = u["url"].getStr
+ if urlStr.len == 0 or urlStr notin text:
+ continue
+
+ replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
+ hideArticle = hasArticle)
+
+ if hasCard and u{"url"}.getStr == get(tweet.card).url:
+ get(tweet.card).url = u.getExpandedUrl
+
+ with hashtags, js{"details", "hashtag_entities"}:
+ for hashtag in hashtags:
+ replacements.extractHashtags(hashtag)
+
+ with cashtags, js{"details", "cashtag_entities"}:
+ for cashtag in cashtags:
+ replacements.extractHashtags(cashtag)
+
+ with mentions, js{"mention_entities"}:
+ for mention in mentions:
+ let
+ name = mention{"screen_name"}.getStr
+ slice = mention.extractSlice
+ idx = tweet.reply.find(name)
+
+ if slice.a >= textSlice.a:
+ replacements.add ReplaceSlice(kind: rkMention, slice: slice,
+ url: "/" & name, display: mention["name"].getStr)
+ elif idx == -1 and tweet.replyId != 0:
+ tweet.reply.add name
+
+ replacements.deduplicate
+ replacements.sort(cmp)
+
+ tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
+
+proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) =
+ let
+ textRange = js{"details", "display_text_range"}
+ textSlice = textRange{0}.getInt .. textRange{1}.getInt
+ hasQuote = "quoted_tweet_results" in js
+ hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
+ hasAttribution = tweet.attribution.isSome
+
+ tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
+ hasQuote or hasJobCard or hasAttribution,
+ hasArticle)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let
entities = ? js{"entity_set"}
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
textSlice = 0..text.runeLen
+ hasAttribution = tweet.attribution.isSome
- tweet.expandTextEntities(entities, text, textSlice)
+ tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
+proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
+ let runes = text.toRunes
+ var replacements: seq[ReplaceSlice]
+
+ for entity in entities:
+ let
+ fromIdx = entity{"from_index"}.getInt
+ toIdx = entity{"to_index"}.getInt
+ url = entity{"ref", "url"}.getStr
+ if url.len > 0:
+ replacements.add ReplaceSlice(
+ kind: rkUrl,
+ slice: fromIdx ..< toIdx,
+ url: url,
+ display: $runes[fromIdx ..< min(toIdx, runes.len)]
+ )
+
+ replacements.sort(cmp)
+ result = runes.replacedWith(replacements, 0 ..< runes.len)
+
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
let url =
- if t.photos.len > 0: t.photos[0]
- elif t.video.isSome: get(t.video).thumb
- elif t.gif.isSome: get(t.gif).thumb
+ if t.media.len > 0: t.media[0].getThumb
elif t.card.isSome: get(t.card).image
else: ""
diff --git a/src/prefs.nim b/src/prefs.nim
index fa40a6d..1a75f75 100644
--- a/src/prefs.nim
+++ b/src/prefs.nim
@@ -1,22 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import tables
+import tables, strutils
import types, prefs_impl
from config import get
from parsecfg import nil
-export genUpdatePrefs, genResetPrefs
+export genUpdatePrefs, genResetPrefs, genApplyPrefs
var defaultPrefs*: Prefs
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
genDefaultPrefs()
-proc getPrefs*(cookies: Table[string, string]): Prefs =
+proc getPrefs*(cookies, params: Table[string, string]): Prefs =
result = defaultPrefs
- genCookiePrefs(cookies)
+ genParsePrefs(cookies)
+ genParsePrefs(params)
-template getPref*(cookies: Table[string, string], pref): untyped =
- bind genCookiePref
- var res = defaultPrefs.`pref`
- genCookiePref(cookies, pref, res)
- res
+proc encodePrefs*(prefs: Prefs): string =
+ var encPairs: seq[string]
+ genEncodePrefs(prefs)
+ encPairs.join(",")
diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim
index 8e2ac8f..8519bd5 100644
--- a/src/prefs_impl.nim
+++ b/src/prefs_impl.nim
@@ -60,6 +60,9 @@ genPrefs:
stickyProfile(checkbox, true):
"Make profile sidebar stick to top"
+ stickyNav(checkbox, true):
+ "Keep navbar fixed to top"
+
bidiSupport(checkbox, false):
"Support bidirectional text (makes clicking on tweets harder)"
@@ -75,6 +78,12 @@ genPrefs:
hideReplies(checkbox, false):
"Hide tweet replies"
+ hideRelated(checkbox, true):
+ "Hide related tweets under replies"
+
+ hideCommunityNotes(checkbox, false):
+ "Hide community notes"
+
squareAvatars(checkbox, false):
"Square profile pictures"
@@ -94,6 +103,17 @@ genPrefs:
autoplayGifs(checkbox, true):
"Autoplay gifs"
+ compactGallery(checkbox, false):
+ "Compact media gallery (no profile info or text)"
+
+ gallerySize(select, "Medium"):
+ "Gallery column size"
+ options: @["Small", "Medium", "Large"]
+
+ mediaView(select, "Timeline"):
+ "Default media view"
+ options: @["Timeline", "Grid", "Gallery"]
+
"Link replacements (blank to disable)":
replaceTwitter(input, ""):
"Twitter -> Nitter"
@@ -127,7 +147,7 @@ macro genDefaultPrefs*(): untyped =
result.add quote do:
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
-macro genCookiePrefs*(cookies): untyped =
+macro genParsePrefs*(prefs): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let
@@ -137,37 +157,17 @@ macro genCookiePrefs*(cookies): untyped =
options = pref.options
result.add quote do:
- if `name` in `cookies`:
+ if `name` in `prefs`:
when `kind` == input or `name` == "theme":
- result.`ident` = `cookies`[`name`]
+ result.`ident` = `prefs`[`name`]
elif `kind` == checkbox:
- result.`ident` = `cookies`[`name`] == "on"
+ result.`ident` = `prefs`[`name`] == "on" or
+ `prefs`[`name`] == "true" or
+ `prefs`[`name`] == "1"
else:
- let value = `cookies`[`name`]
+ let value = `prefs`[`name`]
if value in `options`: result.`ident` = value
-macro genCookiePref*(cookies, prefName, res): untyped =
- result = nnkStmtList.newTree()
- for pref in allPrefs():
- let ident = ident(pref.name)
- if ident != prefName:
- continue
-
- let
- name = pref.name
- kind = newLit(pref.kind)
- options = pref.options
-
- result.add quote do:
- if `name` in `cookies`:
- when `kind` == input or `name` == "theme":
- `res` = `cookies`[`name`]
- elif `kind` == checkbox:
- `res` = `cookies`[`name`] == "on"
- else:
- let value = `cookies`[`name`]
- if value in `options`: `res` = value
-
macro genUpdatePrefs*(): untyped =
result = nnkStmtList.newTree()
let req = ident("request")
@@ -202,6 +202,36 @@ macro genResetPrefs*(): untyped =
result.add quote do:
savePref(`name`, "", `req`, expire=true)
+macro genEncodePrefs*(prefs): untyped =
+ result = nnkStmtList.newTree()
+ for pref in allPrefs():
+ let
+ name = newLit(pref.name)
+ ident = ident(pref.name)
+ kind = newLit(pref.kind)
+ defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
+
+ result.add quote do:
+ when `kind` == checkbox:
+ if `prefs`.`ident` != `defaultIdent`:
+ if `prefs`.`ident`:
+ encPairs.add `name` & "=on"
+ else:
+ encPairs.add `name` & "="
+ else:
+ if `prefs`.`ident` != `defaultIdent`:
+ encPairs.add `name` & "=" & `prefs`.`ident`
+
+macro genApplyPrefs*(params, req): untyped =
+ result = nnkStmtList.newTree()
+ for pref in allPrefs():
+ let name = newLit(pref.name)
+ result.add quote do:
+ if `name` in `params`:
+ savePref(`name`, `params`[`name`], `req`)
+ else:
+ savePref(`name`, "", `req`, expire=true)
+
macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do:
diff --git a/src/query.nim b/src/query.nim
index 06e1da2..ecb428d 100644
--- a/src/query.nim
+++ b/src/query.nim
@@ -1,15 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri
-import types
+import types, utils
const
validFilters* = @[
"media", "images", "twimg", "videos",
- "native_video", "consumer_video", "pro_video",
+ "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions",
- "replies", "retweets", "nativeretweets",
- "verified", "safe"
+ "replies", "retweets", "nativeretweets"
]
emptyQuery* = "include:nativeretweets"
@@ -21,32 +20,43 @@ template `@`(param: string): untyped =
proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query(
kind: parseEnum[QueryKind](@"f", tweets),
+ view: @"view",
text: @"q",
filters: validFilters.filterIt("f-" & it in pms),
excludes: validFilters.filterIt("e-" & it in pms),
since: @"since",
until: @"until",
- near: @"near"
+ minLikes: validateNumber(@"min_faves")
)
+ # articles is an internal tab kind, not a valid search filter
+ if result.kind == QueryKind.articles:
+ result.kind = tweets
+
if name.len > 0:
result.fromUser = name.split(",")
proc getMediaQuery*(name: string): Query =
Query(
- kind: media,
+ kind: QueryKind.media,
filters: @["twimg", "native_video"],
fromUser: @[name],
sep: "OR"
)
+proc getArticlesQuery*(name: string): Query =
+ Query(
+ kind: QueryKind.articles,
+ fromUser: @[name]
+ )
+
proc getReplyQuery*(name: string): Query =
Query(
kind: replies,
fromUser: @[name]
)
-proc genQueryParam*(query: Query): string =
+proc genQueryParam*(query: Query; maxId=""): string =
var
filters: seq[string]
param: string
@@ -55,15 +65,20 @@ proc genQueryParam*(query: Query): string =
return query.text
for i, user in query.fromUser:
- param &= &"from:{user} "
- if i < query.fromUser.high:
- param &= "OR "
+ if i == 0:
+ param = "("
- if query.fromUser.len > 0 and query.kind in {posts, media}:
- param &= "filter:self_threads OR -filter:replies "
+ param &= &"from:{user}"
+ if i < query.fromUser.high:
+ param &= " OR "
+ else:
+ param &= ")"
+
+ if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}:
+ param &= " (filter:self_threads OR -filter:replies)"
if "nativeretweets" notin query.excludes:
- param &= "include:nativeretweets "
+ param &= " include:nativeretweets"
for f in query.filters:
filters.add "filter:" & f
@@ -73,38 +88,51 @@ proc genQueryParam*(query: Query): string =
for i in query.includes:
filters.add "include:" & i
- result = strip(param & filters.join(&" {query.sep} "))
+ if filters.len > 0:
+ result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
+ else:
+ result = strip(param)
+
if query.since.len > 0:
result &= " since:" & query.since
- if query.until.len > 0:
+ if query.until.len > 0 and maxId.len == 0:
result &= " until:" & query.until
- if query.near.len > 0:
- result &= &" near:\"{query.near}\" within:15mi"
+ if query.minLikes.len > 0:
+ result &= " min_faves:" & query.minLikes
if query.text.len > 0:
if result.len > 0:
result &= " " & query.text
else:
result = query.text
+ if result.len > 0 and maxId.len > 0:
+ result &= " max_id:" & maxId
+
proc genQueryUrl*(query: Query): string =
- if query.kind notin {tweets, users}: return
+ var params: seq[string]
- var params = @[&"f={query.kind}"]
- if query.text.len > 0:
- params.add "q=" & encodeUrl(query.text)
- for f in query.filters:
- params.add &"f-{f}=on"
- for e in query.excludes:
- params.add &"e-{e}=on"
- for i in query.includes.filterIt(it != "nativeretweets"):
- params.add &"i-{i}=on"
+ if query.view.len > 0:
+ params.add "view=" & encodeUrl(query.view)
- if query.since.len > 0:
- params.add "since=" & query.since
- if query.until.len > 0:
- params.add "until=" & query.until
- if query.near.len > 0:
- params.add "near=" & query.near
+ # media doubles as the profile media tab, where f isn't part of the URL scheme
+ if query.kind in {tweets, users, lists, top} or
+ (query.kind == QueryKind.media and query.fromUser.len == 0):
+ params.add &"f={query.kind}"
+ if query.text.len > 0:
+ params.add "q=" & encodeUrl(query.text)
+ for f in query.filters:
+ params.add &"f-{f}=on"
+ for e in query.excludes:
+ params.add &"e-{e}=on"
+ for i in query.includes.filterIt(it != "nativeretweets"):
+ params.add &"i-{i}=on"
+
+ if query.since.len > 0:
+ params.add "since=" & query.since
+ if query.until.len > 0:
+ params.add "until=" & query.until
+ if query.minLikes.len > 0:
+ params.add "min_faves=" & query.minLikes
if params.len > 0:
result &= params.join("&")
diff --git a/src/redis_cache.nim b/src/redis_cache.nim
index 559d299..b9ddbcc 100644
--- a/src/redis_cache.nim
+++ b/src/redis_cache.nim
@@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
else:
let user = await getGraphUserById(userId)
result = user.username
- await setEx(key, baseCacheTime, result)
- if result.len > 0 and user.id.len > 0:
- await all(cacheUserId(result, user.id), cache(user))
+ if result.len > 0:
+ await setEx(key, baseCacheTime, result)
+ if user.id.len > 0:
+ await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return
@@ -158,6 +159,48 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
# if not result.isNil:
# await cache(result)
+proc cache*(data: Broadcast) {.async.} =
+ if data.id.len == 0: return
+ await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
+
+proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
+ if id.len == 0: return
+ let cached = await get("bc:" & id)
+ if cached != redisNil:
+ cached.deserialize(Broadcast)
+ else:
+ result = await getBroadcastInfo(id)
+ await cache(result)
+ result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
+
+proc cache*(data: AudioSpace) {.async.} =
+ if data.id.len == 0: return
+ let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime
+ await setEx("sp:" & data.id, ttl, compress(toFlatty(data)))
+
+proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} =
+ if id.len == 0: return
+ let cached = await get("sp:" & id)
+ if cached != redisNil:
+ cached.deserialize(AudioSpace)
+ else:
+ result = await getAudioSpace(id)
+ await cache(result)
+ result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
+
+proc cache*(data: AccountInfo; name: string) {.async.} =
+ await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
+
+proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
+ if username.len == 0: return
+ let name = toLower(username)
+ let cached = await get("ai:" & name)
+ if cached != redisNil:
+ cached.deserialize(AccountInfo)
+ elif fetch:
+ result = await getAboutAccount(username)
+ await cache(result, name)
+
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return
let rail = await get("pr2:" & toLower(id))
@@ -167,6 +210,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
result = await getPhotoRail(id)
await cache(result, id)
+proc cache*(data: Community) {.async.} =
+ if data.id.len == 0: return
+ await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data)))
+
+proc getCachedCommunity*(id: string): Future[Community] {.async.} =
+ if id.len == 0: return
+ let cached = await get("cm:" & id)
+ if cached != redisNil:
+ cached.deserialize(Community)
+ else:
+ result = await getGraphCommunity(id)
+ await cache(result)
+
+proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} =
+ if id.len == 0: return
+ let cached = await get("cmm:" & id)
+ if cached != redisNil:
+ cached.deserialize(seq[User])
+ else:
+ let mods = await getGraphCommunityModerators(id)
+ result = mods.content
+ await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result)))
+
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil
else: await get("l:" & id)
diff --git a/src/routes/article.nim b/src/routes/article.nim
new file mode 100644
index 0000000..0a7d1dc
--- /dev/null
+++ b/src/routes/article.nim
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, tables, strutils
+import jester, karax/vdom
+import ".."/[types, api]
+import ../views/[article, general]
+import router_utils
+
+export api, article, vdom, general, router_utils
+
+proc createArticleRouter*(cfg: Config) =
+ router articleRoute:
+ get "/i/article/@id":
+ cond @"id".allCharsInSet(Digits)
+
+ let article = await getGraphArticle(@"id")
+ if article == nil:
+ resp Http404, showError("Article not found", cfg)
+
+ var tweetIds: seq[string]
+ for e in article.entities.values:
+ if e.kind == "TWEET":
+ tweetIds.add e.tweetId
+
+ var tweets = initTable[int64, Tweet]()
+ if tweetIds.len > 0:
+ try:
+ for t in await getGraphTweetResults(tweetIds):
+ tweets[t.id] = t
+ except CatchableError:
+ discard
+
+ let
+ prefs = requestPrefs()
+ path = getPath()
+ html = renderArticle(article, tweets, path, prefs, @"id")
+ twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id"
+ resp renderMain(html, request, cfg, prefs, titleText=article.title,
+ twitterLink=twitterUrl)
+
+ get "/@name/article/@id/?":
+ cond '.' notin @"name"
+ cond @"id".allCharsInSet(Digits)
+ redirect("/i/article/" & @"id")
+
+ get "/@name/status/@id/article":
+ cond '.' notin @"name"
+ cond @"id".allCharsInSet(Digits)
+ redirect("/i/article/" & @"id")
diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim
new file mode 100644
index 0000000..d3bb95a
--- /dev/null
+++ b/src/routes/broadcast.nim
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, strutils
+import jester
+
+import router_utils
+import ".."/[types, formatters, redis_cache]
+import ../views/[general, broadcast]
+import media
+
+export broadcast
+
+proc createBroadcastRouter*(cfg: Config) =
+ router broadcastRoute:
+ get "/i/broadcasts/@id":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ var bc: Broadcast
+ try:
+ bc = await getCachedBroadcast(@"id")
+ except:
+ discard
+
+ if bc.id.len == 0:
+ resp Http404, showError("Broadcast not found", cfg)
+
+ let prefs = requestPrefs()
+ resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
+ bc.title, ogTitle=bc.title)
+
+ get "/i/broadcasts/@id/stream":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ var bc: Broadcast
+ try:
+ bc = await getCachedBroadcast(@"id")
+ except:
+ discard
+
+ if bc.m3u8Url.len == 0:
+ resp Http404
+
+ let manifest = await safeFetch(bc.m3u8Url)
+ if manifest.len == 0:
+ resp Http502
+
+ resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime
diff --git a/src/routes/community.nim b/src/routes/community.nim
new file mode 100644
index 0000000..b850b6b
--- /dev/null
+++ b/src/routes/community.nim
@@ -0,0 +1,89 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strformat
+
+import jester
+
+import router_utils
+import ".."/[types, redis_cache, api]
+import ../views/[general, timeline, community]
+
+export community
+
+template respCommunity*(cmty: Community; title: string; nav, vnode: typed) =
+ if cmty.id.len == 0 or cmty.name.len == 0:
+ resp Http404, showError(&"""Community "{@"id"}" not found""", cfg)
+
+ let html = renderCommunity(vnode, nav, cmty)
+ resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner)
+
+proc createCommunityRouter*(cfg: Config) =
+ router community:
+ get "/i/communities/@id/?":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor())
+ respCommunity(cmty, cmty.name,
+ renderCommunityTabs(QueryKind.posts, cmty),
+ renderTimelineTweets(tl, prefs, request.path))
+
+ get "/i/communities/@id/latest":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor())
+ respCommunity(cmty, cmty.name & " - Latest",
+ renderCommunityTabs(QueryKind.replies, cmty),
+ renderTimelineTweets(tl, prefs, request.path))
+
+ get "/i/communities/@id/media":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityMedia(cmty.id, getCursor())
+ respCommunity(cmty, cmty.name & " - Media",
+ renderCommunityTabs(QueryKind.media, cmty),
+ renderTimelineTweets(tl, prefs, request.path))
+
+ get "/i/communities/@id/about":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ mods = await getCachedCommunityModerators(cmty.id)
+ respCommunity(cmty, cmty.name & " - About",
+ renderCommunityTabs(QueryKind.userList, cmty),
+ renderCommunityAbout(cmty, mods))
+
+ get "/i/communities/@id/members":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ members = await getGraphCommunityMembers(cmty.id, getCursor())
+ respCommunity(cmty, cmty.name & " - Members",
+ renderMemberTabs(cmty, false),
+ renderTimelineUsers(members, prefs, request.path))
+
+ get "/i/communities/@id/moderators":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ mods = await getCachedCommunityModerators(cmty.id)
+ respCommunity(cmty, cmty.name & " - Moderators",
+ renderMemberTabs(cmty, true),
+ renderTimelineUsers(Result[User](content: mods), prefs, request.path))
+
+ get "/i/communities/@id/hashtag/@tag":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor())
+ respCommunity(cmty, cmty.name & " - #" & @"tag",
+ renderHashtagHeader(cmty, @"tag"),
+ renderTimelineTweets(tl, prefs, request.path))
diff --git a/src/routes/embed.nim b/src/routes/embed.nim
index 994364b..24bba2d 100644
--- a/src/routes/embed.nim
+++ b/src/routes/embed.nim
@@ -1,29 +1,79 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import asyncdispatch, strutils, strformat, options
+import asyncdispatch, strutils, strformat, json
import jester, karax/vdom
-import ".."/[types, api]
+import ".."/[types, api, formatters]
import ../views/[embed, tweet, general]
+include "../views/oembed.nimf"
import router_utils
export api, embed, vdom, tweet, general, router_utils
+proc parseTweetPath(path: string): tuple[username, id: string] =
+ let parts = path.split('/')
+ if parts.len >= 3 and parts[1] in ["status", "statuses"]:
+ let tweetId = parts[2].split('?')[0].split('#')[0]
+ if tweetId.len > 0 and tweetId.allCharsInSet(Digits):
+ return (parts[0], tweetId)
+ return ("", "")
+
+proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] =
+ var path = url
+ if path.startsWith("https://"):
+ path = path[8..^1]
+ elif path.startsWith("http://"):
+ path = path[7..^1]
+
+ const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
+ "www.twitter.com/", "www.x.com/"]
+
+ for prefix in twitterPrefixes:
+ if path.startsWith(prefix):
+ return parseTweetPath(path[prefix.len..^1])
+
+ let nitterPrefix = cfg.hostname & "/"
+ if path.startsWith(nitterPrefix):
+ return parseTweetPath(path[nitterPrefix.len..^1])
+
+ # Fall back: strip any hostname and try to parse as a tweet path.
+ # Handles requests where the URL's host differs from cfg.hostname
+ # (e.g. localhost in dev/CI, or a reverse proxy with a different domain).
+ let slashPos = path.find('/')
+ if slashPos > 0:
+ let afterHost = path[slashPos + 1..^1]
+ let parsed = parseTweetPath(afterHost)
+ if parsed.username.len > 0:
+ return parsed
+
+ return ("", "")
+
proc createEmbedRouter*(cfg: Config) =
router embed:
get "/i/videos/tweet/@id":
- let tweet = await getGraphTweetResult(@"id")
- if tweet == nil or tweet.video.isNone:
- resp Http404
+ let
+ id = @"id"
+ tweet = await getTweetByRestId(id)
+ prefs = requestPrefs()
+
+ if tweet == nil:
+ resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id)
+
+ if not tweet.hasVideos:
+ resp renderErrorEmbed("No video in tweet", prefs, cfg, request,
+ tweetId=id, username=tweet.user.username)
resp renderVideoEmbed(tweet, cfg, request)
get "/@user/status/@id/embed":
let
- tweet = await getGraphTweetResult(@"id")
- prefs = cookiePrefs()
+ id = @"id"
+ user = @"user"
+ tweet = await getTweetByRestId(id)
+ prefs = requestPrefs()
path = getPath()
if tweet == nil:
- resp Http404
+ resp renderErrorEmbed("Tweet not found", prefs, cfg, request,
+ tweetId=id, username=user)
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
@@ -34,3 +84,57 @@ proc createEmbedRouter*(cfg: Config) =
redirect(&"/i/status/{id}/embed")
else:
resp Http404
+
+ get "/api/oembed":
+ responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
+
+ let
+ url = @"url"
+ format = @"format"
+
+ if format.len > 0 and format != "json":
+ resp Http501, "Only JSON format is supported"
+
+ if url.len == 0:
+ resp Http400, "Missing url parameter"
+
+ let (username, tweetId) = parseTweetUrl(url, cfg)
+ if username.len == 0 or tweetId.len == 0:
+ resp Http400, "Invalid tweet URL"
+
+ let tweet = await getTweetByRestId(tweetId)
+ if tweet == nil:
+ resp Http404
+
+ let
+ maxwidthParam = @"maxwidth"
+ maxwidth = if maxwidthParam.len > 0:
+ try: clamp(parseInt(maxwidthParam), 220, 550)
+ except ValueError: 550
+ else: 550
+ embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed"
+ authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username
+ title = stripHtml(tweet.text)
+
+ var response = %*{
+ "version": "1.0",
+ "type": "rich",
+ "provider_name": cfg.title,
+ "provider_url": getUrlPrefix(cfg),
+ "title": title,
+ "author_name": tweet.user.fullname,
+ "author_url": authorUrl,
+ "url": embedUrl,
+ "width": maxwidth,
+ "height": newJNull(),
+ "cache_age": "3153600000",
+ "html": renderOembedIframe(embedUrl, maxwidth)
+ }
+
+ if tweet.media.len > 0:
+ let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb)
+ response["thumbnail_url"] = %thumbUrl
+ response["thumbnail_width"] = %maxwidth
+ response["thumbnail_height"] = %maxwidth
+
+ respJson response
diff --git a/src/routes/list.nim b/src/routes/list.nim
index ac3e97e..b4ab091 100644
--- a/src/routes/list.nim
+++ b/src/routes/list.nim
@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
let
html = renderList(vnode, timeline.query, list)
- rss = &"""/i/lists/{@"id"}/rss"""
+ rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
@@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/?":
cond '.' notin @"id"
let
- prefs = cookiePrefs()
+ prefs = requestPrefs()
list = await getCachedList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path)
@@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/members":
cond '.' notin @"id"
let
- prefs = cookiePrefs()
+ prefs = requestPrefs()
list = await getCachedList(id=(@"id"))
members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
diff --git a/src/routes/media.nim b/src/routes/media.nim
index de51061..3442916 100644
--- a/src/routes/media.nim
+++ b/src/routes/media.nim
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils, httpclient, os, hashes, base64, re
import asynchttpserver, asyncstreams, asyncfile, asyncnet
+import asyncdispatch
import jester
@@ -15,7 +16,9 @@ const
maxAge* = "max-age=604800"
proc safeFetch*(url: string): Future[string] {.async.} =
- let client = newAsyncHttpClient()
+ # maxRedirects=0: the caller already validated the host, so never follow a
+ # redirect off the allowlisted host (would re-open the #1411 SSRF).
+ let client = newAsyncHttpClient(maxRedirects = 0)
try: result = await client.getContent(url)
except: discard
finally: client.close()
@@ -30,47 +33,79 @@ template respond*(req: asynchttpserver.Request; headers) =
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
result = Http200
- let
- request = req.getNativeReq()
- client = newAsyncHttpClient()
+ let request = req.getNativeReq()
+ var fetchUrl = url
- try:
- let res = await client.get(url)
- if res.status != "200 OK":
- if res.status != "404 Not Found":
- echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
- return Http404
-
- let hashed = $hash(url)
- if request.headers.getOrDefault("If-None-Match") == hashed:
- return Http304
-
- let contentLength =
- if res.headers.hasKey("content-length"):
- res.headers["content-length", 0]
+ for attempt in 0 .. 2:
+ let client = newAsyncHttpClient(maxRedirects = 0)
+ var shouldRetry = false
+ try:
+ let resFut = client.get(fetchUrl)
+ let completed = await withTimeout(resFut, 5000)
+ if not completed:
+ if attempt < 2:
+ echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl]
+ shouldRetry = true
+ else:
+ echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl]
+ return Http504
else:
- ""
+ let res = resFut.read()
+ if res.status != "200 OK":
+ if res.status == "404 Not Found":
+ return Http404
+ if res.status.startsWith("30") and res.headers.hasKey("location"):
+ let location = res.headers["location", 0]
+ if isTwitterUrl(location):
+ fetchUrl = location
+ shouldRetry = true
+ continue
+ else:
+ return Http403
+ if attempt < 2:
+ echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl]
+ shouldRetry = true
+ else:
+ echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl]
+ return Http404
+ else:
+ let hashed = $hash(url)
+ if request.headers.getOrDefault("If-None-Match") == hashed:
+ return Http304
- let headers = newHttpHeaders({
- "Content-Type": res.headers["content-type", 0],
- "Content-Length": contentLength,
- "Cache-Control": maxAge,
- "ETag": hashed
- })
+ let contentLength =
+ if res.headers.hasKey("content-length"):
+ res.headers["content-length", 0]
+ else:
+ ""
- respond(request, headers)
+ let headers = newHttpHeaders({
+ "content-type": res.headers["content-type", 0],
+ "content-length": contentLength,
+ "cache-control": maxAge,
+ "etag": hashed
+ })
- var (hasValue, data) = (true, "")
- while hasValue:
- (hasValue, data) = await res.bodyStream.read()
- if hasValue:
- await request.client.send(data)
- data.setLen 0
- except HttpRequestError, ProtocolError, OSError:
- echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
- result = Http404
- finally:
- client.close()
+ respond(request, headers)
+
+ var (hasValue, data) = (true, "")
+ while hasValue:
+ (hasValue, data) = await res.bodyStream.read()
+ if hasValue:
+ await request.client.send(data)
+ data.setLen 0
+ return Http200
+ except CatchableError:
+ if attempt < 2:
+ echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl]
+ shouldRetry = true
+ else:
+ echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl]
+ result = Http404
+ finally:
+ client.close()
+ if not shouldRetry:
+ break
template check*(code): untyped =
if code != Http200:
@@ -86,6 +121,12 @@ proc decoded*(req: jester.Request; index: int): string =
if based: decode(encoded)
else: decodeUrl(encoded)
+proc normalizeImgUrl*(url: var string) =
+ if not url.startsWith("http"):
+ if "twimg.com" notin url:
+ url.insert(twimg)
+ url.insert(https)
+
proc createMediaRouter*(cfg: Config) =
router media:
get "/pic/?":
@@ -93,10 +134,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1)
- if "twimg.com" notin url:
- url.insert(twimg)
- if not url.startsWith(https):
- url.insert(https)
+ cond "/amplify_video/" notin url
+ normalizeImgUrl(url)
url.add("?name=orig")
let uri = parseUri(url)
@@ -107,10 +146,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1)
- if "twimg.com" notin url:
- url.insert(twimg)
- if not url.startsWith(https):
- url.insert(https)
+ cond "/amplify_video/" notin url
+ normalizeImgUrl(url)
let uri = parseUri(url)
cond isTwitterUrl(uri) == true
@@ -120,12 +157,12 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
let url = decoded(request, 2)
- cond "http" in url
+ cond isTwitterUrl(url)
if getHmac(url) != request.matches[1]:
resp Http403, showError("Failed to verify signature", cfg)
- if ".mp4" in url or ".ts" in url or ".m4s" in url:
+ if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url:
let code = await proxyMedia(request, url)
check code
@@ -139,6 +176,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url:
let vid = await safeFetch(url)
- content = proxifyVideo(vid, cookiePref(proxyVideos))
+ content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
resp content, m3u8Mime
diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim
index b8af03d..7f04de2 100644
--- a/src/routes/preferences.nim
+++ b/src/routes/preferences.nim
@@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
router preferences:
get "/settings":
let
- prefs = cookiePrefs()
- html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
+ prefs = requestPrefs()
+ prefsCode = encodePrefs(prefs)
+ prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
+ html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
resp renderMain(html, request, cfg, prefs, "Preferences")
get "/settings/@i?":
@@ -38,3 +40,6 @@ proc createPrefRouter*(cfg: Config) =
savePref("hlsPlayback", "on", request)
redirect(refPath())
+ post "/enablemp4":
+ savePref("mp4Playback", "on", request)
+ redirect(refPath())
diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim
index 1baf873..5f074a5 100644
--- a/src/routes/resolver.nim
+++ b/src/routes/resolver.nim
@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
router resolver:
get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
- respResolved(await resolve(url, cookiePrefs()), "card")
+ respResolved(await resolve(url, requestPrefs()), "card")
get "/t.co/@url":
let url = "https://t.co/" & @"url"
- respResolved(await resolve(url, cookiePrefs()), "t.co")
+ respResolved(await resolve(url, requestPrefs()), "t.co")
diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim
index a071a0d..612a96b 100644
--- a/src/routes/router_utils.nim
+++ b/src/routes/router_utils.nim
@@ -4,26 +4,19 @@ from jester import Request, cookies
import ../views/general
import ".."/[utils, prefs, types]
-export utils, prefs, types, uri
+export utils, prefs, types, uri, json
template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req):
+ let sameSite = if cfg.useHttps: None else: Lax
setCookie(pref, value, daysForward(when expire: -10 else: 360),
- httpOnly=true, secure=cfg.useHttps, sameSite=None)
+ httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
-template cookiePrefs*(): untyped {.dirty.} =
- getPrefs(cookies(request))
-
-template cookiePref*(pref): untyped {.dirty.} =
- getPref(cookies(request), pref)
-
-template themePrefs*(): Prefs =
- var res = defaultPrefs
- res.theme = cookiePref(theme)
- res
+template requestPrefs*(): untyped {.dirty.} =
+ getPrefs(cookies(request), params(request))
template showError*(error: string; cfg: Config): string =
- renderMain(renderError(error), request, cfg, themePrefs(), "Error")
+ renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
template getPath*(): untyped {.dirty.} =
$(parseUri(request.path) ? filterParams(request.params))
@@ -43,5 +36,28 @@ template getCursor*(req: Request): string =
proc getNames*(name: string): seq[string] =
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
+template applyUrlPrefs*() {.dirty.} =
+ if @"prefs".len > 0:
+ var prefParams = initTable[string, string]()
+ for pair in @"prefs".split(','):
+ let kv = pair.split('=', maxsplit=1)
+ if kv.len == 2:
+ prefParams[kv[0]] = kv[1]
+ elif kv.len == 1 and kv[0].len > 0:
+ prefParams[kv[0]] = ""
+ genApplyPrefs(prefParams, request)
+
+ # Rebuild URL without prefs param
+ var params: seq[(string, string)]
+ for k, v in request.params:
+ if k != "prefs":
+ params.add (k, v)
+
+ if params.len > 0:
+ let cleanUrl = request.getNativeReq.url ? params
+ redirect($cleanUrl)
+ else:
+ redirect(request.path)
+
template respJson*(node: JsonNode) =
resp $node, "application/json"
diff --git a/src/routes/rss.nim b/src/routes/rss.nim
index b0e781d..038096c 100644
--- a/src/routes/rss.nim
+++ b/src/routes/rss.nim
@@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
if cursor.len > 0:
result &= ":" & cursor
-proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
+proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
var profile: Profile
let
name = req.params.getOrDefault("name")
@@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
return Rss(feed: profile.user.username, cursor: "suspended")
if profile.user.fullname.len > 0:
- let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
+ let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
return Rss(feed: rss, cursor: profile.tweets.bottom)
template respRss*(rss, page) =
@@ -60,12 +60,15 @@ template respRss*(rss, page) =
proc createRssRouter*(cfg: Config) =
router rss:
get "/search/rss":
- cond cfg.enableRss
+ if not cfg.enableRSSSearch:
+ resp Http403, showError("RSS feed is disabled", cfg)
if @"q".len > 200:
resp Http400, showError("Search input too long.", cfg)
- let query = initQuery(params(request))
- if query.kind != tweets:
+ let
+ prefs = requestPrefs()
+ query = initQuery(params(request))
+ if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}:
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
let
@@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) =
let tweets = await getGraphTweetSearch(query, cursor)
rss.cursor = tweets.bottom
- rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
+ rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
await cacheRss(key, rss)
respRss(rss, "Search")
get "/@name/rss":
- cond cfg.enableRss
cond '.' notin @"name"
+ if not cfg.enableRSSUserTweets:
+ resp Http403, showError("RSS feed is disabled", cfg)
let
+ prefs = requestPrefs()
name = @"name"
key = redisKey("twitter", name, getCursor())
@@ -94,24 +99,23 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
- rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
+ rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/@tab/rss":
- cond cfg.enableRss
cond '.' notin @"name"
- cond @"tab" in ["with_replies", "media", "search"]
+ cond @"tab" in ["with_replies", "media", "search", "articles"]
+ # articles can't be approximated by search, so multi-user is unsupported
+ cond not (@"tab" == "articles" and ',' in @"name")
+ if not cfg.tabRssEnabled(@"tab"):
+ resp Http403, showError("RSS feed is disabled", cfg)
let
+ prefs = requestPrefs()
name = @"name"
tab = @"tab"
- query =
- case tab
- of "with_replies": getReplyQuery(name)
- of "media": getMediaQuery(name)
- of "search": initQuery(params(request), name=name)
- else: Query(fromUser: @[name])
+ query = request.getQuery(tab, name, prefs)
let searchKey = if tab != "search": ""
else: ":" & $hash(genQueryUrl(query))
@@ -122,14 +126,15 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
- rss = await timelineRss(request, cfg, query)
+ rss = await timelineRss(request, cfg, query, prefs)
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/lists/@slug/rss":
- cond cfg.enableRss
cond @"name" != "i"
+ if not cfg.enableRSSList:
+ resp Http403, showError("RSS feed is disabled", cfg)
let
slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug)
@@ -145,8 +150,10 @@ proc createRssRouter*(cfg: Config) =
redirect(url)
get "/i/lists/@id/rss":
- cond cfg.enableRss
+ if not cfg.enableRSSList:
+ resp Http403, showError("RSS feed is disabled", cfg)
let
+ prefs = requestPrefs()
id = @"id"
cursor = getCursor()
key = redisKey("lists", id, cursor)
@@ -159,7 +166,7 @@ proc createRssRouter*(cfg: Config) =
list = await getCachedList(id=id)
timeline = await getGraphListTweets(list.id, cursor)
rss.cursor = timeline.bottom
- rss.feed = renderListRss(timeline.content, list, cfg)
+ rss.feed = renderListRss(timeline.content, list, cfg, prefs)
await cacheRss(key, rss)
respRss(rss, "List")
diff --git a/src/routes/search.nim b/src/routes/search.nim
index e9f991d..7c7fd14 100644
--- a/src/routes/search.nim
+++ b/src/routes/search.nim
@@ -19,10 +19,22 @@ proc createSearchRouter*(cfg: Config) =
resp Http400, showError("Search input too long.", cfg)
let
- prefs = cookiePrefs()
- query = initQuery(params(request))
+ prefs = requestPrefs()
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
+ var query = initQuery(params(request))
+ # x.com URL compat: f=user and f=list map to our kind names
+ # (f=live already falls back to tweets/Latest; f=media matches natively)
+ if @"f" == "user":
+ query.kind = users
+ elif @"f" == "list":
+ query.kind = lists
+
+ # media searches support view modes, defaulting like /user/media
+ if query.kind == QueryKind.media and
+ query.view notin ["timeline", "grid", "gallery"]:
+ query.view = prefs.mediaView.toLowerAscii
+
case query.kind
of users:
if "," in q:
@@ -33,19 +45,24 @@ proc createSearchRouter*(cfg: Config) =
except InternalError:
users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
- of tweets:
+ of tweets, top, QueryKind.media:
let
tweets = await getGraphTweetSearch(query, getCursor())
- rss = "/search/rss?" & genQueryUrl(query)
+ rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss)
+ of lists:
+ let listResults = await getGraphListSearch(query, getCursor())
+ resp renderMain(renderListSearch(listResults, prefs, getPath()),
+ request, cfg, prefs, title)
else:
resp Http404, showError("Invalid search", cfg)
get "/hashtag/@hash":
- redirect("/search?q=" & encodeUrl("#" & @"hash"))
+ redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
get "/opensearch":
- let url = getUrlPrefix(cfg) & "/search?q="
- resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
- generateOpenSearchXML(cfg.title, cfg.hostname, url)
+ let
+ url = getUrlPrefix(cfg) & "/search?f=tweets&q="
+ headers = {"Content-Type": "application/opensearchdescription+xml"}
+ resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
diff --git a/src/routes/space.nim b/src/routes/space.nim
new file mode 100644
index 0000000..bd956ea
--- /dev/null
+++ b/src/routes/space.nim
@@ -0,0 +1,36 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, strutils
+import jester
+
+import router_utils
+import ".."/[types, formatters, redis_cache]
+import ../views/[general, space]
+import media
+
+export space
+
+proc createSpaceRouter*(cfg: Config) =
+ router spaceRoute:
+ get "/i/spaces/@id":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ let sp = await getCachedAudioSpace(@"id")
+
+ if sp.id.len == 0:
+ resp Http404, showError("Space not found", cfg)
+
+ let prefs = requestPrefs()
+ resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs,
+ sp.title, ogTitle=sp.title)
+
+ get "/i/spaces/@id/stream":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ let sp = await getCachedAudioSpace(@"id")
+
+ if sp.m3u8Url.len == 0:
+ resp Http404
+
+ let manifest = await safeFetch(sp.m3u8Url)
+ if manifest.len == 0:
+ resp Http502
+
+ resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime
diff --git a/src/routes/status.nim b/src/routes/status.nim
index 7e89220..32a4447 100644
--- a/src/routes/status.nim
+++ b/src/routes/status.nim
@@ -21,18 +21,18 @@ proc createStatusRouter*(cfg: Config) =
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)
- let prefs = cookiePrefs()
+ let
+ prefs = requestPrefs()
+ sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance)
# used for the infinite scroll feature
if @"scroll".len > 0:
- let replies = await getReplies(id, getCursor())
+ let replies = await getReplies(id, getCursor(), sort)
if replies.content.len == 0:
- resp Http404, ""
- resp $renderReplies(replies, prefs, getPath())
+ resp Http204
+ resp $renderReplies(replies, prefs, getPath(), sort=sort)
- let conv = await getTweet(id, getCursor())
- if conv == nil:
- echo "nil conv"
+ let conv = await getTweet(id, getCursor(), sort)
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found"
@@ -46,15 +46,19 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text
var
- images = conv.tweet.photos
+ images = conv.tweet.getPhotos.mapIt(it.url)
video = ""
- if conv.tweet.video.isSome():
- images = @[get(conv.tweet.video).thumb]
+ let
+ firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
+ else: photoMedia
+
+ if firstMediaKind == videoMedia:
+ images = @[conv.tweet.media[0].getThumb]
video = getVideoEmbed(cfg, conv.tweet.id)
- elif conv.tweet.gif.isSome():
- images = @[get(conv.tweet.gif).thumb]
- video = getPicUrl(get(conv.tweet.gif).url)
+ elif firstMediaKind == gifMedia:
+ images = @[conv.tweet.media[0].getThumb]
+ video = getPicUrl(conv.tweet.media[0].gif.url)
elif conv.tweet.card.isSome():
let card = conv.tweet.card.get()
if card.image.len > 0:
@@ -62,9 +66,33 @@ proc createStatusRouter*(cfg: Config) =
elif card.video.isSome():
images = @[card.video.get().thumb]
- let html = renderConversation(conv, prefs, getPath() & "#m")
+ let
+ tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id
+ oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl)
+
+ let html = renderConversation(conv, prefs, getPath() & "#m", sort)
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
- images=images, video=video)
+ images=images, video=video, oembed=oembedUrl)
+
+ get "/@name/status/@id/history/?":
+ cond '.' notin @"name"
+ let id = @"id"
+
+ if id.len > 19 or id.any(c => not c.isDigit):
+ resp Http404, showError("Invalid tweet ID", cfg)
+
+ let edits = await getGraphEditHistory(id)
+ if edits.latest == nil or edits.latest.id == 0:
+ resp Http404, showError("Tweet history not found", cfg)
+
+ let
+ prefs = requestPrefs()
+ title = "History for " & pageTitle(edits.latest)
+ ogTitle = "Edit History for " & pageTitle(edits.latest.user)
+ desc = edits.latest.text
+
+ let html = renderEditHistory(edits, prefs, getPath())
+ resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"]
@@ -76,6 +104,6 @@ proc createStatusRouter*(cfg: Config) =
get "/i/web/status/@id":
redirect("/i/status/" & @"id")
-
+
get "/@name/thread/@id/?":
redirect("/$1/status/$2" % [@"name", @"id"])
diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim
index 7a10e91..c7949a2 100644
--- a/src/routes/timeline.nim
+++ b/src/routes/timeline.nim
@@ -4,20 +4,39 @@ import jester, karax/vdom
import router_utils
import ".."/[types, redis_cache, formatters, query, api]
-import ../views/[general, profile, timeline, status, search]
+import ../views/[general, profile, timeline, status, search, about_account]
export vdom
export uri, sequtils
export router_utils
export redis_cache, formatters, query, api
-export profile, timeline, status
+export profile, timeline, status, about_account
-proc getQuery*(request: Request; tab, name: string): Query =
+proc tabRssEnabled*(cfg: Config; tab: string): bool =
case tab
- of "with_replies": getReplyQuery(name)
- of "media": getMediaQuery(name)
- of "search": initQuery(params(request), name=name)
- else: Query(fromUser: @[name])
+ of "": cfg.enableRSSUserTweets
+ of "with_replies": cfg.enableRSSUserReplies
+ of "media": cfg.enableRSSUserMedia
+ of "articles": cfg.enableRSSUserArticles
+ of "search": cfg.enableRSSSearch
+ else: false
+
+proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
+ let view = request.params.getOrDefault("view")
+ case tab
+ of "with_replies":
+ result = getReplyQuery(name)
+ of "articles":
+ result = getArticlesQuery(name)
+ of "media":
+ 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] =
if cond:
@@ -45,20 +64,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
let
rail =
- skipIf(skipRail or query.kind == media, @[]):
+ skipIf(skipRail or query.kind == QueryKind.media, @[]):
getCachedPhotoRail(userId)
user = getCachedUser(name)
+ info = getCachedAccountInfo(name, fetch=false)
result =
case query.kind
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
+ of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after)
else: Profile(tweets: await getGraphTweetSearch(query, after))
result.user = await user
result.photoRail = await rail
+ result.accountInfo = await info
result.tweets.query = query
@@ -105,16 +127,72 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user":
respUserId()
+ get "/intent/follow/?":
+ let username = request.params.getOrDefault("screen_name")
+ if username.len == 0:
+ resp Http400, showError("Missing screen_name parameter", cfg)
+ redirect("/" & username)
+
+ get "/@name/about/?":
+ cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
+ let
+ prefs = requestPrefs()
+ name = @"name"
+ info = await getCachedAccountInfo(name)
+ if info.suspended:
+ resp showError(getSuspended(name), cfg)
+ if info.username.len == 0:
+ resp Http404, showError("User \"" & name & "\" not found", cfg)
+ let aboutHtml = renderAboutAccount(info)
+ resp renderMain(aboutHtml, request, cfg, prefs,
+ "About @" & info.username)
+
+ get "/@name/@kind/?":
+ cond @"kind" in ["followers", "following"]
+ cond '.' notin @"name"
+ cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
+ let
+ prefs = requestPrefs()
+ name = @"name"
+ userId = await getUserId(name)
+
+ if userId.len == 0:
+ resp Http404, showError("User \"" & name & "\" not found", cfg)
+ if userId == "suspended":
+ resp showError(getSuspended(name), cfg)
+
+ let
+ cursor = getCursor()
+ isFollowers = @"kind" == "followers"
+ user = await getCachedUser(name)
+ results = if isFollowers: await getGraphFollowers(userId, cursor)
+ else: await getGraphFollowing(userId, cursor)
+
+ if user.protected:
+ resp renderMain(renderProtected(user.username), request, cfg, prefs,
+ "Protected account @" & name)
+
+ let
+ tab = if isFollowers: "Followers" else: "Following"
+ title = (if isFollowers: "People following @" else: "People followed by @") & name
+ html = renderUserList(user, results, prefs, request.path, tab)
+
+ resp renderMain(html, request, cfg, prefs, title,
+ images = @[user.getUserPic("_400x400")])
+
get "/@name/?@tab?/?":
cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
- cond @"tab" in ["with_replies", "media", "search", ""]
+ cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
+ cond @"tab" in ["with_replies", "media", "search", "articles", ""]
+ # articles can't be approximated by search, so multi-user is unsupported
+ cond not (@"tab" == "articles" and ',' in @"name")
let
- prefs = cookiePrefs()
+ prefs = requestPrefs()
after = getCursor()
names = getNames(@"name")
- var query = request.getQuery(@"tab", @"name")
+ var query = request.getQuery(@"tab", @"name", prefs)
if names.len != 1:
query.fromUser = names
@@ -122,7 +200,8 @@ proc createTimelineRouter*(cfg: Config) =
if @"scroll".len > 0:
if query.fromUser.len != 1:
var timeline = await getGraphTweetSearch(query, after)
- if timeline.content.len == 0: resp Http404
+ if timeline.content.len == 0:
+ resp Http204
timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath())
else:
@@ -132,7 +211,9 @@ proc createTimelineRouter*(cfg: Config) =
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rss =
- if @"tab".len == 0:
+ if not cfg.tabRssEnabled(@"tab"):
+ ""
+ elif @"tab".len == 0:
"/$1/rss" % @"name"
elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim
index 0c085d4..345dee7 100644
--- a/src/routes/unsupported.nim
+++ b/src/routes/unsupported.nim
@@ -10,14 +10,14 @@ export feature
proc createUnsupportedRouter*(cfg: Config) =
router unsupported:
template feature {.dirty.} =
- resp renderMain(renderFeature(), request, cfg, themePrefs())
+ resp renderMain(renderFeature(), request, cfg, requestPrefs())
get "/about/feature": feature()
get "/login/?@i?": feature()
get "/@name/lists/?": feature()
get "/intent/?@i?":
- cond @"i" notin ["user"]
+ cond @"i" notin ["user", "follow"]
feature()
get "/i/@i?/?@j?":
diff --git a/src/sass/_article.scss b/src/sass/_article.scss
new file mode 100644
index 0000000..9488658
--- /dev/null
+++ b/src/sass/_article.scss
@@ -0,0 +1,278 @@
+.article-page {
+ max-width: 700px;
+ margin: 0 auto 20px;
+ background-color: var(--bg_panel);
+
+ > .top-ref {
+ padding-top: 20px;
+ }
+
+ .article-cover {
+ width: 100%;
+ display: block;
+ }
+
+ .article-body {
+ padding: 20px;
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+
+ .article-title {
+ display: block;
+ font-size: 2rem;
+ line-height: 1.3;
+ margin: 0 0 10px;
+ color: var(--fg_color);
+ }
+
+ .article-author {
+ margin-bottom: 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--border_grey);
+ font-size: 14px;
+
+ .article-author-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .article-avatar {
+ display: flex;
+ }
+
+ .avatar {
+ width: 40px;
+ height: 40px;
+ }
+
+ .article-author-name {
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+
+ .fullname {
+ font-size: 15px;
+ }
+
+ .verified-icon {
+ margin-left: 2px;
+ }
+ }
+
+ .article-author-meta {
+ display: flex;
+ align-items: center;
+ }
+
+ .fullname {
+ font-weight: 700;
+ color: var(--fg_color);
+ max-width: unset;
+ text-overflow: unset;
+ overflow: visible;
+ white-space: normal;
+ }
+
+ .username,
+ .article-date-sep,
+ .article-date {
+ color: var(--fg_dark);
+ }
+
+ .username {
+ margin-left: 0;
+ }
+
+ .article-date-sep {
+ margin: 0 4px;
+ }
+
+ .article-date {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .tweet-stats {
+ margin-top: 6px;
+
+ .tweet-stat {
+ padding-top: 0;
+ }
+ }
+ }
+
+ > h1 {
+ display: block;
+ font-size: 1.8rem;
+ margin: 25px 0 15px;
+ }
+
+ > h2 {
+ font-size: 1.4rem;
+ font-weight: bold;
+ margin: 20px 0 12px;
+ }
+
+ > h3 {
+ font-size: 1.2rem;
+ font-weight: bold;
+ margin: 18px 0 10px;
+ }
+
+ > p {
+ font-size: 16px;
+ line-height: 1.7;
+ margin: 16px 0;
+ word-wrap: break-word;
+ }
+
+ .blockquote-attribution {
+ display: block;
+ margin-top: 0.5em;
+ }
+
+ > blockquote {
+ border-left: 3px solid var(--accent);
+ padding-left: 16px;
+ margin: 16px 0;
+ color: var(--fg_faded);
+ font-size: 16px;
+ line-height: 1.7;
+ }
+
+ > pre {
+ background-color: var(--bg_elements);
+ padding: 12px 16px;
+ border-radius: 6px;
+ overflow-x: auto;
+ margin: 16px 0;
+
+ code {
+ font-family: monospace;
+ font-size: 14px;
+ color: var(--fg_color);
+ }
+ }
+
+ code {
+ background-color: var(--bg_elements);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-family: monospace;
+ font-size: 0.9em;
+ }
+
+ > ul,
+ > ol {
+ margin: 16px 0;
+ padding-left: 2em;
+
+ li {
+ font-size: 16px;
+ line-height: 1.7;
+ margin: 6px 0;
+ }
+ }
+
+ .article-media {
+ text-align: center;
+ margin: 20px 0;
+
+ img,
+ video {
+ max-width: 100%;
+ border-radius: 12px;
+ }
+
+ .article-media-caption {
+ color: var(--fg_faded);
+ font-size: 0.875rem;
+ margin-top: 6px;
+ }
+ }
+
+ > a,
+ > p a,
+ > h1 a,
+ > h2 a,
+ > h3 a,
+ > blockquote a,
+ > ul a,
+ > ol a {
+ color: var(--accent);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .article-divider {
+ border: none;
+ border-top: 1px solid var(--border_grey);
+ margin: 30px 0;
+ }
+
+ .timeline-item {
+ margin: 20px 0;
+ border: 1px solid var(--border_grey);
+ border-radius: 12px;
+ overflow: hidden;
+ }
+ }
+}
+
+.conversation .article-page {
+ max-width: 100%;
+ margin-bottom: 0;
+}
+
+.article-card {
+ .card-image-container {
+ position: relative;
+ }
+
+ .card-image img {
+ height: auto;
+ }
+
+ .article-card-badge {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 4px;
+ }
+}
+
+.quote .article-card {
+ margin: 0;
+
+ .card-container {
+ border: none;
+ border-radius: 0;
+ border-top: solid 1px var(--dark_grey);
+ }
+}
+
+@media (max-width: 700px) {
+ .article-page {
+ .article-body {
+ padding: 12px 15px 25px;
+
+ .article-title {
+ font-size: 1.6rem;
+ }
+ }
+ }
+}
diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss
new file mode 100644
index 0000000..dd93606
--- /dev/null
+++ b/src/sass/_broadcast.scss
@@ -0,0 +1,75 @@
+.broadcast-page {
+ max-width: 800px;
+ width: 100%;
+ margin: 20px auto 0;
+}
+
+.broadcast-panel {
+ background-color: var(--bg_panel);
+ border: 1px solid var(--border_grey);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.broadcast-player {
+ position: relative;
+ background: black;
+
+ video,
+ img {
+ display: block;
+ width: 100%;
+ }
+}
+
+.broadcast-info {
+ padding: 14px 16px;
+}
+
+.broadcast-title {
+ font-size: 18px;
+ font-weight: bold;
+ margin: 0 0 12px;
+}
+
+.broadcast-user-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.broadcast-user {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--fg_color);
+
+ img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ }
+}
+
+.broadcast-username {
+ color: var(--fg_dark);
+}
+
+.broadcast-meta {
+ color: var(--fg_faded);
+ font-size: 14px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex-shrink: 0;
+ line-height: 1.5em;
+}
+
+.broadcast-live {
+ background: #e0245e;
+ color: white;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: bold;
+ font-size: 12px;
+}
diff --git a/src/sass/_space.scss b/src/sass/_space.scss
new file mode 100644
index 0000000..5fe2e7e
--- /dev/null
+++ b/src/sass/_space.scss
@@ -0,0 +1,149 @@
+.space-page {
+ max-width: 800px;
+ width: 100%;
+ margin: 20px auto 0;
+}
+
+.space-panel {
+ background-color: var(--bg_panel);
+ border: 1px solid var(--border_grey);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.space-player {
+ position: relative;
+ background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%);
+ min-height: 140px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ audio {
+ width: 100%;
+ padding: 15px;
+ box-sizing: border-box;
+
+ &:not([controls]) {
+ display: none;
+ }
+ }
+
+ .video-overlay {
+ background-color: transparent;
+ }
+}
+
+.space-live {
+ background: #e0245e;
+ color: white;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 12px;
+ text-transform: uppercase;
+ position: absolute;
+ top: 8px;
+ right: 8px;
+}
+
+.space-info {
+ padding: 16px;
+}
+
+.space-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.space-title {
+ font-size: 18px;
+ font-weight: bold;
+ margin: 0;
+ line-height: 1.3;
+ flex: 1;
+}
+
+.space-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+ font-size: 14px;
+ color: var(--fg_faded);
+}
+
+.listener-count {
+ color: var(--fg_color);
+}
+
+.space-state {
+ color: var(--fg_dark);
+}
+
+.space-participants {
+ border-top: 1px solid var(--border_grey);
+ padding-top: 12px;
+}
+
+.space-participant {
+ margin-bottom: 10px;
+
+ a {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--fg_color);
+ padding: 6px 0;
+ }
+
+ img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+}
+
+.participant-info {
+ min-width: 0;
+}
+
+.participant-name {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ strong {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .verified-icon {
+ margin-bottom: 0;
+ position: relative;
+ top: -2px;
+ }
+}
+
+.host-badge {
+ background: var(--accent);
+ color: white;
+ padding: 2px 7px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ position: relative;
+ top: 1px;
+}
+
+.participant-username {
+ color: var(--fg_dark);
+ font-size: 13px;
+}
diff --git a/src/sass/general.scss b/src/sass/general.scss
index 9feb3d3..e6247d6 100644
--- a/src/sass/general.scss
+++ b/src/sass/general.scss
@@ -1,39 +1,40 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
.panel-container {
- margin: auto;
- font-size: 130%;
+ margin: auto;
+ font-size: 130%;
}
.error-panel {
- @include center-panel(var(--error_red));
- text-align: center;
+ @include center-panel(var(--error_red));
+ text-align: center;
}
.search-bar > form {
- @include center-panel(var(--darkest_grey));
+ @include center-panel(var(--darkest_grey));
- button {
- background: var(--bg_elements);
- color: var(--fg_color);
- border: 0;
- border-radius: 3px;
- cursor: pointer;
- font-weight: bold;
- width: 30px;
- height: 30px;
- }
+ button {
+ background: var(--bg_elements);
+ color: var(--fg_color);
+ border: 0;
+ border-radius: 3px;
+ cursor: pointer;
+ font-weight: bold;
+ width: 30px;
+ height: 30px;
+ padding: 0px 5px 1px 8px;
+ }
- input {
- font-size: 16px;
- width: 100%;
- background: var(--bg_elements);
- color: var(--fg_color);
- border: 0;
- border-radius: 4px;
- padding: 4px;
- margin-right: 8px;
- height: unset;
- }
+ input {
+ font-size: 16px;
+ width: 100%;
+ background: var(--bg_elements);
+ color: var(--fg_color);
+ border: 0;
+ border-radius: 4px;
+ padding: 4px;
+ margin-right: 8px;
+ height: unset;
+ }
}
diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css
index 94e11ee..5fde51a 100644
--- a/src/sass/include/_mixins.css
+++ b/src/sass/include/_mixins.css
@@ -66,18 +66,7 @@
}
#search-panel-toggle:checked ~ .search-panel {
- @if $rows == 6 {
- max-height: 200px !important;
- }
- @if $rows == 5 {
- max-height: 300px !important;
- }
- @if $rows == 4 {
- max-height: 300px !important;
- }
- @if $rows == 3 {
- max-height: 365px !important;
- }
+ max-height: 380px !important;
}
}
}
diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss
index 0c95ff6..127cccb 100644
--- a/src/sass/include/_variables.scss
+++ b/src/sass/include/_variables.scss
@@ -1,46 +1,43 @@
// colors
-$bg_color: #0F0F0F;
-$fg_color: #F8F8F2;
-$fg_faded: #F8F8F2CF;
-$fg_dark: #FF6C60;
-$fg_nav: #FF6C60;
+$bg_color: #0f0f0f;
+$fg_color: #f8f8f2;
+$fg_faded: #f8f8f2cf;
+$fg_dark: #ff6c60;
+$fg_nav: #ff6c60;
$bg_panel: #161616;
$bg_elements: #121212;
-$bg_overlays: #1F1F1F;
-$bg_hover: #1A1A1A;
+$bg_overlays: #1f1f1f;
+$bg_hover: #1a1a1a;
$grey: #888889;
$dark_grey: #404040;
$darker_grey: #282828;
$darkest_grey: #222222;
-$border_grey: #3E3E35;
+$border_grey: #3e3e35;
-$accent: #FF6C60;
-$accent_light: #FFACA0;
-$accent_dark: #8A3731;
-$accent_border: #FF6C6091;
+$accent: #ff6c60;
+$accent_light: #ffaca0;
+$accent_dark: #8a3731;
+$accent_border: #ff6c6091;
-$play_button: #D8574D;
-$play_button_hover: #FF6C60;
+$play_button: #d8574d;
+$play_button_hover: #ff6c60;
-$more_replies_dots: #AD433B;
-$error_red: #420A05;
+$more_replies_dots: #ad433b;
+$error_red: #420a05;
-$verified_blue: #1DA1F2;
-$verified_business: #FAC82B;
-$verified_government: #C1B6A4;
+$verified_blue: #1da1f2;
+$verified_business: #fac82b;
+$verified_government: #c1b6a4;
$icon_text: $fg_color;
$tab: $fg_color;
$tab_selected: $accent;
-$shadow: rgba(0,0,0,.6);
-$shadow_dark: rgba(0,0,0,.2);
+$shadow: rgba(0, 0, 0, 0.6);
+$shadow_dark: rgba(0, 0, 0, 0.2);
//fonts
-$font_0: Helvetica Neue;
-$font_1: Helvetica;
-$font_2: Arial;
-$font_3: sans-serif;
-$font_4: fontello;
+$font_0: sans-serif;
+$font_1: fontello;
diff --git a/src/sass/index.scss b/src/sass/index.scss
index 6cab48e..404f7d5 100644
--- a/src/sass/index.scss
+++ b/src/sass/index.scss
@@ -1,180 +1,220 @@
-@import '_variables';
+@import "_variables";
-@import 'tweet/_base';
-@import 'profile/_base';
-@import 'general';
-@import 'navbar';
-@import 'inputs';
-@import 'timeline';
-@import 'search';
+@import "tweet/_base";
+@import "profile/_base";
+@import "general";
+@import "navbar";
+@import "inputs";
+@import "timeline";
+@import "search";
+@import "broadcast";
+@import "space";
+@import "_article";
body {
- // colors
- --bg_color: #{$bg_color};
- --fg_color: #{$fg_color};
- --fg_faded: #{$fg_faded};
- --fg_dark: #{$fg_dark};
- --fg_nav: #{$fg_nav};
+ // colors
+ --bg_color: #{$bg_color};
+ --fg_color: #{$fg_color};
+ --fg_faded: #{$fg_faded};
+ --fg_dark: #{$fg_dark};
+ --fg_nav: #{$fg_nav};
- --bg_panel: #{$bg_panel};
- --bg_elements: #{$bg_elements};
- --bg_overlays: #{$bg_overlays};
- --bg_hover: #{$bg_hover};
+ --bg_panel: #{$bg_panel};
+ --bg_elements: #{$bg_elements};
+ --bg_overlays: #{$bg_overlays};
+ --bg_hover: #{$bg_hover};
- --grey: #{$grey};
- --dark_grey: #{$dark_grey};
- --darker_grey: #{$darker_grey};
- --darkest_grey: #{$darkest_grey};
- --border_grey: #{$border_grey};
+ --grey: #{$grey};
+ --dark_grey: #{$dark_grey};
+ --darker_grey: #{$darker_grey};
+ --darkest_grey: #{$darkest_grey};
+ --border_grey: #{$border_grey};
- --accent: #{$accent};
- --accent_light: #{$accent_light};
- --accent_dark: #{$accent_dark};
- --accent_border: #{$accent_border};
+ --accent: #{$accent};
+ --accent_light: #{$accent_light};
+ --accent_dark: #{$accent_dark};
+ --accent_border: #{$accent_border};
- --play_button: #{$play_button};
- --play_button_hover: #{$play_button_hover};
+ --play_button: #{$play_button};
+ --play_button_hover: #{$play_button_hover};
- --more_replies_dots: #{$more_replies_dots};
- --error_red: #{$error_red};
+ --more_replies_dots: #{$more_replies_dots};
+ --error_red: #{$error_red};
- --verified_blue: #{$verified_blue};
- --verified_business: #{$verified_business};
- --verified_government: #{$verified_government};
- --icon_text: #{$icon_text};
+ --verified_blue: #{$verified_blue};
+ --verified_business: #{$verified_business};
+ --verified_government: #{$verified_government};
+ --icon_text: #{$icon_text};
- --tab: #{$fg_color};
- --tab_selected: #{$accent};
+ --tab: #{$fg_color};
+ --tab_selected: #{$accent};
- --profile_stat: #{$fg_color};
+ --profile_stat: #{$fg_color};
- background-color: var(--bg_color);
- color: var(--fg_color);
- font-family: $font_0, $font_1, $font_2, $font_3;
- font-size: 14px;
- line-height: 1.3;
- margin: 0;
+ background-color: var(--bg_color);
+ color: var(--fg_color);
+ font-family: $font_0, $font_1;
+ font-size: 15px;
+ line-height: 1.3;
+ margin: 0;
}
* {
- outline: unset;
- margin: 0;
- text-decoration: none;
+ outline: unset;
+ margin: 0;
+ text-decoration: none;
+}
+
+img {
+ dynamic-range-limit: standard;
}
h1 {
- display: inline;
+ display: inline;
}
-h2, h3 {
- font-weight: normal;
+h2,
+h3 {
+ font-weight: normal;
}
p {
- margin: 14px 0;
+ margin: 14px 0;
}
a {
- color: var(--accent);
+ color: var(--accent);
- &:hover {
- text-decoration: underline;
- }
+ &:hover {
+ text-decoration: underline;
+ }
}
fieldset {
- border: 0;
- padding: 0;
- margin-top: -0.6em;
+ border: 0;
+ padding: 0;
+ margin-top: -0.6em;
}
legend {
- width: 100%;
- padding: .6em 0 .3em 0;
- border: 0;
- font-size: 16px;
- font-weight: 600;
- border-bottom: 1px solid var(--border_grey);
- margin-bottom: 8px;
+ width: 100%;
+ padding: 0.6em 0 0.3em 0;
+ border: 0;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border_grey);
+ margin-bottom: 8px;
}
-.preferences .note {
+.preferences {
+ .note {
border-top: 1px solid var(--border_grey);
border-bottom: 1px solid var(--border_grey);
padding: 6px 0 8px 0;
margin-bottom: 8px;
margin-top: 16px;
+ }
+
+ .bookmark-note {
+ margin: 0;
+ margin-bottom: 10px;
+ }
}
ul {
- padding-left: 1.3em;
+ padding-left: 1.3em;
}
.container {
- display: flex;
- flex-wrap: wrap;
- box-sizing: border-box;
- padding-top: 50px;
- margin: auto;
- min-height: 100vh;
+ display: flex;
+ flex-wrap: wrap;
+ box-sizing: border-box;
+ margin: auto;
+ min-height: 100vh;
+}
+
+body.fixed-nav .container {
+ padding-top: 50px;
}
.icon-container {
- display: inline;
+ display: inline;
}
.overlay-panel {
- max-width: 600px;
- width: 100%;
- margin: 0 auto;
- margin-top: 10px;
- background-color: var(--bg_overlays);
- padding: 10px 15px;
- align-self: start;
+ max-width: 600px;
+ width: 100%;
+ margin: 0 auto;
+ margin-top: 10px;
+ background-color: var(--bg_overlays);
+ padding: 10px 15px;
+ align-self: start;
- ul {
- margin-bottom: 14px;
- }
+ ul {
+ margin-bottom: 14px;
+ }
- p {
- word-break: break-word;
- }
+ p {
+ word-break: break-word;
+ }
}
.verified-icon {
- color: var(--icon_text);
- border-radius: 50%;
- flex-shrink: 0;
- margin: 2px 0 3px 3px;
- padding-top: 3px;
- height: 11px;
- width: 14px;
- font-size: 8px;
- display: inline-block;
- text-align: center;
- vertical-align: middle;
+ display: inline-block;
+ position: relative;
+ width: 14px;
+ height: 14px;
+ margin-bottom: 2px;
- &.blue {
- background-color: var(--verified_blue);
+ .verified-icon-circle {
+ position: absolute;
+ font-size: 15px;
+ }
+
+ .verified-icon-check {
+ position: absolute;
+ font-size: 9px;
+ margin: 5px 3px;
+ }
+
+ &.blue {
+ .verified-icon-circle {
+ color: var(--verified_blue);
}
- &.business {
- color: var(--bg_panel);
- background-color: var(--verified_business);
+ .verified-icon-check {
+ color: var(--icon_text);
+ }
+ }
+
+ &.business {
+ .verified-icon-circle {
+ color: var(--verified_business);
}
- &.government {
- color: var(--bg_panel);
- background-color: var(--verified_government);
+ .verified-icon-check {
+ color: var(--bg_panel);
}
+ }
+
+ &.government {
+ .verified-icon-circle {
+ color: var(--verified_government);
+ }
+
+ .verified-icon-check {
+ color: var(--bg_panel);
+ }
+ }
}
-@media(max-width: 600px) {
- .preferences-container {
- max-width: 95vw;
- }
+@media (max-width: 600px) {
+ .preferences-container {
+ max-width: 95vw;
+ }
- .nav-item, .nav-item .icon-container {
- font-size: 16px;
- }
+ .nav-item,
+ .nav-item .icon-container {
+ font-size: 16px;
+ }
}
diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss
index 17c2a22..2b6016f 100644
--- a/src/sass/inputs.scss
+++ b/src/sass/inputs.scss
@@ -1,185 +1,216 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
button {
- @include input-colors;
- background-color: var(--bg_elements);
- color: var(--fg_color);
- border: 1px solid var(--accent_border);
- padding: 3px 6px;
- font-size: 14px;
- cursor: pointer;
- float: right;
+ @include input-colors;
+ background-color: var(--bg_elements);
+ color: var(--fg_color);
+ border: 1px solid var(--accent_border);
+ padding: 3px 6px;
+ font-size: 14px;
+ cursor: pointer;
+ float: right;
}
input[type="text"],
input[type="date"],
+input[type="number"],
select {
- @include input-colors;
- background-color: var(--bg_elements);
- padding: 1px 4px;
- color: var(--fg_color);
- border: 1px solid var(--accent_border);
- border-radius: 0;
- font-size: 14px;
+ @include input-colors;
+ background-color: var(--bg_elements);
+ padding: 1px 4px;
+ color: var(--fg_color);
+ border: 1px solid var(--accent_border);
+ border-radius: 0;
+ font-size: 14px;
}
-input[type="text"] {
- height: 16px;
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+input[type="text"],
+input[type="number"] {
+ height: 16px;
}
select {
- height: 20px;
- padding: 0 2px;
- line-height: 1;
+ height: 20px;
+ padding: 0 2px;
+ line-height: 1;
}
input[type="date"]::-webkit-inner-spin-button {
- display: none;
+ display: none;
+}
+
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ display: none;
+ -webkit-appearance: none;
+ margin: 0;
}
input[type="date"]::-webkit-clear-button {
- margin-left: 17px;
- filter: grayscale(100%);
- filter: hue-rotate(120deg);
+ margin-left: 17px;
+ filter: grayscale(100%);
+ filter: hue-rotate(120deg);
}
input::-webkit-calendar-picker-indicator {
- opacity: 0;
+ opacity: 0;
}
input::-webkit-datetime-edit-day-field:focus,
input::-webkit-datetime-edit-month-field:focus,
input::-webkit-datetime-edit-year-field:focus {
- background-color: var(--accent);
- color: var(--fg_color);
- outline: none;
+ background-color: var(--accent);
+ color: var(--fg_color);
+ outline: none;
}
.date-range {
- .date-input {
- display: inline-block;
- position: relative;
- }
+ .date-input {
+ display: inline-block;
+ position: relative;
+ }
- .icon-container {
- pointer-events: none;
- position: absolute;
- top: 2px;
- right: 5px;
- }
+ .icon-container {
+ pointer-events: none;
+ position: absolute;
+ top: 2px;
+ right: 5px;
+ }
- .search-title {
- margin: 0 2px;
- }
+ .search-title {
+ margin: 0 2px;
+ }
}
.icon-button button {
- color: var(--accent);
- text-decoration: none;
- background: none;
- border: none;
- float: none;
- padding: unset;
- padding-left: 4px;
+ color: var(--accent);
+ text-decoration: none;
+ background: none;
+ border: none;
+ float: none;
+ padding: unset;
+ padding-left: 4px;
- &:hover {
- color: var(--accent_light);
- }
+ &:hover {
+ color: var(--accent_light);
+ }
}
.checkbox {
- position: absolute;
- top: 1px;
- right: 0;
- height: 17px;
- width: 17px;
- background-color: var(--bg_elements);
- border: 1px solid var(--accent_border);
+ position: absolute;
+ top: 1px;
+ right: 0;
+ height: 17px;
+ width: 17px;
+ background-color: var(--bg_elements);
+ border: 1px solid var(--accent_border);
- &:after {
- content: "";
- position: absolute;
- display: none;
- }
+ &:after {
+ content: "";
+ position: absolute;
+ display: none;
+ }
}
.checkbox-container {
- display: block;
- position: relative;
- margin-bottom: 5px;
+ display: block;
+ position: relative;
+ margin-bottom: 5px;
+ cursor: pointer;
+ user-select: none;
+ padding-right: 22px;
+
+ input {
+ position: absolute;
+ opacity: 0;
cursor: pointer;
- user-select: none;
- padding-right: 22px;
+ height: 0;
+ width: 0;
- input {
- position: absolute;
- opacity: 0;
- cursor: pointer;
- height: 0;
- width: 0;
-
- &:checked ~ .checkbox:after {
- display: block;
- }
+ &:checked ~ .checkbox:after {
+ display: block;
}
+ }
- &:hover input ~ .checkbox {
- border-color: var(--accent);
- }
+ &:hover input ~ .checkbox {
+ border-color: var(--accent);
+ }
- &:active input ~ .checkbox {
- border-color: var(--accent_light);
- }
+ &:active input ~ .checkbox {
+ border-color: var(--accent_light);
+ }
- .checkbox:after {
- left: 2px;
- bottom: 0;
- font-size: 13px;
- font-family: $font_4;
- content: '\e803';
- }
+ .checkbox:after {
+ left: 2px;
+ bottom: 0;
+ font-size: 13px;
+ font-family: $font_1;
+ content: "\e811";
+ }
}
.pref-group {
- display: inline;
+ display: inline;
}
.preferences {
- button {
- margin: 6px 0 3px 0;
- }
+ button {
+ margin: 6px 0 3px 0;
+ }
- label {
- padding-right: 150px;
- }
+ label {
+ padding-right: 150px;
+ }
- select {
- position: absolute;
- top: 0;
- right: 0;
- display: block;
- -moz-appearance: none;
- -webkit-appearance: none;
- appearance: none;
- }
+ select {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: block;
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ appearance: none;
+ min-width: 100px;
+ }
- input[type="text"] {
- position: absolute;
- right: 0;
- max-width: 140px;
- }
+ input[type="text"],
+ input[type="number"] {
+ position: absolute;
+ right: 0;
+ max-width: 140px;
+ }
- .pref-group {
- display: block;
- }
+ .pref-group {
+ display: block;
+ }
- .pref-input {
- position: relative;
- margin-bottom: 6px;
- }
+ .pref-input {
+ position: relative;
+ margin-bottom: 6px;
+ }
- .pref-reset {
- float: left;
- }
+ .pref-reset {
+ float: left;
+ }
+
+ .prefs-code {
+ background-color: var(--bg_elements);
+ border: 1px solid var(--accent_border);
+ color: var(--fg_color);
+ font-size: 13px;
+ padding: 6px 8px;
+ margin: 4px 0;
+ word-break: break-all;
+ white-space: pre-wrap;
+ user-select: all;
+ }
}
diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss
index 47a8765..c999022 100644
--- a/src/sass/navbar.scss
+++ b/src/sass/navbar.scss
@@ -1,89 +1,90 @@
-@import '_variables';
+@import "_variables";
nav {
- display: flex;
- align-items: center;
- position: fixed;
- background-color: var(--bg_overlays);
- box-shadow: 0 0 4px $shadow;
- padding: 0;
- width: 100%;
- height: 50px;
- z-index: 1000;
- font-size: 16px;
+ display: flex;
+ align-items: center;
+ background-color: var(--bg_overlays);
+ box-shadow: 0 0 4px $shadow;
+ padding: 0;
+ width: 100%;
+ height: 50px;
+ z-index: 1000;
+ font-size: 16px;
- a, .icon-button button {
- color: var(--fg_nav);
- }
+ a,
+ .icon-button button {
+ color: var(--fg_nav);
+ }
+
+ body.fixed-nav & {
+ position: fixed;
+ }
}
.inner-nav {
- margin: auto;
- box-sizing: border-box;
- padding: 0 10px;
- display: flex;
- align-items: center;
- flex-basis: 920px;
- height: 50px;
+ margin: auto;
+ box-sizing: border-box;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ flex-basis: 920px;
+ height: 50px;
}
.site-name {
- font-size: 15px;
- font-weight: 600;
- line-height: 1;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1;
- &:hover {
- color: var(--accent_light);
- text-decoration: unset;
- }
+ &:hover {
+ color: var(--accent_light);
+ text-decoration: unset;
+ }
}
.site-logo {
- display: block;
- width: 35px;
- height: 35px;
+ display: block;
+ width: 35px;
+ height: 35px;
}
.nav-item {
- display: flex;
- flex: 1;
- line-height: 50px;
- height: 50px;
- overflow: hidden;
- flex-wrap: wrap;
- align-items: center;
+ display: flex;
+ flex: 1;
+ line-height: 50px;
+ height: 50px;
+ overflow: hidden;
+ flex-wrap: wrap;
+ align-items: center;
- &.right {
- text-align: right;
- justify-content: flex-end;
- }
+ &.right {
+ text-align: right;
+ justify-content: flex-end;
+ }
- &.right a {
- padding-left: 4px;
-
- &:hover {
- color: var(--accent_light);
- text-decoration: unset;
- }
- }
+ &.right a:hover {
+ color: var(--accent_light);
+ text-decoration: unset;
+ }
}
.lp {
- height: 14px;
- display: inline-block;
- position: relative;
- top: 2px;
- fill: var(--fg_nav);
+ height: 14px;
+ display: inline-block;
+ position: relative;
+ top: 2px;
+ fill: var(--fg_nav);
- &:hover {
- fill: var(--accent_light);
- }
+ &:hover {
+ fill: var(--accent_light);
+ }
}
-.icon-info:before {
- margin: 0 -3px;
+.icon-info {
+ margin: 0 -3px;
}
.icon-cog {
- font-size: 15px;
+ font-size: 15px;
+ padding-left: 0 !important;
}
diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss
index b7f33e6..81b3d78 100644
--- a/src/sass/profile/_base.scss
+++ b/src/sass/profile/_base.scss
@@ -1,83 +1,118 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
-@import 'card';
-@import 'photo-rail';
+@import "card";
+@import "about-account";
+@import "photo-rail";
+@import "community";
.profile-tabs {
- @include panel(auto, 900px);
+ @include panel(auto, 900px);
- .timeline-container {
- float: right;
- width: 68% !important;
- max-width: unset;
- }
+ .timeline-container {
+ float: right;
+ width: 68% !important;
+ max-width: unset;
+ }
}
.profile-banner {
- margin-bottom: 4px;
- background-color: var(--bg_panel);
+ margin-bottom: 4px;
+ background-color: var(--bg_panel);
- a {
- display: block;
- position: relative;
- padding: 33.34% 0 0 0;
- }
+ a {
+ display: block;
+ position: relative;
+ padding: 33.34% 0 0 0;
+ }
- img {
- max-width: 100%;
- position: absolute;
- top: 0;
- }
+ img {
+ max-width: 100%;
+ position: absolute;
+ top: 0;
+ }
}
.profile-tab {
- padding: 0 4px 0 0;
- box-sizing: border-box;
- display: inline-block;
- font-size: 14px;
- text-align: left;
- vertical-align: top;
- max-width: 32%;
+ padding: 0 4px 0 0;
+ box-sizing: border-box;
+ display: inline-block;
+ font-size: 14px;
+ text-align: left;
+ vertical-align: top;
+ max-width: 32%;
+ top: 0;
+
+ body.fixed-nav & {
top: 50px;
+ }
}
.profile-result {
- min-height: 54px;
+ min-height: 54px;
- .username {
- margin: 0 !important;
- }
+ .username {
+ margin: 0 !important;
+ }
- .tweet-header {
- margin-bottom: unset;
- }
+ .tweet-header {
+ margin-bottom: unset;
+ }
}
-@media(max-width: 700px) {
- .profile-tabs {
- width: 100vw;
- max-width: 600px;
+.profile-tabs.media-only {
+ max-width: none;
+ width: 100%;
- .timeline-container {
- width: 100% !important;
+ .timeline-container {
+ float: none;
+ width: 100% !important;
+ max-width: none;
+ padding: 0 10px;
+ box-sizing: border-box;
+ }
- .tab-item wide {
- flex-grow: 1.4;
- }
- }
+ .timeline-container > .tab {
+ max-width: 900px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+@media (max-width: 700px) {
+ .profile-tabs {
+ width: 100vw;
+ max-width: 600px;
+
+ .timeline-container {
+ width: 100% !important;
+
+ .tab-item wide {
+ flex-grow: 1.4;
+ }
}
+ }
- .profile-tab {
- width: 100%;
- max-width: unset;
- position: initial !important;
- padding: 0;
+ .profile-tabs.media-only {
+ width: 100%;
+ max-width: none;
+
+ .timeline-container {
+ width: 100vw !important;
+ padding: 0;
}
+ }
+
+ .profile-tab {
+ width: 100%;
+ max-width: unset;
+ position: initial !important;
+ padding: 0;
+ }
}
@media (min-height: 900px) {
- .profile-tab.sticky {
- position: sticky;
- }
+ .profile-tab.sticky {
+ position: sticky;
+ }
}
diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss
new file mode 100644
index 0000000..92d13c9
--- /dev/null
+++ b/src/sass/profile/_community.scss
@@ -0,0 +1,203 @@
+.community-header {
+ padding: 12px 15px;
+ border-bottom: 1px solid var(--border_grey);
+ background-color: var(--bg_panel);
+
+ .community-name {
+ font-size: 22px;
+ margin-bottom: 6px;
+
+ a {
+ color: inherit;
+ }
+ }
+
+ .community-category {
+ display: inline-block;
+ background-color: var(--bg_elements);
+ border: 1px solid var(--border_grey);
+ border-radius: 16px;
+ padding: 2px 12px;
+ font-size: 13px;
+ color: var(--fg_faded);
+ margin-bottom: 8px;
+ }
+
+ .community-description {
+ color: var(--fg_faded);
+ margin-bottom: 8px;
+ line-height: 1.4;
+ }
+
+ .community-member-count {
+ font-weight: bold;
+ color: inherit;
+ }
+
+ .community-stats {
+ color: var(--grey);
+ font-size: 14px;
+ }
+}
+
+.community-about {
+ padding: 16px 15px 15px;
+ background-color: var(--bg_panel);
+
+ h2 {
+ font-size: 18px;
+ margin: 0 0 12px;
+ }
+
+ .community-info {
+ border-bottom: 1px solid var(--border_grey);
+ padding-bottom: 12px;
+ }
+
+ .community-info-item {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ align-items: center;
+
+ .verified-icon {
+ margin-left: 2px;
+ }
+
+ > .icon-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ color: var(--grey);
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ }
+
+ strong {
+ color: var(--fg_color);
+ }
+
+ a {
+ color: var(--accent);
+ }
+ }
+
+ .community-rules {
+ border-bottom: 1px solid var(--border_grey);
+ padding: 16px 0 12px;
+ }
+
+ .community-rules-intro {
+ color: var(--fg_faded);
+ font-size: 14px;
+ margin: 0 0 12px;
+ }
+
+ .community-rule {
+ display: flex;
+ gap: 10px;
+ padding: 10px 0;
+ align-items: flex-start;
+
+ .community-rule-number {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background-color: var(--accent);
+ color: var(--fg_color);
+ font-weight: bold;
+ font-size: 13px;
+ flex-shrink: 0;
+ }
+
+ .community-rule-content p {
+ margin: 4px 0 0;
+ color: var(--fg_faded);
+ font-size: 14px;
+ }
+ }
+
+ .community-moderators {
+ padding-top: 16px;
+
+ h2 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .community-mods-link {
+ font-size: 14px;
+ font-weight: normal;
+ color: var(--accent);
+ }
+ }
+
+ .community-moderator {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ align-items: center;
+
+ .community-mod-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ }
+
+ .community-mod-info {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .community-mod-name {
+ display: flex;
+ align-items: center;
+ font-weight: bold;
+ color: var(--fg_color);
+
+ .verified-icon {
+ margin-left: 2px;
+ }
+ }
+
+ .community-mod-username {
+ color: var(--fg_faded);
+ font-size: 14px;
+ }
+ }
+}
+
+.community-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 15px;
+ border-bottom: 1px solid var(--border_grey);
+
+ .community-tag {
+ display: inline-block;
+ background-color: var(--bg_elements);
+ border: 1px solid var(--border_grey);
+ border-radius: 16px;
+ padding: 4px 12px;
+ font-size: 13px;
+ color: var(--accent);
+ }
+}
+
+.community-hashtag-header {
+ padding: 12px 15px;
+ border-bottom: 1px solid var(--border_grey);
+
+ .community-hashtag-title {
+ font-size: 20px;
+ color: var(--accent);
+ margin: 0;
+ }
+}
diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss
new file mode 100644
index 0000000..aa12f49
--- /dev/null
+++ b/src/sass/profile/about-account.scss
@@ -0,0 +1,71 @@
+@import '_variables';
+
+.about-account {
+ max-width: 500px;
+ width: 100%;
+ margin: 20px auto 0;
+ align-self: flex-start;
+ background: var(--bg_panel);
+ border-radius: 4px;
+ padding: 12px 20px 20px;
+}
+
+.about-account-header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 16px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--border_grey);
+}
+
+.about-account-avatar img {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ margin-bottom: 4px;
+}
+
+.about-account-name {
+ @include breakable;
+ font-weight: bold;
+}
+
+.about-account-body {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.about-account-at {
+ font-size: 18px;
+ font-weight: bold;
+}
+
+.about-account-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ > span:first-child {
+ color: var(--fg_faded);
+ flex-shrink: 0;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.about-account-label {
+ color: var(--fg_faded);
+ font-size: 13px;
+}
+
+@media(max-width: 700px) {
+ .about-account {
+ max-width: none;
+ margin: 10px;
+ }
+}
diff --git a/src/sass/search.scss b/src/sass/search.scss
index f70f7ea..fa2a2d8 100644
--- a/src/sass/search.scss
+++ b/src/sass/search.scss
@@ -1,122 +1,194 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
.search-title {
- font-weight: bold;
- display: inline-block;
- margin-top: 4px;
+ font-weight: bold;
+ display: inline-block;
+ margin-top: 4px;
}
.search-field {
+ display: flex;
+ flex-wrap: wrap;
+
+ button {
+ margin: 0 2px 0 0;
+ padding: 0px 1px 1px 4px;
+ height: 23px;
display: flex;
- flex-wrap: wrap;
+ align-items: center;
+ }
- button {
- margin: 0 2px 0 0;
- height: 23px;
- display: flex;
- align-items: center;
- }
+ .pref-input {
+ margin: 0 4px 0 0;
+ flex-grow: 1;
+ height: 23px;
+ }
- .pref-input {
- margin: 0 4px 0 0;
- flex-grow: 1;
- height: 23px;
- }
+ input[type="text"],
+ input[type="number"] {
+ height: calc(100% - 4px);
+ width: calc(100% - 8px);
+ }
- input[type="text"] {
- height: calc(100% - 4px);
- width: calc(100% - 8px);
- }
+ > label {
+ display: inline;
+ background-color: var(--bg_elements);
+ color: var(--fg_color);
+ border: 1px solid var(--accent_border);
+ padding: 1px 1px 2px 4px;
+ font-size: 14px;
+ cursor: pointer;
+ margin-bottom: 2px;
- > label {
- display: inline;
- background-color: var(--bg_elements);
- color: var(--fg_color);
- border: 1px solid var(--accent_border);
- padding: 1px 6px 2px 6px;
- font-size: 14px;
- cursor: pointer;
- margin-bottom: 2px;
+ @include input-colors;
+ }
- @include input-colors;
- }
-
- @include create-toggle(search-panel, 200px);
+ @include create-toggle(search-panel, 380px);
}
.search-panel {
- width: 100%;
- max-height: 0;
- overflow: hidden;
- transition: max-height 0.4s;
+ width: 100%;
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.4s;
- flex-grow: 1;
- font-weight: initial;
- text-align: left;
+ flex-grow: 1;
+ font-weight: initial;
+ text-align: left;
- > div {
- line-height: 1.7em;
- }
+ .checkbox-container {
+ display: inline;
+ padding-right: unset;
+ margin-bottom: 5px;
+ margin-left: 23px;
+ }
- .checkbox-container {
- display: inline;
- padding-right: unset;
- margin-bottom: unset;
- margin-left: 23px;
- }
+ .checkbox {
+ right: unset;
+ left: -22px;
+ line-height: 1.6em;
+ }
- .checkbox {
- right: unset;
- left: -22px;
- }
-
- .checkbox-container .checkbox:after {
- top: -4px;
- }
+ .checkbox-container .checkbox:after {
+ top: -4px;
+ }
}
.search-row {
- display: flex;
- flex-wrap: wrap;
- line-height: unset;
+ display: flex;
+ flex-wrap: wrap;
+ line-height: unset;
- > div {
- flex-grow: 1;
- flex-shrink: 1;
- }
+ > div {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+
+ input {
+ height: 21px;
+ }
+
+ .pref-input {
+ display: block;
+ padding-bottom: 5px;
input {
- height: 21px;
- }
-
- .pref-input {
- display: block;
- padding-bottom: 5px;
-
- input {
- height: 21px;
- margin-top: 1px;
- }
+ height: 21px;
+ margin-top: 1px;
}
+ }
}
.search-toggles {
- flex-grow: 1;
- display: grid;
- grid-template-columns: repeat(6, auto);
- grid-column-gap: 10px;
+ flex-grow: 1;
+ display: grid;
+ grid-template-columns: repeat(5, auto);
+ grid-column-gap: 10px;
+}
+
+.list-result {
+ display: flex;
+ align-items: flex-start;
+
+ .list-result-banner {
+ flex-shrink: 0;
+ width: 56px;
+ height: 56px;
+ margin-right: 10px;
+ border-radius: 8px;
+ overflow: hidden;
+ background-color: var(--darker_grey);
+ // stay above the tweet-link overlay's hover background
+ z-index: 1;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .list-result-body {
+ min-width: 0;
+ pointer-events: none;
+ z-index: 1;
+
+ a {
+ pointer-events: all;
+ }
+ }
+
+ .list-result-title {
+ align-items: baseline;
+ }
+
+ .list-members {
+ flex-shrink: 0;
+ margin-left: 0.3em;
+ color: var(--fg_faded);
+ }
+
+ .list-result-context {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: 2px;
+ color: var(--fg_faded);
+
+ a {
+ color: var(--fg_dark);
+ }
+
+ a.fullname {
+ color: var(--fg_color);
+ }
+
+ .list-facepile {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ margin-right: 4px;
+ }
+ }
+
+ .list-result-description {
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: all;
+ }
}
.profile-tabs {
- @include search-resize(820px, 5);
- @include search-resize(725px, 4);
- @include search-resize(600px, 6);
- @include search-resize(560px, 5);
- @include search-resize(480px, 4);
- @include search-resize(410px, 3);
+ @include search-resize(820px, 5);
+ @include search-resize(715px, 4);
+ @include search-resize(700px, 5);
+ @include search-resize(485px, 4);
+ @include search-resize(410px, 3);
}
-@include search-resize(560px, 5);
-@include search-resize(480px, 4);
+@include search-resize(700px, 5);
+@include search-resize(485px, 4);
@include search-resize(410px, 3);
diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss
index c8ce309..b7d4a9f 100644
--- a/src/sass/timeline.scss
+++ b/src/sass/timeline.scss
@@ -1,162 +1,505 @@
-@import '_variables';
+@import "_variables";
.timeline-container {
- @include panel(100%, 600px);
+ @include panel(100%, 600px);
}
-.timeline {
- background-color: var(--bg_panel);
+.timeline-container.media-only {
+ max-width: none;
+ width: 100%;
+ padding: 0 10px;
+ box-sizing: border-box;
- > div:not(:first-child) {
- border-top: 1px solid var(--border_grey);
- }
+ > .tab,
+ > .timeline-header {
+ max-width: 900px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+@media (max-width: 700px) {
+ .timeline-container.media-only {
+ padding: 0;
+ }
+}
+
+.timeline > div:not(:first-child) {
+ border-top: 1px solid var(--border_grey);
}
.timeline-header {
- width: 100%;
- background-color: var(--bg_panel);
- text-align: center;
- padding: 8px;
- display: block;
- font-weight: bold;
- margin-bottom: 5px;
- box-sizing: border-box;
+ width: 100%;
+ background-color: var(--bg_panel);
+ text-align: center;
+ padding: 8px;
+ display: block;
+ font-weight: bold;
+ margin-bottom: 4px;
+ box-sizing: border-box;
- button {
- float: unset;
- }
+ button {
+ float: unset;
+ }
}
.timeline-banner img {
- width: 100%;
+ width: 100%;
}
.timeline-description {
- font-weight: normal;
+ font-weight: normal;
}
.tab {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- list-style: none;
- margin: 0 0 5px 0;
- background-color: var(--bg_panel);
- padding: 0;
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ list-style: none;
+ margin: 0 0 4px 0;
+ background-color: var(--bg_panel);
+ padding: 0;
}
.tab-item {
- flex: 1 1 0;
- text-align: center;
- margin-top: 0;
+ flex: 1 1 0;
+ text-align: center;
+ margin-top: 0;
- a {
- border-bottom: .1rem solid transparent;
- color: var(--tab);
- display: block;
- padding: 8px 0;
- text-decoration: none;
- font-weight: bold;
+ a {
+ border-bottom: 0.1rem solid transparent;
+ color: var(--tab);
+ display: block;
+ padding: 8px 0;
+ text-decoration: none;
+ font-weight: bold;
- &:hover {
- text-decoration: none;
- }
-
- &.active {
- border-bottom-color: var(--tab_selected);
- color: var(--tab_selected);
- }
+ &:hover {
+ text-decoration: none;
}
- &.active a {
- border-bottom-color: var(--tab_selected);
- color: var(--tab_selected);
+ &.active {
+ border-bottom-color: var(--tab_selected);
+ color: var(--tab_selected);
}
+ }
- &.wide {
- flex-grow: 1.2;
- flex-basis: 50px;
- }
+ &.active a {
+ border-bottom-color: var(--tab_selected);
+ color: var(--tab_selected);
+ }
+
+ &.wide {
+ flex-grow: 1.2;
+ flex-basis: 50px;
+ }
}
.timeline-footer {
- background-color: var(--bg_panel);
- padding: 6px 0;
+ background-color: var(--bg_panel);
+ padding: 6px 0;
}
.timeline-protected {
- text-align: center;
+ text-align: center;
- p {
- margin: 8px 0;
- }
+ p {
+ margin: 8px 0;
+ }
- h2 {
- color: var(--accent);
- font-size: 20px;
- font-weight: 600;
- }
-}
-
-.timeline-none {
+ h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
- text-align: center;
+ }
+}
+
+.timeline-none {
+ color: var(--accent);
+ font-size: 20px;
+ font-weight: 600;
+ text-align: center;
}
.timeline-end {
- background-color: var(--bg_panel);
- color: var(--accent);
- font-size: 16px;
- font-weight: 600;
- text-align: center;
+ background-color: var(--bg_panel);
+ color: var(--accent);
+ font-size: 16px;
+ font-weight: 600;
+ text-align: center;
}
.show-more {
- background-color: var(--bg_panel);
- text-align: center;
- padding: .75em 0;
- display: block !important;
+ background-color: var(--bg_panel);
+ text-align: center;
+ padding: 0.75em 0;
+ display: block !important;
- a {
- background-color: var(--darkest_grey);
- display: inline-block;
- height: 2em;
- padding: 0 2em;
- line-height: 2em;
+ a {
+ background-color: var(--darkest_grey);
+ display: inline-block;
+ height: 2em;
+ padding: 0 2em;
+ line-height: 2em;
- &:hover {
- background-color: var(--darker_grey);
- }
+ &:hover {
+ background-color: var(--darker_grey);
}
+ }
}
.top-ref {
- background-color: var(--bg_color);
- border-top: none !important;
+ background-color: var(--bg_color);
+ border-top: none !important;
- .icon-down {
- font-size: 20px;
- display: flex;
- justify-content: center;
- text-decoration: none;
+ .icon-down {
+ font-size: 20px;
+ display: flex;
+ justify-content: center;
+ text-decoration: none;
- &:hover {
- color: var(--accent_light);
- }
-
- &::before {
- transform: rotate(180deg) translateY(-1px);
- }
+ &:hover {
+ color: var(--accent_light);
}
+
+ &::before {
+ transform: rotate(180deg) translateY(-1px);
+ }
+ }
}
.timeline-item {
- overflow-wrap: break-word;
- border-left-width: 0;
- min-width: 0;
- padding: .75em;
- display: flex;
- position: relative;
+ overflow-wrap: break-word;
+ border-left-width: 0;
+ min-width: 0;
+ padding: 0.75em;
+ display: flex;
+ position: relative;
+ 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: unquote("clamp(190px, 22vw, 350px)");
+
+ &[data-col-size="small"] {
+ column-width: unquote("max(130px, 11vw)");
+ }
+
+ &[data-col-size="large"] {
+ column-width: unquote("clamp(350px, 22vw, 480px)");
+ }
+
+ &.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: 520px) {
+ .timeline.media-gallery-view {
+ padding: 8px 0;
+ }
}
diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss
index 69f51c0..2f6693e 100644
--- a/src/sass/tweet/_base.scss
+++ b/src/sass/tweet/_base.scss
@@ -1,240 +1,284 @@
-@import '_variables';
-@import '_mixins';
-@import 'thread';
-@import 'media';
-@import 'video';
-@import 'embed';
-@import 'card';
-@import 'poll';
-@import 'quote';
+@import "_variables";
+@import "_mixins";
+@import "thread";
+@import "media";
+@import "video";
+@import "embed";
+@import "card";
+@import "poll";
+@import "quote";
.tweet-body {
- flex: 1;
- min-width: 0;
- margin-left: 58px;
- pointer-events: none;
- z-index: 1;
+ flex: 1;
+ min-width: 0;
+ margin-left: 58px;
+ pointer-events: none;
+ z-index: 1;
}
.tweet-content {
- font-family: $font_3;
- line-height: 1.3em;
- pointer-events: all;
- display: inline;
+ line-height: 1.3em;
+ pointer-events: all;
+ display: inline;
}
.tweet-bidi {
- display: block !important;
+ display: block !important;
}
.tweet-header {
- padding: 0;
- vertical-align: bottom;
- flex-basis: 100%;
- margin-bottom: .2em;
+ padding: 0;
+ vertical-align: bottom;
+ flex-basis: 100%;
+ margin-bottom: 0.2em;
- a {
- display: inline-block;
- word-break: break-all;
- max-width: 100%;
- pointer-events: all;
- }
+ a {
+ display: inline-block;
+ word-break: break-all;
+ max-width: 100%;
+ pointer-events: all;
+ }
}
.tweet-name-row {
- padding: 0;
- display: flex;
- justify-content: space-between;
+ padding: 0;
+ display: flex;
+ justify-content: space-between;
+
+ .verified-icon {
+ margin-left: 2px;
+ }
}
.fullname-and-username {
- display: flex;
- min-width: 0;
+ display: flex;
+ min-width: 0;
}
.fullname {
- @include ellipsis;
- flex-shrink: 2;
- max-width: 80%;
- font-size: 14px;
- font-weight: 700;
- color: var(--fg_color);
+ @include ellipsis;
+ flex-shrink: 2;
+ max-width: 80%;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--fg_color);
}
.username {
- @include ellipsis;
- min-width: 1.6em;
- margin-left: .4em;
- word-wrap: normal;
+ @include ellipsis;
+ min-width: 1.6em;
+ margin-left: 0.4em;
+ word-wrap: normal;
}
.tweet-date {
- display: flex;
- flex-shrink: 0;
- margin-left: 4px;
+ display: flex;
+ flex-shrink: 0;
+ margin-left: 4px;
}
-.tweet-date a, .username, .show-more a {
- color: var(--fg_dark);
+.tweet-date a,
+.username,
+.show-more a {
+ color: var(--fg_dark);
}
.tweet-published {
- margin: 0;
- margin-top: 5px;
- color: var(--grey);
- pointer-events: all;
+ margin-top: 6px;
+ margin-bottom: 0px;
+ color: var(--grey);
}
.tweet-avatar {
- display: contents !important;
+ display: contents !important;
- img {
- float: left;
- margin-top: 3px;
- margin-left: -58px;
- width: 48px;
- height: 48px;
- }
+ img {
+ float: left;
+ margin-top: 3px;
+ margin-left: -58px;
+ width: 48px;
+ height: 48px;
+ }
}
.avatar {
- &.round {
- border-radius: 50%;
- -webkit-user-select: none;
- }
-
- &.mini {
- position: unset;
- margin-right: 5px;
- margin-top: -1px;
- width: 20px;
- height: 20px;
- }
-}
+ &.round {
+ border-radius: 50%;
+ user-select: none;
+ -webkit-user-select: none;
+ }
-.tweet-embed {
- display: flex;
- flex-direction: column;
- justify-content: center;
- height: 100%;
- background-color: var(--bg_panel);
-
- .tweet-content {
- font-size: 18px;
- }
-
- .tweet-body {
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 0.75em * 2);
- }
-
- .card-image img {
- height: auto;
- }
-
- .avatar {
- position: absolute;
- }
+ &.mini {
+ position: unset;
+ margin-right: 5px;
+ margin-top: -1px;
+ width: 20px;
+ height: 20px;
+ }
}
.attribution {
- display: flex;
- pointer-events: all;
- margin: 5px 0;
+ display: flex;
+ pointer-events: all;
+ margin: 5px 0;
- strong {
- color: var(--fg_color);
- }
+ strong {
+ color: var(--fg_color);
+ }
}
.media-tag-block {
- padding-top: 5px;
- pointer-events: all;
+ padding-top: 5px;
+ pointer-events: all;
+ color: var(--fg_faded);
+
+ .icon-container {
+ padding-right: 2px;
+ }
+
+ .media-tag,
+ .icon-container {
color: var(--fg_faded);
-
- .icon-container {
- padding-right: 2px;
- }
-
- .media-tag, .icon-container {
- color: var(--fg_faded);
- }
+ }
}
.timeline-container .media-tag-block {
- font-size: 13px;
+ font-size: 13px;
}
.tweet-geo {
- color: var(--fg_faded);
+ color: var(--fg_faded);
}
.replying-to {
- color: var(--fg_faded);
- margin: -2px 0 4px;
+ color: var(--fg_faded);
+ margin: -2px 0 4px;
- a {
- pointer-events: all;
- }
+ a {
+ pointer-events: all;
+ }
}
-.retweet-header, .pinned, .tweet-stats {
- align-content: center;
- color: var(--grey);
- display: flex;
- flex-shrink: 0;
- flex-wrap: wrap;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
+.retweet-header,
+.pinned,
+.tweet-stats {
+ align-content: center;
+ color: var(--grey);
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 22px;
- span {
- @include ellipsis;
- }
+ span {
+ @include ellipsis;
+ }
}
.retweet-header {
- margin-top: -5px !important;
+ margin-top: -5px !important;
}
.tweet-stats {
- margin-bottom: -3px;
- -webkit-user-select: none;
+ margin-bottom: -3px;
+ user-select: none;
+ -webkit-user-select: none;
}
.tweet-stat {
- padding-top: 5px;
- min-width: 1em;
- margin-right: 0.8em;
+ padding-top: 5px;
+ min-width: 1em;
+ margin-right: 0.8em;
}
.show-thread {
- display: block;
- pointer-events: all;
- padding-top: 2px;
+ display: block;
+ pointer-events: all;
+ padding-top: 2px;
}
.unavailable-box {
- width: 100%;
- height: 100%;
- padding: 12px;
- border: solid 1px var(--dark_grey);
- box-sizing: border-box;
- border-radius: 10px;
- background-color: var(--bg_color);
- z-index: 2;
+ width: 100%;
+ height: 100%;
+ padding: 12px;
+ border: solid 1px var(--dark_grey);
+ box-sizing: border-box;
+ border-radius: 10px;
+ background-color: var(--bg_color);
+ z-index: 2;
}
.tweet-link {
- height: 100%;
- width: 100%;
- left: 0;
- top: 0;
- position: absolute;
- -webkit-user-select: none;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+ position: absolute;
+ user-select: none;
+ -webkit-user-select: none;
- &:hover {
- background-color: var(--bg_hover);
- }
+ &:hover {
+ background-color: var(--bg_hover);
+ }
+}
+
+.latest-post-version {
+ border-bottom: 1px solid var(--dark_grey);
+ border-top: 1px solid var(--dark_grey);
+ padding: 01ch 0px;
+ margin: 1ch 0px;
+ color: var(--grey);
+
+ a {
+ pointer-events: all;
+ }
+}
+
+.community-note {
+ background-color: var(--bg_elements);
+ margin-top: 10px;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ overflow: hidden;
+ pointer-events: all;
+
+ &:hover {
+ background-color: var(--bg_panel);
+ border-color: var(--grey);
+ }
+}
+
+.community-note-header {
+ background-color: var(--bg_hover);
+ font-weight: 700;
+ padding: 8px 10px;
+ padding-top: 6px;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+
+ .icon-container {
+ flex-shrink: 0;
+ color: var(--accent);
+ }
+}
+
+.community-note-text {
+ white-space: pre-line;
+ padding: 10px 10px;
+ padding-top: 6px;
+}
+
+.disclosures {
+ display: flex;
+ flex-direction: column;
+ color: var(--grey);
+ font-size: 14px;
+ margin-top: 4px;
+ margin-bottom: -2px;
+
+ .icon-attention-circled {
+ margin-right: -3px;
+ }
}
diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss
index 5575191..7441d11 100644
--- a/src/sass/tweet/card.scss
+++ b/src/sass/tweet/card.scss
@@ -1,119 +1,119 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
.card {
- margin: 5px 0;
- pointer-events: all;
- max-height: unset;
+ margin: 5px 0;
+ pointer-events: all;
+ max-height: unset;
}
.card-container {
- border-radius: 10px;
- border-width: 1px;
- border-style: solid;
- border-color: var(--dark_grey);
- background-color: var(--bg_elements);
- overflow: hidden;
- color: inherit;
- display: flex;
- flex-direction: row;
- text-decoration: none !important;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ background-color: var(--bg_elements);
+ overflow: hidden;
+ color: inherit;
+ display: flex;
+ flex-direction: row;
+ text-decoration: none !important;
- &:hover {
- border-color: var(--grey);
- }
+ &:hover {
+ border-color: var(--grey);
+ }
- .attachments {
- margin: 0;
- border-radius: 0;
- }
+ .attachments {
+ margin: 0;
+ border-radius: 0;
+ }
}
.card-content {
- padding: 0.5em;
+ padding: 0.5em;
}
.card-title {
- @include ellipsis;
- white-space: unset;
- font-weight: bold;
- font-size: 1.1em;
+ @include ellipsis;
+ white-space: unset;
+ font-weight: bold;
+ font-size: 1.1em;
}
.card-description {
- margin: 0.3em 0;
- white-space: pre-wrap;
+ margin: 0.3em 0;
+ white-space: pre-wrap;
}
.card-destination {
- @include ellipsis;
- color: var(--grey);
- display: block;
+ @include ellipsis;
+ color: var(--grey);
+ display: block;
}
.card-content-container {
- color: unset;
- overflow: auto;
- &:hover {
- text-decoration: none;
- }
+ color: unset;
+ overflow: auto;
+
+ &:hover {
+ text-decoration: none;
+ }
}
.card-image-container {
- width: 98px;
- flex-shrink: 0;
- position: relative;
- overflow: hidden;
- &:before {
- content: "";
- display: block;
- padding-top: 100%;
- }
+ width: 98px;
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+
+ &:before {
+ content: "";
+ display: block;
+ padding-top: 100%;
+ }
}
.card-image {
- position: absolute;
- top: 0;
- left: 0;
- bottom: 0;
- right: 0;
- background-color: var(--bg_overlays);
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background-color: var(--bg_overlays);
- img {
- width: 100%;
- height: 100%;
- max-height: 400px;
- display: block;
- object-fit: cover;
- }
+ img {
+ width: 100%;
+ height: 100%;
+ max-height: 400px;
+ display: block;
+ object-fit: cover;
+ }
}
.card-overlay {
- @include play-button;
- opacity: 0.8;
- display: flex;
- justify-content: center;
- align-items: center;
+ @include play-button;
+ opacity: 0.8;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
.large {
- .card-container {
- display: block;
- }
+ .card-container {
+ display: block;
+ }
- .card-image-container {
- width: unset;
+ .card-image-container {
+ width: unset;
- &:before {
- display: none;
- }
+ &:before {
+ display: none;
}
+ }
- .card-image {
- position: unset;
- border-style: solid;
- border-color: var(--dark_grey);
- border-width: 0;
- border-bottom-width: 1px;
- }
+ .card-image {
+ position: unset;
+ border-style: solid;
+ border-color: var(--dark_grey);
+ border-width: 0;
+ border-bottom-width: 1px;
+ }
}
diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss
index 227fc5e..9ff8403 100644
--- a/src/sass/tweet/embed.scss
+++ b/src/sass/tweet/embed.scss
@@ -1,17 +1,159 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
-.embed-video {
- .gallery-video {
- width: 100%;
- height: 100%;
- position: absolute;
- background-color: black;
- top: 0%;
- left: 0%;
- }
+// Embed page: transparent background, no scrollbars
+html:has(body > .embed-wrapper),
+html:has(body > .embed-video) {
+ background: transparent;
+ overflow: hidden;
- .video-container {
- max-height: unset;
- }
+ body {
+ background: transparent;
+ overflow: hidden;
+ }
+}
+
+// Tweet embed wrapper
+.embed-wrapper {
+ box-sizing: border-box;
+ border: 1px solid var(--border_grey);
+ border-radius: 12px;
+ overflow: hidden;
+
+ .embed-footer {
+ display: block;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border_grey);
+ background: var(--bg_panel);
+ color: var(--accent);
+ font-size: 14px;
+ font-weight: 500;
+ text-align: center;
+ text-decoration: none;
+ transition: background-color 0.15s;
+
+ &:hover {
+ background: var(--bg_hover);
+ }
+ }
+}
+
+// Tweet embed content
+.tweet-embed {
+ position: relative;
+ background-color: var(--bg_panel);
+ transition: background-color 0.15s;
+
+ &:hover {
+ background-color: var(--bg_hover);
+ }
+
+ .timeline-item {
+ pointer-events: none;
+ background-color: transparent;
+ }
+
+ .tweet-link:hover {
+ background-color: transparent;
+ }
+
+ .tweet-content {
+ font-size: 18px;
+ }
+
+ .avatar:not(.mini) {
+ position: absolute;
+ }
+
+ // Cap media height in embeds
+ .still-image img,
+ .quote-media-container img,
+ .quote-media-container video {
+ max-height: 600px;
+ }
+
+ &.error-embed {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ padding: 20px;
+
+ .error-panel {
+ margin: 0;
+ }
+ }
+}
+
+// Video-only embed
+.embed-video {
+ position: relative;
+ min-height: 300px;
+ background-color: black;
+ border: 1px solid var(--border_grey);
+
+ .attachments {
+ margin: 0;
+ border-radius: 0;
+ max-height: 560px;
+ background-color: unset;
+ }
+
+ .card {
+ margin: 0;
+ }
+
+ .gallery-video {
+ width: 100%;
+ }
+
+ .gallery-video>.attachment {
+ max-height: 560px;
+ width: 100%;
+ }
+
+ video {
+ width: 100%;
+ height: auto;
+ max-height: 560px;
+ object-fit: contain;
+ }
+
+ .video-download {
+ display: none;
+ }
+
+ .video-overlay-link {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ padding: 6px 12px;
+ background: rgba(30, 30, 30, 0.75);
+ backdrop-filter: blur(4px);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 700;
+ text-decoration: none;
+ border-radius: 9999px;
+ border: 1px solid transparent;
+ transition:
+ background 0.15s,
+ opacity 0.15s;
+ z-index: 10;
+
+ &:hover {
+ background: rgba(60, 60, 60, 0.9);
+ }
+ }
+
+ // Hide button while playing, show on hover or when paused
+ &.video-playing .video-overlay-link {
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ &.video-playing:hover .video-overlay-link {
+ opacity: 1;
+ pointer-events: auto;
+ }
}
diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss
index 91c9dab..3001a86 100644
--- a/src/sass/tweet/media.scss
+++ b/src/sass/tweet/media.scss
@@ -1,119 +1,168 @@
-@import '_variables';
+@import "_variables";
.gallery-row {
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- align-items: center;
- overflow: hidden;
- flex-grow: 1;
- max-height: 379.5px;
- max-width: 533px;
- pointer-events: all;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ flex-grow: 1;
+ max-height: 379.5px;
+ max-width: 533px;
+ pointer-events: all;
+
+ &.mixed-row {
+ .attachment {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 0;
+ max-height: 379.5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: #101010;
+ }
+
+ .still-image,
+ .still-image img,
+ .attachment > video,
+ .attachment > img {
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ }
.still-image {
- width: 100%;
- display: flex;
+ display: flex;
+ align-self: stretch;
}
+
+ .still-image img {
+ flex-basis: auto;
+ flex-grow: 0;
+ object-fit: cover;
+ }
+
+ .attachment > video,
+ .attachment > img {
+ object-fit: cover;
+ }
+
+ .attachment > video {
+ object-fit: contain;
+ }
+ }
}
.attachments {
- margin-top: .35em;
- display: flex;
- flex-direction: row;
- width: 100%;
- max-height: 600px;
- border-radius: 7px;
- overflow: hidden;
- flex-flow: column;
- background-color: var(--bg_color);
- align-items: center;
- pointer-events: all;
-
- .image-attachment {
- width: 100%;
- }
+ margin-top: 0.35em;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ max-height: 600px;
+ border-radius: 7px;
+ overflow: hidden;
+ flex-flow: column;
+ background-color: var(--bg_color);
+ align-items: center;
+ pointer-events: all;
}
.attachment {
- position: relative;
- line-height: 0;
- overflow: hidden;
- margin: 0 .25em 0 0;
- flex-grow: 1;
- box-sizing: border-box;
- min-width: 2em;
+ position: relative;
+ line-height: 0;
+ overflow: hidden;
+ margin: 0 0.25em 0 0;
+ flex-grow: 1;
+ box-sizing: border-box;
+ min-width: 2em;
- &:last-child {
- margin: 0;
- max-height: 530px;
- }
-}
-
-.gallery-gif video {
+ &:last-child {
+ margin: 0;
max-height: 530px;
- background-color: #101010;
-}
-
-.still-image {
- max-height: 379.5px;
- max-width: 533px;
- justify-content: center;
-
- img {
- object-fit: cover;
- max-width: 100%;
- max-height: 379.5px;
- flex-basis: 300px;
- flex-grow: 1;
- }
-}
-
-.image {
- display: inline-block;
-}
-
-// .single-image {
-// display: inline-block;
-// width: 100%;
-// max-height: 600px;
-
-// .attachments {
-// width: unset;
-// max-height: unset;
-// display: inherit;
-// }
-// }
-
-.overlay-circle {
- border-radius: 50%;
- background-color: var(--dark_grey);
- width: 40px;
- height: 40px;
- align-items: center;
- display: flex;
- border-width: 5px;
- border-color: var(--play_button);
- border-style: solid;
-}
-
-.overlay-triangle {
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 12px 0 12px 17px;
- border-color: transparent transparent transparent var(--play_button);
- margin-left: 14px;
+ }
}
.media-gif {
- display: table;
- background-color: unset;
- width: unset;
+ display: table;
+ background-color: unset;
+ width: unset;
+ max-height: unset;
+}
+
+.media-gif video,
+.media-gif img {
+ width: 100%;
+ height: 100%;
+ max-height: 530px;
+ background-color: #101010;
+}
+
+.still-image {
+ max-height: 379.5px;
+ max-width: 533px;
+
+ img {
+ object-fit: cover;
+ max-width: 100%;
+ max-height: 379.5px;
+ flex-basis: 300px;
+ flex-grow: 1;
+ }
+}
+
+.alt-text {
+ margin: 0px;
+ padding: 11px 7px;
+ box-sizing: border-box;
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ width: 2.98em;
+ max-height: 25px;
+ white-space: pre;
+ overflow: hidden;
+ border-radius: 10px;
+ color: var(--fg_color);
+ font-size: 12px;
+ font-weight: bold;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(12px);
+}
+
+.alt-text:hover {
+ padding: 7px;
+ width: Min(230px, calc(100% - 10px * 2));
+ max-height: calc(100% - 10px);
+ line-height: 1.2em;
+ white-space: pre-wrap;
+ transition-duration: 0.4s;
+ transition-property: max-height;
+}
+
+.overlay-circle {
+ border-radius: 50%;
+ background-color: var(--dark_grey);
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ display: flex;
+ border-width: 5px;
+ border-color: var(--play_button);
+ border-style: solid;
+}
+
+.overlay-triangle {
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 12px 0 12px 17px;
+ border-color: transparent transparent transparent var(--play_button);
+ margin-left: 14px;
}
.media-body {
- flex: 1;
- padding: 0;
- white-space: pre-wrap;
+ flex: 1;
+ padding: 0;
+ white-space: pre-wrap;
}
diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss
index 57590c8..6d54e00 100644
--- a/src/sass/tweet/poll.scss
+++ b/src/sass/tweet/poll.scss
@@ -1,42 +1,42 @@
-@import '_variables';
+@import "_variables";
.poll-meter {
- overflow: hidden;
- position: relative;
- margin: 6px 0;
- height: 26px;
- background: var(--bg_color);
- border-radius: 5px;
- display: flex;
- align-items: center;
+ overflow: hidden;
+ position: relative;
+ margin: 6px 0;
+ height: 26px;
+ background: var(--bg_color);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
}
.poll-choice-bar {
- height: 100%;
- position: absolute;
- background: var(--dark_grey);
+ height: 100%;
+ position: absolute;
+ background: var(--dark_grey);
}
.poll-choice-value {
- position: relative;
- font-weight: bold;
- margin-left: 5px;
- margin-right: 6px;
- min-width: 30px;
- text-align: right;
- pointer-events: all;
+ position: relative;
+ font-weight: bold;
+ margin-left: 5px;
+ margin-right: 6px;
+ min-width: 30px;
+ text-align: right;
+ pointer-events: all;
}
.poll-choice-option {
- position: relative;
- pointer-events: all;
+ position: relative;
+ pointer-events: all;
}
.poll-info {
- color: var(--grey);
- pointer-events: all;
+ color: var(--grey);
+ pointer-events: all;
}
.leader .poll-choice-bar {
- background: var(--accent_dark);
+ background: var(--accent_dark);
}
diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss
index b4bc60e..f722455 100644
--- a/src/sass/tweet/quote.scss
+++ b/src/sass/tweet/quote.scss
@@ -1,94 +1,121 @@
-@import '_variables';
+@import "_variables";
.quote {
- margin-top: 10px;
- border: solid 1px var(--dark_grey);
- border-radius: 10px;
- background-color: var(--bg_elements);
+ margin-top: 10px;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ background-color: var(--bg_elements);
+ overflow: hidden;
+ pointer-events: all;
+ position: relative;
+ width: 100%;
+
+ &:hover {
+ border-color: var(--grey);
+ }
+
+ &.unavailable:hover {
+ border-color: var(--dark_grey);
+ }
+
+ .tweet-name-row {
+ padding: 8px 10px 6px 10px;
+ }
+
+ .quote-text {
overflow: hidden;
- pointer-events: all;
- position: relative;
- width: 100%;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ padding: 10px;
+ padding-top: 0;
+ }
+
+ .show-thread {
+ padding: 0px 10px 6px 10px;
+ margin-top: -6px;
+ }
+
+ .quote-latest {
+ padding: 0px 10px 6px 10px;
+ color: var(--grey);
+ }
+
+ .replying-to {
+ padding: 0px 10px;
+ padding-bottom: 4px;
+ margin: unset;
+ }
+
+ .community-note {
+ background-color: var(--bg_panel);
+ border: unset;
+ border-top: solid 1px var(--dark_grey);
+ border-radius: unset;
+ margin-top: 0;
&:hover {
- border-color: var(--grey);
+ border-top-color: var(--grey);
}
- &.unavailable:hover {
- border-color: var(--dark_grey);
- }
-
- .tweet-name-row {
- padding: 6px 8px;
- margin-top: 1px;
- }
-
- .quote-text {
- overflow: hidden;
- white-space: pre-wrap;
- word-wrap: break-word;
- padding: 0px 8px 8px 8px;
- }
-
- .show-thread {
- padding: 0px 8px 6px 8px;
- margin-top: -6px;
- }
-
- .replying-to {
- padding: 0px 8px;
- margin: unset;
+ .community-note-header {
+ background-color: var(--bg_panel);
+ padding-bottom: 0;
}
+ }
}
.unavailable-quote {
- padding: 12px;
+ padding: 12px;
+ display: block;
}
.quote-link {
- width: 100%;
- height: 100%;
- left: 0;
- top: 0;
- position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ position: absolute;
}
.quote-media-container {
- max-height: 300px;
+ max-height: 300px;
+ display: flex;
+
+ .card {
+ margin: unset;
+ }
+
+ .attachments {
+ border-radius: 0;
+ }
+
+ .media-gif {
+ width: 100%;
display: flex;
+ justify-content: center;
+ }
- .card {
- margin: unset;
+ .media-gif > .attachment {
+ display: flex;
+ justify-content: center;
+ background-color: var(--bg_color);
+
+ video,
+ img {
+ height: unset;
+ width: unset;
+ max-height: 100%;
+ max-width: 100%;
}
+ }
- .attachments {
- border-radius: 0;
- }
+ .gallery-row .attachment,
+ .gallery-row .attachment > video,
+ .gallery-row .attachment > img {
+ max-height: 300px;
+ }
- .media-gif {
- width: 100%;
- display: flex;
- justify-content: center;
- }
-
- .gallery-gif .attachment {
- display: flex;
- justify-content: center;
- background-color: var(--bg_color);
-
- video {
- height: unset;
- width: unset;
- max-height: 100%;
- max-width: 100%;
- }
- }
-
- .gallery-video, .gallery-gif {
- max-height: 300px;
- }
-
- .still-image img {
- max-height: 250px
- }
+ .still-image img {
+ max-height: 250px;
+ }
}
diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss
index 19fb3e0..134e375 100644
--- a/src/sass/tweet/thread.scss
+++ b/src/sass/tweet/thread.scss
@@ -1,138 +1,196 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
-.conversation {
- @include panel(100%, 600px);
+.conversation,
+.edit-history {
+ @include panel(100%, 600px);
- .show-more {
- margin-bottom: 10px;
- }
+ .show-more {
+ margin-bottom: 10px;
+ }
}
-.main-thread {
- margin-bottom: 20px;
- background-color: var(--bg_panel);
-}
-
-.main-tweet, .replies {
- padding-top: 50px;
- margin-top: -50px;
-}
-
-.main-tweet .tweet-content {
- font-size: 18px;
-}
-
-@media(max-width: 600px) {
- .main-tweet .tweet-content {
- font-size: 16px;
- }
+.main-thread,
+.latest-edit {
+ margin-bottom: 20px;
}
.reply {
- background-color: var(--bg_panel);
- margin-bottom: 10px;
+ margin-bottom: 10px;
+}
+
+.reply-sort {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 2px 14px;
+ margin-bottom: 10px;
+ padding: 8px 12px;
+ background-color: var(--bg_panel);
+ font-size: 14px;
+}
+
+.reply-sort-label {
+ color: var(--fg_faded);
+ margin-right: 2px;
+}
+
+.reply-sort-option {
+ color: var(--tab);
+ font-weight: bold;
+ text-decoration: none;
+ border-bottom: 0.1rem solid transparent;
+
+ &:hover {
+ color: var(--fg_color);
+ text-decoration: none;
+ }
+
+ &.active {
+ color: var(--tab_selected);
+ border-bottom-color: var(--tab_selected);
+ }
+}
+
+.main-tweet,
+.replies,
+.edit-history > div {
+ body.fixed-nav & {
+ padding-top: 50px;
+ margin-top: -50px;
+ }
+}
+
+.edit-history-header {
+ padding: 10px;
+ margin-bottom: 5px;
+ font-size: 16px;
+ font-weight: bold;
+ background-color: var(--bg_panel);
+}
+
+.tweet-edit {
+ margin-bottom: 5px;
+}
+
+.main-tweet .tweet-content {
+ font-size: 18px;
+}
+
+@media (max-width: 600px) {
+ .main-tweet .tweet-content {
+ font-size: 16px;
+ }
}
.thread-line {
- .timeline-item::before,
- &.timeline-item::before {
- background: var(--accent_dark);
- content: '';
- position: relative;
- min-width: 3px;
- width: 3px;
- left: 26px;
- border-radius: 2px;
- margin-left: -3px;
- margin-bottom: 37px;
- top: 56px;
- z-index: 1;
- pointer-events: none;
- }
+ .timeline-item::before,
+ &.timeline-item::before {
+ background: var(--accent_dark);
+ content: "";
+ position: relative;
+ min-width: 3px;
+ width: 3px;
+ left: 26px;
+ border-radius: 2px;
+ margin-left: -3px;
+ margin-bottom: 37px;
+ top: 56px;
+ z-index: 1;
+ pointer-events: none;
+ }
- .with-header:not(:first-child)::after {
- background: var(--accent_dark);
- content: '';
- position: relative;
- float: left;
- min-width: 3px;
- width: 3px;
- right: calc(100% - 26px);
- border-radius: 2px;
- margin-left: -3px;
- margin-bottom: 37px;
- bottom: 10px;
- height: 30px;
- z-index: 1;
- pointer-events: none;
- }
+ .with-header:not(:first-child)::after {
+ background: var(--accent_dark);
+ content: "";
+ position: relative;
+ float: left;
+ min-width: 3px;
+ width: 3px;
+ right: calc(100% - 26px);
+ border-radius: 2px;
+ margin-left: -3px;
+ margin-bottom: 37px;
+ bottom: 10px;
+ height: 30px;
+ z-index: 1;
+ pointer-events: none;
+ }
- .unavailable::before {
- top: 48px;
- margin-bottom: 28px;
- }
+ .unavailable::before {
+ top: 48px;
+ margin-bottom: 28px;
+ }
- .more-replies::before {
- content: '...';
- background: unset;
- color: var(--more_replies_dots);
- font-weight: bold;
- font-size: 20px;
- line-height: 0.25em;
- left: 1.2em;
- width: 5px;
- top: 2px;
- margin-bottom: 0;
- margin-left: -2.5px;
- }
+ .more-replies::before {
+ content: "...";
+ background: unset;
+ color: var(--more_replies_dots);
+ font-weight: bold;
+ font-size: 20px;
+ line-height: 0.25em;
+ left: 1.2em;
+ width: 5px;
+ top: 2px;
+ margin-bottom: 0;
+ margin-left: -2.5px;
+ }
- .earlier-replies {
- padding-bottom: 0;
- margin-bottom: -5px;
- }
+ .earlier-replies {
+ padding-bottom: 0;
+ margin-bottom: -5px;
+ }
}
.timeline-item.thread-last::before {
- background: unset;
- min-width: unset;
- width: 0;
- margin: 0;
+ background: unset;
+ min-width: unset;
+ width: 0;
+ margin: 0;
}
.more-replies {
- padding-top: 0.3em !important;
+ padding-top: 0.3em !important;
}
.more-replies-text {
- @include ellipsis;
- display: block;
- margin-left: 58px;
- padding: 7px 0;
+ @include ellipsis;
+ display: block;
+ margin-left: 58px;
+ padding: 7px 0;
}
.timeline-item.thread.more-replies-thread {
- padding: 0 0.75em;
+ padding: 0 0.75em;
+
+ &::before {
+ top: 40px;
+ margin-bottom: 31px;
+ }
+
+ .more-replies {
+ display: flex;
+ padding-top: unset !important;
+ margin-top: 8px;
&::before {
- top: 40px;
- margin-bottom: 31px;
+ display: inline-block;
+ position: relative;
+ top: -1px;
+ line-height: 0.4em;
}
- .more-replies {
- display: flex;
- padding-top: unset !important;
- margin-top: 8px;
-
- &::before {
- display: inline-block;
- position: relative;
- top: -1px;
- line-height: 0.4em;
- }
-
- .more-replies-text {
- display: inline;
- }
+ .more-replies-text {
+ display: inline;
}
+ }
+}
+
+.related-header {
+ padding: 8px 12px;
+ margin-top: 10px;
+ background-color: var(--bg_panel);
+ color: var(--fg_faded);
+ font-size: 14px;
+ border-bottom: 1px solid var(--border_grey);
}
diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss
index 98a1c29..28fc125 100644
--- a/src/sass/tweet/video.scss
+++ b/src/sass/tweet/video.scss
@@ -1,68 +1,111 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
video {
- max-height: 100%;
- width: 100%;
+ height: 100%;
+ width: 100%;
}
.gallery-video {
- display: flex;
- overflow: hidden;
-}
-
-.gallery-video.card-container {
+ display: flex;
+ overflow: hidden;
+
+ &.card-container {
flex-direction: column;
-}
+ width: 100%;
+ }
-.video-container {
+ > .attachment {
min-height: 80px;
min-width: 200px;
max-height: 530px;
margin: 0;
- display: flex;
- align-items: center;
- justify-content: center;
img {
- max-height: 100%;
- max-width: 100%;
+ max-height: 100%;
+ max-width: 100%;
}
+ }
+}
+
+.video-download {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 2;
+ color: var(--accent);
+ text-decoration: none;
+ opacity: 0;
+ transition: opacity 0.2s;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
+
+ .icon-container {
+ margin: 0;
+ }
+
+ .icon-download-alt {
+ font-size: 18px;
+ }
+}
+
+.attachment:hover .video-download {
+ opacity: 1;
+
+ &:hover {
+ color: var(--fg_color);
+ }
+}
+
+@media (hover: none) {
+ .video-download {
+ opacity: 1;
+ }
}
.video-overlay {
- @include play-button;
- background-color: $shadow;
+ @include play-button;
+ background-color: $shadow;
- p {
- position: relative;
- z-index: 0;
- text-align: center;
- top: calc(50% - 20px);
- font-size: 20px;
- line-height: 1.3;
- margin: 0 20px;
- }
+ p {
+ position: relative;
+ z-index: 0;
+ text-align: center;
+ top: calc(50% - 20px);
+ font-size: 20px;
+ line-height: 1.3;
+ margin: 0 20px;
+ }
- div {
- position: relative;
- z-index: 0;
- top: calc(50% - 20px);
- margin: 0 auto;
- width: 40px;
- height: 40px;
- }
+ .overlay-circle {
+ position: relative;
+ z-index: 0;
+ top: calc(50% - 20px);
+ margin: 0 auto;
+ width: 40px;
+ height: 40px;
+ }
- form {
- width: 100%;
- height: 100%;
- align-items: center;
- justify-content: center;
- display: flex;
- }
+ .overlay-duration {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ background-color: #0000007a;
+ line-height: 1em;
+ padding: 4px 6px 4px 6px;
+ border-radius: 5px;
+ font-weight: bold;
+ }
- button {
- padding: 5px 8px;
- font-size: 16px;
- }
+ form {
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ }
+
+ button {
+ padding: 5px 8px;
+ font-size: 16px;
+ }
}
diff --git a/src/tid.nim b/src/tid.nim
new file mode 100644
index 0000000..ba1f8ec
--- /dev/null
+++ b/src/tid.nim
@@ -0,0 +1,64 @@
+import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
+import nimcrypto
+import experimental/parser/tid
+
+randomize()
+
+const defaultKeyword = "obfiowerehiring";
+const pairsUrl =
+ "https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
+
+var
+ cachedPairs: seq[TidPair] = @[]
+ lastCached = 0
+ # refresh every hour
+ ttlSec = 60 * 60
+
+proc getPair(): Future[TidPair] {.async.} =
+ if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
+ let client = newAsyncHttpClient()
+ defer: client.close()
+
+ let resp = await client.get(pairsUrl)
+ if resp.status == $Http200:
+ cachedPairs = parseTidPairs(await resp.body)
+ lastCached = int(epochTime())
+
+ if cachedPairs.len == 0:
+ raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs")
+
+ return sample(cachedPairs)
+
+proc encodeSha256(text: string): array[32, byte] =
+ let
+ data = cast[ptr byte](addr text[0])
+ dataLen = uint(len(text))
+ digest = sha256.digest(data, dataLen)
+ return digest.data
+
+proc encodeBase64[T](data: T): string =
+ return encode(data).replace("=", "")
+
+proc decodeBase64(data: string): seq[byte] =
+ return cast[seq[byte]](decode(data))
+
+proc genTid*(path: string): Future[string] {.async.} =
+ let
+ pair = await getPair()
+
+ timeNow = int(epochTime() - 1682924400)
+ timeNowBytes = @[
+ byte(timeNow and 0xff),
+ byte((timeNow shr 8) and 0xff),
+ byte((timeNow shr 16) and 0xff),
+ byte((timeNow shr 24) and 0xff)
+ ]
+
+ data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
+ hashBytes = encodeSha256(data)
+ keyBytes = decodeBase64(pair.verification)
+ bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
+ randomNum = byte(rand(256))
+ tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
+
+ return encodeBase64(tid)
diff --git a/src/types.nim b/src/types.nim
index f16fe6f..0a748ba 100644
--- a/src/types.nim
+++ b/src/types.nim
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import times, sequtils, options, tables, uri
+import times, sequtils, options, tables
import prefs_impl
genPrefsType()
@@ -11,21 +11,16 @@ type
BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum
- tweets, replies, media
+ tweets, replies, media, articles
- Api* {.pure.} = enum
- tweetDetail
- tweetResult
- search
- list
- listBySlug
- listMembers
- listTweets
- userRestId
- userScreenName
- userTweets
- userTweetsAndReplies
- userMedia
+ ApiUrl* = object
+ endpoint*: string
+ params*: seq[(string, string)]
+ skipTid*: bool
+
+ ApiReq* = object
+ oauth*: ApiUrl
+ cookie*: ApiUrl
RateLimit* = object
limit*: int
@@ -42,7 +37,7 @@ type
pending*: int
limited*: bool
limitedAt*: int
- apis*: Table[Api, RateLimit]
+ apis*: Table[string, RateLimit]
case kind*: SessionKind
of oauth:
oauthToken*: string
@@ -51,10 +46,6 @@ type
authToken*: string
ct0*: string
- SessionAwareUrl* = object
- oauthUrl*: Uri
- cookieUrl*: Uri
-
Error* = enum
null = 0
noUserMatches = 17
@@ -70,6 +61,7 @@ type
rateLimited = 88
expiredToken = 89
listIdOrSlug = 112
+ timelineUnavailable = 131
tweetNotFound = 144
tweetNotAuthorized = 179
forbidden = 200
@@ -106,6 +98,59 @@ type
suspended*: bool
joinDate*: DateTime
+ AccountInfo* = object
+ username*: string
+ fullname*: string
+ userPic*: string
+ joinDate*: DateTime
+ verifiedType*: VerifiedType
+ suspended*: bool
+ basedIn*: string
+ source*: string
+ usernameChanges*: int
+ lastUsernameChange*: DateTime
+ affiliateUsername*: string
+ affiliateLabel*: string
+ isIdentityVerified*: bool
+ verifiedSince*: DateTime
+ overrideVerifiedYear*: int
+
+ Broadcast* = object
+ id*: string
+ title*: string
+ state*: string
+ thumb*: string
+ mediaKey*: string
+ m3u8Url*: string
+ totalWatched*: int
+ startTime*: DateTime
+ endTime*: DateTime
+ replayStart*: int
+ availableForReplay*: bool
+ user*: User
+
+ SpaceParticipant* = object
+ userId*: string
+ username*: string
+ displayName*: string
+ avatarUrl*: string
+ isVerified*: bool
+
+ AudioSpace* = object
+ id*: string
+ title*: string
+ state*: string
+ mediaKey*: string
+ m3u8Url*: string
+ totalLiveListeners*: int
+ totalReplayWatched*: int
+ startTime*: DateTime
+ endTime*: DateTime
+ availableForReplay*: bool
+ creator*: User
+ admins*: seq[SpaceParticipant]
+ speakers*: seq[SpaceParticipant]
+
VideoType* = enum
m3u8 = "application/x-mpegURL"
mp4 = "video/mp4"
@@ -129,10 +174,15 @@ type
variants*: seq[VideoVariant]
QueryKind* = enum
- posts, replies, media, users, tweets, userList
+ posts, replies, media, users, tweets, userList, followers, following, lists, top,
+ articles
+
+ RankingMode* = enum
+ Relevance, Recency, Likes
Query* = object
kind*: QueryKind
+ view*: string
text*: string
filters*: seq[string]
includes*: seq[string]
@@ -140,12 +190,33 @@ type
fromUser*: seq[string]
since*: string
until*: string
- near*: string
+ minLikes*: string
sep*: string
Gif* = object
url*: string
thumb*: string
+ altText*: string
+
+ Photo* = object
+ url*: string
+ altText*: string
+
+ MediaKind* = enum
+ photoMedia
+ videoMedia
+ gifMedia
+
+ Media* = object
+ case kind*: MediaKind
+ of photoMedia:
+ photo*: Photo
+ of videoMedia:
+ video*: Video
+ of gifMedia:
+ gif*: Gif
+
+ MediaEntities* = seq[Media]
GalleryPhoto* = object
url*: string
@@ -154,6 +225,44 @@ type
PhotoRail* = seq[GalleryPhoto]
+ Article* = ref object
+ title*: string
+ coverImage*: string
+ user*: User
+ time*: DateTime
+ stats*: TweetStats
+ paragraphs*: seq[ArticleParagraph]
+ entities*: Table[int, ArticleEntity]
+ media*: Table[string, ArticleMedia]
+
+ ArticleParagraph* = object
+ text*: string
+ kind*: string
+ inlineStyles*: seq[ArticleStyle]
+ entityRanges*: seq[ArticleEntityRange]
+
+ ArticleStyle* = object
+ offset*: int
+ length*: int
+ style*: string
+
+ ArticleEntityRange* = object
+ offset*: int
+ length*: int
+ key*: int
+
+ ArticleEntity* = object
+ kind*: string
+ url*: string
+ mediaIds*: seq[string]
+ tweetId*: string
+ markdown*: string
+ caption*: string
+
+ ArticleMedia* = object
+ kind*: string
+ url*: string
+
Poll* = object
options*: seq[string]
values*: seq[int]
@@ -203,6 +312,12 @@ type
likes*: int
views*: int
+ ArticlePreview* = object
+ title*: string
+ previewText*: string
+ coverImage*: string
+ tweetId*: int64
+
Tweet* = ref object
id*: int64
threadId*: int64
@@ -221,13 +336,17 @@ type
stats*: TweetStats
retweet*: Option[Tweet]
attribution*: Option[User]
+ attributionLink*: string
mediaTags*: seq[User]
quote*: Option[Tweet]
card*: Option[Card]
poll*: Option[Poll]
- gif*: Option[Gif]
- video*: Option[Video]
- photos*: seq[string]
+ media*: MediaEntities
+ history*: seq[int64]
+ note*: string
+ isAd*: bool
+ isAI*: bool
+ articlePreview*: Option[ArticlePreview]
Tweets* = seq[Tweet]
@@ -241,6 +360,7 @@ type
content*: Tweets
hasMore*: bool
cursor*: string
+ related*: bool
Conversation* = ref object
tweet*: Tweet
@@ -248,6 +368,10 @@ type
after*: Chain
replies*: Result[Chain]
+ EditHistory* = object
+ latest*: Tweet
+ history*: Tweets
+
Timeline* = Result[Tweets]
Profile* = object
@@ -255,6 +379,7 @@ type
photoRail*: PhotoRail
pinned*: Option[Tweet]
tweets*: Timeline
+ accountInfo*: AccountInfo
List* = object
id*: string
@@ -265,6 +390,29 @@ type
members*: int
banner*: string
+ ListSearchResult* = object
+ list*: List
+ owner*: User
+ followersContext*: string
+ facepiles*: seq[string]
+
+ CommunityRule* = object
+ name*: string
+ description*: string
+
+ Community* = object
+ id*: string
+ name*: string
+ description*: string
+ memberCount*: int
+ banner*: string
+ creator*: User
+ category*: string
+ joinPolicy*: string
+ createdAt*: DateTime
+ rules*: seq[CommunityRule]
+ hashtags*: seq[string]
+
GlobalObjects* = ref object
tweets*: Table[string, Tweet]
users*: Table[string, User]
@@ -281,10 +429,20 @@ type
hmacKey*: string
base64Media*: bool
minTokens*: int
- enableRss*: bool
+ enableRSSUserTweets*: bool
+ enableRSSUserReplies*: bool
+ enableRSSUserMedia*: bool
+ enableRSSUserArticles*: bool
+ enableRSSSearch*: bool
+ enableRSSList*: bool
enableDebug*: bool
proxy*: string
proxyAuth*: string
+ apiProxy*: string
+ disableTid*: bool
+ maxConcurrentReqs*: int
+ maxRetries*: int
+ retryDelayMs*: int
rssCacheTime*: int
listCacheTime*: int
@@ -303,3 +461,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[tweet]
+
+proc getPhotos*(tweet: Tweet): seq[Photo] =
+ tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
+
+proc getVideos*(tweet: Tweet): seq[Video] =
+ tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
+
+proc hasPhotos*(tweet: Tweet): bool =
+ tweet.media.anyIt(it.kind == photoMedia)
+
+proc hasVideos*(tweet: Tweet): bool =
+ tweet.media.anyIt(it.kind == videoMedia)
+
+proc hasGifs*(tweet: Tweet): bool =
+ tweet.media.anyIt(it.kind == gifMedia)
+
+proc getThumb*(media: Media): string =
+ case media.kind
+ of photoMedia: media.photo.url
+ of videoMedia: media.video.thumb
+ of gifMedia: media.gif.thumb
diff --git a/src/utils.nim b/src/utils.nim
index c96a6dd..95b46de 100644
--- a/src/utils.nim
+++ b/src/utils.nim
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, strformat, uri, tables, base64
+import sequtils, strutils, strformat, uri, tables, base64
import nimcrypto
var
@@ -9,7 +9,7 @@ var
const
https* = "https://"
twimg* = "pbs.twimg.com/"
- nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
+ nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
twitterDomains = @[
"twitter.com",
"pic.twitter.com",
@@ -17,7 +17,9 @@ const
"abs.twimg.com",
"pbs.twimg.com",
"video.twimg.com",
- "x.com"
+ "x.com",
+ "pscp.tv",
+ "video.pscp.tv"
]
proc setHmacKey*(key: string) =
@@ -38,12 +40,14 @@ proc getVidUrl*(link: string): string =
&"/video/{sig}/{encodeUrl(link)}"
proc getPicUrl*(link: string): string =
+ if link.len == 0: return
if base64Media:
&"/pic/enc/{encode(link, safe=true)}"
else:
&"/pic/{encodeUrl(link)}"
proc getOrigPicUrl*(link: string): string =
+ if link.len == 0: return
if base64Media:
&"/pic/orig/enc/{encode(link, safe=true)}"
else:
@@ -55,7 +59,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
result.add p
proc isTwitterUrl*(uri: Uri): bool =
- uri.hostname in twitterDomains
+ uri.scheme in ["http", "https"] and
+ (uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv"))
proc isTwitterUrl*(url: string): bool =
isTwitterUrl(parseUri(url))
+
+proc validateNumber*(value: string): string =
+ if value.anyIt(not it.isDigit):
+ return ""
+ return value
diff --git a/src/views/about_account.nim b/src/views/about_account.nim
new file mode 100644
index 0000000..aedd444
--- /dev/null
+++ b/src/views/about_account.nim
@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, formatters]
+
+proc renderAboutAccount*(info: AccountInfo): VNode =
+ let user = User(
+ username: info.username,
+ fullname: info.fullname,
+ userPic: info.userPic,
+ verifiedType: info.verifiedType
+ )
+
+ buildHtml(tdiv(class="about-account")):
+ tdiv(class="about-account-header"):
+ a(class="about-account-avatar", href=(&"/{info.username}")):
+ genImg(getUserPic(info.userPic, "_200x200"))
+ tdiv(class="about-account-name"):
+ linkUser(user, class="profile-card-fullname")
+ verifiedIcon(user)
+ linkUser(user, class="profile-card-username")
+
+ tdiv(class="about-account-body"):
+ tdiv(class="about-account-row"):
+ span: icon "calendar"
+ tdiv:
+ span(class="about-account-label"): text "Date joined"
+ span(class="about-account-value"):
+ text info.joinDate.format("MMMM YYYY")
+
+ if info.basedIn.len > 0:
+ tdiv(class="about-account-row"):
+ span: icon "location"
+ tdiv:
+ span(class="about-account-label"): text "Account based in"
+ span(class="about-account-value"): text info.basedIn
+
+ if info.verifiedType != VerifiedType.none:
+ if info.overrideVerifiedYear != 0:
+ tdiv(class="about-account-row"):
+ span: icon "ok"
+ tdiv:
+ span(class="about-account-label"): text "Verified"
+ span(class="about-account-value"):
+ let year = abs(info.overrideVerifiedYear)
+ let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
+ text "Since " & $year & era
+ elif info.verifiedSince.year > 0:
+ tdiv(class="about-account-row"):
+ span: icon "ok"
+ tdiv:
+ span(class="about-account-label"): text "Verified"
+ span(class="about-account-value"):
+ text "Since " & info.verifiedSince.format("MMMM YYYY")
+
+ if info.isIdentityVerified:
+ tdiv(class="about-account-row"):
+ span: icon "ok"
+ tdiv:
+ span(class="about-account-label"): text "ID Verified"
+ span(class="about-account-value"): text "Yes"
+
+ if info.affiliateUsername.len > 0:
+ tdiv(class="about-account-row"):
+ span: icon "group"
+ tdiv:
+ span(class="about-account-label"): text "An affiliate of"
+ span(class="about-account-value"):
+ a(href=(&"/{info.affiliateUsername}")):
+ if info.affiliateLabel.len > 0:
+ text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
+ else:
+ text "@" & info.affiliateUsername
+
+ if info.usernameChanges > 0:
+ tdiv(class="about-account-row"):
+ span(class="about-account-at"): text "@"
+ tdiv:
+ span(class="about-account-label"):
+ text $info.usernameChanges & " username change"
+ if info.usernameChanges > 1: text "s"
+ if info.lastUsernameChange.year > 0:
+ span(class="about-account-value"):
+ text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
+
+ if info.source.len > 0:
+ tdiv(class="about-account-row"):
+ span: icon "link"
+ tdiv:
+ span(class="about-account-label"): text "Connected via"
+ span(class="about-account-value"): text info.source
diff --git a/src/views/article.nim b/src/views/article.nim
new file mode 100644
index 0000000..eedd4cf
--- /dev/null
+++ b/src/views/article.nim
@@ -0,0 +1,248 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, tables, unicode, bitops, uri
+import karax/[karaxdsl, vdom]
+
+import renderutils, tweet, timeline
+import ".."/[types, utils, formatters]
+
+proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article;
+ tweets: Table[int64, Tweet]; path: string;
+ prefs: Prefs): VNode =
+ if paragraph.entityRanges.len == 0:
+ return text ""
+
+ let er = paragraph.entityRanges[0]
+ if er.key notin article.entities:
+ return text ""
+
+ let entity = article.entities[er.key]
+
+ case entity.kind
+ of "MEDIA":
+ buildHtml(tdiv(class="article-media")):
+ for id in entity.mediaIds:
+ let media = article.media.getOrDefault(id)
+ if media.url.len == 0:
+ continue
+ case media.kind
+ of "ApiGif":
+ video(src=getVidUrl(media.url), controls="", autoplay="", loop="",
+ muted="")
+ of "ApiVideo":
+ video(src=getVidUrl(media.url), controls="")
+ else:
+ a(href=getOrigPicUrl(media.url), target="_blank"):
+ img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy")
+ if entity.caption.len > 0:
+ p(class="article-media-caption"): text entity.caption
+ of "TWEET":
+ let tweet = tweets.getOrDefault(
+ try: parseBiggestInt(entity.tweetId)
+ except ValueError: 0, nil)
+ if tweet != nil:
+ renderTweet(tweet, prefs, path)
+ else:
+ text ""
+ of "MARKDOWN":
+ var content = entity.markdown
+ if content.startsWith("```"):
+ let firstNl = content.find('\n')
+ if firstNl >= 0: content = content[firstNl + 1 .. ^1]
+ if content.endsWith("```"): content = content[0 .. ^4]
+ content = content.strip
+ buildHtml(pre()):
+ code(): text content
+ of "DIVIDER":
+ buildHtml(hr(class="article-divider"))
+ else:
+ text ""
+
+proc wrapStyle(node: VNode; style: int): VNode =
+ result = node
+ if style.testBit(4): result = buildHtml(code()): result
+ if style.testBit(0): result = buildHtml(strong()): result
+ if style.testBit(1): result = buildHtml(em()): result
+ if style.testBit(2): result = buildHtml(del()): result
+ if style.testBit(3): result = buildHtml(underlined()): result
+
+proc addContent(target: VNode; content: string; style = 0) =
+ var first = true
+ for line in content.split('\n'):
+ if not first:
+ target.add VNode(kind: VNodeKind.br)
+ first = false
+ var pos = 0
+ while pos < line.len:
+ let atPos = line.find('@', pos)
+ if atPos == -1:
+ target.add wrapStyle(text line[pos .. ^1], style)
+ break
+ if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}:
+ target.add wrapStyle(text line[pos .. atPos], style)
+ pos = atPos + 1
+ continue
+ var j = atPos + 1
+ while j < line.len and j - atPos - 1 < 15 and
+ line[j] in Letters + Digits + {'_'}:
+ inc j
+ if j == atPos + 1:
+ target.add wrapStyle(text line[pos .. atPos], style)
+ pos = atPos + 1
+ continue
+ if atPos > pos:
+ target.add wrapStyle(text line[pos ..< atPos], style)
+ let username = line[atPos + 1 ..< j]
+ let link = a.newVNode()
+ link.setAttr("href", "/" & username)
+ link.add wrapStyle(text ("@" & username), style)
+ target.add link
+ pos = j
+
+proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int;
+ styles: seq[ArticleStyle]) =
+ if styles.len == 0:
+ target.addContent($runes[start ..< start + length])
+ return
+
+ var
+ lastStyle = 0
+ lastStart = start
+ let endPos = start + length
+
+ for i in start ..< endPos:
+ var style = 0
+ for sr in styles:
+ let
+ sStart = sr.offset
+ sEnd = sStart + sr.length
+ if sStart <= i and sEnd > i:
+ case sr.style
+ of "Bold": style.setBit(0)
+ of "Italic": style.setBit(1)
+ of "Strikethrough": style.setBit(2)
+ of "Underline": style.setBit(3)
+ of "Code": style.setBit(4)
+ else: discard
+
+ if style != lastStyle:
+ if i > lastStart:
+ addContent(target, $runes[lastStart ..< i], lastStyle)
+ lastStyle = style
+ lastStart = i
+
+ if lastStart < endPos:
+ addContent(target, $runes[lastStart ..< endPos], lastStyle)
+
+proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode =
+ let text = paragraph.text
+
+ result = case paragraph.kind
+ of "header-one": h1.newVNode()
+ of "header-two": h2.newVNode()
+ of "header-three": h3.newVNode()
+ of "ordered-list-item", "unordered-list-item": li.newVNode()
+ of "blockquote": VNode(kind: VNodeKind.blockquote)
+ of "code-block":
+ let pre = pre.newVNode()
+ let code = code.newVNode()
+ code.add text text
+ pre.add code
+ return pre
+ else: p.newVNode()
+
+ let
+ runes = text.toRunes
+ textLen = runes.len
+ var last = 0
+ for er in paragraph.entityRanges:
+ if er.offset > last:
+ applyInlineStyles(result, runes, last, er.offset - last,
+ paragraph.inlineStyles)
+
+ last = er.offset + er.length
+
+ var target = result
+ if er.key in article.entities:
+ let entity = article.entities[er.key]
+ if entity.kind == "LINK":
+ let parsed = parseUri(entity.url)
+ if parsed.scheme in ["http", "https"]:
+ target = a.newVNode()
+ if parsed.isTwitterUrl:
+ target.setAttr("href", parsed.path)
+ else:
+ target.setAttr("href", entity.url)
+
+ applyInlineStyles(target, runes, er.offset, er.length,
+ paragraph.inlineStyles)
+ if target != result:
+ result.add target
+
+ if last < textLen:
+ applyInlineStyles(result, runes, last, textLen - last,
+ paragraph.inlineStyles)
+
+ if paragraph.kind == "blockquote" and result.len > 0:
+ let lastChild = result[result.len - 1]
+ if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and
+ lastChild[0].kind == VNodeKind.text:
+ lastChild.setAttr("class", "blockquote-attribution")
+
+proc renderArticle*(article: Article; tweets: Table[int64, Tweet];
+ path: string; prefs: Prefs; tweetId=""): VNode =
+ let author = article.user
+
+ let main = buildHtml(article(class="article-body")):
+ h1(class="article-title"): text article.title
+
+ tdiv(class="article-author"):
+ tdiv(class="article-author-row"):
+ a(class="article-avatar", href=("/" & author.username)):
+ genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass)
+ tdiv(class="article-author-info"):
+ tdiv(class="article-author-name"):
+ linkUser(author, class="fullname")
+ verifiedIcon(author)
+ tdiv(class="article-author-meta"):
+ linkUser(author, class="username")
+ span(class="article-date-sep"): text " · "
+ a(class="article-date",
+ href=("/" & author.username & "/status/" & tweetId)):
+ text article.time.getShortTime
+ if not prefs.hideTweetStats:
+ renderStats(article.stats)
+
+ var listKind = ""
+ var list: VNode = nil
+
+ for paragraph in article.paragraphs:
+ let isListItem = paragraph.kind in [
+ "ordered-list-item", "unordered-list-item"]
+
+ if not isListItem and list != nil:
+ main.add list
+ list = nil
+ listKind = ""
+
+ if paragraph.kind == "atomic":
+ main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs)
+ elif isListItem:
+ if paragraph.kind != listKind:
+ if list != nil:
+ main.add list
+ list = if paragraph.kind == "ordered-list-item": ol.newVNode()
+ else: ul.newVNode()
+ listKind = paragraph.kind
+ list.add renderTextParagraph(paragraph, article)
+ else:
+ main.add renderTextParagraph(paragraph, article)
+
+ if list != nil:
+ main.add list
+
+ buildHtml(tdiv(class="article-page")):
+ if article.coverImage.len > 0:
+ a(href=getOrigPicUrl(article.coverImage), target="_blank"):
+ img(class="article-cover", src=getSmallPic(article.coverImage), alt="")
+ main
+ renderToTop()
diff --git a/src/views/broadcast.nim b/src/views/broadcast.nim
new file mode 100644
index 0000000..bfcb9ba
--- /dev/null
+++ b/src/views/broadcast.nim
@@ -0,0 +1,75 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, utils, formatters]
+
+proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
+ let
+ isLive = bc.state == "RUNNING"
+ thumb = getPicUrl(bc.thumb)
+ source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
+ getVidUrl(bc.m3u8Url) else: bc.m3u8Url
+ stateText =
+ if isLive: "LIVE"
+ elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
+ elif bc.state.len > 0: bc.state
+ else: "Ended"
+ durationMs =
+ if bc.startTime.year > 1 and bc.endTime.year > 1:
+ int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
+ else: 0
+ duration = if durationMs > 0: getDuration(durationMs) else: ""
+
+ buildHtml(tdiv(class="broadcast-page")):
+ tdiv(class="broadcast-panel"):
+ tdiv(class="broadcast-player"):
+ if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
+ video(poster=thumb, data-url=source, data-autoload="false",
+ data-start=($bc.replayStart), muted=prefs.muteVideos)
+ verbatim ""
+ tdiv(class="overlay-circle"): span(class="overlay-triangle")
+ if isLive:
+ tdiv(class="broadcast-live"): text "LIVE"
+ elif duration.len > 0:
+ tdiv(class="overlay-duration"): text duration
+ verbatim "
"
+ elif bc.m3u8Url.len > 0:
+ img(src=thumb, alt=bc.title)
+ tdiv(class="video-overlay"):
+ buttonReferer "/enablehls", "Enable hls playback", path
+ if isLive:
+ tdiv(class="broadcast-live"): text "LIVE"
+ elif duration.len > 0:
+ tdiv(class="overlay-duration"): text duration
+ elif bc.thumb.len > 0:
+ img(src=thumb, alt=bc.title)
+ tdiv(class="video-overlay"):
+ if bc.availableForReplay:
+ p: text "Stream unavailable"
+ else:
+ p: text "Replay is not available"
+ else:
+ tdiv(class="video-overlay"):
+ p: text "Broadcast not found"
+
+ tdiv(class="broadcast-info"):
+ h2(class="broadcast-title"): text bc.title
+
+ tdiv(class="broadcast-user-row"):
+ a(class="broadcast-user", href=("/" & bc.user.username)):
+ genImg(getUserPic(bc.user.userPic, "_bigger"))
+ tdiv:
+ tdiv:
+ strong: text bc.user.fullname
+ verifiedIcon(bc.user)
+ span(class="broadcast-username"): text "@" & bc.user.username
+
+ tdiv(class="broadcast-meta"):
+ if bc.totalWatched > 0:
+ span: text insertSep($bc.totalWatched, ',') & " views"
+ if isLive:
+ span(class="broadcast-live"): text stateText
+ else:
+ span: text stateText
diff --git a/src/views/community.nim b/src/views/community.nim
new file mode 100644
index 0000000..52f9041
--- /dev/null
+++ b/src/views/community.nim
@@ -0,0 +1,128 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, utils, formatters]
+
+proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode =
+ let
+ path = &"/i/communities/{community.id}"
+ q = Query(kind: kind)
+ buildHtml(tdiv):
+ ul(class="tab"):
+ li(class=q.getTabClass(posts)):
+ a(href=path): text "Top"
+ li(class=q.getTabClass(replies)):
+ a(href=(path & "/latest")): text "Latest"
+ li(class=q.getTabClass(media)):
+ a(href=(path & "/media")): text "Media"
+ li(class=q.getTabClass(userList)):
+ a(href=(path & "/about")): text "About"
+ if community.hashtags.len > 0:
+ tdiv(class="community-tags"):
+ for tag in community.hashtags:
+ let bare = tag.strip(chars={'#'})
+ a(class="community-tag",
+ href=(&"/i/communities/{community.id}/hashtag/{bare}")):
+ text tag
+
+proc renderMemberTabs*(community: Community; isModerators: bool): VNode =
+ let path = &"/i/communities/{community.id}"
+ buildHtml(ul(class="tab")):
+ li(class=(if not isModerators: "tab-item active" else: "tab-item")):
+ a(href=(path & "/members")): text "All"
+ li(class=(if isModerators: "tab-item active" else: "tab-item")):
+ a(href=(path & "/moderators")): text "Moderators"
+
+proc renderHashtagHeader*(community: Community; tag: string): VNode =
+ buildHtml(tdiv(class="community-hashtag-header")):
+ h2(class="community-hashtag-title"): text "#" & tag
+
+proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode =
+ buildHtml(tdiv(class="community-about")):
+ tdiv(class="community-info"):
+ h2: text "Community Info"
+ tdiv(class="community-info-item"):
+ icon "group"
+ if community.joinPolicy == "Open":
+ text "Anyone can join this Community."
+ else:
+ text "Membership is by approval only."
+
+ tdiv(class="community-info-item"):
+ icon "info"
+ text "All Communities are publicly visible."
+
+ tdiv(class="community-info-item"):
+ icon "calendar"
+ let
+ date = community.createdAt.format("MMMM d, yyyy")
+ creator = community.creator.username
+ span:
+ text &"Created {date} by "
+ a(href=(&"/{creator}")): text &"@{creator}"
+ if community.creator.verifiedType != none:
+ verifiedIcon(community.creator)
+
+ if community.rules.len > 0:
+ tdiv(class="community-rules"):
+ h2: text "Rules"
+ p(class="community-rules-intro"):
+ text "These are set and enforced by Community admins and are in addition to "
+ a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules"
+ text "."
+
+ for i, rule in community.rules:
+ tdiv(class="community-rule"):
+ span(class="community-rule-number"): text $(i + 1)
+ tdiv(class="community-rule-content"):
+ strong: text rule.name
+ if rule.description.len > 0:
+ p: text rule.description
+
+ if moderators.len > 0:
+ tdiv(class="community-moderators"):
+ h2:
+ text "Moderators"
+ a(class="community-mods-link",
+ href=(&"/i/communities/{community.id}/moderators")):
+ text "See all"
+ for user in moderators:
+ tdiv(class="community-moderator"):
+ a(href=(&"/{user.username}")):
+ genImg(user.getUserPic("_bigger"), class="community-mod-avatar")
+ tdiv(class="community-mod-info"):
+ a(href=(&"/{user.username}"), class="community-mod-name"):
+ text user.fullname
+ if user.verifiedType != none:
+ verifiedIcon(user)
+ a(href=(&"/{user.username}"), class="community-mod-username"):
+ text &"@{user.username}"
+
+proc renderCommunity*(body, nav: VNode; community: Community): VNode =
+ buildHtml(tdiv(class="timeline-container")):
+ if community.banner.len > 0:
+ tdiv(class="timeline-banner"):
+ a(href=getPicUrl(community.banner), target="_blank"):
+ genImg(community.banner)
+
+ tdiv(class="community-header"):
+ h1(class="community-name"):
+ a(href=(&"/i/communities/{community.id}")): text community.name
+
+ if community.category.len > 0:
+ span(class="community-category"): text community.category
+
+ if community.description.len > 0:
+ tdiv(class="community-description"):
+ text community.description
+
+ tdiv(class="community-stats"):
+ a(class="community-member-count",
+ href=(&"/i/communities/{community.id}/members")):
+ text insertSep($community.memberCount, ',')
+ text " Members"
+
+ nav
+ body
diff --git a/src/views/embed.nim b/src/views/embed.nim
index ba49f45..5136a35 100644
--- a/src/views/embed.nim
+++ b/src/views/embed.nim
@@ -1,22 +1,76 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import options
import karax/[karaxdsl, vdom]
from jester import Request
-import ".."/[types, formatters]
+import ".."/[types, formatters, prefs]
import general, tweet
-const doctype = "\n"
+const
+ doctype = "\n"
+ embedResizeJs = staticRead("../../public/js/embedResize.js")
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
- let thumb = get(tweet.video).thumb
- let vidUrl = getVideoEmbed(cfg, tweet.id)
- let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
+ let
+ video = tweet.getVideos()[0]
+ thumb = video.thumb
+ vidUrl = getVideoEmbed(cfg, tweet.id)
+ prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos)
+ tweetUrl = getLink(tweet)
+
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
+ base(target="_blank")
body:
tdiv(class="embed-video"):
- renderVideo(get(tweet.video), prefs, "")
+ renderVideo(video, prefs, "")
+ a(class="video-overlay-link", href=tweetUrl):
+ text "Watch on " & cfg.hostname
+
+ script:
+ verbatim embedResizeJs
+
+ result = doctype & $node
+
+proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
+ let node = buildHtml(html(lang="en")):
+ renderHead(prefs, cfg, req)
+ base(target="_blank")
+
+ body:
+ tdiv(class="embed-wrapper"):
+ tdiv(class="tweet-embed"):
+ a(class="tweet-link", href=getLink(tweet))
+ renderTweet(tweet, prefs, path, mainTweet=true)
+ a(class="embed-footer", href=getLink(tweet)):
+ text "Read more on " & cfg.hostname
+
+ script:
+ verbatim embedResizeJs
+
+ result = doctype & $node
+
+proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request;
+ tweetId = ""; username = ""): string =
+ let link = if tweetId.len > 0:
+ if username.len > 0: "/" & username & "/status/" & tweetId
+ else: "/i/status/" & tweetId
+ else: "/"
+
+ let node = buildHtml(html(lang="en")):
+ renderHead(prefs, cfg, req)
+ base(target="_blank")
+
+ body:
+ tdiv(class="embed-wrapper"):
+ tdiv(class="tweet-embed error-embed"):
+ a(class="tweet-link", href=link)
+ tdiv(class="error-panel"):
+ span: text error
+ a(class="embed-footer", href=link):
+ text "Read more on " & cfg.hostname
+
+ script:
+ verbatim embedResizeJs
result = doctype & $node
diff --git a/src/views/general.nim b/src/views/general.nim
index 0091c74..d979898 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
- if cfg.enableRss and rss.len > 0:
+ if rss.len > 0:
icon "rss", title="RSS Feed", href=rss
- icon "bird", title="Open in Twitter", href=canonical
+ icon "bird", title="Open in X", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
- rss=""; canonical=""): VNode =
- var theme = prefs.theme.toTheme
- if "theme" in req.params:
- theme = req.params["theme"].toTheme
+ rss=""; alternate=""; oembed=""): VNode =
+ let theme = prefs.theme.toTheme
let ogType =
if video.len > 0: "video"
@@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head):
- link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
- link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
+ link(rel="stylesheet", type="text/css", href="/css/style.css?v=106")
+ link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
@@ -66,15 +64,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl)
- if canonical.len > 0:
- link(rel="canonical", href=canonical)
+ if alternate.len > 0:
+ link(rel="alternate", href=alternate, title="View on X")
- if cfg.enableRss and rss.len > 0:
+ if rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
+ if oembed.len > 0:
+ let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
+ link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
+
if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="")
- script(src="/js/hlsPlayback.js", `defer`="")
+ script(src="/js/hlsPlayback.js?v=1", `defer`="")
if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="")
@@ -86,6 +88,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
text cfg.title
meta(name="viewport", content="width=device-width, initial-scale=1.0")
+ meta(name="referrer", content="same-origin")
meta(name="theme-color", content="#1F1F1F")
meta(property="og:type", content=ogType)
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
@@ -98,6 +101,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
for url in images:
+ if url.len == 0: continue
let preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
@@ -119,20 +123,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
# this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason
link(rel="preload", type="font/woff2", `as`="font",
- href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
+ href="/fonts/fontello.woff2?59696369", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
- images: seq[string] = @[]; banner=""): string =
+ images: seq[string] = @[]; banner="";
+ twitterLink=""; oembed=""): string =
- let canonical = getTwitterLink(req.path, req.params)
+ let twitterLink =
+ if twitterLink.len > 0: twitterLink
+ else: getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
- rss, canonical)
+ rss, twitterLink, oembed)
- body:
- renderNavbar(cfg, req, rss, canonical)
+ let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
+ body(class=bodyClass):
+ renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"):
body
diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf
new file mode 100644
index 0000000..4f7f947
--- /dev/null
+++ b/src/views/oembed.nimf
@@ -0,0 +1,7 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+## SPDX-License-Identifier: AGPL-3.0-only
+#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
+# result = ""
+
+# result = result.strip()
+#end proc
diff --git a/src/views/preferences.nim b/src/views/preferences.nim
index 1787704..b051a01 100644
--- a/src/views/preferences.nim
+++ b/src/views/preferences.nim
@@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
result[2].add stmt
-proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
+proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
+ prefsUrl: string): VNode =
buildHtml(tdiv(class="overlay-panel")):
fieldset(class="preferences"):
form(`method`="post", action="/saveprefs", autocomplete="off"):
@@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
renderPrefs()
+ legend: text "Bookmark"
+ p(class="bookmark-note"):
+ text "Save this URL to restore your preferences (?prefs works on all pages)"
+ pre(class="prefs-code"):
+ text prefsUrl
+ p(class="bookmark-note"):
+ verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
+
h4(class="note"):
text "Preferences are stored client-side using cookies without any personal information."
diff --git a/src/views/profile.nim b/src/views/profile.nim
index 2b2e410..c9012ed 100644
--- a/src/views/profile.nim
+++ b/src/views/profile.nim
@@ -2,7 +2,7 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
-import renderutils, search
+import renderutils, search, timeline
import ".."/[types, utils, formatters]
proc renderStat(num: int; class: string; text=""): VNode =
@@ -12,7 +12,14 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
-proc renderUserCard*(user: User; prefs: Prefs): VNode =
+proc renderStatLink(num: int; class, href: string): VNode =
+ buildHtml(li(class=class)):
+ a(href=href):
+ span(class="profile-stat-header"): text capitalizeAscii(class)
+ span(class="profile-stat-num"):
+ text insertSep($num, ',')
+
+proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"):
let
@@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname")
+ verifiedIcon(user)
linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"):
@@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
else:
span: text place
+ if info.basedIn.len > 0:
+ tdiv(class="profile-location"):
+ span: icon "location"
+ span: text "Based in " & info.basedIn
+
if user.website.len > 0:
tdiv(class="profile-website"):
span:
@@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(href=url): text url.shortLink
tdiv(class="profile-joindate"):
- span(title=getJoinDateFull(user)):
+ a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
icon "calendar", getJoinDate(user)
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets")
- renderStat(user.following, "following")
- renderStat(user.followers, "followers")
+ renderStatLink(user.following, "following", &"/{user.username}/following")
+ renderStatLink(user.followers, "followers", &"/{user.username}/followers")
renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode =
@@ -93,7 +106,7 @@ proc renderBanner(banner: string): VNode =
else:
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
-proc renderProtected(username: string): VNode =
+proc renderProtected*(username: string): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header timeline-protected"):
h2: text "This account's tweets are protected."
@@ -101,19 +114,52 @@ proc renderProtected(username: string): VNode =
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username]
+ let
+ isGalleryView = profile.tweets.query.kind == QueryKind.media and
+ profile.tweets.query.view == "gallery"
+ viewClass = if isGalleryView: " media-only" else: ""
- buildHtml(tdiv(class="profile-tabs")):
- if not prefs.hideBanner:
+ buildHtml(tdiv(class=("profile-tabs" & viewClass))):
+ if not isGalleryView and not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(profile.user.banner)
- let sticky = if prefs.stickyProfile: " sticky" else: ""
- tdiv(class=("profile-tab" & sticky)):
- renderUserCard(profile.user, prefs)
- if profile.photoRail.len > 0:
- renderPhotoRail(profile)
+ if not isGalleryView:
+ let sticky = if prefs.stickyProfile: " sticky" else: ""
+ tdiv(class=("profile-tab" & sticky)):
+ renderUserCard(profile.user, prefs, profile.accountInfo)
+ if profile.photoRail.len > 0:
+ renderPhotoRail(profile)
if profile.user.protected:
renderProtected(profile.user.username)
else:
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
+
+proc renderFollowTabs(user: User; activeTab: string): VNode =
+ buildHtml(ul(class="tab")):
+ for tab in ["Following", "Followers"]:
+ li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
+ a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
+
+proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
+ path, activeTab: string): VNode =
+ # Check if we've loaded all results (for page 1)
+ var displayResults = results
+ if results.beginning:
+ let expectedCount = if activeTab == "Followers": user.followers else: user.following
+ if results.content.len >= expectedCount:
+ displayResults.bottom = ""
+
+ buildHtml(tdiv(class="profile-tabs")):
+ if not prefs.hideBanner:
+ tdiv(class="profile-banner"):
+ renderBanner(user.banner)
+
+ let sticky = if prefs.stickyProfile: " sticky" else: ""
+ tdiv(class=("profile-tab" & sticky)):
+ renderUserCard(user, prefs, AccountInfo())
+
+ tdiv(class="timeline-container"):
+ renderFollowTabs(user, activeTab)
+ renderTimelineUsers(displayResults, prefs, path)
diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim
index 41ef8df..af2f05d 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils]
const smallWebp* = "?name=small&format=webp"
+const mediumWebp* = "?name=medium&format=webp"
proc getSmallPic*(url: string): string =
+ if url.len == 0: return
result = url
if "?" notin url and not url.endsWith("placeholder.png"):
result &= smallWebp
result = getPicUrl(result)
-proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
+proc getMediumPic*(url: string): string =
+ if url.len == 0: return
+ result = url
+ if "?" notin url and not url.endsWith("placeholder.png"):
+ result &= mediumWebp
+ result = getPicUrl(result)
+
+proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")):
@@ -20,13 +29,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
else:
span(class=c, title=title)
- if text.len > 0:
- text " " & text
+ if label.len > 0:
+ text " " & label
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii()
- icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
+ buildHtml(tdiv(class=(&"verified-icon {lower}"))):
+ icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
+ icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
else:
text ""
@@ -40,7 +51,6 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
if isName:
- verifiedIcon(user)
if user.protected:
text " "
icon "lock", title="Protected account"
@@ -64,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
text text
proc genCheckbox*(pref, label: string; state: bool): VNode =
- buildHtml(label(class="pref-group checkbox-container")):
+ buildHtml(label(class="pref-group checkbox-container", title=pref)):
text label
input(name=pref, `type`="checkbox", checked=state)
span(class="checkbox")
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
let p = placeholder
- buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
- buildHtml(tdiv(class="pref-group pref-input")):
+ buildHtml(tdiv(class="pref-group pref-input", title=pref)):
label(`for`=pref): text label
select(name=pref):
for opt in options:
@@ -89,9 +99,16 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state)
icon "calendar"
-proc genImg*(url: string; class=""): VNode =
+proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
+ let p = placeholder
+ buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ if label.len > 0:
+ label(`for`=pref): text label
+ input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
+
+proc genImg*(url: string; class=""; alt=""): VNode =
buildHtml():
- img(src=getPicUrl(url), class=class, alt="", loading="lazy")
+ img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active"
diff --git a/src/views/rss.nimf b/src/views/rss.nimf
index 819f99c..4738705 100644
--- a/src/views/rss.nimf
+++ b/src/views/rss.nimf
@@ -1,31 +1,140 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
-#import strutils, xmltree, strformat, options, unicode
+#import strutils, sequtils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs
+## Snowflake ID cutoff for RSS GUID format transition
+## Corresponds to approximately December 14, 2025 UTC
+#const guidCutoff = 2000000000000000000'i64
#
#proc getTitle(tweet: Tweet; retweet: string): string =
-#if tweet.pinned: result = "Pinned: "
-#elif retweet.len > 0: result = &"RT by @{retweet}: "
-#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
+#var prefix = ""
+#if tweet.pinned: prefix = "Pinned: "
+#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
+#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
#end if
-#var text = stripHtml(tweet.text)
+#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ")
##if unicode.runeLen(text) > 32:
## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if
-#result &= xmltree.escape(text)
-#if result.len > 0: return
+#text = xmltree.escape(text)
+## article tweets' text is just the article link; the title says more
+#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0:
+# result = prefix & xmltree.escape(tweet.articlePreview.get().title)
+# return
#end if
-#if tweet.photos.len > 0:
-# result &= "Image"
-#elif tweet.video.isSome:
-# result &= "Video"
-#elif tweet.gif.isSome:
-# result &= "Gif"
+#if text.len > 0:
+# result = prefix & text
+# return
+#end if
+#if tweet.media.len > 0:
+# result = prefix
+# let firstKind = tweet.media[0].kind
+# if tweet.media.anyIt(it.kind != firstKind):
+# result &= "Media"
+# else:
+# case firstKind
+# of photoMedia: result &= "Image"
+# of videoMedia: result &= "Video"
+# of gifMedia: result &= "Gif"
+# end case
+# end if
+#end if
+#if result.len == 0 and tweet.card.isSome:
+# let card = tweet.card.get()
+# if card.kind notin {hidden, unknown} and card.title.len > 0:
+# result = prefix & xmltree.escape(card.title)
+# end if
#end if
#end proc
#
#proc getDescription(desc: string; cfg: Config): string =
-Twitter feed for: ${desc}. Generated by ${cfg.hostname}
+Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
+#end proc
+#
+#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
+#case media.kind
+#of photoMedia:
+# let photo = media.photo
+
+#of videoMedia:
+# let video = media.video
+
+
Video
+
+
+#of gifMedia:
+# let gif = media.gif
+# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
+# let url = &"{urlPrefix}{getPicUrl(gif.url)}"
+
+#end case
+#end proc
+#
+#proc renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string =
+#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs)
+#let title = xmltree.escape(card.title)
+
+Link
+#if cardLink.len > 0:
+
+#end if
+#if card.image.len > 0:
+
+#end if
+#if title.len > 0:
+# if card.image.len > 0:
+
+# end if
+${title}
+#end if
+#if cardLink.len > 0:
+
+#end if
+#if card.text.len > 0:
+${xmltree.escape(card.text)}
+#end if
+#if cardLink.len > 0:
+# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink)
+${destText}
+#elif card.dest.len > 0:
+${xmltree.escape(card.dest)}
+#end if
+#end proc
+#
+#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string =
+#let link = urlPrefix & "/i/article/" & $article.tweetId
+
+Article
+
+#if article.coverImage.len > 0:
+
+#end if
+#if article.title.len > 0:
+# if article.coverImage.len > 0:
+
+# end if
+${xmltree.escape(article.title)}
+#end if
+
+#if article.previewText.len > 0:
+${xmltree.escape(article.previewText)}
+#end if
+#end proc
+#
+#proc renderRssPoll(poll: Poll): string =
+
+Poll
+
+#for i in 0 ..< poll.options.len:
+# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0
+# let pct = (&"{perc:.0f}").strip(chars={'.'})
+# let line = pct & "% — " & xmltree.escape(poll.options[i])
+${line}
+#end for
+#let votesStr = insertSep($poll.votes, ',')
+${votesStr} votes • ${xmltree.escape(poll.status)}
+
#end proc
#
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
@@ -46,35 +155,46 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end if
#end proc
#
-#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
+#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
#let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg)
-#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
+#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
+#if text.len > 0:
${text.replace("\n", "
\n")}
-#if tweet.quote.isSome and get(tweet.quote).available:
-# let quoteLink = getLink(get(tweet.quote))
-${cfg.hostname}${quoteLink}
#end if
-#if tweet.photos.len > 0:
-# for photo in tweet.photos:
-
+#if tweet.media.len > 0:
+# for media in tweet.media:
+${renderRssMedia(media, tweet, urlPrefix)}
# end for
-#elif tweet.video.isSome:
-
-#elif tweet.gif.isSome:
-# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
-# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
-
-#elif tweet.card.isSome:
-# let card = tweet.card.get()
-# if card.image.len > 0:
-
-# end if
+#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}:
+${renderRssCard(tweet.card.get(), prefs, urlPrefix)}
+#end if
+#if tweet.articlePreview.isSome:
+${renderRssArticle(tweet.articlePreview.get(), urlPrefix)}
+#end if
+#if tweet.poll.isSome:
+${renderRssPoll(tweet.poll.get())}
+#end if
+#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
+Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}
+#end if
+#if tweet.quote.isSome and get(tweet.quote).available:
+# let quoteTweet = get(tweet.quote)
+# let quoteLink = urlPrefix & getLink(quoteTweet)
+
+
+${quoteTweet.user.fullname} (@${quoteTweet.user.username})
+
+${renderRssTweet(quoteTweet, cfg, prefs)}
+
+
+
#end if
#end proc
#
-#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
+#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
#let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string]
#for thread in tweets:
@@ -88,19 +208,24 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# if link in links: continue
# end if
# links.add link
+# let useGlobalGuid = tweet.id >= guidCutoff
-
${getTitle(tweet, retweet)}
@${tweet.user.username}
-
+
${getRfc822Time(tweet)}
+#if useGlobalGuid:
+ ${tweet.id}
+#else:
${urlPrefix & link}
+#end if
${urlPrefix & link}
# end for
#end for
#end proc
#
-#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
+#proc renderTimelineRss*(profile: Profile; cfg: Config; prefs: Prefs; multi=false): string =
#let urlPrefix = getUrlPrefix(cfg)
#result = ""
#let handle = (if multi: "" else: "@") & profile.user.username
@@ -126,13 +251,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0:
-${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
+${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
#end if
#end proc
#
-#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
+#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = ""
@@ -144,12 +269,12 @@ ${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
${getDescription(&"{list.name} by @{list.username}", cfg)}
en-us
40
-${renderRssTweets(tweets, cfg)}
+${renderRssTweets(tweets, cfg, prefs)}
#end proc
#
-#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
+#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
#let link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name)
#result = ""
@@ -162,7 +287,7 @@ ${renderRssTweets(tweets, cfg)}
${getDescription(&"Search \"{escName}\"", cfg)}
en-us
40
-${renderRssTweets(tweets, cfg)}
+${renderRssTweets(tweets, cfg, prefs)}
#end proc
diff --git a/src/views/search.nim b/src/views/search.nim
index 9f7fc95..4d4ed5e 100644
--- a/src/views/search.nim
+++ b/src/views/search.nim
@@ -10,14 +10,12 @@ const toggles = {
"media": "Media",
"videos": "Videos",
"news": "News",
- "verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
- "safe": "Safe",
"quote": "Quotes",
- "pro_video": "Pro videos"
+ "spaces": "Spaces"
}.toOrderedTable
proc renderSearch*(): VNode =
@@ -38,29 +36,62 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
a(href=(link & "/with_replies")): text "Tweets & Replies"
li(class=query.getTabClass(media)):
a(href=(link & "/media")): text "Media"
+ if query.fromUser.len == 1:
+ li(class=query.getTabClass(QueryKind.articles)):
+ a(href=(link & "/articles")): text "Articles"
li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search"
+proc mediaViewUrl(query: Query; view: string): string =
+ var q = query
+ q.view = view
+ "?" & genQueryUrl(q)
+
+proc renderMediaViewTabs*(query: Query): VNode =
+ let currentView = if query.view.len > 0: query.view else: "timeline"
+ 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=query.mediaViewUrl("timeline")): text "Timeline"
+ li(class=cls("grid")):
+ a(href=query.mediaViewUrl("grid")): text "Grid"
+ li(class=cls("gallery")):
+ a(href=query.mediaViewUrl("gallery")): text "Gallery"
+
proc renderSearchTabs*(query: Query): VNode =
var q = query
+ # the media view mode only applies to the Media tab
+ q.view = ""
buildHtml(ul(class="tab")):
+ li(class=query.getTabClass(top)):
+ q.kind = top
+ a(href=("?" & genQueryUrl(q))): text "Top"
li(class=query.getTabClass(tweets)):
q.kind = tweets
- a(href=("?" & genQueryUrl(q))): text "Tweets"
+ a(href=("?" & genQueryUrl(q))): text "Latest"
+ li(class=query.getTabClass(media)):
+ q.kind = media
+ q.view = query.view
+ a(href=("?" & genQueryUrl(q))): text "Media"
li(class=query.getTabClass(users)):
q.kind = users
+ q.view = ""
a(href=("?" & genQueryUrl(q))): text "Users"
+ li(class=query.getTabClass(lists)):
+ q.kind = lists
+ a(href=("?" & genQueryUrl(q))): text "Lists"
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
- @[q.near, q.until, q.since].anyIt(it.len > 0))
+ @[q.minLikes, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",")
let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")):
- hiddenField("f", "tweets")
+ hiddenField("f", $query.kind)
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
@@ -85,36 +116,58 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
- span(class="search-title"): text "Near"
- genInput("near", "", query.near, "Location...", autofocus=false)
+ span(class="search-title"): text "Minimum likes"
+ genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode =
let query = results.query
- buildHtml(tdiv(class="timeline-container")):
+ let containerClass =
+ if query.fromUser.len == 0 and query.kind == QueryKind.media and
+ query.view == "gallery": "timeline-container media-only"
+ else: "timeline-container"
+ buildHtml(tdiv(class=containerClass)):
if query.fromUser.len > 1:
tdiv(class="timeline-header"):
text query.fromUser.join(" | ")
if query.fromUser.len > 0:
- renderProfileTabs(query, query.fromUser.join(","))
+ if query.kind != QueryKind.media or query.view != "gallery":
+ renderProfileTabs(query, query.fromUser.join(","))
+ if query.kind == QueryKind.media and query.fromUser.len == 1:
+ renderMediaViewTabs(query)
- if query.fromUser.len == 0 or query.kind == tweets:
+ if query.fromUser.len == 0 or query.kind == QueryKind.tweets:
tdiv(class="timeline-header"):
renderSearchPanel(query)
if query.fromUser.len == 0:
renderSearchTabs(query)
+ if query.kind == QueryKind.media:
+ renderMediaViewTabs(query)
renderTimelineTweets(results, prefs, path, pinned)
+proc renderSearchForm(kind, placeholder, value: string): VNode =
+ buildHtml(form(`method`="get", action="/search",
+ class="search-field", autocomplete="off")):
+ hiddenField("f", kind)
+ genInput("q", "", value, placeholder, class="pref-inline")
+ button(`type`="submit"): icon "search"
+
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"):
- form(`method`="get", action="/search", class="search-field", autocomplete="off"):
- hiddenField("f", "users")
- genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
- button(`type`="submit"): icon "search"
+ renderSearchForm("users", "Enter username...", results.query.text)
renderSearchTabs(results.query)
renderTimelineUsers(results, prefs)
+
+proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs;
+ path: string): VNode =
+ buildHtml(tdiv(class="timeline-container")):
+ tdiv(class="timeline-header"):
+ renderSearchForm("lists", "Enter search...", results.query.text)
+
+ renderSearchTabs(results.query)
+ renderTimelineLists(results, prefs, path)
diff --git a/src/views/space.nim b/src/views/space.nim
new file mode 100644
index 0000000..a5cac7b
--- /dev/null
+++ b/src/views/space.nim
@@ -0,0 +1,86 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, utils, formatters]
+
+proc renderParticipant(p: SpaceParticipant; role: string): VNode =
+ buildHtml(tdiv(class="space-participant")):
+ a(href=("/" & p.username)):
+ genImg(p.avatarUrl.replace("_normal", "_bigger"))
+ tdiv(class="participant-info"):
+ tdiv(class="participant-name"):
+ strong: text p.displayName
+ if p.isVerified:
+ tdiv(class="verified-icon blue"):
+ icon "circle", class="verified-icon-circle", title="Verified account"
+ icon "ok", class="verified-icon-check", title="Verified account"
+ if role.len > 0:
+ span(class="host-badge"): text role
+ span(class="participant-username"): text "@" & p.username
+
+proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode =
+ let
+ isLive = sp.state == "RUNNING"
+ source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"):
+ getVidUrl(sp.m3u8Url) else: sp.m3u8Url
+ stateText =
+ if isLive: "LIVE"
+ elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY")
+ elif sp.state.len > 0: sp.state
+ else: "Ended"
+ durationMs =
+ if sp.startTime.year > 1 and sp.endTime.year > 1:
+ int((sp.endTime - sp.startTime).inMilliseconds)
+ else: 0
+ duration = if durationMs > 0: getDuration(durationMs) else: ""
+ totalListeners =
+ if sp.totalReplayWatched > 0: sp.totalReplayWatched
+ else: sp.totalLiveListeners
+
+ buildHtml(tdiv(class="space-page")):
+ tdiv(class="space-panel"):
+ tdiv(class="space-player"):
+ if sp.m3u8Url.len > 0 and prefs.hlsPlayback:
+ audio(data-url=source, data-autoload="false")
+ verbatim ""
+ tdiv(class="overlay-circle"): span(class="overlay-triangle")
+ if isLive:
+ tdiv(class="space-live"): text "LIVE"
+ elif duration.len > 0:
+ tdiv(class="overlay-duration"): text duration
+ verbatim "
"
+ elif sp.m3u8Url.len > 0:
+ tdiv(class="video-overlay"):
+ buttonReferer "/enablehls", "Enable hls playback", path
+ if isLive:
+ tdiv(class="space-live"): text "LIVE"
+ elif duration.len > 0:
+ tdiv(class="overlay-duration"): text duration
+ elif sp.availableForReplay:
+ tdiv(class="video-overlay"):
+ p: text "Audio stream unavailable"
+ else:
+ tdiv(class="video-overlay"):
+ p: text "Replay is not available"
+
+ tdiv(class="space-info"):
+ tdiv(class="space-header"):
+ h2(class="space-title"): text sp.title
+ tdiv(class="space-meta"):
+ if totalListeners > 0:
+ span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners"
+ if isLive:
+ span(class="space-live"): text stateText
+ else:
+ span(class="space-state"): text stateText
+
+ if sp.admins.len > 0 or sp.speakers.len > 0:
+ tdiv(class="space-participants"):
+ for admin in sp.admins:
+ let role = if admin.username == sp.creator.username: "Host"
+ else: "Co-host"
+ renderParticipant(admin, role)
+ for speaker in sp.speakers:
+ renderParticipant(speaker, "")
diff --git a/src/views/status.nim b/src/views/status.nim
index 71c2c67..b16c211 100644
--- a/src/views/status.nim
+++ b/src/views/status.nim
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
+import sequtils
import karax/[karaxdsl, vdom]
import ".."/[types, formatters]
@@ -28,16 +29,46 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore:
renderMoreReplies(thread)
-proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
+proc renderReplySort(sort: RankingMode): VNode =
+ buildHtml(tdiv(class="reply-sort")):
+ span(class="reply-sort-label"): text "Sort replies:"
+ for mode in RankingMode:
+ let
+ cls = if mode == sort: "reply-sort-option active"
+ else: "reply-sort-option"
+ label = case mode
+ of Relevance: "Relevant"
+ of Recency: "Recent"
+ of Likes: "Liked"
+ a(class=cls, href=("?sort=" & $mode & "#r")):
+ text label
+
+proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string;
+ tweet: Tweet = nil; sort = Relevance): VNode =
buildHtml(tdiv(class="replies", id="r")):
+ var hasReplies = false
+ var replyCount = 0
for thread in replies.content:
- if thread.content.len == 0: continue
+ if thread.content.len == 0 or thread.related: continue
+ hasReplies = true
+ replyCount += thread.content.len
renderReplyThread(thread, prefs, path)
- if replies.bottom.len > 0:
- renderMore(Query(), replies.bottom, focus="#r")
+ if hasReplies and replies.bottom.len > 0:
+ if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
+ let extra = if sort == Relevance: "" else: "sort=" & $sort & "&"
+ renderMore(Query(), replies.bottom, focus="#r", extra=extra)
-proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
+proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
+ buildHtml(tdiv(class="related-tweets")):
+ tdiv(class="related-header"):
+ text "Related tweets"
+ for thread in replies.content:
+ if thread.content.len == 0 or not thread.related: continue
+ renderReplyThread(thread, prefs, path)
+
+proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
+ sort = Relevance): VNode =
let hasAfter = conv.after.content.len > 0
let threadId = conv.tweet.threadId
buildHtml(tdiv(class="conversation")):
@@ -70,6 +101,25 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
- renderReplies(conv.replies, prefs, path)
+ renderReplySort(sort)
+ renderReplies(conv.replies, prefs, path, conv.tweet, sort)
+
+ if not prefs.hideRelated:
+ if conv.replies.content.anyIt(it.related and it.content.len > 0):
+ renderRelated(conv.replies, prefs, path)
renderToTop(focus="#m")
+
+proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode =
+ buildHtml(tdiv(class="edit-history")):
+ tdiv(class="latest-edit"):
+ tdiv(class="edit-history-header"):
+ text "Latest post"
+ renderTweet(edits.latest, prefs, path)
+
+ tdiv(class="previous-edits"):
+ tdiv(class="edit-history-header"):
+ text "Version history"
+ for tweet in edits.history:
+ tdiv(class="tweet-edit"):
+ renderTweet(tweet, prefs, path)
diff --git a/src/views/timeline.nim b/src/views/timeline.nim
index abeb6d3..2890dd1 100644
--- a/src/views/timeline.nim
+++ b/src/views/timeline.nim
@@ -5,12 +5,38 @@ import karax/[karaxdsl, vdom]
import ".."/[types, query, formatters]
import tweet, renderutils
+proc timelineViewClass(query: Query): string =
+ if query.kind != QueryKind.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 =
if query.kind != posts:
result = genQueryUrl(query)
if result.len > 0:
result &= "&"
+proc getSearchMaxId(results: Timeline; path: string): string =
+ if results.query.kind != tweets or results.content.len == 0 or
+ results.query.until.len == 0:
+ return
+
+ let lastThread = results.content[^1]
+ if lastThread.len == 0 or lastThread[^1].id == 0:
+ return
+
+ # 2000000 is the minimum decrement to guarantee no result overlap
+ var maxId = lastThread[^1].id - 2_000_000'i64
+ if maxId <= 0:
+ maxId = lastThread[^1].id - 1
+
+ if maxId > 0:
+ return "maxid:" & $maxId
+
proc renderToTop*(focus="#"): VNode =
buildHtml(tdiv(class="top-ref")):
icon "down", href=focus
@@ -24,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
a(href=(p & url)):
text "Load newest"
-proc renderMore*(query: Query; cursor: string; focus=""): VNode =
+proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
buildHtml(tdiv(class="show-more")):
- a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
+ a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
text "Load more"
proc renderNoMore(): VNode =
@@ -39,7 +65,7 @@ proc renderNoneFound(): VNode =
h2(class="timeline-none"):
text "No items found"
-proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
+proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
buildHtml(tdiv(class="thread-line")):
let sortedThread = thread.sortedByIt(it.id)
for i, tweet in sortedThread:
@@ -53,10 +79,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
let show = i == thread.high and sortedThread[0].id != tweet.threadId
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
renderTweet(tweet, prefs, path, class=(header & "thread"),
- index=i, last=(i == thread.high), showThread=show)
+ index=i, last=(i == thread.high), bigThumb=bigThumb)
proc renderUser(user: User; prefs: Prefs): VNode =
- buildHtml(tdiv(class="timeline-item")):
+ buildHtml(tdiv(class="timeline-item", data-username=user.username)):
a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"):
@@ -66,6 +92,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(user, class="fullname")
+ verifiedIcon(user)
linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"):
@@ -87,15 +114,106 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
else:
renderNoMore()
+proc mentionUsername(word: string): string =
+ # "@user" -> "user" for well-formed mentions, "" otherwise
+ if word.len > 1 and word[0] == '@' and
+ word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}):
+ word[1 .. ^1]
+ else: ""
+
+proc mentionedUser(s: string): string =
+ # last @mention in strings like "65 followers including @user"
+ let words = s.split(' ')
+ for i in countdown(words.high, 0):
+ result = mentionUsername(words[i])
+ if result.len > 0: return
+
+proc renderMentionedText(s: string): VNode =
+ # linkify @mentions in plain API strings like "65 followers including @user"
+ let words = s.split(' ')
+ buildHtml(span):
+ for i in 0 ..< words.len:
+ if i > 0: text " "
+ let username = mentionUsername(words[i])
+ if username.len > 0:
+ a(href=("/" & username)): text words[i]
+ else:
+ text words[i]
+
+proc renderListCard(r: ListSearchResult): VNode =
+ let listUrl = "/i/lists/" & r.list.id
+ buildHtml(tdiv(class="timeline-item list-result")):
+ a(class="tweet-link", href=listUrl)
+ a(class="list-result-banner", href=listUrl):
+ if r.list.banner.len > 0:
+ genImg(r.list.banner)
+ tdiv(class="list-result-body"):
+ tdiv(class="list-result-title fullname-and-username"):
+ a(class="list-name fullname", href=listUrl): text r.list.name
+ span(class="list-members"):
+ text &"· {insertSep($r.list.members, ',')} members"
+ tdiv(class="list-result-context"):
+ if r.followersContext.len > 0:
+ # the first facepile belongs to the "including @user" account
+ let mentioned = mentionedUser(r.followersContext)
+ for i in 0 ..< r.facepiles.len:
+ if i == 0 and mentioned.len > 0:
+ a(class="facepile-link", href=("/" & mentioned)):
+ genImg(r.facepiles[i], class="list-facepile")
+ else:
+ genImg(r.facepiles[i], class="list-facepile")
+ renderMentionedText(r.followersContext)
+ else:
+ if r.owner.username.len > 0:
+ a(class="facepile-link", href=("/" & r.owner.username)):
+ genImg(r.owner.getUserPic("_mini"), class="list-facepile")
+ else:
+ genImg(r.owner.getUserPic("_mini"), class="list-facepile")
+ linkUser(r.owner, class="fullname")
+ linkUser(r.owner, class="username")
+ if r.list.description.len > 0:
+ tdiv(class="list-result-description"):
+ text r.list.description
+
+proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs;
+ path=""): VNode =
+ buildHtml(tdiv(class="timeline")):
+ if not results.beginning:
+ renderNewer(results.query, path)
+
+ if results.content.len > 0:
+ for list in results.content:
+ renderListCard(list)
+ if results.bottom.len > 0:
+ renderMore(results.query, results.bottom)
+ renderToTop()
+ elif results.beginning:
+ renderNoneFound()
+ else:
+ 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;
pinned=none(Tweet)): VNode =
- buildHtml(tdiv(class="timeline")):
+ buildHtml(tdiv(class=results.query.timelineViewClass)):
if not results.beginning:
renderNewer(results.query, parseUri(path).path)
if not prefs.hidePins and pinned.isSome:
let tweet = get pinned
- renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
+ renderTweet(tweet, prefs, path)
if results.content.len == 0:
if not results.beginning:
@@ -103,26 +221,24 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else:
renderNoneFound()
else:
- var retweets: seq[int64]
+ let filtered = filterThreads(results.content, prefs)
- for thread in results.content:
- if thread.len == 1:
- let
- tweet = thread[0]
- retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
+ if results.query.view == "gallery":
+ let bigThumb = prefs.gallerySize == "Large"
+ let galClass = if prefs.compactGallery: "gallery-masonry compact" else: "gallery-masonry"
+ tdiv(class=galClass, `data-col-size`=prefs.gallerySize.toLowerAscii):
+ for thread in filtered:
+ if thread.len == 1: renderTweet(thread[0], prefs, path, bigThumb=bigThumb)
+ else: renderThread(thread, prefs, path, bigThumb)
+ else:
+ for thread in filtered:
+ if thread.len == 1:
+ renderTweet(thread[0], prefs, path)
+ else: renderThread(thread, prefs, path)
- if retweetId in retweets or tweet.id in retweets or
- tweet.pinned and prefs.hidePins:
- continue
-
- var hasThread = tweet.hasThread
- if retweetId != 0 and tweet.retweet.isSome:
- retweets &= retweetId
- hasThread = get(tweet.retweet).hasThread
- renderTweet(tweet, prefs, path, showThread=hasThread)
- else:
- renderThread(thread, prefs, path)
-
- if results.bottom.len > 0:
+ var cursor = getSearchMaxId(results, path)
+ if cursor.len > 0:
+ renderMore(results.query, cursor)
+ elif results.bottom.len > 0:
renderMore(results.query, results.bottom)
renderToTop()
diff --git a/src/views/tweet.nim b/src/views/tweet.nim
index 8ff8cb1..8971ab8 100644
--- a/src/views/tweet.nim
+++ b/src/views/tweet.nim
@@ -5,21 +5,39 @@ from jester import Request
import renderutils
import ".."/[types, utils, formatters]
-import general
const doctype = "\n"
-proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
+proc renderMiniAvatar*(user: User; prefs: Prefs): VNode =
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
-proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
+proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode =
+ let url = "/i/article/" & $preview.tweetId
+ buildHtml(tdiv(class="article-card card large")):
+ a(class="card-container", href=url):
+ if preview.coverImage.len > 0:
+ tdiv(class="card-image-container"):
+ tdiv(class="card-image"):
+ genImg(preview.coverImage)
+ span(class="article-card-badge"): text "Article"
+ tdiv(class="card-content-container"):
+ tdiv(class="card-content"):
+ h2(class="card-title"): text preview.title
+ if preview.previewText.len > 0:
+ p(class="card-description"): text preview.previewText
+
+proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs;
+ path = ""): VNode =
buildHtml(tdiv):
if pinned:
+ let pinnedLabel =
+ if "/i/communities/" in path: "Pinned by Community mods"
+ else: "Pinned Tweet"
tdiv(class="pinned"):
- span: icon "pin", "Pinned Tweet"
+ span: icon("pin", pinnedLabel)
elif retweet.len > 0:
tdiv(class="retweet-header"):
- span: icon "retweet", retweet & " retweeted"
+ span: icon("retweet", retweet & " retweeted")
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)):
@@ -31,28 +49,28 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(tweet.user, class="fullname")
+ verifiedIcon(tweet.user)
linkUser(tweet.user, class="username")
span(class="tweet-date"):
a(href=getLink(tweet), title=tweet.getTime):
text tweet.getShortTime
-proc renderAlbum(tweet: Tweet): VNode =
- let
- groups = if tweet.photos.len < 3: @[tweet.photos]
- else: tweet.photos.distribute(2)
+proc renderAltText(altText: string): VNode =
+ buildHtml(p(class="alt-text")):
+ text "ALT " & altText
- buildHtml(tdiv(class="attachments")):
- for i, photos in groups:
- let margin = if i > 0: ".25em" else: ""
- tdiv(class="gallery-row", style={marginTop: margin}):
- for photo in photos:
- tdiv(class="attachment image"):
- let
- named = "name=" in photo
- small = if named: photo else: photo & smallWebp
- a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
- genImg(small)
+proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
+ buildHtml(tdiv(class="attachment")):
+ let
+ named = "name=" in photo.url
+ thumb = if named: photo.url
+ elif bigThumb: photo.url & mediumWebp
+ else: photo.url & smallWebp
+ a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
+ genImg(thumb, alt=photo.altText)
+ if photo.altText.len > 0:
+ renderAltText(photo.altText)
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
case playbackType
@@ -62,11 +80,11 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
proc hasMp4Url(video: Video): bool =
video.variants.anyIt(it.contentType == mp4)
-proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
+proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
buildHtml(tdiv(class="video-overlay")):
case playbackType
of mp4:
- p: text "mp4 playback disabled in preferences"
+ buttonReferer "/enablemp4", "Enable mp4 playback", path
of m3u8, vmap:
buttonReferer "/enablehls", "Enable hls playback", path
@@ -78,51 +96,109 @@ proc renderVideoUnavailable(video: Video): VNode =
else:
p: text "This media is unavailable"
-proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
+proc getVideoDownloadUrl(videoData: Video): string =
+ let mp4Vars = videoData.variants.filterIt(it.contentType == mp4)
+ if mp4Vars.len == 0: return ""
+ let best = mp4Vars.sortedByIt(it.bitrate)[^1].url
+ if best.startsWith("http"): getVidUrl(best) else: best
+
+proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
let
- container = if video.description.len == 0 and video.title.len == 0: ""
- else: " card-container"
- playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
- else: video.playbackType
+ playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
+ else: videoData.playbackType
+ thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
+
+ buildHtml(tdiv(class="attachment")):
+ if not videoData.available:
+ img(src=thumb, loading="lazy")
+ renderVideoUnavailable(videoData)
+ elif not prefs.isPlaybackEnabled(playbackType):
+ img(src=thumb, loading="lazy")
+ renderVideoDisabled(playbackType, path)
+ else:
+ let
+ vars = videoData.variants.filterIt(it.contentType == playbackType)
+ vidUrl = vars.sortedByIt(it.resolution)[^1].url
+ source = if prefs.proxyVideos and vidUrl.startsWith("http"):
+ getVidUrl(vidUrl) else: vidUrl
+ case playbackType
+ of mp4:
+ video(poster=thumb, controls="", muted=prefs.muteVideos):
+ source(src=source, `type`="video/mp4")
+ of m3u8, vmap:
+ video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
+ verbatim ""
+ tdiv(class="overlay-circle"): span(class="overlay-triangle")
+ if videoData.durationMs > 0:
+ tdiv(class="overlay-duration"): text getDuration(videoData)
+ verbatim "
"
+ if videoData.available:
+ let dlUrl = getVideoDownloadUrl(videoData)
+ if dlUrl.len > 0:
+ a(class="video-download", href=dlUrl, download="",
+ title="Download video"): icon "download-alt"
+
+proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
+ let hasCardContent = video.description.len > 0 or video.title.len > 0
buildHtml(tdiv(class="attachments card")):
- tdiv(class="gallery-video" & container):
- tdiv(class="attachment video-container"):
- let thumb = getSmallPic(video.thumb)
- if not video.available:
- img(src=thumb, loading="lazy")
- renderVideoUnavailable(video)
- elif not prefs.isPlaybackEnabled(playbackType):
- img(src=thumb, loading="lazy")
- renderVideoDisabled(playbackType, path)
- else:
- let
- vars = video.variants.filterIt(it.contentType == playbackType)
- vidUrl = vars.sortedByIt(it.resolution)[^1].url
- source = if prefs.proxyVideos: getVidUrl(vidUrl)
- else: vidUrl
- case playbackType
- of mp4:
- video(poster=thumb, controls="", muted=prefs.muteVideos):
- source(src=source, `type`="video/mp4")
- of m3u8, vmap:
- video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
- verbatim ""
- tdiv(class="overlay-circle"): span(class="overlay-triangle")
- verbatim "
"
- if container.len > 0:
+ tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
+ renderVideoAttachment(video, prefs, path, bigThumb)
+ if hasCardContent:
tdiv(class="card-content"):
h2(class="card-title"): text video.title
if video.description.len > 0:
p(class="card-description"): text video.description
-proc renderGif(gif: Gif; prefs: Prefs): VNode =
+proc renderGifAttachment(gif: Gif; prefs: Prefs; path=""): VNode =
+ let thumb = getSmallPic(gif.thumb)
+
+ buildHtml(tdiv(class="attachment")):
+ if not prefs.mp4Playback:
+ img(src=thumb, loading="lazy")
+ renderVideoDisabled(mp4, path)
+ elif prefs.autoplayGifs:
+ video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
+ source(src=getPicUrl(gif.url), `type`="video/mp4")
+ else:
+ video(class="gif", poster=thumb, controls="", muted="", loop=""):
+ source(src=getPicUrl(gif.url), `type`="video/mp4")
+ if gif.altText.len > 0:
+ renderAltText(gif.altText)
+
+proc renderGif(gif: Gif; prefs: Prefs; path=""): VNode =
buildHtml(tdiv(class="attachments media-gif")):
- tdiv(class="gallery-gif", style={maxHeight: "unset"}):
- tdiv(class="attachment"):
- video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
- controls="", muted="", loop=""):
- source(src=getPicUrl(gif.url), `type`="video/mp4")
+ renderGifAttachment(gif, prefs, path)
+
+proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
+ if media.len == 0:
+ return nil
+
+ if media.len == 1:
+ let item = media[0]
+ if item.kind == videoMedia:
+ return renderVideo(item.video, prefs, path, bigThumb)
+ if item.kind == gifMedia:
+ return renderGif(item.gif, prefs, path)
+
+ let
+ groups = if media.len < 3: @[media]
+ else: media.distribute(2)
+
+ buildHtml(tdiv(class="attachments")):
+ for i, mediaGroup in groups:
+ let margin = if i > 0: ".25em" else: ""
+ let rowClass = "gallery-row" &
+ (if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
+ tdiv(class=rowClass, style={marginTop: margin}):
+ for mediaItem in mediaGroup:
+ case mediaItem.kind
+ of photoMedia:
+ renderPhotoAttachment(mediaItem.photo, bigThumb)
+ of videoMedia:
+ renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
+ of gifMedia:
+ renderGifAttachment(mediaItem.gif, prefs, path)
proc renderPoll(poll: Poll): VNode =
buildHtml(tdiv(class="poll")):
@@ -178,7 +254,7 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',')
else: ""
-proc renderStats(stats: TweetStats): VNode =
+proc renderStats*(stats: TweetStats): VNode =
buildHtml(tdiv(class="tweet-stats")):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
@@ -192,8 +268,9 @@ proc renderReply(tweet: Tweet): VNode =
if i > 0: text " "
a(href=("/" & u)): text "@" & u
-proc renderAttribution(user: User; prefs: Prefs): VNode =
- buildHtml(a(class="attribution", href=("/" & user.username))):
+proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode =
+ let href = if link.len > 0: link else: "/" & user.username
+ buildHtml(a(class="attribution", href=href)):
renderMiniAvatar(user, prefs)
strong: text user.fullname
verifiedIcon(user)
@@ -207,19 +284,28 @@ proc renderMediaTags(tags: seq[User]): VNode =
if i < tags.high:
text ", "
+proc renderLatestPost(username: string; id: int64): VNode =
+ buildHtml(tdiv(class="latest-post-version")):
+ text "There's a new version of this post. "
+ a(href=getLink(id, username)):
+ text "See the latest post"
+
+proc renderCommunityNote(note: string; prefs: Prefs): VNode =
+ buildHtml(tdiv(class="community-note")):
+ tdiv(class="community-note-header"):
+ icon "group"
+ span: text "Community note"
+ tdiv(class="community-note-text", dir="auto"):
+ verbatim replaceUrls(note, prefs)
+
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")):
- if quote.photos.len > 0:
- renderAlbum(quote)
- elif quote.video.isSome:
- renderVideo(quote.video.get(), prefs, path)
- elif quote.gif.isSome:
- renderGif(quote.gif.get(), prefs)
+ renderMedia(quote.media, prefs, path)
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
if not quote.available:
return buildHtml(tdiv(class="quote unavailable")):
- tdiv(class="unavailable-quote"):
+ a(class="unavailable-quote", href=getLink(quote, focus=false)):
if quote.tombstone.len > 0:
text quote.tombstone
elif quote.text.len > 0:
@@ -234,6 +320,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="fullname-and-username"):
renderMiniAvatar(quote.user, prefs)
linkUser(quote.user, class="fullname")
+ verifiedIcon(quote.user)
linkUser(quote.user, class="username")
span(class="tweet-date"):
@@ -247,12 +334,31 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="quote-text", dir="auto"):
verbatim replaceUrls(quote.text, prefs)
+ if quote.media.len > 0:
+ renderQuoteMedia(quote, prefs, path)
+
+ if quote.articlePreview.isSome:
+ renderArticleCard(quote.articlePreview.get(), prefs)
+
+ if quote.note.len > 0 and not prefs.hideCommunityNotes:
+ renderCommunityNote(quote.note, prefs)
+
if quote.hasThread:
a(class="show-thread", href=getLink(quote)):
text "Show this thread"
- if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
- renderQuoteMedia(quote, prefs, path)
+ if quote.history.len > 0 and quote.id != max(quote.history):
+ tdiv(class="quote-latest"):
+ text "There's a new version of this post"
+
+proc renderDisclosures*(tweet: Tweet): VNode =
+ buildHtml(tdiv(class="disclosures")):
+ if tweet.isAI:
+ span(data-disclosure="ai"):
+ icon "attention-circled", "Made with AI"
+ if tweet.isAd:
+ span(data-disclosure="ad"):
+ icon "attention-circled", "Paid partnership (ad)"
proc renderLocation*(tweet: Tweet): string =
let (place, url) = tweet.getLocation()
@@ -266,14 +372,15 @@ proc renderLocation*(tweet: Tweet): string =
return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
- last=false; showThread=false; mainTweet=false; afterTweet=false): VNode =
+ last=false; mainTweet=false; afterTweet=false;
+ bigThumb=false): VNode =
var divClass = class
if index == -1 or last:
divClass = "thread-last " & class
if not tweet.available:
- return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
- tdiv(class="unavailable-box"):
+ return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
+ a(class="unavailable-box", href=getLink(tweet)):
if tweet.tombstone.len > 0:
text tweet.tombstone
elif tweet.text.len > 0:
@@ -294,15 +401,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tweet = tweet.retweet.get
retweet = fullTweet.user.fullname
- buildHtml(tdiv(class=("timeline-item " & divClass))):
+ buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
if not mainTweet:
a(class="tweet-link", href=getLink(tweet))
tdiv(class="tweet-body"):
- renderHeader(tweet, retweet, pinned, prefs)
+ renderHeader(tweet, retweet, pinned, prefs, path)
if not afterTweet and index == 0 and tweet.reply.len > 0 and
- (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
+ (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
renderReply(tweet)
var tweetClass = "tweet-content media-body"
@@ -313,17 +420,16 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
if tweet.attribution.isSome:
- renderAttribution(tweet.attribution.get(), prefs)
+ renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink)
if tweet.card.isSome and tweet.card.get().kind != hidden:
renderCard(tweet.card.get(), prefs, path)
- if tweet.photos.len > 0:
- renderAlbum(tweet)
- elif tweet.video.isSome:
- renderVideo(tweet.video.get(), prefs, path)
- elif tweet.gif.isSome:
- renderGif(tweet.gif.get(), prefs)
+ if tweet.articlePreview.isSome:
+ renderArticleCard(tweet.articlePreview.get(), prefs)
+
+ if tweet.media.len > 0:
+ renderMedia(tweet.media, prefs, path, bigThumb)
if tweet.poll.isSome:
renderPoll(tweet.poll.get())
@@ -331,25 +437,29 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path)
+ if tweet.note.len > 0 and not prefs.hideCommunityNotes:
+ renderCommunityNote(tweet.note, prefs)
+
+ if tweet.isAI or tweet.isAd:
+ renderDisclosures(tweet)
+
+ let
+ hasEdits = tweet.history.len > 1
+ isLatest = hasEdits and tweet.id == max(tweet.history)
+
if mainTweet:
- p(class="tweet-published"): text &"{getTime(tweet)}"
+ p(class="tweet-published"):
+ if hasEdits and isLatest:
+ a(href=(getLink(tweet, focus=false) & "/history")):
+ text &"Last edited {getTime(tweet)}"
+ else:
+ text &"{getTime(tweet)}"
+
+ if hasEdits and not isLatest:
+ renderLatestPost(tweet.user.username, max(tweet.history))
if tweet.mediaTags.len > 0:
renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats:
renderStats(tweet.stats)
-
- if showThread:
- a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
- text "Show this thread"
-
-proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
- let node = buildHtml(html(lang="en")):
- renderHead(prefs, cfg, req)
-
- body:
- tdiv(class="tweet-embed"):
- renderTweet(tweet, prefs, path, mainTweet=true)
-
- result = doctype & $node
diff --git a/tests/base.py b/tests/base.py
index 010dfbb..841094d 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -54,6 +54,18 @@ class Timeline(object):
none = '.timeline-none'
protected = '.timeline-protected'
photo_rail = '.photo-rail-grid'
+ media_view_tabs = '.media-view-tabs'
+ media_view_timeline = '.media-view-tabs a[href*="view=timeline"]'
+ media_view_grid = '.media-view-tabs a[href*="view=grid"]'
+ media_view_gallery = '.media-view-tabs a[href*="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 Search(object):
+ tab_item = '.tab .tab-item'
+ tab_active = '.tab .tab-item.active a'
class Conversation(object):
@@ -64,6 +76,8 @@ class Conversation(object):
thread = '.reply'
tweet = '.timeline-item'
tweet_text = '.tweet-content'
+ reply_sort = '.reply-sort'
+ reply_sort_active = '.reply-sort-option.active'
class Poll(object):
@@ -79,7 +93,7 @@ class Media(object):
row = '.gallery-row'
image = '.still-image'
video = '.gallery-video'
- gif = '.gallery-gif'
+ gif = '.media-gif'
class BaseTestCase(BaseCase):
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..3d87c74
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,4 @@
+from seleniumbase.config import settings
+
+settings.SKIP_JS_WAITS = True
+settings.WAIT_FOR_RSC_ON_PAGE_LOADS = False
diff --git a/tests/poetry.lock b/tests/poetry.lock
new file mode 100644
index 0000000..d13bfe0
--- /dev/null
+++ b/tests/poetry.lock
@@ -0,0 +1,1716 @@
+# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+
+[[package]]
+name = "async-generator"
+version = "1.10"
+description = "Async generators and context managers for Python 3.5+"
+optional = false
+python-versions = ">=3.5"
+groups = ["main"]
+files = [
+ {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"},
+ {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"},
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
+ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+description = "Screen-scraping library"
+optional = false
+python-versions = ">=3.7.0"
+groups = ["main"]
+files = [
+ {file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"},
+ {file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"},
+]
+
+[package.dependencies]
+soupsieve = ">=1.6.1"
+typing-extensions = ">=4.0.0"
+
+[package.extras]
+cchardet = ["cchardet"]
+chardet = ["chardet"]
+charset-normalizer = ["charset-normalizer"]
+html5lib = ["html5lib"]
+lxml = ["lxml"]
+
+[[package]]
+name = "behave"
+version = "1.2.6"
+description = "behave is behaviour-driven development, Python style"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+groups = ["main"]
+files = [
+ {file = "behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"},
+ {file = "behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86"},
+]
+
+[package.dependencies]
+parse = ">=1.8.2"
+parse-type = ">=0.4.2"
+six = ">=1.11"
+
+[package.extras]
+develop = ["coverage", "invoke (>=0.21.0)", "modernize (>=0.5)", "path.py (>=8.1.2)", "pathlib", "pycmd", "pylint", "pytest (>=3.0)", "pytest-cov", "tox"]
+docs = ["sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6)"]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
+ {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+markers = "os_name == \"nt\" and implementation_name != \"pypy\""
+files = [
+ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
+ {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
+ {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
+ {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
+ {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
+ {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
+ {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
+ {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
+ {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
+ {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
+ {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
+ {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
+ {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
+ {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
+ {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
+ {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
+ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
+ {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
+]
+
+[package.dependencies]
+pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
+
+[[package]]
+name = "chardet"
+version = "5.2.0"
+description = "Universal encoding detector for Python 3"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
+ {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
+ {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
+ {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
+ {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
+ {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
+ {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
+ {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
+ {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
+ {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
+ {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "cssselect"
+version = "1.4.0"
+description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8"},
+ {file = "cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
+ {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "execnet"
+version = "2.1.2"
+description = "execnet: rapid multi-Python deployment"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"},
+ {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"},
+]
+
+[package.extras]
+testing = ["hatch", "pre-commit", "pytest", "tox"]
+
+[[package]]
+name = "fasteners"
+version = "0.20"
+description = "A python package that provides useful locks"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+ {file = "fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7"},
+ {file = "fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8"},
+]
+
+[[package]]
+name = "filelock"
+version = "3.24.3"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"},
+ {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"},
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
+ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
+ {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "importlib-metadata"
+version = "8.7.1"
+description = "Read metadata from Python packages"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
+ {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
+]
+
+[package.dependencies]
+zipp = ">=3.20"
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+perf = ["ipython"]
+test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
+type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
+ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
+ {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
+ {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins (>=0.5.0)"]
+profiling = ["gprof2dot"]
+rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
+ {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
+ {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
+ {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
+ {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
+ {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
+ {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
+ {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
+ {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
+ {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
+ {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
+ {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
+ {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
+ {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
+ {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
+ {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
+ {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
+ {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
+ {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
+ {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
+ {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
+ {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
+ {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
+ {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
+ {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
+ {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
+ {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
+ {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
+ {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
+ {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
+ {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
+ {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
+ {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
+ {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
+ {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
+ {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
+ {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
+ {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
+ {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
+ {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
+ {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
+ {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
+ {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
+ {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
+ {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
+ {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
+ {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
+ {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
+ {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
+ {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
+ {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
+ {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
+ {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
+ {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
+ {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
+ {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
+ {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
+ {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
+ {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
+ {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
+ {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
+ {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
+ {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
+ {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
+ {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
+ {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
+ {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
+ {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
+ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "mouseinfo"
+version = "0.1.3"
+description = "An application to display XY position and RGB color information for the pixel currently under the mouse. Works on Python 2 and 3."
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7"},
+]
+
+[package.dependencies]
+pyperclip = "*"
+python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""}
+
+[[package]]
+name = "mycdp"
+version = "1.3.2"
+description = "Autogenerated CDP utilities for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "mycdp-1.3.2-py3-none-any.whl", hash = "sha256:d097b12a494223b89a666c87915d8acd48d08b92770ff9f5955ab764676790a0"},
+ {file = "mycdp-1.3.2.tar.gz", hash = "sha256:945c405eb35d9759bd24c3676b4633124fac222ac132f735e9d2d812b49f1b3d"},
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
+ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
+]
+
+[[package]]
+name = "nest-asyncio"
+version = "1.6.0"
+description = "Patch asyncio to allow nested event loops"
+optional = false
+python-versions = ">=3.5"
+groups = ["main"]
+files = [
+ {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"},
+ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
+]
+
+[[package]]
+name = "outcome"
+version = "1.3.0.post0"
+description = "Capture the outcome of Python function calls."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"},
+ {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"},
+]
+
+[package.dependencies]
+attrs = ">=19.2.0"
+
+[[package]]
+name = "packaging"
+version = "26.0"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
+ {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
+]
+
+[[package]]
+name = "parameterized"
+version = "0.9.0"
+description = "Parameterized testing with any Python test framework"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"},
+ {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"},
+]
+
+[package.extras]
+dev = ["jinja2"]
+
+[[package]]
+name = "parse"
+version = "1.21.1"
+description = "parse() is the opposite of format()"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47"},
+ {file = "parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3"},
+]
+
+[[package]]
+name = "parse-type"
+version = "0.6.6"
+description = "Simplifies to build parse types based on the parse module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c"},
+ {file = "parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2"},
+]
+
+[package.dependencies]
+parse = {version = ">=1.18.0", markers = "python_version >= \"3.0\""}
+six = ">=1.15"
+
+[package.extras]
+develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-cov", "pytest-html (>=1.19.0)", "ruff ; python_version >= \"3.7\"", "setuptools", "setuptools-scm", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0) ; python_version <= \"3.6\"", "virtualenv (>=20.0.0) ; python_version > \"3.6\"", "wheel"]
+docs = ["Sphinx (>=1.6)", "sphinx_bootstrap_theme (>=0.6.0)"]
+testing = ["pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"]
+
+[[package]]
+name = "pdbp"
+version = "1.8.2"
+description = "pdbp (Pdb+): A drop-in replacement for pdb and pdbpp."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8"},
+ {file = "pdbp-1.8.2.tar.gz", hash = "sha256:367c25c17555d3ac1f024b9ad494ff50e6e20f6494a84741487f3e6596d88f94"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4.6", markers = "platform_system == \"Windows\""}
+pygments = ">=2.19.2"
+tabcompleter = ">=1.4.0"
+
+[[package]]
+name = "pip"
+version = "26.0.1"
+description = "The PyPA recommended tool for installing Python packages."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b"},
+ {file = "pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8"},
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.2"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"},
+ {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
+ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["coverage", "pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pyautogui"
+version = "0.9.54"
+description = "PyAutoGUI lets Python control the mouse and keyboard, and other GUI automation tasks. For Windows, macOS, and Linux, on Python 3 and 2."
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "PyAutoGUI-0.9.54.tar.gz", hash = "sha256:dd1d29e8fd118941cb193f74df57e5c6ff8e9253b99c7b04f39cfc69f3ae04b2"},
+]
+
+[package.dependencies]
+mouseinfo = "*"
+pygetwindow = ">=0.0.5"
+pymsgbox = "*"
+pyscreeze = ">=0.1.21"
+python3-Xlib = {version = "*", markers = "platform_system == \"Linux\" and python_version >= \"3.0\""}
+pytweening = ">=1.0.4"
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\""
+files = [
+ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
+ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
+]
+
+[[package]]
+name = "pygetwindow"
+version = "0.0.9"
+description = "A simple, cross-platform module for obtaining GUI information on application's windows."
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "PyGetWindow-0.0.9.tar.gz", hash = "sha256:17894355e7d2b305cd832d717708384017c1698a90ce24f6f7fbf0242dd0a688"},
+]
+
+[package.dependencies]
+pyrect = "*"
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
+ {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pymsgbox"
+version = "2.0.1"
+description = "A simple, cross-platform, pure Python module for JavaScript-like message boxes."
+optional = false
+python-versions = ">=3.4"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b"},
+ {file = "pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d"},
+]
+
+[[package]]
+name = "pynose"
+version = "1.5.5"
+description = "pynose fixes nose to extend unittest and make testing easier"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "pynose-1.5.5-py3-none-any.whl", hash = "sha256:673751d53fcfc79b1e48c14f36c7a24779ad43676eeb85736934de6a2b3d8ec8"},
+ {file = "pynose-1.5.5.tar.gz", hash = "sha256:81da4e26473f98dd37497248eef4352d3221d1d56edf874a00c6bdda6daf7f49"},
+]
+
+[[package]]
+name = "pyotp"
+version = "2.9.0"
+description = "Python One Time Password Library"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"},
+ {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"},
+]
+
+[package.extras]
+test = ["coverage", "mypy", "ruff", "wheel"]
+
+[[package]]
+name = "pyperclip"
+version = "1.11.0"
+description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)"
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273"},
+ {file = "pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6"},
+]
+
+[[package]]
+name = "pyreadline3"
+version = "3.5.4"
+description = "A python implementation of GNU readline."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "platform_system == \"Windows\""
+files = [
+ {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"},
+ {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"},
+]
+
+[package.extras]
+dev = ["build", "flake8", "mypy", "pytest", "twine"]
+
+[[package]]
+name = "pyrect"
+version = "0.2.0"
+description = "PyRect is a simple module with a Rect class for Pygame-like rectangular areas."
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78"},
+]
+
+[[package]]
+name = "pyscreeze"
+version = "1.0.1"
+description = "A simple, cross-platform screenshot module for Python 2 and 3."
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "pyscreeze-1.0.1.tar.gz", hash = "sha256:cf1662710f1b46aa5ff229ee23f367da9e20af4a78e6e365bee973cad0ead4be"},
+]
+
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+groups = ["main"]
+files = [
+ {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"},
+ {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
+ {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"},
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
+ {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
+iniconfig = ">=1.0.1"
+packaging = ">=22"
+pluggy = ">=1.5,<2"
+pygments = ">=2.7.2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-html"
+version = "4.0.2"
+description = "pytest plugin for generating HTML reports"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pytest_html-4.0.2-py3-none-any.whl", hash = "sha256:907c3e68462df129d3ee96dee58bd63f70216b06421836b22fd3fd57ef314acb"},
+ {file = "pytest_html-4.0.2.tar.gz", hash = "sha256:88682b9e8e51392472546a70a2139b27d6bc1834a4afd3e41da33c9d9f91e4a4"},
+]
+
+[package.dependencies]
+jinja2 = ">=3.0.0"
+pytest = ">=7.0.0"
+pytest-metadata = ">=2.0.0"
+
+[package.extras]
+docs = ["pip-tools (>=6.13.0)"]
+test = ["assertpy (>=1.1)", "beautifulsoup4 (>=4.11.1)", "black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-mock (>=3.7.0)", "pytest-rerunfailures (>=11.1.2)", "pytest-xdist (>=2.4.0)", "selenium (>=4.3.0)", "tox (>=3.24.5)"]
+
+[[package]]
+name = "pytest-metadata"
+version = "3.1.1"
+description = "pytest plugin for test session metadata"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b"},
+ {file = "pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0"
+
+[package.extras]
+test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "tox (>=3.24.5)"]
+
+[[package]]
+name = "pytest-ordering"
+version = "0.6"
+description = "pytest plugin to run your tests in a specific order"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6"},
+ {file = "pytest_ordering-0.6-py2-none-any.whl", hash = "sha256:27fba3fc265f5d0f8597e7557885662c1bdc1969497cd58aff6ed21c3b617de2"},
+ {file = "pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6"},
+]
+
+[package.dependencies]
+pytest = "*"
+
+[[package]]
+name = "pytest-rerunfailures"
+version = "16.1"
+description = "pytest plugin to re-run tests to eliminate flaky failures"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86"},
+ {file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"},
+]
+
+[package.dependencies]
+packaging = ">=17.1"
+pytest = ">=7.4,<8.2.2 || >8.2.2"
+
+[[package]]
+name = "pytest-xdist"
+version = "3.8.0"
+description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"},
+ {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"},
+]
+
+[package.dependencies]
+execnet = ">=2.1"
+pytest = ">=7.0.0"
+
+[package.extras]
+psutil = ["psutil (>=3.0)"]
+setproctitle = ["setproctitle"]
+testing = ["filelock"]
+
+[[package]]
+name = "python-xlib"
+version = "0.33"
+description = "Python X Library"
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32"},
+ {file = "python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398"},
+]
+
+[package.dependencies]
+six = ">=1.10.0"
+
+[[package]]
+name = "python3-xlib"
+version = "0.15"
+description = "Python3 X Library"
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "python3-xlib-0.15.tar.gz", hash = "sha256:dc4245f3ae4aa5949c1d112ee4723901ade37a96721ba9645f2bfa56e5b383f8"},
+]
+
+[[package]]
+name = "pytweening"
+version = "1.2.0"
+description = "A collection of tweening (aka easing) functions."
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "platform_system == \"Linux\""
+files = [
+ {file = "pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
+ {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
+ {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
+ {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
+ {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
+ {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
+ {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
+ {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
+ {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
+ {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
+ {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
+ {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
+ {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
+ {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
+ {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
+ {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
+ {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
+ {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
+ {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
+ {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
+ {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
+ {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
+ {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
+ {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
+ {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
+ {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
+ {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
+ {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
+ {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
+ {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
+ {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
+ {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
+ {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
+ {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
+ {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
+ {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
+ {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
+ {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
+ {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
+ {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
+ {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
+ {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
+ {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
+ {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
+ {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
+ {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
+ {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
+ {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
+ {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
+ {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
+ {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
+ {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
+ {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
+ {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
+ {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
+ {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
+ {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
+ {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
+ {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
+ {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
+ {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
+ {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
+ {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
+ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
+ {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset_normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "rich"
+version = "14.3.2"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.8.0"
+groups = ["main"]
+files = [
+ {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"},
+ {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "sbvirtualdisplay"
+version = "1.4.0"
+description = "A customized pyvirtualdisplay for SeleniumBase."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "sbvirtualdisplay-1.4.0-py3-none-any.whl", hash = "sha256:516de155219aa342c4e090a3c5126cfe6b12416334bcba3255268e44a5e8a206"},
+ {file = "sbvirtualdisplay-1.4.0.tar.gz", hash = "sha256:29a365b509cd7bfde4f758603b7b75703909b11cdf4245abc8f828ed35660d9b"},
+]
+
+[package.extras]
+coverage = ["coverage (>=7.6.1) ; python_version < \"3.9\"", "coverage (>=7.6.9) ; python_version >= \"3.9\"", "pytest-cov (>=5.0.0) ; python_version < \"3.9\"", "pytest-cov (>=6.0.0) ; python_version >= \"3.9\""]
+flake8 = ["flake8 (==5.0.4) ; python_version < \"3.9\"", "flake8 (==7.1.1) ; python_version >= \"3.9\"", "mccabe (==0.7.0)", "pycodestyle (==2.12.1) ; python_version >= \"3.9\"", "pycodestyle (==2.9.1) ; python_version < \"3.9\"", "pyflakes (==2.5.0) ; python_version < \"3.9\"", "pyflakes (==3.2.0) ; python_version >= \"3.9\""]
+
+[[package]]
+name = "selenium"
+version = "4.40.0"
+description = "Official Python bindings for Selenium WebDriver"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "selenium-4.40.0-py3-none-any.whl", hash = "sha256:c8823fc02e2c771d9ad9a0cf899cee7de1a57a6697e3d0b91f67566129f2b729"},
+ {file = "selenium-4.40.0.tar.gz", hash = "sha256:a88f5905d88ad0b84991c2386ea39e2bbde6d6c334be38df5842318ba98eaa8c"},
+]
+
+[package.dependencies]
+certifi = ">=2026.1.4"
+trio = ">=0.31.0,<1.0"
+trio-typing = ">=0.10.0"
+trio-websocket = ">=0.12.2,<1.0"
+types-certifi = ">=2021.10.8.3"
+types-urllib3 = ">=1.26.25.14"
+typing_extensions = ">=4.15.0,<5.0"
+urllib3 = {version = ">=2.6.3,<3.0", extras = ["socks"]}
+websocket-client = ">=1.8.0,<2.0"
+
+[[package]]
+name = "seleniumbase"
+version = "4.46.5"
+description = "A complete web automation framework for end-to-end testing."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "seleniumbase-4.46.5-py3-none-any.whl", hash = "sha256:d87ca08ed642c2ed5ddb0be74259f570ecdc443e0a99ea0076abde0d013c8586"},
+ {file = "seleniumbase-4.46.5.tar.gz", hash = "sha256:a1e22217874da901d361ce7577bf8d4eda990563d3dd2cfada612663380036c6"},
+]
+
+[package.dependencies]
+attrs = ">=25.4.0"
+beautifulsoup4 = ">=4.14.3,<4.15.0"
+behave = "1.2.6"
+certifi = ">=2026.1.4"
+chardet = "5.2.0"
+charset-normalizer = ">=3.4.4,<4"
+colorama = ">=0.4.6"
+cssselect = {version = ">=1.4.0,<2", markers = "python_version >= \"3.10\""}
+exceptiongroup = ">=1.3.1"
+execnet = {version = "2.1.2", markers = "python_version >= \"3.10\""}
+fasteners = ">=0.20"
+filelock = {version = ">=3.20.3", markers = "python_version >= \"3.10\""}
+h11 = "0.16.0"
+idna = ">=3.11"
+iniconfig = {version = "2.3.0", markers = "python_version >= \"3.10\""}
+Jinja2 = ">=3.1.6"
+markdown-it-py = {version = "4.0.0", markers = "python_version >= \"3.10\""}
+MarkupSafe = ">=3.0.3"
+mdurl = "0.1.2"
+mycdp = ">=1.3.2"
+nest-asyncio = "1.6.0"
+outcome = "1.3.0.post0"
+packaging = ">=26.0"
+parameterized = "0.9.0"
+parse = ">=1.21.0"
+parse-type = ">=0.6.6"
+pdbp = ">=1.8.2"
+pip = ">=26.0.1"
+platformdirs = {version = ">=4.5.1", markers = "python_version >= \"3.10\""}
+pluggy = "1.6.0"
+PyAutoGUI = {version = ">=0.9.54", markers = "platform_system == \"Linux\""}
+pygments = ">=2.19.2"
+pynose = ">=1.5.5"
+pyotp = "2.9.0"
+pyreadline3 = {version = ">=3.5.4", markers = "platform_system == \"Windows\""}
+pytest = {version = "9.0.2", markers = "python_version >= \"3.11\""}
+pytest-html = "4.0.2"
+pytest-metadata = "3.1.1"
+pytest-ordering = "0.6"
+pytest-rerunfailures = {version = "16.1", markers = "python_version >= \"3.10\""}
+pytest-xdist = "3.8.0"
+python-xlib = {version = "0.33", markers = "platform_system == \"Linux\""}
+pyyaml = ">=6.0.3"
+requests = ">=2.32.5,<2.33.0"
+rich = ">=14.3.2,<15"
+sbvirtualdisplay = ">=1.4.0"
+selenium = {version = "4.40.0", markers = "python_version >= \"3.10\""}
+setuptools = {version = ">=82.0.0", markers = "python_version >= \"3.10\""}
+six = ">=1.17.0"
+sniffio = "1.3.1"
+sortedcontainers = "2.4.0"
+soupsieve = ">=2.8.3,<2.9.0"
+tabcompleter = ">=1.4.0"
+trio = {version = ">=0.32.0,<1", markers = "python_version >= \"3.10\""}
+trio-websocket = ">=0.12.2,<0.13.0"
+typing-extensions = ">=4.15.0"
+urllib3 = {version = ">=1.26.20,<3", markers = "python_version >= \"3.10\""}
+websocket-client = ">=1.9.0,<1.10.0"
+websockets = {version = ">=16.0", markers = "python_version >= \"3.10\""}
+wheel = ">=0.46.3"
+wsproto = {version = ">=1.3.2,<1.4.0", markers = "python_version >= \"3.10\""}
+
+[package.extras]
+allure = ["allure-behave (>=2.13.5)", "allure-pytest (>=2.13.5)", "allure-python-commons (>=2.13.5)"]
+coverage = ["coverage (>=7.10.7) ; python_version < \"3.10\"", "coverage (>=7.13.4) ; python_version >= \"3.10\"", "pytest-cov (>=7.0.0)"]
+flake8 = ["flake8 (==7.3.0)", "mccabe (==0.7.0)", "pycodestyle (==2.14.0)", "pyflakes (==3.4.0)"]
+ipdb = ["ipdb (==0.13.13)", "ipython (==7.34.0)"]
+mss = ["mss (==10.1.0)"]
+pdfminer = ["cffi (==2.0.0)", "cryptography (==46.0.5)", "pdfminer.six (==20251107) ; python_version < \"3.10\"", "pdfminer.six (==20260107) ; python_version >= \"3.10\"", "pycparser (==2.23) ; python_version < \"3.10\"", "pycparser (==3.0) ; python_version >= \"3.10\""]
+pillow = ["Pillow (>=11.3.0) ; python_version < \"3.10\"", "Pillow (>=12.1.1) ; python_version >= \"3.10\""]
+pip-system-certs = ["pip-system-certs (==4.0) ; platform_system == \"Windows\""]
+playwright = ["playwright (>=1.58.0)"]
+proxy = ["proxy.py (==2.4.3)"]
+psutil = ["psutil (>=7.2.2)"]
+pyautogui = ["PyAutoGUI (>=0.9.54) ; platform_system != \"Linux\""]
+selenium-stealth = ["selenium-stealth (==1.0.6)"]
+selenium-wire = ["Brotli (==1.1.0)", "blinker (==1.7.0)", "h2 (==4.1.0)", "hpack (==4.0.0)", "hyperframe (==6.0.1)", "kaitaistruct (==0.10)", "pyOpenSSL (>=24.2.1)", "pyasn1 (==0.6.1)", "pyparsing (>=3.1.4)", "selenium-wire (==5.1.0)", "zstandard (>=0.23.0)"]
+
+[[package]]
+name = "setuptools"
+version = "82.0.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0"},
+ {file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"},
+]
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""]
+core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
+type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+description = "Sniff out which async library your code is running under"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+description = "A modern CSS selector implementation for Beautiful Soup."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"},
+ {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
+]
+
+[[package]]
+name = "tabcompleter"
+version = "1.4.0"
+description = "tabcompleter --- Autocompletion in the Python console."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b"},
+ {file = "tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814"},
+]
+
+[package.dependencies]
+pyreadline3 = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "trio"
+version = "0.33.0"
+description = "A friendly Python library for async concurrency and I/O"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b"},
+ {file = "trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"},
+]
+
+[package.dependencies]
+attrs = ">=23.2.0"
+cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""}
+idna = "*"
+outcome = "*"
+sniffio = ">=1.3.0"
+sortedcontainers = "*"
+
+[[package]]
+name = "trio-typing"
+version = "0.10.0"
+description = "Static type checking support for Trio and related projects"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "trio-typing-0.10.0.tar.gz", hash = "sha256:065ee684296d52a8ab0e2374666301aec36ee5747ac0e7a61f230250f8907ac3"},
+ {file = "trio_typing-0.10.0-py3-none-any.whl", hash = "sha256:6d0e7ec9d837a2fe03591031a172533fbf4a1a95baf369edebfc51d5a49f0264"},
+]
+
+[package.dependencies]
+async-generator = "*"
+importlib-metadata = "*"
+mypy-extensions = ">=0.4.2"
+packaging = "*"
+trio = ">=0.16.0"
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+mypy = ["mypy (>=1.0)"]
+
+[[package]]
+name = "trio-websocket"
+version = "0.12.2"
+description = "WebSocket library for Trio"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"},
+ {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"},
+]
+
+[package.dependencies]
+outcome = ">=1.2.0"
+trio = ">=0.11"
+wsproto = ">=0.14"
+
+[[package]]
+name = "types-certifi"
+version = "2021.10.8.3"
+description = "Typing stubs for certifi"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f"},
+ {file = "types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a"},
+]
+
+[[package]]
+name = "types-urllib3"
+version = "1.26.25.14"
+description = "Typing stubs for urllib3"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"},
+ {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
+ {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
+]
+
+[package.dependencies]
+pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""}
+
+[package.extras]
+brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
+
+[[package]]
+name = "websocket-client"
+version = "1.9.0"
+description = "WebSocket client for Python with low level API options"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"},
+ {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"},
+]
+
+[package.extras]
+docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"]
+optional = ["python-socks", "wsaccel"]
+test = ["pytest", "websockets"]
+
+[[package]]
+name = "websockets"
+version = "16.0"
+description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"},
+ {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"},
+ {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"},
+ {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"},
+ {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"},
+ {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"},
+ {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"},
+ {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"},
+ {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"},
+ {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"},
+ {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"},
+ {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"},
+ {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"},
+ {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"},
+ {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"},
+ {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"},
+ {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"},
+ {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"},
+ {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"},
+ {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"},
+ {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"},
+ {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"},
+ {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"},
+ {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"},
+ {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"},
+ {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"},
+ {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"},
+ {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"},
+ {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"},
+ {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"},
+ {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"},
+ {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"},
+ {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"},
+ {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"},
+ {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"},
+ {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"},
+ {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"},
+ {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"},
+ {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"},
+ {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"},
+ {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"},
+ {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"},
+ {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"},
+ {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"},
+ {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"},
+ {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"},
+ {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"},
+ {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"},
+ {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"},
+ {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"},
+ {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"},
+ {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"},
+ {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"},
+ {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"},
+ {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"},
+ {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"},
+ {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"},
+ {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"},
+ {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"},
+ {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"},
+ {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"},
+]
+
+[[package]]
+name = "wheel"
+version = "0.46.3"
+description = "Command line tool for manipulating wheel files"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"},
+ {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"},
+]
+
+[package.dependencies]
+packaging = ">=24.0"
+
+[package.extras]
+test = ["pytest (>=6.0.0)", "setuptools (>=77)"]
+
+[[package]]
+name = "wsproto"
+version = "1.3.2"
+description = "Pure-Python WebSocket protocol implementation"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"},
+ {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"},
+]
+
+[package.dependencies]
+h11 = ">=0.16.0,<1"
+
+[[package]]
+name = "zipp"
+version = "3.23.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"},
+ {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"},
+]
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
+type = ["pytest-mypy"]
+
+[metadata]
+lock-version = "2.1"
+python-versions = "^3.14"
+content-hash = "11e7820330aef0a91b8b1b35791aaa335cf1352f21316f2dedaf5439afe474ac"
diff --git a/tests/poetry.toml b/tests/poetry.toml
new file mode 100644
index 0000000..ab1033b
--- /dev/null
+++ b/tests/poetry.toml
@@ -0,0 +1,2 @@
+[virtualenvs]
+in-project = true
diff --git a/tests/pyproject.toml b/tests/pyproject.toml
new file mode 100644
index 0000000..1907e60
--- /dev/null
+++ b/tests/pyproject.toml
@@ -0,0 +1,11 @@
+[tool.poetry]
+name = "nitter-tests"
+version = "0.0.0"
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.14"
+seleniumbase = "4.46.5"
+
+[tool.pytest.ini_options]
+addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5"
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 56ea4c0..e47d1cc 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1 +1 @@
-seleniumbase
+seleniumbase==4.46.5
diff --git a/tests/test_about_account.py b/tests/test_about_account.py
new file mode 100644
index 0000000..2239d9d
--- /dev/null
+++ b/tests/test_about_account.py
@@ -0,0 +1,76 @@
+from base import BaseTestCase, Profile
+from parameterized import parameterized
+
+
+class AboutAccount(object):
+ header = '.about-account-header'
+ name = '.about-account-name'
+ body = '.about-account-body'
+ row = '.about-account-row'
+ label = '.about-account-label'
+ value = '.about-account-value'
+
+
+# (username, expected_labels)
+# Each label is checked for presence in the page text
+about_data = [
+ ['jack', ['Date joined', 'Account based in', 'Connected via']],
+ ['NASA', ['Date joined']],
+ ['elonmusk', ['Date joined']],
+]
+
+about_verified = [
+ ['jack', 'Verified', 'Since '],
+]
+
+about_affiliate = [
+ ['jack', 'An affiliate of', 'Square'],
+ ['elonmusk', 'An affiliate of', 'X'],
+]
+
+
+class AboutAccountTest(BaseTestCase):
+ @parameterized.expand(about_data)
+ def test_about_page_has_labels(self, username, expected_labels):
+ """About page shows expected info labels"""
+ self.open_nitter(f'{username}/about')
+ self.assert_element_visible(AboutAccount.header)
+ self.assert_element_visible(AboutAccount.body)
+ for label in expected_labels:
+ self.assert_text(label, AboutAccount.body)
+
+ @parameterized.expand(about_verified)
+ def test_about_verified(self, username, label, value_prefix):
+ """About page shows verification info for verified accounts"""
+ self.open_nitter(f'{username}/about')
+ self.assert_text(label, AboutAccount.body)
+ self.assert_text(value_prefix, AboutAccount.body)
+
+ @parameterized.expand(about_affiliate)
+ def test_about_affiliate(self, username, label, affiliate):
+ """About page shows affiliate info"""
+ self.open_nitter(f'{username}/about')
+ self.assert_text(label, AboutAccount.body)
+ self.assert_text(f'@{affiliate}', AboutAccount.body)
+
+ def test_about_page_title(self):
+ """Title contains account name"""
+ self.open_nitter('jack/about')
+ self.assert_text('jack', AboutAccount.name)
+
+ def test_about_join_date(self):
+ """About page always shows join date"""
+ self.open_nitter('jack/about')
+ self.assert_text('Date joined', AboutAccount.body)
+ self.assert_text('March 2006', AboutAccount.body)
+
+ def test_about_invalid_user(self):
+ """About page for non-existent user shows error"""
+ self.open_nitter('thisprofiledoesntexist/about')
+ self.assert_text('User "thisprofiledoesntexist" not found')
+
+ def test_joindate_links_to_about(self):
+ """Join date on profile page links to about page"""
+ self.open_nitter('jack')
+ link = self.find_element(Profile.joinDate + ' a')
+ self.assertIn('/jack/about', link.get_attribute('href'))
diff --git a/tests/test_article.py b/tests/test_article.py
new file mode 100644
index 0000000..287b948
--- /dev/null
+++ b/tests/test_article.py
@@ -0,0 +1,327 @@
+from base import BaseTestCase
+from parameterized import parameterized
+
+
+class ArticleSelectors:
+ page = '.article-page'
+ cover = '.article-cover'
+ body = '.article-body'
+ title = '.article-title'
+ author = '.article-author'
+ fullname = '.article-author .fullname'
+ username = '.article-author .username'
+ date = '.article-author .article-date'
+ avatar = '.article-author img.avatar'
+ verified = '.article-author .verified-icon'
+ media = '.article-media'
+ caption = '.article-media-caption'
+ divider = '.article-divider'
+
+
+articles = [
+ ['2064166507438059759',
+ '1s,秒杀一切,开源一个 X 文章发布 Skill【重磅升级】',
+ 'punk2898', 'Punk'],
+
+ ['2064689664213041529',
+ 'SpaceX Thesis & Valuation Memorandum',
+ 'Dialectic_Group', 'Dialectic'],
+
+ ['2064691088636424322',
+ 'Consciousness and AI: The Problem of Inner Experience',
+ 'CosmicOrFun', 'Cosmic Orphan'],
+
+ ['2064755789391110154',
+ 'DeFi Markets Update 2026-06-10',
+ 'SteakhouseFi', 'Steakhouse Financial'],
+
+ ['2064755231901319527',
+ 'The machine economy has a killswitch and somebody just pulled it.',
+ '1914ad', 'Justin Bechler HMP-028'],
+
+ ['2062858677149675788',
+ 'Yakshinis',
+ 'CosmicOrFun', 'Cosmic Orphan'],
+]
+
+articles_with_media = [
+ ['2064166507438059759', 6],
+ ['2064689664213041529', 11],
+ ['2064755789391110154', 5],
+]
+
+articles_with_dividers = [
+ ['2064166507438059759', 1],
+ ['2064689664213041529', 6],
+]
+
+
+class ArticleBasicTest(BaseTestCase):
+ @parameterized.expand(articles)
+ def test_article_loads(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'i/article/{tweet_id}')
+ self.assert_element_visible(ArticleSelectors.page)
+ self.assert_element_visible(ArticleSelectors.body)
+ self.assert_text(title, ArticleSelectors.title)
+
+ @parameterized.expand(articles)
+ def test_article_author(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'i/article/{tweet_id}')
+ self.assert_element_visible(ArticleSelectors.author)
+ self.assert_text(f'@{username}', ArticleSelectors.username)
+
+ @parameterized.expand(articles)
+ def test_article_has_cover(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'i/article/{tweet_id}')
+ self.assert_element_visible(ArticleSelectors.cover)
+ src = self.get_attribute(ArticleSelectors.cover, 'src')
+ self.assertIn('/pic/', src)
+
+ @parameterized.expand(articles)
+ def test_article_has_date(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'i/article/{tweet_id}')
+ date_text = self.get_text(ArticleSelectors.date)
+ self.assertTrue(len(date_text) > 3)
+
+ @parameterized.expand(articles)
+ def test_article_author_avatar(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'i/article/{tweet_id}')
+ self.assert_element_visible(ArticleSelectors.avatar)
+ src = self.get_attribute(ArticleSelectors.avatar, 'src')
+ self.assertIn('/pic/', src)
+ self.assertGreater(len(src), len('/pic/'))
+
+ @parameterized.expand(articles)
+ def test_article_author_verified(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'i/article/{tweet_id}')
+ self.assert_element_visible(ArticleSelectors.verified)
+
+ def test_article_author_verified_business(self):
+ self.open_nitter('i/article/2064755789391110154')
+ self.assert_element_visible('.article-author .verified-icon.business')
+
+
+class ArticleContentTest(BaseTestCase):
+ def test_article_has_paragraphs(self):
+ self.open_nitter('i/article/2064689664213041529')
+ paragraphs = self.find_elements('.article-body p')
+ self.assertGreater(len(paragraphs), 10)
+
+ def test_article_has_headers(self):
+ self.open_nitter('i/article/2064689664213041529')
+ headers = self.find_elements('.article-body h1, .article-body h2')
+ self.assertGreater(len(headers), 5)
+
+ def test_article_has_bold_text(self):
+ self.open_nitter('i/article/2064166507438059759')
+ bold = self.find_elements('.article-body strong')
+ self.assertGreater(len(bold), 0)
+
+ def test_article_has_italic_text(self):
+ self.open_nitter('i/article/2064166507438059759')
+ italic = self.find_elements('.article-body em')
+ self.assertGreater(len(italic), 0)
+
+ def test_article_has_blockquotes(self):
+ self.open_nitter('i/article/2064166507438059759')
+ self.assert_element_visible('.article-body blockquote')
+
+ def test_article_has_lists(self):
+ self.open_nitter('i/article/2064166507438059759')
+ self.assert_element_visible('.article-body ul')
+
+ def test_article_has_emoji_text(self):
+ self.open_nitter('i/article/2064166507438059759')
+ body = self.get_text(ArticleSelectors.body)
+ self.assertTrue(any(ord(c) > 0x1F000 for c in body))
+
+ def test_article_has_links(self):
+ self.open_nitter('i/article/2064691088636424322')
+ links = self.find_elements('.article-body a[href]')
+ self.assertGreater(len(links), 0)
+
+ def test_article_twitter_links_localized(self):
+ self.open_nitter('i/article/2064755789391110154')
+ links = self.find_elements('.article-body a[href^="https://x.com"]')
+ self.assertEqual(len(links), 0, 'x.com links should be converted to local paths')
+
+ @parameterized.expand(articles_with_media)
+ def test_article_media_count(self, tweet_id, expected_count):
+ self.open_nitter(f'i/article/{tweet_id}')
+ media = self.find_elements(ArticleSelectors.media)
+ self.assertEqual(len(media), expected_count)
+
+ @parameterized.expand(articles_with_dividers)
+ def test_article_divider_count(self, tweet_id, expected_count):
+ self.open_nitter(f'i/article/{tweet_id}')
+ dividers = self.find_elements(ArticleSelectors.divider)
+ self.assertEqual(len(dividers), expected_count)
+
+
+class ArticleMediaTest(BaseTestCase):
+ def test_media_images_proxied(self):
+ self.open_nitter('i/article/2064689664213041529')
+ self.assert_element_visible(ArticleSelectors.media)
+ img = self.find_element(f'{ArticleSelectors.media} img')
+ src = img.get_attribute('src')
+ self.assertIn('/pic/', src)
+ self.assertFalse(src.startswith('https://pbs.twimg.com'))
+
+ def test_cover_image_proxied(self):
+ self.open_nitter('i/article/2064689664213041529')
+ self.assert_element_visible(ArticleSelectors.cover)
+ src = self.get_attribute(ArticleSelectors.cover, 'src')
+ self.assertIn('/pic/', src)
+ self.assertFalse(src.startswith('https://pbs.twimg.com'))
+
+ def test_embedded_tweet(self):
+ self.open_nitter('i/article/2064755789391110154')
+ self.assert_element_visible('.article-body .timeline-item')
+
+ def test_multiple_embedded_tweets(self):
+ self.open_nitter('i/article/2064755231901319527')
+ tweets = self.find_elements('.article-body .timeline-item')
+ self.assertGreaterEqual(len(tweets), 3)
+
+ def test_media_caption_displayed(self):
+ self.open_nitter('i/article/2064689664213041529')
+ self.assert_element_visible(ArticleSelectors.caption)
+ captions = self.find_elements(ArticleSelectors.caption)
+ self.assertGreaterEqual(len(captions), 5)
+
+ def test_media_caption_text(self):
+ self.open_nitter('i/article/2064689664213041529')
+ self.assert_text_visible('FIGURE 1', ArticleSelectors.caption)
+
+ def test_media_caption_alt_attribute(self):
+ self.open_nitter('i/article/2064689664213041529')
+ img = self.find_element(f'{ArticleSelectors.media} img')
+ alt = img.get_attribute('alt')
+ self.assertGreater(len(alt), 0)
+
+ def test_no_caption_when_absent(self):
+ self.open_nitter('i/article/2062858677149675788')
+ captions = self.find_elements(ArticleSelectors.caption)
+ self.assertEqual(len(captions), 0)
+
+
+class ArticleMentionTest(BaseTestCase):
+ def test_mention_linkified(self):
+ self.open_nitter('i/article/2064755231901319527')
+ link = self.find_element('.article-body a[href="/ZachXBT"]')
+ self.assertEqual(link.text, '@ZachXBT')
+
+ def test_multiple_mentions_linkified(self):
+ self.open_nitter('i/article/2064755231901319527')
+ links = self.find_elements('.article-body a[href^="/"]')
+ mention_hrefs = [l.get_attribute('href') for l in links
+ if l.text.startswith('@')]
+ usernames = [h.split('/')[-1] for h in mention_hrefs]
+ self.assertIn('ZachXBT', usernames)
+ self.assertIn('River', usernames)
+
+ def test_mention_in_different_article(self):
+ self.open_nitter('i/article/2064689664213041529')
+ link = self.find_element('.article-body a[href="/FutureJurvetson"]')
+ self.assertEqual(link.text, '@FutureJurvetson')
+
+ def test_no_spurious_whitespace_in_styled_paragraph(self):
+ """Styled paragraphs should not have extra whitespace from VNode serialization."""
+ self.open_nitter('i/article/2064166507438059759')
+ source = self.get_page_source()
+ self.assertNotIn('white-space: pre-wrap', source)
+ self.assertNotIn('white-space:pre-wrap', source)
+
+
+class ArticleCardTest(BaseTestCase):
+ @parameterized.expand(articles)
+ def test_status_page_shows_article_card(self, tweet_id, title, username, fullname):
+ self.open_nitter(f'{username}/status/{tweet_id}')
+ self.assert_element_visible('.article-card')
+ self.assert_text(title, '.article-card .card-title')
+
+ def test_article_card_has_cover_image(self):
+ self.open_nitter('Dialectic_Group/status/2064689664213041529')
+ self.assert_element_visible('.article-card .card-image img')
+ src = self.get_attribute('.article-card .card-image img', 'src')
+ self.assertIn('/pic/', src)
+
+ def test_article_card_has_badge(self):
+ self.open_nitter('Dialectic_Group/status/2064689664213041529')
+ self.assert_element_visible('.article-card-badge')
+ self.assert_text('Article', '.article-card-badge')
+
+ def test_article_card_has_preview_text(self):
+ self.open_nitter('CosmicOrFun/status/2064691088636424322')
+ self.assert_element_visible('.article-card .card-description')
+
+ def test_article_card_links_to_article(self):
+ self.open_nitter('punk2898/status/2064166507438059759')
+ href = self.get_attribute('.article-card .card-container', 'href')
+ self.assertIn('/article/', href)
+
+ def test_article_url_stripped_from_tweet_text(self):
+ self.open_nitter('punk2898/status/2064166507438059759')
+ self.assert_element_visible('.article-card')
+ source = self.get_page_source()
+ # Main tweet text should not contain article URL
+ import re
+ main = re.search(r'id="m".*?tweet-content[^>]*>(.*?)', source, re.DOTALL)
+ self.assertIsNotNone(main)
+ self.assertNotIn('/article/', main.group(1))
+
+
+class ArticleQuotedCardTest(BaseTestCase):
+ """Article cards inside quoted tweets (1914ad quoting own article)."""
+ quoted_tweet = '1914ad/status/2064789532071891085'
+ quoted_article_id = '2063677483548102688'
+
+ def test_quoted_card_visible(self):
+ self.open_nitter(self.quoted_tweet)
+ self.assert_element_visible('.quote .article-card')
+
+ def test_quoted_card_has_title(self):
+ self.open_nitter(self.quoted_tweet)
+ self.assert_text('David Bailey Already Won', '.quote .article-card .card-title')
+
+ def test_quoted_card_has_badge(self):
+ self.open_nitter(self.quoted_tweet)
+ self.assert_element_visible('.quote .article-card-badge')
+ self.assert_text('Article', '.quote .article-card-badge')
+
+ def test_quoted_card_has_cover_image(self):
+ self.open_nitter(self.quoted_tweet)
+ # Scroll to element to trigger lazy loading
+ self.scroll_to('.quote .article-card .card-image img')
+ self.assert_element_visible('.quote .article-card .card-image img')
+ src = self.get_attribute('.quote .article-card .card-image img', 'src')
+ self.assertIn('/pic/', src)
+
+ def test_quoted_card_has_description(self):
+ self.open_nitter(self.quoted_tweet)
+ self.assert_element_visible('.quote .article-card .card-description')
+
+ def test_quoted_card_links_to_article(self):
+ self.open_nitter(self.quoted_tweet)
+ href = self.get_attribute('.quote .article-card .card-container', 'href')
+ self.assertIn(f'/article/{self.quoted_article_id}', href)
+
+
+class ArticleRoutingTest(BaseTestCase):
+ def test_username_article_route_redirects(self):
+ self.open_nitter('punk2898/article/2064166507438059759')
+ self.assert_element_visible(ArticleSelectors.page)
+ self.assert_text('1s', ArticleSelectors.title)
+
+ def test_status_article_route_redirects(self):
+ self.open_nitter('punk2898/status/2064166507438059759/article')
+ self.assert_element_visible(ArticleSelectors.page)
+ self.assert_text('1s', ArticleSelectors.title)
+
+ def test_invalid_id_returns_404(self):
+ self.open_nitter('i/article/notanumber')
+ self.assert_element_not_visible(ArticleSelectors.page)
+
+ def test_nonexistent_article(self):
+ self.open_nitter('i/article/1')
+ self.assert_element_visible('.error-panel')
diff --git a/tests/test_card.py b/tests/test_card.py
index 504c079..daee099 100644
--- a/tests/test_card.py
+++ b/tests/test_card.py
@@ -1,3 +1,5 @@
+import os
+import unittest
from base import BaseTestCase, Card, Conversation
from parameterized import parameterized
@@ -13,21 +15,16 @@ card = [
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', True],
- ['nim_lang/status/1082989146040340480',
- 'Nim in 2018: A short recap',
- 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.',
- 'nim-lang.org', True]
+ ['NASA/status/2061872347477418301',
+ 'Nancy Grace Roman Space Telescope - NASA Science',
+ 'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.',
+ 'science.nasa.gov', True]
]
no_thumb = [
- ['FluentAI/status/1116417904831029248',
- 'LinkedIn',
- 'This link will take you to a page that’s not on LinkedIn',
- 'lnkd.in'],
-
['Thom_Wolf/status/1122466524860702729',
- 'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in',
- '',
+ 'GitHub - facebookresearch/XLM: PyTorch original implementation of Cross-lingual Language Model',
+ 'PyTorch original implementation of Cross-lingual Language Model Pretraining.',
'github.com'],
['brent_p/status/1088857328680488961',
@@ -42,20 +39,17 @@ no_thumb = [
]
playable = [
- ['nim_lang/status/1118234460904919042',
- 'Nim development blog 2019-03',
- 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...',
- 'youtube.com'],
-
- ['nim_lang/status/1121090879823986688',
- 'Nim - First natively compiled language w/ hot code-reloading at...',
- '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...',
+ ['NASA/status/2047048645845897398',
+ 'NASA\'s Artemis II News Conference with Moon Astronauts',
+ 'Live from NASA\'s Johnson Space Center in Houston',
'youtube.com']
]
class CardTest(BaseTestCase):
@parameterized.expand(card)
def test_card(self, tweet, title, description, destination, large):
+ if os.environ.get('GITHUB_ACTIONS') == 'true' and '2061872347477418301' in tweet:
+ self.skipTest('Card image unreliable from GitHub datacenter IPs')
self.open_nitter(tweet)
c = Card(Conversation.main + " ")
self.assert_text(title, c.title)
@@ -77,7 +71,7 @@ class CardTest(BaseTestCase):
if len(description) > 0:
self.assert_text(description, c.description)
- @parameterized.expand(playable)
+ @parameterized.expand(playable, skip_on_empty=True)
def test_card_playable(self, tweet, title, description, destination):
self.open_nitter(tweet)
c = Card(Conversation.main + " ")
diff --git a/tests/test_community.py b/tests/test_community.py
new file mode 100644
index 0000000..b266044
--- /dev/null
+++ b/tests/test_community.py
@@ -0,0 +1,211 @@
+from base import BaseTestCase
+from parameterized import parameterized
+
+
+COMMUNITY_ID = '1493446837214187523'
+COMMUNITY_PATH = f'i/communities/{COMMUNITY_ID}'
+
+
+class CommunityTest(BaseTestCase):
+ def test_top_page_loads(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.community-header')
+ self.assert_text('Build in Public', '.community-name')
+
+ def test_banner_visible(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.timeline-banner img')
+
+ def test_member_count(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.community-member-count')
+ self.assert_text('Members', '.community-member-count')
+
+ def test_description_visible(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.community-description')
+
+ def test_tabs_present(self):
+ self.open_nitter(COMMUNITY_PATH)
+ tabs = self.find_elements('.tab a')
+ labels = [t.text for t in tabs]
+ self.assertEqual(labels, ['Top', 'Latest', 'Media', 'About'])
+
+ def test_top_tab_active(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.tab .active a[href$="/' + COMMUNITY_ID + '"]')
+
+ def test_top_has_tweets(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.timeline-item .tweet-body')
+
+ def test_top_has_pagination(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.show-more')
+ self.assert_text('Load more', '.show-more')
+
+ def test_latest_has_tweets(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/latest')
+ self.assert_element_visible('.timeline-item .tweet-body')
+
+ def test_latest_tab_active(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/latest')
+ self.assert_element_visible('.tab .active a[href$="/latest"]')
+
+ def test_media_has_tweets(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/media')
+ self.assert_element_visible('.timeline-item .tweet-body')
+
+ def test_media_tab_active(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/media')
+ self.assert_element_visible('.tab .active a[href$="/media"]')
+
+ def test_about_page(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_element_visible('.community-about')
+ self.assert_text('Community Info', '.community-info h2')
+
+ def test_about_rules(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_element_visible('.community-rules')
+ self.assert_text('Rules', '.community-rules h2')
+ rules = self.find_elements('.community-rule')
+ self.assertGreater(len(rules), 0)
+
+ def test_about_creator(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_text('Created', '.community-info')
+ link = self.find_element('.community-info-item a')
+ self.assertTrue(link.text.startswith('@'))
+ self.assertGreater(len(link.text), 1)
+
+ def test_about_tab_active(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_element_visible('.tab .active a[href$="/about"]')
+
+ def test_about_moderators(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_element_visible('.community-moderators')
+ self.assert_text('Moderators', '.community-moderators h2')
+ mods = self.find_elements('.community-moderator')
+ self.assertGreater(len(mods), 0)
+
+ def test_about_moderators_have_avatars(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ avatars = self.find_elements('.community-mod-avatar')
+ self.assertGreater(len(avatars), 0)
+
+ def test_about_moderators_link_to_profiles(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ links = self.find_elements('.community-mod-username')
+ self.assertGreater(len(links), 0)
+ for link in links:
+ self.assertTrue(link.text.startswith('@'))
+ self.assertTrue(link.get_attribute('href').startswith('http'))
+
+ def test_about_see_all_link(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ link = self.find_element('.community-mods-link')
+ self.assertEqual(link.text, 'See all')
+ self.assertIn('/moderators', link.get_attribute('href'))
+
+ def test_members_page(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/members')
+ self.assert_element_visible('.timeline-item')
+ users = self.find_elements('.timeline-item .username')
+ self.assertGreater(len(users), 0)
+
+ def test_members_has_member_tabs(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/members')
+ tabs = self.find_elements('.tab a')
+ labels = [t.text for t in tabs]
+ self.assertEqual(labels, ['All', 'Moderators'])
+
+ def test_members_all_tab_active(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/members')
+ self.assert_element_visible('.tab .active a[href$="/members"]')
+
+ def test_members_count_is_link(self):
+ self.open_nitter(COMMUNITY_PATH)
+ link = self.find_element('.community-member-count')
+ self.assertIn('Members', link.text)
+ self.assertIn('/members', link.get_attribute('href'))
+
+ def test_moderators_page(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/moderators')
+ self.assert_element_visible('.timeline-item')
+ users = self.find_elements('.timeline-item .username')
+ self.assertGreater(len(users), 0)
+
+ def test_moderators_tab_active(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/moderators')
+ self.assert_element_visible('.tab .active a[href$="/moderators"]')
+
+ def test_moderators_has_member_tabs(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/moderators')
+ tabs = self.find_elements('.tab a')
+ labels = [t.text for t in tabs]
+ self.assertEqual(labels, ['All', 'Moderators'])
+
+ def test_pinned_tweet_label(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.pinned')
+ self.assert_text('Pinned by Community mods', '.pinned')
+
+ def test_hashtags_visible(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.community-tags')
+ tags = self.find_elements('.community-tag')
+ self.assertGreater(len(tags), 0)
+
+ def test_hashtags_are_links(self):
+ self.open_nitter(COMMUNITY_PATH)
+ tags = self.find_elements('.community-tag')
+ for tag in tags:
+ href = tag.get_attribute('href')
+ self.assertIn('/hashtag/', href)
+ self.assertTrue(tag.text.startswith('#'))
+
+ def test_hashtag_page(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
+ self.assert_element_visible('.timeline-item .tweet-body')
+
+ def test_hashtag_shows_header(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
+ self.assert_element_visible('.community-header')
+ self.assert_text('Build in Public', '.community-name')
+
+ def test_hashtag_shows_tag_title(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
+ self.assert_element_visible('.community-hashtag-header')
+ self.assert_text('#buildinpublic', '.community-hashtag-title')
+
+ def test_hashtag_no_main_tabs(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
+ tabs = self.find_elements('.tab a')
+ tab_labels = [t.text for t in tabs]
+ self.assertNotIn('Top', tab_labels)
+ self.assertNotIn('About', tab_labels)
+
+ def test_category_visible(self):
+ self.open_nitter(COMMUNITY_PATH)
+ self.assert_element_visible('.community-category')
+
+ def test_about_join_policy(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_text('Anyone can join', '.community-info')
+
+ def test_about_visibility_note(self):
+ self.open_nitter(f'{COMMUNITY_PATH}/about')
+ self.assert_text('publicly visible', '.community-info')
+
+ def test_404_invalid_id(self):
+ self.open_nitter('i/communities/999')
+ self.assert_element_visible('.error-panel')
+ self.assert_text('not found', '.error-panel')
+
+ @parameterized.expand(['', '/latest', '/media', '/about',
+ '/members', '/moderators'])
+ def test_page_no_error(self, suffix):
+ self.open_nitter(f'{COMMUNITY_PATH}{suffix}')
+ self.assert_element_not_visible('.error-panel')
diff --git a/tests/test_embed.py b/tests/test_embed.py
new file mode 100644
index 0000000..cc7a28a
--- /dev/null
+++ b/tests/test_embed.py
@@ -0,0 +1,282 @@
+import os
+import unittest
+import requests
+from base import BaseTestCase, Media
+from parameterized import parameterized
+
+
+class Embed:
+ container = '.tweet-embed'
+ footer = '.embed-footer'
+ tweet_content = '.tweet-content'
+ tweet_header = '.tweet-header'
+ fullname = '.fullname'
+ username = '.username'
+ avatar = '.avatar'
+ stats = '.tweet-stats'
+ quote = '.quote'
+ error_panel = '.error-panel'
+
+
+class TweetEmbedTest(BaseTestCase):
+ """Test tweet embed rendering."""
+ tweet = 'elonmusk/status/1141367104702038016'
+
+ def test_embed_container_visible(self):
+ self.open_nitter(self.tweet + '/embed')
+ self.assert_element_visible(Embed.container)
+
+ def test_embed_has_footer(self):
+ self.open_nitter(self.tweet + '/embed')
+ self.assert_element_visible(Embed.footer)
+ self.assert_text_visible('Read more on', Embed.footer)
+
+ def test_embed_has_tweet_content(self):
+ self.open_nitter(self.tweet + '/embed')
+ self.assert_element_visible(Embed.tweet_content)
+
+ def test_embed_has_avatar(self):
+ self.open_nitter(self.tweet + '/embed')
+ self.assert_element_visible(Embed.avatar)
+
+ def test_embed_has_username(self):
+ self.open_nitter(self.tweet + '/embed')
+ self.assert_element_visible(Embed.username)
+
+ def test_embed_has_stats(self):
+ self.open_nitter(self.tweet + '/embed')
+ self.assert_element_visible(Embed.stats)
+
+ def test_embed_footer_links_to_tweet(self):
+ self.open_nitter(self.tweet + '/embed')
+ href = self.get_attribute(Embed.footer, 'href')
+ self.assertIn('/elonmusk/status/1141367104702038016', href)
+
+
+class TweetEmbedMediaTest(BaseTestCase):
+ """Test embed rendering with various media types."""
+
+ def test_embed_with_image(self):
+ self.open_nitter('mobile_test/status/519364660823207936/embed')
+ self.assert_element_visible(Embed.container)
+ self.scroll_to(Media.container)
+ self.assert_element_visible(Media.image)
+
+ def test_embed_with_gif(self):
+ self.open_nitter('elonmusk/status/1141367104702038016/embed')
+ self.assert_element_visible(Embed.container)
+ self.scroll_to(Media.container)
+ self.assert_element_visible(Media.gif)
+
+ @unittest.skipIf(os.environ.get('GITHUB_ACTIONS') == 'true',
+ 'TweetResultByRestId is Cloudflare-blocked from GitHub datacenter IPs')
+ def test_embed_with_video(self):
+ self.open_nitter('d0m96/status/1078373829917974528/embed')
+ self.assert_element_visible(Embed.container)
+ self.scroll_to(Media.container)
+ self.assert_element_visible(Media.video)
+
+ def test_embed_with_gallery(self):
+ self.open_nitter('mobile_test/status/451108446603980803/embed')
+ self.assert_element_visible(Embed.container)
+ self.scroll_to(Media.container)
+ self.assert_element_visible(Media.row)
+
+
+class TweetEmbedQuoteTest(BaseTestCase):
+ """Test embed rendering with quoted tweets."""
+
+ def test_embed_with_quote_shows_quote(self):
+ self.open_nitter('elonmusk/status/1138827760107790336/embed')
+ self.assert_element_visible(Embed.container)
+ self.assert_element_visible(Embed.quote)
+
+ def test_embed_quote_has_content(self):
+ self.open_nitter('elonmusk/status/1138827760107790336/embed')
+ quote = self.find_element(Embed.quote)
+ self.assertIsNotNone(quote.text)
+
+
+class EmbedErrorTest(BaseTestCase):
+ """Test embed error handling."""
+
+ def test_nonexistent_tweet_shows_error(self):
+ self.open_nitter('nobody/status/1/embed')
+ self.assert_element_visible('.tweet-embed.error-embed')
+ self.assert_text_visible('not found', Embed.error_panel)
+
+ def test_protected_account_embed_shows_error(self):
+ self.open_nitter('mobile_test_7/status/1/embed')
+ self.assert_element_visible('.tweet-embed.error-embed')
+
+ def test_invalid_tweet_id_shows_error(self):
+ self.open_nitter('jack/status/notanumber/embed')
+ self.assert_element_visible('.tweet-embed.error-embed')
+
+
+class OEmbedApiTest(BaseTestCase):
+ """Test oEmbed API endpoint."""
+ base_url = 'http://localhost:8080'
+ tweet_url = 'https://twitter.com/elonmusk/status/1141367104702038016'
+
+ def test_oembed_returns_json(self):
+ resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.headers['Content-Type'], 'application/json')
+
+ def test_oembed_has_required_fields(self):
+ resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
+ data = resp.json()
+ self.assertEqual(data['type'], 'rich')
+ self.assertEqual(data['version'], '1.0')
+ self.assertIn('html', data)
+ self.assertIn('author_name', data)
+ self.assertIn('provider_name', data)
+
+ def test_oembed_html_contains_iframe(self):
+ resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}')
+ data = resp.json()
+ self.assertIn('