"
@@ -50,8 +42,6 @@ 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)
@@ -61,15 +51,8 @@ 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
@@ -93,8 +76,6 @@ 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)
@@ -111,7 +92,6 @@ proc getSessionPoolDebug*(): JsonNode =
for session in sessionPool:
let sessionJson = %*{
- "kind": $session.kind,
"apis": newJObject(),
"pending": session.pending,
}
@@ -142,12 +122,11 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available")
-proc isLimited(session: Session; req: ApiReq): bool =
+proc isLimited(session: Session; api: Api): bool =
if session.isNil:
return true
- let api = req.endpoint(session)
- if session.limited and api != graphUserTweetsV2:
+ if session.limited and api != Api.userTweets:
if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false
log "resetting limit: ", session.pretty
@@ -161,8 +140,8 @@ proc isLimited(session: Session; req: ApiReq): bool =
else:
return false
-proc isReady(session: Session; req: ApiReq): bool =
- not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
+proc isReady(session: Session; api: Api): bool =
+ not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
proc invalidate*(session: var Session) =
if session.isNil: return
@@ -177,29 +156,24 @@ proc release*(session: Session) =
if session.isNil: return
dec session.pending
-proc getSession*(req: ApiReq): Future[Session] {.async.} =
+proc getSession*(api: Api): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len:
- if result.isReady(req): break
+ if result.isReady(api): break
result = sessionPool.sample()
- if not result.isNil and result.isReady(req):
+ if not result.isNil and result.isReady(api):
inc result.pending
else:
- 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
+ log "no sessions available for API: ", api
raise noSessionsError()
-proc setLimited*(session: Session; req: ApiReq) =
- let api = req.endpoint(session)
+proc setLimited*(session: Session; api: Api) =
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; req: ApiReq; remaining, reset, limit: int) =
+proc setRateLimit*(session: Session; api: Api; 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 b46a979..1b05ffe 100644
--- a/src/config.nim
+++ b/src/config.nim
@@ -13,8 +13,6 @@ 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"),
@@ -39,20 +37,10 @@ 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),
- 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),
+ enableRss: cfg.get("Config", "enableRSS", true),
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
- 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)
+ proxyAuth: cfg.get("Config", "proxyAuth", "")
)
return (conf, cfg)
diff --git a/src/consts.nim b/src/consts.nim
index c88ae68..792a519 100644
--- a/src/consts.nim
+++ b/src/consts.nim
@@ -1,108 +1,116 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils
+import uri, strutils
const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
- bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
- bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
- 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"
+ gql = parseUri("https://api.x.com") / "graphql"
- 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"
+ 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"
gqlFeatures* = """{
- "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,
+ "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,
- "responsive_web_graphql_timeline_navigation_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,
+ "verified_phone_label_enabled": false,
+ "vibe_api_enabled": false,
+ "view_counts_everywhere_api_enabled": true,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": 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_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
- "responsive_web_grok_community_note_auto_translation_is_enabled": true,
- "responsive_web_enhance_cards_enabled": false
+ "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
}""".replace(" ", "").replace("\n", "")
- tweetVars* = """{
+ tweetVariables* = """{
"postId": "$1",
$2
- "ranking_mode": "$3",
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
- "withBirdwatchNotes": true,
+ "withBirdwatchNotes": false,
"withVoice": false,
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
- tweetDetailVars* = """{
+ tweetDetailVariables* = """{
"focalTweetId": "$1",
$2
"referrer": "profile",
@@ -115,26 +123,21 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- tweetEditHistoryVars* = """{
- "tweetId": "$1",
- "withQuickPromoteEligibilityTweetFields": true
-}""".replace(" ", "").replace("\n", "")
-
- restIdVars* = """{
+ restIdVariables* = """{
"rest_id": "$1", $2
- "count": $3
-}""".replace(" ", "").replace("\n", "")
+ "count": 20
+}"""
- userMediaVars* = """{
+ userMediaVariables* = """{
"userId": "$1", $2
- "count": $3,
+ "count": 20,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- userTweetsVars* = """{
+ userTweetsVariables* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -142,7 +145,7 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- userTweetsAndRepliesVars* = """{
+ userTweetsAndRepliesVariables* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -150,70 +153,5 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- 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}"""
+ fieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim
index e22a51f..40986f5 100644
--- a/src/experimental/parser.nim
+++ b/src/experimental/parser.nim
@@ -1,2 +1,2 @@
-import parser/[user, graphql, article]
-export user, graphql, article
+import parser/[user, graphql]
+export user, graphql
diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim
deleted file mode 100644
index ae5de7c..0000000
--- a/src/experimental/parser/article.nim
+++ /dev/null
@@ -1,88 +0,0 @@
-# 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 85be202..045a5d6 100644
--- a/src/experimental/parser/graphql.nim
+++ b/src/experimental/parser/graphql.nim
@@ -1,6 +1,6 @@
import options, strutils
import jsony
-import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
+import user, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User =
@@ -10,61 +10,27 @@ 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:
- let bio = userResult.profileBio.get
- result.bio = bio.description
- result.expandUserEntities(bio.entities)
+ result.bio = userResult.profileBio.get.description
proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
return
- 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()
+ let raw = json.fromJson(GraphUser)
+ let userResult = raw.data.userResult.result
- if userResult.unavailableReason.get("") == "Suspended" or
- userResult.reason.get("") == "Suspended":
+ if userResult.unavailableReason.get("") == "Suspended":
return User(suspended: true)
result = parseUserResult(userResult)
@@ -87,25 +53,3 @@ 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 db2c98d..45e6e1d 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?f=tweets&q=%23" & name)
+ result.add a(symbol & name, href = "/search?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
deleted file mode 100644
index 28fccea..0000000
--- a/src/experimental/parser/tid.nim
+++ /dev/null
@@ -1,8 +0,0 @@
-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 866973c..498757a 100644
--- a/src/experimental/parser/user.nim
+++ b/src/experimental/parser/user.nim
@@ -9,10 +9,12 @@ let
unReplace = "$1@$2"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)"""
- htReplace = "$1$2$3"
+ htReplace = "$1$2$3"
-proc expandUserEntities*(user: var User; ent: Entities) =
- let orig = user.bio.toRunes
+proc expandUserEntities(user: var User; raw: RawUser) =
+ let
+ orig = user.bio.toRunes
+ ent = raw.entities
if ent.url.urls.len > 0:
user.website = ent.url.urls[0].expandedUrl
@@ -56,17 +58,15 @@ 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.entities)
+ result.expandUserEntities(raw)
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
deleted file mode 100644
index 946f8c3..0000000
--- a/src/experimental/types/article.nim
+++ /dev/null
@@ -1,79 +0,0 @@
-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
deleted file mode 100644
index ba9210b..0000000
--- a/src/experimental/types/graphfollowers.nim
+++ /dev/null
@@ -1,17 +0,0 @@
-# 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 9e520d8..4cb3757 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 ec41f89..d732b4e 100644
--- a/src/experimental/types/graphuser.nim
+++ b/src/experimental/types/graphuser.nim
@@ -1,10 +1,9 @@
import options, strutils
from ../../types import User, VerifiedType
-import user as userType # Entities, for modern profile_bio parsing
type
GraphUser* = object
- data*: tuple[userResult: Option[UserData], user: Option[UserData]]
+ data*: tuple[userResult: UserData]
UserData* = object
result*: UserResult
@@ -16,7 +15,6 @@ type
UserBio* = object
description*: string
- entities*: Entities
UserAvatar* = object
imageUrl*: string
@@ -24,41 +22,15 @@ 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
deleted file mode 100644
index ad036d9..0000000
--- a/src/experimental/types/tid.nim
+++ /dev/null
@@ -1,4 +0,0 @@
-type
- TidPair* = object
- animationKey*: string
- verification*: string
diff --git a/src/formatters.nim b/src/formatters.nim
index 958e518..cafaa4f 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, math
+import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
import std/[enumerate, re]
import types, utils, query
const
cards = "cards.twitter.com/cards"
tco = "https://t.co"
- twitter = parseUri("https://x.com")
+ twitter = parseUri("https://twitter.com")
let
twRegex = re"(?<=(? 0 and "youtu" in result:
- let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
- result = result.replace(ytRegex, youtubeHost)
+ result = result.replace(ytRegex, prefs.replaceYouTube)
if prefs.replaceTwitter.len > 0:
- let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result:
- result = result.replace(tco, https & twitterHost & "/t.co")
+ result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
if "x.com" in result:
- result = result.replace(xRegex, twitterHost)
+ result = result.replace(xRegex, prefs.replaceTwitter)
result = result.replacef(xLinkRegex, a(
- twitterHost & "$2", href = https & twitterHost & "$1"))
+ prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
if "twitter.com" in result:
- result = result.replace(cards, twitterHost & "/cards")
- result = result.replace(twRegex, twitterHost)
+ result = result.replace(cards, prefs.replaceTwitter & "/cards")
+ result = result.replace(twRegex, prefs.replaceTwitter)
result = result.replacef(twLinkRegex, a(
- twitterHost & "$2", href = https & twitterHost & "$1"))
+ prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" 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(rdShortRegex, prefs.replaceReddit & "/comments/")
+ result = result.replace(rdRegex, prefs.replaceReddit)
+ if prefs.replaceReddit in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result:
@@ -91,17 +88,7 @@ proc getM3u8Url*(content: string): string =
if re.find(content, m3u8Regex, matches) != -1:
result = matches[0]
-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", "")
+proc proxifyVideo*(manifest: string; proxy: bool): string =
var replacements: seq[(string, string)]
for line in manifest.splitLines:
let url =
@@ -109,13 +96,9 @@ proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): 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
- 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)
+ if url.startsWith('/'):
+ let path = "https://video.twimg.com" & url
+ replacements.add (url, if proxy: path.getVidUrl else: path)
return manifest.multiReplace(replacements)
proc getUserPic*(userPic: string; style=""): string =
@@ -140,30 +123,25 @@ 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*(time: DateTime): string =
- if time.year == 0: return ""
+proc getShortTime*(tweet: Tweet): string =
let now = now()
- let since = now - time
+ let since = now - tweet.time
- if now.year != time.year:
- result = time.format("d MMM yyyy")
+ if now.year != tweet.time.year:
+ result = tweet.time.format("d MMM yyyy")
elif since.inDays >= 1:
- result = time.format("MMM d")
+ result = tweet.time.format("MMM d")
elif since.inHours >= 1:
result = $since.inHours & "h"
elif since.inMinutes >= 1:
@@ -173,33 +151,13 @@ proc getShortTime*(time: DateTime): 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
- return getLink(tweet.id, username, focus)
+ if username.len == 0:
+ username = "i"
+ result = &"/{username}/status/{tweet.id}"
+ if focus: result &= "#m"
proc getTwitterLink*(path: string; params: Table[string, string]): string =
var
@@ -227,7 +185,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?f=tweets&q=place:" & loc[1] else: ""
+ let url = if loc.len > 1: "/search?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 2553dd9..664e9a6 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(userAgent="", headers=heads, proxy=proxy)
+ result = newAsyncHttpClient(headers=heads, proxy=proxy)
else:
result = pool.conns.pop()
result.headers = heads
diff --git a/src/nitter.nim b/src/nitter.nim
index a76dda6..f81dc1c 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, normalizedPath
+from os import getEnv
import jester
-import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
+import types, config, prefs, formatters, redis_cache, http_pool, auth
import views/[general, about]
import routes/[
- preferences, timeline, status, media, search, rss, list, community, debug,
- unsupported, embed, resolver, broadcast, space, article, router_utils]
+ preferences, timeline, status, media, search, rss, list, debug,
+ unsupported, embed, resolver, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -34,62 +34,39 @@ 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 = normalizedPath(cfg.staticDir)
+ staticDir = 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, requestPrefs())
+ resp renderMain(renderSearch(), request, cfg, themePrefs())
get "/about":
- resp renderMain(renderAbout(), request, cfg, requestPrefs())
+ resp renderMain(renderAbout(), request, cfg, themePrefs())
get "/explore":
redirect("/about")
@@ -100,7 +77,7 @@ routes:
get "/i/redirect":
let url = decodeUrl(@"url")
if url.len == 0: resp Http404
- redirect(replaceUrls(url, requestPrefs()))
+ redirect(replaceUrls(url, cookiePrefs()))
error Http404:
resp Http404, showError("Page not found", cfg)
@@ -125,18 +102,14 @@ 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 eebca2d..700e896 100644
--- a/src/parser.nim
+++ b/src/parser.nim
@@ -1,20 +1,10 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, options, times, math, tables, uri
+import strutils, options, times, math, tables
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/unifiedcard
-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 parseGraphTweet(js: JsonNode): Tweet
proc parseUser(js: JsonNode; id=""): User =
if js.isNull: return
@@ -31,7 +21,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(js{"privacy", "protected"}.getBool),
+ protected: js{"protected"}.getBool,
joinDate: js{"created_at"}.getTime
)
@@ -39,17 +29,17 @@ proc parseUser(js: JsonNode; id=""): User =
result.verifiedType = blue
with verifiedType, js{"verified_type"}:
- result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
+ result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
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:
+ if js{"core"}.notNull and js{"legacy"}.notNull:
user = js
else:
return
@@ -61,164 +51,15 @@ 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(
- user{"verification", "is_blue_verified"}.getBool(false)):
+ if user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}:
- 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
- )
+ result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
proc parseGraphList*(js: JsonNode): List =
if js.isNull: return
@@ -229,17 +70,15 @@ proc parseGraphList*(js: JsonNode): List =
if list.isNull:
return
- 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
+ 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
)
- for url in js{"facepile_urls"}:
- result.facepiles.add url.getStr
proc parsePoll(js: JsonNode): Poll =
let vals = js{"binding_values"}
@@ -295,85 +134,60 @@ 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.media.addMedia(Photo(
- url: m{"media_url_https"}.getImageStr,
- altText: m{"ext_alt_text"}.getStr
- ))
+ result.photos.add m{"media_url_https"}.getImageStr
of "video":
- result.media.addMedia(parseVideo(m))
+ result.video = some(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.media.addMedia(Gif(
+ result.gif = some Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
- thumb: m{"media_url_https"}.getImageStr,
- altText: m{"ext_alt_text"}.getStr
- ))
+ thumb: m{"media_url_https"}.getImageStr
+ )
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":
- parsedMedia.addMedia(Photo(
- url: mediaInfo{"original_img_url"}.getImageStr,
- altText: mediaInfo{"alt_text"}.getStr
- ))
+ result.photos.add mediaInfo{"original_img_url"}.getImageStr
of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
- parsedMedia.addMedia(Video(
+ result.video = some 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":
- parsedMedia.addMedia(Gif(
+ result.gif = some Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
- thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
- altText: mediaInfo{"alt_text"}.getStr
- ))
+ thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
+ )
else: discard
- if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
- result.media = parsedMedia
+ # 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()
proc parsePromoVideo(js: JsonNode): Video =
result = Video(
@@ -396,23 +210,14 @@ proc parsePromoVideo(js: JsonNode): Video =
result.variants.add variant
proc parseBroadcast(js: JsonNode): Card =
- let
- image = js{"broadcast_thumbnail_large"}.getImageVal
- broadcastUrl = js{"broadcast_url"}.getStrVal
- broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
- streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
+ let image = js{"broadcast_thumbnail_large"}.getImageVal
result = Card(
kind: broadcast,
- url: "/i/broadcasts/" & broadcastId,
+ url: js{"broadcast_url"}.getStrVal,
title: js{"broadcaster_display_name"}.getStrVal,
text: js{"broadcast_title"}.getStrVal,
image: image,
- video: some Video(
- thumb: image,
- available: true,
- playbackType: m3u8,
- variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
- )
+ video: some Video(thumb: image)
)
proc parseCard(js: JsonNode; urls: JsonNode): Card =
@@ -451,13 +256,7 @@ 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:
- 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:
+ of audiospace, unknown:
result.title = "This card type is not supported."
else: discard
@@ -468,7 +267,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls:
if u{"url"}.getStr == result.url:
- result.url = u.getExpandedUrl(result.url)
+ result.url = u{"expanded_url"}.getStr
break
if kind in {videoDirectMessage, imageDirectMessage}:
@@ -478,9 +277,8 @@ 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();
- replyId: int64 = 0; hasArticle = false): Tweet =
- if js.isNull: return Tweet()
+proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
+ if js.isNull: return
let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime
@@ -503,9 +301,6 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
)
)
- 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
@@ -523,13 +318,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
# 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 or "rest_id" in rt:
+ if "legacy" in rt:
result.retweet = some parseGraphTweet(rt)
return
with reposts, js{"repostedStatusResults"}:
with rt, reposts{"result"}:
- if "legacy" in rt or "rest_id" in rt:
+ if "legacy" in rt:
result.retweet = some parseGraphTweet(rt)
return
@@ -537,17 +332,15 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
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.photos.add 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.video = some(parsePromoVideo(jsCard{"binding_values"}))
+ else:
result.card = some parseCard(jsCard, js{"entities", "urls"})
- result.expandTweetEntities(js, hasArticle)
+ result.expandTweetEntities(js)
parseLegacyMediaEntities(js, result)
with jsWithheld, js{"withheld_in_countries"}:
@@ -564,7 +357,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
result.text.removeSuffix(" Learn more.")
result.available = false
-proc parseGraphTweet*(js: JsonNode): Tweet =
+proc parseGraphTweet(js: JsonNode): Tweet =
if js.kind == JNull:
return Tweet()
@@ -582,7 +375,7 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
else:
discard
- if "legacy" notin js and "rest_id" notin js:
+ if not js.hasKey("legacy"):
return Tweet()
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
@@ -601,80 +394,12 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
"binding_values": %bindingObj
}
- 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 = parseTweet(js{"legacy"}, jsCard)
+ result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"})
- if result.reply.len == 0:
- with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
- result.reply = @[replyTo.getStr]
+ if result.replyId == 0:
+ result.replyId = js{"reply_to_results", "rest_id"}.getId
with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt
@@ -684,58 +409,21 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
parseMediaEntities(js, 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
+ if result.quote.isSome:
+ result.quote = some(parseGraphTweet(js{"quoted_status_result", "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"}:
+ with quoted, js{"quotedPostResults", "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 "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:
+ 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"):
result.thread.content.add parseGraphTweet(tweet)
let tweetDisplayType = select(
@@ -744,31 +432,11 @@ 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))
@@ -784,7 +452,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)
@@ -792,14 +460,11 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if not tweet.available:
tweet.id = entryId.getId
- if entryId.endsWith(tweetId):
+ if $tweet.id == tweetId:
result.tweet = tweet
else:
result.before.content.add tweet
- elif not entryId.endsWith(tweetId):
- result.before.content.add Tweet(id: entryId.getId)
- elif entryId.startsWith("conversationthread") or
- entryId.startsWith("tweetdetailrelatedtweets"):
+ elif entryId.startsWith("conversationthread"):
let (thread, self) = parseGraphThread(e)
if self:
result.after = thread
@@ -820,60 +485,25 @@ 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):
- let tweet = parseGraphTweet(tweetResult)
+ var tweet = parseGraphTweet(tweetResult)
if not tweet.available:
tweet.id = e.getEntryId.getId
result.add tweet
return
- for tweet in extractTweetsFromModuleItems(e{"content", "items"}):
- result.add tweet
+ 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
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0))
@@ -888,8 +518,12 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
for i in instructions:
if i{"moduleItems"}.notNull:
- for tweet in extractTweetsFromModuleItems(i{"moduleItems"}):
- result.tweets.content.add tweet
+ 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
continue
if i{"entries"}.notNull:
@@ -924,13 +558,18 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
for i in instructions:
if i{"moduleItems"}.notNull:
- for t in extractTweetsFromModuleItems(i{"moduleItems"}):
- let photo = extractGalleryPhoto(t)
- if photo.url.len > 0:
- result.add photo
+ for item in i{"moduleItems"}:
+ with tweetResult, item.getTweetResult("item"):
+ let t = parseGraphTweet(tweetResult)
+ if not t.available:
+ t.id = item.getEntryId.getId
- if result.len == 16:
- return
+ let photo = extractGalleryPhoto(t)
+ if photo.url.len > 0:
+ result.add photo
+
+ if result.len == 16:
+ return
continue
if i.getTypeName != "TimelineAddEntries":
@@ -947,7 +586,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
if result.len == 16:
return
-proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] =
+proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0)
let instructions = select(
@@ -963,73 +602,19 @@ proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=
for e in instruction{"entries"}:
let entryId = e.getEntryId
when T is Tweets:
- if entryId.startsWith("tweet") or entryId.startsWith("search-grid"):
- for tweet in extractTweetsFromEntry(e):
+ if entryId.startsWith("tweet"):
+ with tweetRes, getTweetResult(e):
+ let tweet = parseGraphTweet(tweetRes)
+ if not tweet.available:
+ tweet.id = entryId.getId
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 4b8e0b4..72c50e1 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,6 +72,7 @@ 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())
@@ -88,19 +89,11 @@ 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("-")
- try:
- parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len])
- except ValueError: 0'i64
+ if start < 0:
+ return parseBiggestInt(id)
+ return parseBiggestInt(id[start + 1 ..< id.len])
proc getId*(js: JsonNode): int64 {.inline.} =
case js.kind
@@ -119,9 +112,6 @@ 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:
@@ -185,16 +175,12 @@ 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;
- hideArticle = false) =
+ textLen: int; hideTwitter = false) =
let
- url = js.getExpandedUrl
+ url = js["expanded_url"].getStr
slice = js.extractSlice
- 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 hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
if slice.a < textLen:
result.add ReplaceSlice(kind: rkRemove, slice: slice)
else:
@@ -206,41 +192,28 @@ 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: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
+ if i > 0: repls[idx].slice.b.succ else: textSlice.a
result = newStringOfCap(runes.len)
- for i, rep in validRepls:
- let lower = extractLowerBound(i, i - 1)
- if lower < rep.slice.a:
- result.add $runes[lower ..< rep.slice.a]
+ for i, rep in repls:
+ result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
case rep.kind
of rkHashtag:
- 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)
+ 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)
of rkMention:
- result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display))
+ result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl:
- result.add a(escape(rep.display), href = rep.url)
+ result.add a(rep.display, href = rep.url)
of rkRemove:
discard
- let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
- if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
+ let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
+ if rest.a <= rest.b:
result.add $runes[rest]
proc deduplicate(s: var seq[ReplaceSlice]) =
@@ -265,7 +238,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"}
with urls, ent{"url", "urls"}:
- user.website = urls[0].getExpandedUrl
+ user.website = urls[0]{"expanded_url"}.getStr
var replacements = newSeq[ReplaceSlice]()
@@ -281,7 +254,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; hasArticle=false) =
+ replyTo=""; hasRedundantLink=false) =
let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]()
@@ -292,11 +265,10 @@ 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,
- hideArticle = hasArticle)
+ replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
if hasCard and u{"url"}.getStr == get(tweet.card).url:
- get(tweet.card).url = u.getExpandedUrl
+ get(tweet.card).url = u{"expanded_url"}.getStr
with media, entities{"media"}:
for m in media:
@@ -330,7 +302,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; hasArticle=false) =
+proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
let
entities = ? js{"entities"}
textRange = js{"display_text_range"}
@@ -344,99 +316,23 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) =
replyTo = reply.getStr
tweet.reply.add replyTo
- 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)
+ tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
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, hasRedundantLink=hasAttribution)
+ tweet.expandTextEntities(entities, text, textSlice)
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.media.len > 0: t.media[0].getThumb
+ if t.photos.len > 0: t.photos[0]
+ elif t.video.isSome: get(t.video).thumb
+ elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image
else: ""
diff --git a/src/prefs.nim b/src/prefs.nim
index 1a75f75..fa40a6d 100644
--- a/src/prefs.nim
+++ b/src/prefs.nim
@@ -1,22 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import tables, strutils
+import tables
import types, prefs_impl
from config import get
from parsecfg import nil
-export genUpdatePrefs, genResetPrefs, genApplyPrefs
+export genUpdatePrefs, genResetPrefs
var defaultPrefs*: Prefs
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
genDefaultPrefs()
-proc getPrefs*(cookies, params: Table[string, string]): Prefs =
+proc getPrefs*(cookies: Table[string, string]): Prefs =
result = defaultPrefs
- genParsePrefs(cookies)
- genParsePrefs(params)
+ genCookiePrefs(cookies)
-proc encodePrefs*(prefs: Prefs): string =
- var encPairs: seq[string]
- genEncodePrefs(prefs)
- encPairs.join(",")
+template getPref*(cookies: Table[string, string], pref): untyped =
+ bind genCookiePref
+ var res = defaultPrefs.`pref`
+ genCookiePref(cookies, pref, res)
+ res
diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim
index 8519bd5..8e2ac8f 100644
--- a/src/prefs_impl.nim
+++ b/src/prefs_impl.nim
@@ -60,9 +60,6 @@ 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)"
@@ -78,12 +75,6 @@ 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"
@@ -103,17 +94,6 @@ 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"
@@ -147,7 +127,7 @@ macro genDefaultPrefs*(): untyped =
result.add quote do:
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
-macro genParsePrefs*(prefs): untyped =
+macro genCookiePrefs*(cookies): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let
@@ -157,17 +137,37 @@ macro genParsePrefs*(prefs): untyped =
options = pref.options
result.add quote do:
- if `name` in `prefs`:
+ if `name` in `cookies`:
when `kind` == input or `name` == "theme":
- result.`ident` = `prefs`[`name`]
+ result.`ident` = `cookies`[`name`]
elif `kind` == checkbox:
- result.`ident` = `prefs`[`name`] == "on" or
- `prefs`[`name`] == "true" or
- `prefs`[`name`] == "1"
+ result.`ident` = `cookies`[`name`] == "on"
else:
- let value = `prefs`[`name`]
+ let value = `cookies`[`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,36 +202,6 @@ 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 ecb428d..06e1da2 100644
--- a/src/query.nim
+++ b/src/query.nim
@@ -1,14 +1,15 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri
-import types, utils
+import types
const
validFilters* = @[
"media", "images", "twimg", "videos",
- "native_video", "consumer_video", "spaces",
+ "native_video", "consumer_video", "pro_video",
"links", "news", "quote", "mentions",
- "replies", "retweets", "nativeretweets"
+ "replies", "retweets", "nativeretweets",
+ "verified", "safe"
]
emptyQuery* = "include:nativeretweets"
@@ -20,43 +21,32 @@ 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",
- minLikes: validateNumber(@"min_faves")
+ near: @"near"
)
- # 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: QueryKind.media,
+ kind: 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; maxId=""): string =
+proc genQueryParam*(query: Query): string =
var
filters: seq[string]
param: string
@@ -65,20 +55,15 @@ proc genQueryParam*(query: Query; maxId=""): string =
return query.text
for i, user in query.fromUser:
- if i == 0:
- param = "("
-
- param &= &"from:{user}"
+ param &= &"from:{user} "
if i < query.fromUser.high:
- param &= " OR "
- else:
- param &= ")"
+ param &= "OR "
- if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}:
- param &= " (filter:self_threads OR -filter:replies)"
+ if query.fromUser.len > 0 and query.kind in {posts, 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
@@ -88,51 +73,38 @@ proc genQueryParam*(query: Query; maxId=""): string =
for i in query.includes:
filters.add "include:" & i
- if filters.len > 0:
- result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
- else:
- result = strip(param)
-
+ result = strip(param & filters.join(&" {query.sep} "))
if query.since.len > 0:
result &= " since:" & query.since
- if query.until.len > 0 and maxId.len == 0:
+ if query.until.len > 0:
result &= " until:" & query.until
- if query.minLikes.len > 0:
- result &= " min_faves:" & query.minLikes
+ if query.near.len > 0:
+ result &= &" near:\"{query.near}\" within:15mi"
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 =
- var params: seq[string]
+ if query.kind notin {tweets, users}: return
- if query.view.len > 0:
- params.add "view=" & encodeUrl(query.view)
+ 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"
- # 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 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
if params.len > 0:
result &= params.join("&")
diff --git a/src/redis_cache.nim b/src/redis_cache.nim
index b9ddbcc..559d299 100644
--- a/src/redis_cache.nim
+++ b/src/redis_cache.nim
@@ -144,10 +144,9 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
else:
let user = await getGraphUserById(userId)
result = user.username
- if result.len > 0:
- await setEx(key, baseCacheTime, result)
- if user.id.len > 0:
- await all(cacheUserId(result, user.id), cache(user))
+ await setEx(key, baseCacheTime, result)
+ if result.len > 0 and user.id.len > 0:
+ await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return
@@ -159,48 +158,6 @@ 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))
@@ -210,29 +167,6 @@ 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
deleted file mode 100644
index 0a7d1dc..0000000
--- a/src/routes/article.nim
+++ /dev/null
@@ -1,48 +0,0 @@
-# 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
deleted file mode 100644
index d3bb95a..0000000
--- a/src/routes/broadcast.nim
+++ /dev/null
@@ -1,44 +0,0 @@
-# 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
deleted file mode 100644
index b850b6b..0000000
--- a/src/routes/community.nim
+++ /dev/null
@@ -1,89 +0,0 @@
-# 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 24bba2d..994364b 100644
--- a/src/routes/embed.nim
+++ b/src/routes/embed.nim
@@ -1,79 +1,29 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import asyncdispatch, strutils, strformat, json
+import asyncdispatch, strutils, strformat, options
import jester, karax/vdom
-import ".."/[types, api, formatters]
+import ".."/[types, api]
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
- 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)
+ let tweet = await getGraphTweetResult(@"id")
+ if tweet == nil or tweet.video.isNone:
+ resp Http404
resp renderVideoEmbed(tweet, cfg, request)
get "/@user/status/@id/embed":
let
- id = @"id"
- user = @"user"
- tweet = await getTweetByRestId(id)
- prefs = requestPrefs()
+ tweet = await getGraphTweetResult(@"id")
+ prefs = cookiePrefs()
path = getPath()
if tweet == nil:
- resp renderErrorEmbed("Tweet not found", prefs, cfg, request,
- tweetId=id, username=user)
+ resp Http404
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
@@ -84,57 +34,3 @@ 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 b4ab091..ac3e97e 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 = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
+ rss = &"""/i/lists/{@"id"}/rss"""
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 = requestPrefs()
+ prefs = cookiePrefs()
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 = requestPrefs()
+ prefs = cookiePrefs()
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 3442916..de51061 100644
--- a/src/routes/media.nim
+++ b/src/routes/media.nim
@@ -1,7 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils, httpclient, os, hashes, base64, re
import asynchttpserver, asyncstreams, asyncfile, asyncnet
-import asyncdispatch
import jester
@@ -16,9 +15,7 @@ const
maxAge* = "max-age=604800"
proc safeFetch*(url: string): Future[string] {.async.} =
- # 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)
+ let client = newAsyncHttpClient()
try: result = await client.getContent(url)
except: discard
finally: client.close()
@@ -33,79 +30,47 @@ template respond*(req: asynchttpserver.Request; headers) =
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
result = Http200
- let request = req.getNativeReq()
- var fetchUrl = url
+ let
+ request = req.getNativeReq()
+ client = newAsyncHttpClient()
- 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
+ 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]
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 contentLength =
- if res.headers.hasKey("content-length"):
- res.headers["content-length", 0]
- else:
- ""
+ let headers = newHttpHeaders({
+ "Content-Type": res.headers["content-type", 0],
+ "Content-Length": contentLength,
+ "Cache-Control": maxAge,
+ "ETag": hashed
+ })
- let headers = newHttpHeaders({
- "content-type": res.headers["content-type", 0],
- "content-length": contentLength,
- "cache-control": maxAge,
- "etag": hashed
- })
+ respond(request, headers)
- 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
+ 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()
template check*(code): untyped =
if code != Http200:
@@ -121,12 +86,6 @@ 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/?":
@@ -134,8 +93,10 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1)
- cond "/amplify_video/" notin url
- normalizeImgUrl(url)
+ if "twimg.com" notin url:
+ url.insert(twimg)
+ if not url.startsWith(https):
+ url.insert(https)
url.add("?name=orig")
let uri = parseUri(url)
@@ -146,8 +107,10 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1)
- cond "/amplify_video/" notin url
- normalizeImgUrl(url)
+ if "twimg.com" notin url:
+ url.insert(twimg)
+ if not url.startsWith(https):
+ url.insert(https)
let uri = parseUri(url)
cond isTwitterUrl(uri) == true
@@ -157,12 +120,12 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
let url = decoded(request, 2)
- cond isTwitterUrl(url)
+ cond "http" in 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 or ".aac" in url:
+ if ".mp4" in url or ".ts" in url or ".m4s" in url:
let code = await proxyMedia(request, url)
check code
@@ -176,6 +139,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url:
let vid = await safeFetch(url)
- content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
+ content = proxifyVideo(vid, cookiePref(proxyVideos))
resp content, m3u8Mime
diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim
index 7f04de2..b8af03d 100644
--- a/src/routes/preferences.nim
+++ b/src/routes/preferences.nim
@@ -19,10 +19,8 @@ proc createPrefRouter*(cfg: Config) =
router preferences:
get "/settings":
let
- prefs = requestPrefs()
- prefsCode = encodePrefs(prefs)
- prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
- html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
+ prefs = cookiePrefs()
+ html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
resp renderMain(html, request, cfg, prefs, "Preferences")
get "/settings/@i?":
@@ -40,6 +38,3 @@ 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 5f074a5..1baf873 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, requestPrefs()), "card")
+ respResolved(await resolve(url, cookiePrefs()), "card")
get "/t.co/@url":
let url = "https://t.co/" & @"url"
- respResolved(await resolve(url, requestPrefs()), "t.co")
+ respResolved(await resolve(url, cookiePrefs()), "t.co")
diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim
index 612a96b..a071a0d 100644
--- a/src/routes/router_utils.nim
+++ b/src/routes/router_utils.nim
@@ -4,19 +4,26 @@ from jester import Request, cookies
import ../views/general
import ".."/[utils, prefs, types]
-export utils, prefs, types, uri, json
+export utils, prefs, types, uri
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=sameSite, path="/")
+ httpOnly=true, secure=cfg.useHttps, sameSite=None)
-template requestPrefs*(): untyped {.dirty.} =
- getPrefs(cookies(request), params(request))
+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 showError*(error: string; cfg: Config): string =
- renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
+ renderMain(renderError(error), request, cfg, themePrefs(), "Error")
template getPath*(): untyped {.dirty.} =
$(parseUri(request.path) ? filterParams(request.params))
@@ -36,28 +43,5 @@ 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 038096c..b0e781d 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; prefs: Prefs): Future[Rss] {.async.} =
+proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
var profile: Profile
let
name = req.params.getOrDefault("name")
@@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future
return Rss(feed: profile.user.username, cursor: "suspended")
if profile.user.fullname.len > 0:
- let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
+ let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
return Rss(feed: rss, cursor: profile.tweets.bottom)
template respRss*(rss, page) =
@@ -60,15 +60,12 @@ template respRss*(rss, page) =
proc createRssRouter*(cfg: Config) =
router rss:
get "/search/rss":
- if not cfg.enableRSSSearch:
- resp Http403, showError("RSS feed is disabled", cfg)
+ cond cfg.enableRss
if @"q".len > 200:
resp Http400, showError("Search input too long.", cfg)
- let
- prefs = requestPrefs()
- query = initQuery(params(request))
- if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}:
+ let query = initQuery(params(request))
+ if query.kind != tweets:
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
let
@@ -81,17 +78,15 @@ proc createRssRouter*(cfg: Config) =
let tweets = await getGraphTweetSearch(query, cursor)
rss.cursor = tweets.bottom
- rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
+ rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
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())
@@ -99,23 +94,24 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
- rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
+ rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/@tab/rss":
+ cond cfg.enableRss
cond '.' notin @"name"
- 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)
+ cond @"tab" in ["with_replies", "media", "search"]
let
- prefs = requestPrefs()
name = @"name"
tab = @"tab"
- query = request.getQuery(tab, name, prefs)
+ query =
+ case tab
+ of "with_replies": getReplyQuery(name)
+ of "media": getMediaQuery(name)
+ of "search": initQuery(params(request), name=name)
+ else: Query(fromUser: @[name])
let searchKey = if tab != "search": ""
else: ":" & $hash(genQueryUrl(query))
@@ -126,15 +122,14 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
- rss = await timelineRss(request, cfg, query, prefs)
+ rss = await timelineRss(request, cfg, query)
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)
@@ -150,10 +145,8 @@ proc createRssRouter*(cfg: Config) =
redirect(url)
get "/i/lists/@id/rss":
- if not cfg.enableRSSList:
- resp Http403, showError("RSS feed is disabled", cfg)
+ cond cfg.enableRss
let
- prefs = requestPrefs()
id = @"id"
cursor = getCursor()
key = redisKey("lists", id, cursor)
@@ -166,7 +159,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, prefs)
+ rss.feed = renderListRss(timeline.content, list, cfg)
await cacheRss(key, rss)
respRss(rss, "List")
diff --git a/src/routes/search.nim b/src/routes/search.nim
index 7c7fd14..e9f991d 100644
--- a/src/routes/search.nim
+++ b/src/routes/search.nim
@@ -19,22 +19,10 @@ proc createSearchRouter*(cfg: Config) =
resp Http400, showError("Search input too long.", cfg)
let
- prefs = requestPrefs()
+ prefs = cookiePrefs()
+ query = initQuery(params(request))
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:
@@ -45,24 +33,19 @@ proc createSearchRouter*(cfg: Config) =
except InternalError:
users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
- of tweets, top, QueryKind.media:
+ of tweets:
let
tweets = await getGraphTweetSearch(query, getCursor())
- rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
+ rss = "/search/rss?" & genQueryUrl(query)
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?f=tweets&q=" & encodeUrl("#" & @"hash"))
+ redirect("/search?q=" & encodeUrl("#" & @"hash"))
get "/opensearch":
- let
- url = getUrlPrefix(cfg) & "/search?f=tweets&q="
- headers = {"Content-Type": "application/opensearchdescription+xml"}
- resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
+ let url = getUrlPrefix(cfg) & "/search?q="
+ resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
+ generateOpenSearchXML(cfg.title, cfg.hostname, url)
diff --git a/src/routes/space.nim b/src/routes/space.nim
deleted file mode 100644
index bd956ea..0000000
--- a/src/routes/space.nim
+++ /dev/null
@@ -1,36 +0,0 @@
-# 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 32a4447..7e89220 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 = requestPrefs()
- sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance)
+ let prefs = cookiePrefs()
# used for the infinite scroll feature
if @"scroll".len > 0:
- let replies = await getReplies(id, getCursor(), sort)
+ let replies = await getReplies(id, getCursor())
if replies.content.len == 0:
- resp Http204
- resp $renderReplies(replies, prefs, getPath(), sort=sort)
+ resp Http404, ""
+ resp $renderReplies(replies, prefs, getPath())
- let conv = await getTweet(id, getCursor(), sort)
+ let conv = await getTweet(id, getCursor())
+ if conv == nil:
+ echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found"
@@ -46,19 +46,15 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text
var
- images = conv.tweet.getPhotos.mapIt(it.url)
+ images = conv.tweet.photos
video = ""
- let
- firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
- else: photoMedia
-
- if firstMediaKind == videoMedia:
- images = @[conv.tweet.media[0].getThumb]
+ if conv.tweet.video.isSome():
+ images = @[get(conv.tweet.video).thumb]
video = getVideoEmbed(cfg, conv.tweet.id)
- elif firstMediaKind == gifMedia:
- images = @[conv.tweet.media[0].getThumb]
- video = getPicUrl(conv.tweet.media[0].gif.url)
+ elif conv.tweet.gif.isSome():
+ images = @[get(conv.tweet.gif).thumb]
+ video = getPicUrl(get(conv.tweet.gif).url)
elif conv.tweet.card.isSome():
let card = conv.tweet.card.get()
if card.image.len > 0:
@@ -66,33 +62,9 @@ proc createStatusRouter*(cfg: Config) =
elif card.video.isSome():
images = @[card.video.get().thumb]
- 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)
+ let html = renderConversation(conv, prefs, getPath() & "#m")
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
- 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)
+ images=images, video=video)
get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"]
@@ -104,6 +76,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 c7949a2..7a10e91 100644
--- a/src/routes/timeline.nim
+++ b/src/routes/timeline.nim
@@ -4,39 +4,20 @@ import jester, karax/vdom
import router_utils
import ".."/[types, redis_cache, formatters, query, api]
-import ../views/[general, profile, timeline, status, search, about_account]
+import ../views/[general, profile, timeline, status, search]
export vdom
export uri, sequtils
export router_utils
export redis_cache, formatters, query, api
-export profile, timeline, status, about_account
+export profile, timeline, status
-proc tabRssEnabled*(cfg: Config; tab: string): bool =
+proc getQuery*(request: Request; tab, name: string): Query =
case tab
- 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])
+ of "with_replies": getReplyQuery(name)
+ of "media": getMediaQuery(name)
+ of "search": initQuery(params(request), name=name)
+ else: Query(fromUser: @[name])
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
if cond:
@@ -64,23 +45,20 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
let
rail =
- skipIf(skipRail or query.kind == QueryKind.media, @[]):
+ skipIf(skipRail or query.kind == 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
@@ -127,72 +105,16 @@ 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 @"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")
+ cond @"tab" in ["with_replies", "media", "search", ""]
let
- prefs = requestPrefs()
+ prefs = cookiePrefs()
after = getCursor()
names = getNames(@"name")
- var query = request.getQuery(@"tab", @"name", prefs)
+ var query = request.getQuery(@"tab", @"name")
if names.len != 1:
query.fromUser = names
@@ -200,8 +122,7 @@ 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 Http204
+ if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath())
else:
@@ -211,9 +132,7 @@ proc createTimelineRouter*(cfg: Config) =
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rss =
- if not cfg.tabRssEnabled(@"tab"):
- ""
- elif @"tab".len == 0:
+ if @"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 345dee7..0c085d4 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, requestPrefs())
+ resp renderMain(renderFeature(), request, cfg, themePrefs())
get "/about/feature": feature()
get "/login/?@i?": feature()
get "/@name/lists/?": feature()
get "/intent/?@i?":
- cond @"i" notin ["user", "follow"]
+ cond @"i" notin ["user"]
feature()
get "/i/@i?/?@j?":
diff --git a/src/sass/_article.scss b/src/sass/_article.scss
deleted file mode 100644
index 9488658..0000000
--- a/src/sass/_article.scss
+++ /dev/null
@@ -1,278 +0,0 @@
-.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
deleted file mode 100644
index dd93606..0000000
--- a/src/sass/_broadcast.scss
+++ /dev/null
@@ -1,75 +0,0 @@
-.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
deleted file mode 100644
index 5fe2e7e..0000000
--- a/src/sass/_space.scss
+++ /dev/null
@@ -1,149 +0,0 @@
-.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 e6247d6..9feb3d3 100644
--- a/src/sass/general.scss
+++ b/src/sass/general.scss
@@ -1,40 +1,39 @@
-@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;
- padding: 0px 5px 1px 8px;
- }
+ button {
+ background: var(--bg_elements);
+ color: var(--fg_color);
+ border: 0;
+ border-radius: 3px;
+ cursor: pointer;
+ font-weight: bold;
+ width: 30px;
+ height: 30px;
+ }
- 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 5fde51a..94e11ee 100644
--- a/src/sass/include/_mixins.css
+++ b/src/sass/include/_mixins.css
@@ -66,7 +66,18 @@
}
#search-panel-toggle:checked ~ .search-panel {
- max-height: 380px !important;
+ @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;
+ }
}
}
}
diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss
index 127cccb..0c95ff6 100644
--- a/src/sass/include/_variables.scss
+++ b/src/sass/include/_variables.scss
@@ -1,43 +1,46 @@
// 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, 0.6);
-$shadow_dark: rgba(0, 0, 0, 0.2);
+$shadow: rgba(0,0,0,.6);
+$shadow_dark: rgba(0,0,0,.2);
//fonts
-$font_0: sans-serif;
-$font_1: fontello;
+$font_0: Helvetica Neue;
+$font_1: Helvetica;
+$font_2: Arial;
+$font_3: sans-serif;
+$font_4: fontello;
diff --git a/src/sass/index.scss b/src/sass/index.scss
index 404f7d5..6cab48e 100644
--- a/src/sass/index.scss
+++ b/src/sass/index.scss
@@ -1,220 +1,180 @@
-@import "_variables";
+@import '_variables';
-@import "tweet/_base";
-@import "profile/_base";
-@import "general";
-@import "navbar";
-@import "inputs";
-@import "timeline";
-@import "search";
-@import "broadcast";
-@import "space";
-@import "_article";
+@import 'tweet/_base';
+@import 'profile/_base';
+@import 'general';
+@import 'navbar';
+@import 'inputs';
+@import 'timeline';
+@import 'search';
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-size: 15px;
- line-height: 1.3;
- margin: 0;
+ 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;
}
* {
- outline: unset;
- margin: 0;
- text-decoration: none;
-}
-
-img {
- dynamic-range-limit: standard;
+ outline: unset;
+ margin: 0;
+ text-decoration: none;
}
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: 0.6em 0 0.3em 0;
- border: 0;
- font-size: 16px;
- font-weight: 600;
- border-bottom: 1px solid var(--border_grey);
- margin-bottom: 8px;
+ 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;
}
-.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;
- margin: auto;
- min-height: 100vh;
-}
-
-body.fixed-nav .container {
- padding-top: 50px;
+ display: flex;
+ flex-wrap: wrap;
+ box-sizing: border-box;
+ padding-top: 50px;
+ margin: auto;
+ min-height: 100vh;
}
.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 {
- display: inline-block;
- position: relative;
- width: 14px;
- height: 14px;
- margin-bottom: 2px;
+ 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;
- .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);
+ &.blue {
+ background-color: var(--verified_blue);
}
- .verified-icon-check {
- color: var(--icon_text);
- }
- }
-
- &.business {
- .verified-icon-circle {
- color: var(--verified_business);
+ &.business {
+ color: var(--bg_panel);
+ background-color: var(--verified_business);
}
- .verified-icon-check {
- color: var(--bg_panel);
+ &.government {
+ color: var(--bg_panel);
+ background-color: var(--verified_government);
}
- }
-
- &.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 2b6016f..17c2a22 100644
--- a/src/sass/inputs.scss
+++ b/src/sass/inputs.scss
@@ -1,216 +1,185 @@
-@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="number"] {
- -moz-appearance: textfield;
-}
-
-input[type="text"],
-input[type="number"] {
- height: 16px;
+input[type="text"] {
+ 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;
-}
-
-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;
+ display: none;
}
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);
-
- &:after {
- content: "";
position: absolute;
- display: none;
- }
+ 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;
+ }
}
.checkbox-container {
- display: block;
- position: relative;
- margin-bottom: 5px;
- cursor: pointer;
- user-select: none;
- padding-right: 22px;
-
- input {
- position: absolute;
- opacity: 0;
+ display: block;
+ position: relative;
+ margin-bottom: 5px;
cursor: pointer;
- height: 0;
- width: 0;
+ user-select: none;
+ padding-right: 22px;
- &:checked ~ .checkbox:after {
- display: block;
+ input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+ width: 0;
+
+ &: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_1;
- content: "\e811";
- }
+ .checkbox:after {
+ left: 2px;
+ bottom: 0;
+ font-size: 13px;
+ font-family: $font_4;
+ content: '\e803';
+ }
}
.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;
- min-width: 100px;
- }
+ select {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: block;
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ appearance: none;
+ }
- input[type="text"],
- input[type="number"] {
- position: absolute;
- right: 0;
- max-width: 140px;
- }
+ input[type="text"] {
+ 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;
- }
-
- .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;
- }
+ .pref-reset {
+ float: left;
+ }
}
diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss
index c999022..47a8765 100644
--- a/src/sass/navbar.scss
+++ b/src/sass/navbar.scss
@@ -1,90 +1,89 @@
-@import "_variables";
+@import '_variables';
nav {
- 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);
- }
-
- body.fixed-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;
+
+ a, .icon-button button {
+ color: var(--fg_nav);
+ }
}
.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:hover {
- color: var(--accent_light);
- text-decoration: unset;
- }
+ &.right a {
+ padding-left: 4px;
+
+ &: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 {
- margin: 0 -3px;
+.icon-info:before {
+ margin: 0 -3px;
}
.icon-cog {
- font-size: 15px;
- padding-left: 0 !important;
+ font-size: 15px;
}
diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss
index 81b3d78..b7f33e6 100644
--- a/src/sass/profile/_base.scss
+++ b/src/sass/profile/_base.scss
@@ -1,118 +1,83 @@
-@import "_variables";
-@import "_mixins";
+@import '_variables';
+@import '_mixins';
-@import "card";
-@import "about-account";
-@import "photo-rail";
-@import "community";
+@import 'card';
+@import 'photo-rail';
.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%;
- top: 0;
-
- body.fixed-nav & {
+ 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: 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;
+ }
}
-.profile-tabs.media-only {
- max-width: none;
- width: 100%;
+@media(max-width: 700px) {
+ .profile-tabs {
+ width: 100vw;
+ max-width: 600px;
- .timeline-container {
- float: none;
- width: 100% !important;
- max-width: none;
- padding: 0 10px;
- box-sizing: border-box;
- }
+ .timeline-container {
+ width: 100% !important;
- .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;
- }
+ .tab-item wide {
+ flex-grow: 1.4;
+ }
+ }
}
- }
- .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;
}
- }
-
- .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
deleted file mode 100644
index 92d13c9..0000000
--- a/src/sass/profile/_community.scss
+++ /dev/null
@@ -1,203 +0,0 @@
-.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
deleted file mode 100644
index aa12f49..0000000
--- a/src/sass/profile/about-account.scss
+++ /dev/null
@@ -1,71 +0,0 @@
-@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 fa2a2d8..f70f7ea 100644
--- a/src/sass/search.scss
+++ b/src/sass/search.scss
@@ -1,194 +1,122 @@
-@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;
- align-items: center;
- }
+ flex-wrap: wrap;
- .pref-input {
- margin: 0 4px 0 0;
- flex-grow: 1;
- height: 23px;
- }
+ button {
+ margin: 0 2px 0 0;
+ height: 23px;
+ display: flex;
+ align-items: center;
+ }
- input[type="text"],
- input[type="number"] {
- height: calc(100% - 4px);
- width: calc(100% - 8px);
- }
+ .pref-input {
+ margin: 0 4px 0 0;
+ flex-grow: 1;
+ height: 23px;
+ }
- > 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;
+ input[type="text"] {
+ height: calc(100% - 4px);
+ width: calc(100% - 8px);
+ }
- @include input-colors;
- }
+ > 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 create-toggle(search-panel, 380px);
+ @include input-colors;
+ }
+
+ @include create-toggle(search-panel, 200px);
}
.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;
- .checkbox-container {
- display: inline;
- padding-right: unset;
- margin-bottom: 5px;
- margin-left: 23px;
- }
+ > div {
+ line-height: 1.7em;
+ }
- .checkbox {
- right: unset;
- left: -22px;
- line-height: 1.6em;
- }
+ .checkbox-container {
+ display: inline;
+ padding-right: unset;
+ margin-bottom: unset;
+ margin-left: 23px;
+ }
- .checkbox-container .checkbox:after {
- top: -4px;
- }
+ .checkbox {
+ right: unset;
+ left: -22px;
+ }
+
+ .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;
- }
-
- input {
- height: 21px;
- }
-
- .pref-input {
- display: block;
- padding-bottom: 5px;
+ > div {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
input {
- height: 21px;
- margin-top: 1px;
+ height: 21px;
+ }
+
+ .pref-input {
+ display: block;
+ padding-bottom: 5px;
+
+ input {
+ height: 21px;
+ margin-top: 1px;
+ }
}
- }
}
.search-toggles {
- 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;
- }
+ flex-grow: 1;
+ display: grid;
+ grid-template-columns: repeat(6, auto);
+ grid-column-gap: 10px;
}
.profile-tabs {
- @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(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(700px, 5);
-@include search-resize(485px, 4);
+@include search-resize(560px, 5);
+@include search-resize(480px, 4);
@include search-resize(410px, 3);
diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss
index b7d4a9f..c8ce309 100644
--- a/src/sass/timeline.scss
+++ b/src/sass/timeline.scss
@@ -1,505 +1,162 @@
-@import "_variables";
+@import '_variables';
.timeline-container {
- @include panel(100%, 600px);
+ @include panel(100%, 600px);
}
-.timeline-container.media-only {
- max-width: none;
- width: 100%;
- padding: 0 10px;
- box-sizing: border-box;
+.timeline {
+ background-color: var(--bg_panel);
- > .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);
+ > 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: 4px;
- box-sizing: border-box;
+ width: 100%;
+ background-color: var(--bg_panel);
+ text-align: center;
+ padding: 8px;
+ display: block;
+ font-weight: bold;
+ margin-bottom: 5px;
+ 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 4px 0;
- background-color: var(--bg_panel);
- padding: 0;
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ list-style: none;
+ margin: 0 0 5px 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: 0.1rem solid transparent;
- color: var(--tab);
- display: block;
- padding: 8px 0;
- text-decoration: none;
- font-weight: bold;
+ a {
+ border-bottom: .1rem solid transparent;
+ color: var(--tab);
+ display: block;
+ padding: 8px 0;
+ text-decoration: none;
+ font-weight: bold;
- &:hover {
- text-decoration: none;
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.active {
+ border-bottom-color: var(--tab_selected);
+ color: var(--tab_selected);
+ }
}
- &.active {
- border-bottom-color: var(--tab_selected);
- color: var(--tab_selected);
+ &.active a {
+ border-bottom-color: var(--tab_selected);
+ color: var(--tab_selected);
}
- }
- &.active a {
- border-bottom-color: var(--tab_selected);
- color: var(--tab_selected);
- }
-
- &.wide {
- flex-grow: 1.2;
- flex-basis: 50px;
- }
+ &.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;
- }
+ h2 {
+ color: var(--accent);
+ font-size: 20px;
+ font-weight: 600;
+ }
}
.timeline-none {
- color: var(--accent);
- font-size: 20px;
- font-weight: 600;
- text-align: center;
+ 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: 0.75em 0;
- display: block !important;
+ background-color: var(--bg_panel);
+ text-align: center;
+ padding: .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);
+ &:hover {
+ color: var(--accent_light);
+ }
+
+ &::before {
+ transform: rotate(180deg) translateY(-1px);
+ }
}
-
- &::before {
- transform: rotate(180deg) translateY(-1px);
- }
- }
}
.timeline-item {
- 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;
+ overflow-wrap: break-word;
+ border-left-width: 0;
+ min-width: 0;
+ padding: .75em;
+ display: flex;
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 2f6693e..69f51c0 100644
--- a/src/sass/tweet/_base.scss
+++ b/src/sass/tweet/_base.scss
@@ -1,284 +1,240 @@
-@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 {
- line-height: 1.3em;
- pointer-events: all;
- display: inline;
+ font-family: $font_3;
+ 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: 0.2em;
+ padding: 0;
+ vertical-align: bottom;
+ flex-basis: 100%;
+ margin-bottom: .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;
-
- .verified-icon {
- margin-left: 2px;
- }
+ padding: 0;
+ display: flex;
+ justify-content: space-between;
}
.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: 0.4em;
- word-wrap: normal;
+ @include ellipsis;
+ min-width: 1.6em;
+ margin-left: .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-top: 6px;
- margin-bottom: 0px;
- color: var(--grey);
+ margin: 0;
+ margin-top: 5px;
+ color: var(--grey);
+ pointer-events: all;
}
.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%;
- user-select: none;
- -webkit-user-select: none;
- }
+ &.round {
+ border-radius: 50%;
+ -webkit-user-select: none;
+ }
+
+ &.mini {
+ position: unset;
+ margin-right: 5px;
+ margin-top: -1px;
+ width: 20px;
+ height: 20px;
+ }
+}
- &.mini {
- position: unset;
- margin-right: 5px;
- margin-top: -1px;
- width: 20px;
- height: 20px;
- }
+.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;
+ }
}
.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;
- color: var(--fg_faded);
-
- .icon-container {
- padding-right: 2px;
- }
-
- .media-tag,
- .icon-container {
+ padding-top: 5px;
+ pointer-events: all;
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;
- user-select: none;
- -webkit-user-select: none;
+ margin-bottom: -3px;
+ -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;
- user-select: none;
- -webkit-user-select: none;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+ position: absolute;
+ -webkit-user-select: none;
- &: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;
- }
+ &:hover {
+ background-color: var(--bg_hover);
+ }
}
diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss
index 7441d11..5575191 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: 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;
+ 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;
- &: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-image-container {
- width: unset;
-
- &:before {
- display: none;
+ .card-container {
+ display: block;
}
- }
- .card-image {
- position: unset;
- border-style: solid;
- border-color: var(--dark_grey);
- border-width: 0;
- border-bottom-width: 1px;
- }
+ .card-image-container {
+ width: unset;
+
+ &:before {
+ display: none;
+ }
+ }
+
+ .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 9ff8403..227fc5e 100644
--- a/src/sass/tweet/embed.scss
+++ b/src/sass/tweet/embed.scss
@@ -1,159 +1,17 @@
-@import "_variables";
-@import "_mixins";
+@import '_variables';
+@import '_mixins';
-// Embed page: transparent background, no scrollbars
-html:has(body > .embed-wrapper),
-html:has(body > .embed-video) {
- background: transparent;
- overflow: hidden;
-
- 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);
+ .gallery-video {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ background-color: black;
+ top: 0%;
+ left: 0%;
}
- }
- // 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;
- }
+ .video-container {
+ max-height: unset;
+ }
}
diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss
index 3001a86..91c9dab 100644
--- a/src/sass/tweet/media.scss
+++ b/src/sass/tweet/media.scss
@@ -1,168 +1,119 @@
-@import "_variables";
+@import '_variables';
.gallery-row {
- 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;
- }
+ 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;
.still-image {
- display: flex;
- align-self: stretch;
+ width: 100%;
+ display: flex;
}
-
- .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: 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;
+ 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%;
+ }
}
.attachment {
- position: relative;
- line-height: 0;
- overflow: hidden;
- margin: 0 0.25em 0 0;
- flex-grow: 1;
- box-sizing: border-box;
- min-width: 2em;
+ position: relative;
+ line-height: 0;
+ overflow: hidden;
+ margin: 0 .25em 0 0;
+ flex-grow: 1;
+ box-sizing: border-box;
+ min-width: 2em;
- &:last-child {
- margin: 0;
+ &:last-child {
+ margin: 0;
+ max-height: 530px;
+ }
+}
+
+.gallery-gif video {
max-height: 530px;
- }
-}
-
-.media-gif {
- 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;
+ 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;
- }
+ max-width: 533px;
+ justify-content: center;
+
+ 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);
+.image {
+ display: inline-block;
}
-.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;
-}
+// .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;
+ 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;
+ 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;
}
.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 6d54e00..57590c8 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 f722455..b4bc60e 100644
--- a/src/sass/tweet/quote.scss
+++ b/src/sass/tweet/quote.scss
@@ -1,121 +1,94 @@
-@import "_variables";
+@import '_variables';
.quote {
- 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 {
+ margin-top: 10px;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ background-color: var(--bg_elements);
overflow: hidden;
- 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;
+ pointer-events: all;
+ position: relative;
+ width: 100%;
&:hover {
- border-top-color: var(--grey);
+ border-color: var(--grey);
}
- .community-note-header {
- background-color: var(--bg_panel);
- padding-bottom: 0;
+ &.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;
}
- }
}
.unavailable-quote {
- padding: 12px;
- display: block;
+ padding: 12px;
}
.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;
- display: flex;
-
- .card {
- margin: unset;
- }
-
- .attachments {
- border-radius: 0;
- }
-
- .media-gif {
- width: 100%;
- display: flex;
- justify-content: center;
- }
-
- .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%;
- }
- }
-
- .gallery-row .attachment,
- .gallery-row .attachment > video,
- .gallery-row .attachment > img {
max-height: 300px;
- }
+ display: flex;
- .still-image img {
- max-height: 250px;
- }
+ .card {
+ margin: unset;
+ }
+
+ .attachments {
+ border-radius: 0;
+ }
+
+ .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
+ }
}
diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss
index 134e375..19fb3e0 100644
--- a/src/sass/tweet/thread.scss
+++ b/src/sass/tweet/thread.scss
@@ -1,196 +1,138 @@
-@import "_variables";
-@import "_mixins";
+@import '_variables';
+@import '_mixins';
-.conversation,
-.edit-history {
- @include panel(100%, 600px);
+.conversation {
+ @include panel(100%, 600px);
- .show-more {
- margin-bottom: 10px;
- }
+ .show-more {
+ margin-bottom: 10px;
+ }
}
-.main-thread,
-.latest-edit {
- margin-bottom: 20px;
+.main-thread {
+ margin-bottom: 20px;
+ background-color: var(--bg_panel);
}
-.reply {
- 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 & {
+.main-tweet, .replies {
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;
+ font-size: 18px;
}
-@media (max-width: 600px) {
- .main-tweet .tweet-content {
- font-size: 16px;
- }
+@media(max-width: 600px) {
+ .main-tweet .tweet-content {
+ font-size: 16px;
+ }
+}
+
+.reply {
+ background-color: var(--bg_panel);
+ margin-bottom: 10px;
}
.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;
-
- &::before {
- top: 40px;
- margin-bottom: 31px;
- }
-
- .more-replies {
- display: flex;
- padding-top: unset !important;
- margin-top: 8px;
+ padding: 0 0.75em;
&::before {
- display: inline-block;
- position: relative;
- top: -1px;
- line-height: 0.4em;
+ top: 40px;
+ margin-bottom: 31px;
}
- .more-replies-text {
- display: inline;
- }
- }
-}
+ .more-replies {
+ display: flex;
+ padding-top: unset !important;
+ margin-top: 8px;
-.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);
+ &::before {
+ display: inline-block;
+ position: relative;
+ top: -1px;
+ line-height: 0.4em;
+ }
+
+ .more-replies-text {
+ display: inline;
+ }
+ }
}
diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss
index 28fc125..98a1c29 100644
--- a/src/sass/tweet/video.scss
+++ b/src/sass/tweet/video.scss
@@ -1,111 +1,68 @@
-@import "_variables";
-@import "_mixins";
+@import '_variables';
+@import '_mixins';
video {
- height: 100%;
- width: 100%;
+ max-height: 100%;
+ width: 100%;
}
.gallery-video {
- display: flex;
- overflow: hidden;
-
- &.card-container {
- flex-direction: column;
- width: 100%;
- }
+ display: flex;
+ overflow: hidden;
+}
- > .attachment {
+.gallery-video.card-container {
+ flex-direction: column;
+}
+
+.video-container {
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;
+ }
- .overlay-circle {
- position: relative;
- z-index: 0;
- top: calc(50% - 20px);
- margin: 0 auto;
- width: 40px;
- height: 40px;
- }
+ div {
+ position: relative;
+ z-index: 0;
+ top: calc(50% - 20px);
+ margin: 0 auto;
+ width: 40px;
+ height: 40px;
+ }
- .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;
- }
+ form {
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ }
- form {
- width: 100%;
- height: 100%;
- align-items: center;
- justify-content: center;
- display: flex;
- }
-
- button {
- padding: 5px 8px;
- font-size: 16px;
- }
+ button {
+ padding: 5px 8px;
+ font-size: 16px;
+ }
}
diff --git a/src/tid.nim b/src/tid.nim
deleted file mode 100644
index ba1f8ec..0000000
--- a/src/tid.nim
+++ /dev/null
@@ -1,64 +0,0 @@
-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 0a748ba..f16fe6f 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
+import times, sequtils, options, tables, uri
import prefs_impl
genPrefsType()
@@ -11,16 +11,21 @@ type
BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum
- tweets, replies, media, articles
+ tweets, replies, media
- ApiUrl* = object
- endpoint*: string
- params*: seq[(string, string)]
- skipTid*: bool
-
- ApiReq* = object
- oauth*: ApiUrl
- cookie*: ApiUrl
+ Api* {.pure.} = enum
+ tweetDetail
+ tweetResult
+ search
+ list
+ listBySlug
+ listMembers
+ listTweets
+ userRestId
+ userScreenName
+ userTweets
+ userTweetsAndReplies
+ userMedia
RateLimit* = object
limit*: int
@@ -37,7 +42,7 @@ type
pending*: int
limited*: bool
limitedAt*: int
- apis*: Table[string, RateLimit]
+ apis*: Table[Api, RateLimit]
case kind*: SessionKind
of oauth:
oauthToken*: string
@@ -46,6 +51,10 @@ type
authToken*: string
ct0*: string
+ SessionAwareUrl* = object
+ oauthUrl*: Uri
+ cookieUrl*: Uri
+
Error* = enum
null = 0
noUserMatches = 17
@@ -61,7 +70,6 @@ type
rateLimited = 88
expiredToken = 89
listIdOrSlug = 112
- timelineUnavailable = 131
tweetNotFound = 144
tweetNotAuthorized = 179
forbidden = 200
@@ -98,59 +106,6 @@ 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"
@@ -174,15 +129,10 @@ type
variants*: seq[VideoVariant]
QueryKind* = enum
- posts, replies, media, users, tweets, userList, followers, following, lists, top,
- articles
-
- RankingMode* = enum
- Relevance, Recency, Likes
+ posts, replies, media, users, tweets, userList
Query* = object
kind*: QueryKind
- view*: string
text*: string
filters*: seq[string]
includes*: seq[string]
@@ -190,33 +140,12 @@ type
fromUser*: seq[string]
since*: string
until*: string
- minLikes*: string
+ near*: 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
@@ -225,44 +154,6 @@ 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]
@@ -312,12 +203,6 @@ type
likes*: int
views*: int
- ArticlePreview* = object
- title*: string
- previewText*: string
- coverImage*: string
- tweetId*: int64
-
Tweet* = ref object
id*: int64
threadId*: int64
@@ -336,17 +221,13 @@ type
stats*: TweetStats
retweet*: Option[Tweet]
attribution*: Option[User]
- attributionLink*: string
mediaTags*: seq[User]
quote*: Option[Tweet]
card*: Option[Card]
poll*: Option[Poll]
- media*: MediaEntities
- history*: seq[int64]
- note*: string
- isAd*: bool
- isAI*: bool
- articlePreview*: Option[ArticlePreview]
+ gif*: Option[Gif]
+ video*: Option[Video]
+ photos*: seq[string]
Tweets* = seq[Tweet]
@@ -360,7 +241,6 @@ type
content*: Tweets
hasMore*: bool
cursor*: string
- related*: bool
Conversation* = ref object
tweet*: Tweet
@@ -368,10 +248,6 @@ type
after*: Chain
replies*: Result[Chain]
- EditHistory* = object
- latest*: Tweet
- history*: Tweets
-
Timeline* = Result[Tweets]
Profile* = object
@@ -379,7 +255,6 @@ type
photoRail*: PhotoRail
pinned*: Option[Tweet]
tweets*: Timeline
- accountInfo*: AccountInfo
List* = object
id*: string
@@ -390,29 +265,6 @@ 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]
@@ -429,20 +281,10 @@ type
hmacKey*: string
base64Media*: bool
minTokens*: int
- enableRSSUserTweets*: bool
- enableRSSUserReplies*: bool
- enableRSSUserMedia*: bool
- enableRSSUserArticles*: bool
- enableRSSSearch*: bool
- enableRSSList*: bool
+ enableRss*: bool
enableDebug*: bool
proxy*: string
proxyAuth*: string
- apiProxy*: string
- disableTid*: bool
- maxConcurrentReqs*: int
- maxRetries*: int
- retryDelayMs*: int
rssCacheTime*: int
listCacheTime*: int
@@ -461,24 +303,3 @@ 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 95b46de..c96a6dd 100644
--- a/src/utils.nim
+++ b/src/utils.nim
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import sequtils, strutils, strformat, uri, tables, base64
+import 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", "prefs"]
+ nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
twitterDomains = @[
"twitter.com",
"pic.twitter.com",
@@ -17,9 +17,7 @@ const
"abs.twimg.com",
"pbs.twimg.com",
"video.twimg.com",
- "x.com",
- "pscp.tv",
- "video.pscp.tv"
+ "x.com"
]
proc setHmacKey*(key: string) =
@@ -40,14 +38,12 @@ 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:
@@ -59,13 +55,7 @@ proc filterParams*(params: Table): seq[(string, string)] =
result.add p
proc isTwitterUrl*(uri: Uri): bool =
- uri.scheme in ["http", "https"] and
- (uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv"))
+ uri.hostname in twitterDomains
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
deleted file mode 100644
index aedd444..0000000
--- a/src/views/about_account.nim
+++ /dev/null
@@ -1,93 +0,0 @@
-# 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
deleted file mode 100644
index eedd4cf..0000000
--- a/src/views/article.nim
+++ /dev/null
@@ -1,248 +0,0 @@
-# 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
deleted file mode 100644
index bfcb9ba..0000000
--- a/src/views/broadcast.nim
+++ /dev/null
@@ -1,75 +0,0 @@
-# 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
deleted file mode 100644
index 52f9041..0000000
--- a/src/views/community.nim
+++ /dev/null
@@ -1,128 +0,0 @@
-# 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 5136a35..ba49f45 100644
--- a/src/views/embed.nim
+++ b/src/views/embed.nim
@@ -1,76 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
+import options
import karax/[karaxdsl, vdom]
from jester import Request
-import ".."/[types, formatters, prefs]
+import ".."/[types, formatters]
import general, tweet
-const
- doctype = "\n"
- embedResizeJs = staticRead("../../public/js/embedResize.js")
+const doctype = "\n"
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
- 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 thumb = get(tweet.video).thumb
+ let vidUrl = getVideoEmbed(cfg, tweet.id)
+ let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
- base(target="_blank")
body:
tdiv(class="embed-video"):
- 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
+ renderVideo(get(tweet.video), prefs, "")
result = doctype & $node
diff --git a/src/views/general.nim b/src/views/general.nim
index d979898..0091c74 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -29,17 +29,19 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
- if rss.len > 0:
+ if cfg.enableRss and rss.len > 0:
icon "rss", title="RSS Feed", href=rss
- icon "bird", title="Open in X", href=canonical
+ icon "bird", title="Open in Twitter", 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=""; alternate=""; oembed=""): VNode =
- let theme = prefs.theme.toTheme
+ rss=""; canonical=""): VNode =
+ var theme = prefs.theme.toTheme
+ if "theme" in req.params:
+ theme = req.params["theme"].toTheme
let ogType =
if video.len > 0: "video"
@@ -50,8 +52,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=106")
- link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
+ link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
+ link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
@@ -64,19 +66,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl)
- if alternate.len > 0:
- link(rel="alternate", href=alternate, title="View on X")
+ if canonical.len > 0:
+ link(rel="canonical", href=canonical)
- if rss.len > 0:
+ if cfg.enableRss and 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?v=1", `defer`="")
+ script(src="/js/hlsPlayback.js", `defer`="")
if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="")
@@ -88,7 +86,6 @@ 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))
@@ -101,7 +98,6 @@ 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")
@@ -123,24 +119,20 @@ 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?59696369", crossorigin="anonymous")
+ href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
- images: seq[string] = @[]; banner="";
- twitterLink=""; oembed=""): string =
+ images: seq[string] = @[]; banner=""): string =
- let twitterLink =
- if twitterLink.len > 0: twitterLink
- else: getTwitterLink(req.path, req.params)
+ let canonical = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
- rss, twitterLink, oembed)
+ rss, canonical)
- let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
- body(class=bodyClass):
- renderNavbar(cfg, req, rss, twitterLink)
+ body:
+ renderNavbar(cfg, req, rss, canonical)
tdiv(class="container"):
body
diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf
deleted file mode 100644
index 4f7f947..0000000
--- a/src/views/oembed.nimf
+++ /dev/null
@@ -1,7 +0,0 @@
-#? 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 b051a01..1787704 100644
--- a/src/views/preferences.nim
+++ b/src/views/preferences.nim
@@ -32,8 +32,7 @@ macro renderPrefs*(): untyped =
result[2].add stmt
-proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
- prefsUrl: string): VNode =
+proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
buildHtml(tdiv(class="overlay-panel")):
fieldset(class="preferences"):
form(`method`="post", action="/saveprefs", autocomplete="off"):
@@ -41,14 +40,6 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
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 c9012ed..2b2e410 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, timeline
+import renderutils, search
import ".."/[types, utils, formatters]
proc renderStat(num: int; class: string; text=""): VNode =
@@ -12,14 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
-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 =
+proc renderUserCard*(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"):
let
@@ -33,7 +26,6 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): 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"):
@@ -53,11 +45,6 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): 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:
@@ -66,14 +53,14 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
a(href=url): text url.shortLink
tdiv(class="profile-joindate"):
- a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
+ span(title=getJoinDateFull(user)):
icon "calendar", getJoinDate(user)
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets")
- renderStatLink(user.following, "following", &"/{user.username}/following")
- renderStatLink(user.followers, "followers", &"/{user.username}/followers")
+ renderStat(user.following, "following")
+ renderStat(user.followers, "followers")
renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode =
@@ -106,7 +93,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."
@@ -114,52 +101,19 @@ 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" & viewClass))):
- if not isGalleryView and not prefs.hideBanner:
+ buildHtml(tdiv(class="profile-tabs")):
+ if not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(profile.user.banner)
- 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)
+ let sticky = if prefs.stickyProfile: " sticky" else: ""
+ tdiv(class=("profile-tab" & sticky)):
+ renderUserCard(profile.user, prefs)
+ 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 af2f05d..41ef8df 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -4,23 +4,14 @@ 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 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 =
+proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")):
@@ -29,15 +20,13 @@ proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
else:
span(class=c, title=title)
- if label.len > 0:
- text " " & label
+ if text.len > 0:
+ text " " & text
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii()
- 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")
+ icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
else:
text ""
@@ -51,6 +40,7 @@ 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"
@@ -74,20 +64,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", title=pref)):
+ buildHtml(label(class="pref-group checkbox-container")):
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), title=pref)):
+ buildHtml(tdiv(class=("pref-group pref-input " & class))):
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", title=pref)):
+ buildHtml(tdiv(class="pref-group pref-input")):
label(`for`=pref): text label
select(name=pref):
for opt in options:
@@ -99,16 +89,9 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state)
icon "calendar"
-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 =
+proc genImg*(url: string; class=""): VNode =
buildHtml():
- img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
+ img(src=getPicUrl(url), class=class, 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 4738705..729e65b 100644
--- a/src/views/rss.nimf
+++ b/src/views/rss.nimf
@@ -1,140 +1,31 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
-#import strutils, sequtils, xmltree, strformat, options, unicode
+#import strutils, 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 =
-#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]}: "
+#if tweet.pinned: result = "Pinned: "
+#elif retweet.len > 0: result = &"RT by @{retweet}: "
+#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
#end if
-#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ")
+#var text = stripHtml(tweet.text)
##if unicode.runeLen(text) > 32:
## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if
-#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
+#result &= xmltree.escape(text)
+#if result.len > 0: return
#end if
-#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
+#if tweet.photos.len > 0:
+# result &= "Image"
+#elif tweet.video.isSome:
+# result &= "Video"
+#elif tweet.gif.isSome:
+# result &= "Gif"
#end if
#end proc
#
#proc getDescription(desc: string; cfg: Config): string =
-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)}
-
+Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end proc
#
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
@@ -155,46 +46,38 @@ ${line}
#end if
#end proc
#
-#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
+#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
#let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg)
-#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
-#if text.len > 0:
+#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
${text.replace("\n", "
\n")}
-#end if
-#if tweet.media.len > 0:
-# for media in tweet.media:
-${renderRssMedia(media, tweet, urlPrefix)}
-# end for
-#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)}
-
-
-
+# let quoteLink = getLink(get(tweet.quote))
+${cfg.hostname}${quoteLink}
+#end if
+#if tweet.photos.len > 0:
+# for photo in tweet.photos:
+
+# end for
+#elif tweet.video.isSome:
+
+
Video
+
+
+#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
#end if
#end proc
#
-#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string =
+#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
#let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string]
#for thread in tweets:
@@ -208,24 +91,19 @@ ${renderRssTweet(quoteTweet, cfg, prefs)}
# 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; prefs: Prefs; multi=false): string =
+#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
#let urlPrefix = getUrlPrefix(cfg)
#result = ""
#let handle = (if multi: "" else: "@") & profile.user.username
@@ -251,13 +129,13 @@ ${renderRssTweet(quoteTweet, cfg, prefs)}
#let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0:
-${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
+${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
#end if
#end proc
#
-#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config; prefs: Prefs): string =
+#proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = ""
@@ -269,12 +147,12 @@ ${renderRssTweets(tweetsList, cfg, prefs, userId=profile.user.id)}
${getDescription(&"{list.name} by @{list.username}", cfg)}
en-us
40
-${renderRssTweets(tweets, cfg, prefs)}
+${renderRssTweets(tweets, cfg)}
#end proc
#
-#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config; prefs: Prefs): string =
+#proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
#let link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name)
#result = ""
@@ -287,7 +165,7 @@ ${renderRssTweets(tweets, cfg, prefs)}
${getDescription(&"Search \"{escName}\"", cfg)}
en-us
40
-${renderRssTweets(tweets, cfg, prefs)}
+${renderRssTweets(tweets, cfg)}
#end proc
diff --git a/src/views/search.nim b/src/views/search.nim
index 4d4ed5e..9f7fc95 100644
--- a/src/views/search.nim
+++ b/src/views/search.nim
@@ -10,12 +10,14 @@ const toggles = {
"media": "Media",
"videos": "Videos",
"news": "News",
+ "verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
+ "safe": "Safe",
"quote": "Quotes",
- "spaces": "Spaces"
+ "pro_video": "Pro videos"
}.toOrderedTable
proc renderSearch*(): VNode =
@@ -36,62 +38,29 @@ 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 "Latest"
- li(class=query.getTabClass(media)):
- q.kind = media
- q.view = query.view
- a(href=("?" & genQueryUrl(q))): text "Media"
+ a(href=("?" & genQueryUrl(q))): text "Tweets"
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.minLikes, q.until, q.since].anyIt(it.len > 0))
+ @[q.near, 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", $query.kind)
+ hiddenField("f", "tweets")
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
@@ -116,58 +85,36 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
- span(class="search-title"): text "Minimum likes"
- genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
+ span(class="search-title"): text "Near"
+ genInput("near", "", query.near, "Location...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode =
let query = results.query
- 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)):
+ buildHtml(tdiv(class="timeline-container")):
if query.fromUser.len > 1:
tdiv(class="timeline-header"):
text query.fromUser.join(" | ")
if query.fromUser.len > 0:
- 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)
+ renderProfileTabs(query, query.fromUser.join(","))
- if query.fromUser.len == 0 or query.kind == QueryKind.tweets:
+ if query.fromUser.len == 0 or query.kind == 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"):
- renderSearchForm("users", "Enter username...", results.query.text)
+ 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"
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
deleted file mode 100644
index a5cac7b..0000000
--- a/src/views/space.nim
+++ /dev/null
@@ -1,86 +0,0 @@
-# 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 b16c211..71c2c67 100644
--- a/src/views/status.nim
+++ b/src/views/status.nim
@@ -1,5 +1,4 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import sequtils
import karax/[karaxdsl, vdom]
import ".."/[types, formatters]
@@ -29,46 +28,16 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore:
renderMoreReplies(thread)
-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 =
+proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="replies", id="r")):
- var hasReplies = false
- var replyCount = 0
for thread in replies.content:
- if thread.content.len == 0 or thread.related: continue
- hasReplies = true
- replyCount += thread.content.len
+ if thread.content.len == 0: continue
renderReplyThread(thread, prefs, path)
- 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)
+ if replies.bottom.len > 0:
+ renderMore(Query(), replies.bottom, focus="#r")
-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 =
+proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
let hasAfter = conv.after.content.len > 0
let threadId = conv.tweet.threadId
buildHtml(tdiv(class="conversation")):
@@ -101,25 +70,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
- 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)
+ renderReplies(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 2890dd1..abeb6d3 100644
--- a/src/views/timeline.nim
+++ b/src/views/timeline.nim
@@ -5,38 +5,12 @@ 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
@@ -50,9 +24,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
a(href=(p & url)):
text "Load newest"
-proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
+proc renderMore*(query: Query; cursor: string; focus=""): VNode =
buildHtml(tdiv(class="show-more")):
- a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
+ a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
text "Load more"
proc renderNoMore(): VNode =
@@ -65,7 +39,7 @@ proc renderNoneFound(): VNode =
h2(class="timeline-none"):
text "No items found"
-proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): VNode =
+proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="thread-line")):
let sortedThread = thread.sortedByIt(it.id)
for i, tweet in sortedThread:
@@ -79,10 +53,10 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string; bigThumb=false): V
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), bigThumb=bigThumb)
+ index=i, last=(i == thread.high), showThread=show)
proc renderUser(user: User; prefs: Prefs): VNode =
- buildHtml(tdiv(class="timeline-item", data-username=user.username)):
+ buildHtml(tdiv(class="timeline-item")):
a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"):
@@ -92,7 +66,6 @@ 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"):
@@ -114,106 +87,15 @@ 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=results.query.timelineViewClass)):
+ buildHtml(tdiv(class="timeline")):
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)
+ renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
if results.content.len == 0:
if not results.beginning:
@@ -221,24 +103,26 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else:
renderNoneFound()
else:
- let filtered = filterThreads(results.content, prefs)
+ var retweets: seq[int64]
- 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)
+ for thread in results.content:
+ if thread.len == 1:
+ let
+ tweet = thread[0]
+ retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
- var cursor = getSearchMaxId(results, path)
- if cursor.len > 0:
- renderMore(results.query, cursor)
- elif results.bottom.len > 0:
+ 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:
renderMore(results.query, results.bottom)
renderToTop()
diff --git a/src/views/tweet.nim b/src/views/tweet.nim
index 8971ab8..8ff8cb1 100644
--- a/src/views/tweet.nim
+++ b/src/views/tweet.nim
@@ -5,39 +5,21 @@ 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 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 =
+proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): 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", pinnedLabel)
+ span: icon "pin", "Pinned Tweet"
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)):
@@ -49,28 +31,28 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs;
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 renderAltText(altText: string): VNode =
- buildHtml(p(class="alt-text")):
- text "ALT " & altText
+proc renderAlbum(tweet: Tweet): VNode =
+ let
+ groups = if tweet.photos.len < 3: @[tweet.photos]
+ else: tweet.photos.distribute(2)
-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)
+ 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 isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
case playbackType
@@ -80,11 +62,11 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
proc hasMp4Url(video: Video): bool =
video.variants.anyIt(it.contentType == mp4)
-proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
+proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
buildHtml(tdiv(class="video-overlay")):
case playbackType
of mp4:
- buttonReferer "/enablemp4", "Enable mp4 playback", path
+ p: text "mp4 playback disabled in preferences"
of m3u8, vmap:
buttonReferer "/enablehls", "Enable hls playback", path
@@ -96,109 +78,51 @@ proc renderVideoUnavailable(video: Video): VNode =
else:
p: text "This media is unavailable"
-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 =
+proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
let
- 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
+ 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
buildHtml(tdiv(class="attachments card")):
- tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
- renderVideoAttachment(video, prefs, path, bigThumb)
- if hasCardContent:
+ 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="card-content"):
h2(class="card-title"): text video.title
if video.description.len > 0:
p(class="card-description"): text video.description
-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 =
+proc renderGif(gif: Gif; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments media-gif")):
- 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)
+ 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")
proc renderPoll(poll: Poll): VNode =
buildHtml(tdiv(class="poll")):
@@ -254,7 +178,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)
@@ -268,9 +192,8 @@ proc renderReply(tweet: Tweet): VNode =
if i > 0: text " "
a(href=("/" & u)): text "@" & u
-proc renderAttribution(user: User; prefs: Prefs; link = ""): VNode =
- let href = if link.len > 0: link else: "/" & user.username
- buildHtml(a(class="attribution", href=href)):
+proc renderAttribution(user: User; prefs: Prefs): VNode =
+ buildHtml(a(class="attribution", href=("/" & user.username))):
renderMiniAvatar(user, prefs)
strong: text user.fullname
verifiedIcon(user)
@@ -284,28 +207,19 @@ 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")):
- renderMedia(quote.media, prefs, path)
+ 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)
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
if not quote.available:
return buildHtml(tdiv(class="quote unavailable")):
- a(class="unavailable-quote", href=getLink(quote, focus=false)):
+ tdiv(class="unavailable-quote"):
if quote.tombstone.len > 0:
text quote.tombstone
elif quote.text.len > 0:
@@ -320,7 +234,6 @@ 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"):
@@ -334,31 +247,12 @@ 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.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)"
+ if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
+ renderQuoteMedia(quote, prefs, path)
proc renderLocation*(tweet: Tweet): string =
let (place, url) = tweet.getLocation()
@@ -372,15 +266,14 @@ proc renderLocation*(tweet: Tweet): string =
return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
- last=false; mainTweet=false; afterTweet=false;
- bigThumb=false): VNode =
+ last=false; showThread=false; mainTweet=false; afterTweet=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", data-username=tweet.user.username)):
- a(class="unavailable-box", href=getLink(tweet)):
+ return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
+ tdiv(class="unavailable-box"):
if tweet.tombstone.len > 0:
text tweet.tombstone
elif tweet.text.len > 0:
@@ -401,15 +294,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), data-username=tweet.user.username)):
+ buildHtml(tdiv(class=("timeline-item " & divClass))):
if not mainTweet:
a(class="tweet-link", href=getLink(tweet))
tdiv(class="tweet-body"):
- renderHeader(tweet, retweet, pinned, prefs, path)
+ renderHeader(tweet, retweet, pinned, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and
- (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
+ (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
renderReply(tweet)
var tweetClass = "tweet-content media-body"
@@ -420,16 +313,17 @@ 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, tweet.attributionLink)
+ renderAttribution(tweet.attribution.get(), prefs)
if tweet.card.isSome and tweet.card.get().kind != hidden:
renderCard(tweet.card.get(), prefs, path)
- if tweet.articlePreview.isSome:
- renderArticleCard(tweet.articlePreview.get(), prefs)
-
- if tweet.media.len > 0:
- renderMedia(tweet.media, prefs, path, bigThumb)
+ 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.poll.isSome:
renderPoll(tweet.poll.get())
@@ -437,29 +331,25 @@ 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"):
- 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))
+ p(class="tweet-published"): text &"{getTime(tweet)}"
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 841094d..010dfbb 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -54,18 +54,6 @@ 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):
@@ -76,8 +64,6 @@ 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):
@@ -93,7 +79,7 @@ class Media(object):
row = '.gallery-row'
image = '.still-image'
video = '.gallery-video'
- gif = '.media-gif'
+ gif = '.gallery-gif'
class BaseTestCase(BaseCase):
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index 3d87c74..0000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,4 +0,0 @@
-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
deleted file mode 100644
index d13bfe0..0000000
--- a/tests/poetry.lock
+++ /dev/null
@@ -1,1716 +0,0 @@
-# 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
deleted file mode 100644
index ab1033b..0000000
--- a/tests/poetry.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[virtualenvs]
-in-project = true
diff --git a/tests/pyproject.toml b/tests/pyproject.toml
deleted file mode 100644
index 1907e60..0000000
--- a/tests/pyproject.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[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 e47d1cc..56ea4c0 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1 +1 @@
-seleniumbase==4.46.5
+seleniumbase
diff --git a/tests/test_about_account.py b/tests/test_about_account.py
deleted file mode 100644
index 2239d9d..0000000
--- a/tests/test_about_account.py
+++ /dev/null
@@ -1,76 +0,0 @@
-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
deleted file mode 100644
index 287b948..0000000
--- a/tests/test_article.py
+++ /dev/null
@@ -1,327 +0,0 @@
-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 daee099..504c079 100644
--- a/tests/test_card.py
+++ b/tests/test_card.py
@@ -1,5 +1,3 @@
-import os
-import unittest
from base import BaseTestCase, Card, Conversation
from parameterized import parameterized
@@ -15,16 +13,21 @@ card = [
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', 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]
+ ['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]
]
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/XLM: PyTorch original implementation of Cross-lingual Language Model',
- 'PyTorch original implementation of Cross-lingual Language Model Pretraining.',
+ 'GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in',
+ '',
'github.com'],
['brent_p/status/1088857328680488961',
@@ -39,17 +42,20 @@ no_thumb = [
]
playable = [
- ['NASA/status/2047048645845897398',
- 'NASA\'s Artemis II News Conference with Moon Astronauts',
- 'Live from NASA\'s Johnson Space Center in Houston',
+ ['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...',
'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)
@@ -71,7 +77,7 @@ class CardTest(BaseTestCase):
if len(description) > 0:
self.assert_text(description, c.description)
- @parameterized.expand(playable, skip_on_empty=True)
+ @parameterized.expand(playable)
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
deleted file mode 100644
index b266044..0000000
--- a/tests/test_community.py
+++ /dev/null
@@ -1,211 +0,0 @@
-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
deleted file mode 100644
index cc7a28a..0000000
--- a/tests/test_embed.py
+++ /dev/null
@@ -1,282 +0,0 @@
-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('