"
@@ -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..5bf2b0b 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
@@ -826,54 +491,20 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
)
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 +519,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 +559,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 +587,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 +603,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..0168dac 100644
--- a/src/routes/status.nim
+++ b/src/routes/status.nim
@@ -21,18 +21,16 @@ 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 or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found"
@@ -46,19 +44,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,37 +60,13 @@ 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"]
- cond @"m" in ["video", "photo"]
+ cond @"m" in ["video", "photo", "history"]
redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@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..a6f069c 100644
--- a/src/views/rss.nimf
+++ b/src/views/rss.nimf
@@ -1,49 +1,26 @@
#? 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
#
@@ -51,92 +28,6 @@
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc
#
-#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
-#case media.kind
-#of photoMedia:
-# let photo = media.photo
-
-#of videoMedia:
-# let video = media.video
-
-
Video
-
-
-#of gifMedia:
-# let gif = media.gif
-# let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
-# let url = &"{urlPrefix}{getPicUrl(gif.url)}"
-
-#end case
-#end proc
-#
-#proc renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string =
-#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs)
-#let title = xmltree.escape(card.title)
-
-Link
-#if cardLink.len > 0:
-
-#end if
-#if card.image.len > 0:
-
-#end if
-#if title.len > 0:
-# if card.image.len > 0:
-
-# end if
-${title}
-#end if
-#if cardLink.len > 0:
-
-#end if
-#if card.text.len > 0:
-${xmltree.escape(card.text)}
-#end if
-#if cardLink.len > 0:
-# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink)
-${destText}
-#elif card.dest.len > 0:
-${xmltree.escape(card.dest)}
-#end if
-#end proc
-#
-#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string =
-#let link = urlPrefix & "/i/article/" & $article.tweetId
-
-Article
-
-#if article.coverImage.len > 0:
-
-#end if
-#if article.title.len > 0:
-# if article.coverImage.len > 0:
-
-# end if
-${xmltree.escape(article.title)}
-#end if
-
-#if article.previewText.len > 0:
-${xmltree.escape(article.previewText)}
-#end if
-#end proc
-#
-#proc renderRssPoll(poll: Poll): string =
-
-Poll
-
-#for i in 0 ..< poll.options.len:
-# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0
-# let pct = (&"{perc:.0f}").strip(chars={'.'})
-# let line = pct & "% — " & xmltree.escape(poll.options[i])
-${line}
-#end for
-#let votesStr = insertSep($poll.votes, ',')
-${votesStr} votes • ${xmltree.escape(poll.status)}
-
-#end proc
-#
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
#result = profile.tweets.content
#if profile.pinned.isSome and result.len > 0:
@@ -155,28 +46,27 @@ ${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)}
+#if tweet.photos.len > 0:
+# for photo in tweet.photos:
+
# 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)}
+#elif tweet.video.isSome:
+
+#elif tweet.gif.isSome:
+# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
+# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
+
+#elif tweet.card.isSome:
+# let card = tweet.card.get()
+# if card.image.len > 0:
+
+# end if
#end if
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteTweet = get(tweet.quote)
@@ -185,7 +75,7 @@ ${renderRssPoll(tweet.poll.get())}
${quoteTweet.user.fullname} (@${quoteTweet.user.username})
-${renderRssTweet(quoteTweet, cfg, prefs)}
+${renderRssTweet(quoteTweet, cfg)}