"
@@ -42,6 +50,8 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = now.int64
newest = 0'i64
average = 0'i64
+ oauthTotal, cookieTotal = 0
+ oauthLimited, cookieLimited = 0
for session in sessionPool:
let created = snowflakeToEpoch(session.id)
@@ -51,8 +61,15 @@ proc getSessionPoolHealth*(): JsonNode =
oldest = created
average += created
+ case session.kind
+ of oauth: inc oauthTotal
+ of cookie: inc cookieTotal
+
if session.limited:
limited.incl session.id
+ case session.kind
+ of oauth: inc oauthLimited
+ of cookie: inc cookieLimited
for api in session.apis.keys:
let
@@ -76,6 +93,8 @@ proc getSessionPoolHealth*(): JsonNode =
"sessions": %*{
"total": sessionPool.len,
"limited": limited.card,
+ "oauth": %*{"total": oauthTotal, "limited": oauthLimited},
+ "cookie": %*{"total": cookieTotal, "limited": cookieLimited},
"oldest": $fromUnix(oldest),
"newest": $fromUnix(newest),
"average": $fromUnix(average)
@@ -92,6 +111,7 @@ proc getSessionPoolDebug*(): JsonNode =
for session in sessionPool:
let sessionJson = %*{
+ "kind": $session.kind,
"apis": newJObject(),
"pending": session.pending,
}
@@ -122,11 +142,12 @@ proc rateLimitError*(): ref RateLimitError =
proc noSessionsError*(): ref NoSessionsError =
newException(NoSessionsError, "no sessions available")
-proc isLimited(session: Session; api: Api): bool =
+proc isLimited(session: Session; req: ApiReq): bool =
if session.isNil:
return true
- if session.limited and api != Api.userTweets:
+ let api = req.endpoint(session)
+ if session.limited and api != graphUserTweetsV2:
if (epochTime().int - session.limitedAt) > hourInSeconds:
session.limited = false
log "resetting limit: ", session.pretty
@@ -140,8 +161,8 @@ proc isLimited(session: Session; api: Api): bool =
else:
return false
-proc isReady(session: Session; api: Api): bool =
- not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(api))
+proc isReady(session: Session; req: ApiReq): bool =
+ not (session.isNil or session.pending > maxConcurrentReqs or session.isLimited(req))
proc invalidate*(session: var Session) =
if session.isNil: return
@@ -156,24 +177,29 @@ proc release*(session: Session) =
if session.isNil: return
dec session.pending
-proc getSession*(api: Api): Future[Session] {.async.} =
+proc getSession*(req: ApiReq): Future[Session] {.async.} =
for i in 0 ..< sessionPool.len:
- if result.isReady(api): break
+ if result.isReady(req): break
result = sessionPool.sample()
- if not result.isNil and result.isReady(api):
+ if not result.isNil and result.isReady(req):
inc result.pending
else:
- log "no sessions available for API: ", api
+ if result.isNil:
+ log "no sessions available for API: ", req.cookie.endpoint
+ else:
+ log "no sessions available for API: ", req.endpoint(result), ", last tried: ", result.pretty
raise noSessionsError()
-proc setLimited*(session: Session; api: Api) =
+proc setLimited*(session: Session; req: ApiReq) =
+ let api = req.endpoint(session)
session.limited = true
session.limitedAt = epochTime().int
log "rate limited by api: ", api, ", reqs left: ", session.apis[api].remaining, ", ", session.pretty
-proc setRateLimit*(session: Session; api: Api; remaining, reset, limit: int) =
+proc setRateLimit*(session: Session; req: ApiReq; remaining, reset, limit: int) =
# avoid undefined behavior in race conditions
+ let api = req.endpoint(session)
if api in session.apis:
let rateLimit = session.apis[api]
if rateLimit.reset >= reset and rateLimit.remaining < remaining:
diff --git a/src/config.nim b/src/config.nim
index 1b05ffe..b46a979 100644
--- a/src/config.nim
+++ b/src/config.nim
@@ -13,6 +13,8 @@ proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
proc getConfig*(path: string): (Config, parseCfg.Config) =
var cfg = loadConfig(path)
+ let masterRss = cfg.get("Config", "enableRSS", true)
+
let conf = Config(
# Server
address: cfg.get("Server", "address", "0.0.0.0"),
@@ -37,10 +39,20 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
base64Media: cfg.get("Config", "base64Media", false),
minTokens: cfg.get("Config", "tokenCount", 10),
- enableRss: cfg.get("Config", "enableRSS", true),
+ enableRSSUserTweets: masterRss and cfg.get("Config", "enableRSSUserTweets", true),
+ enableRSSUserReplies: masterRss and cfg.get("Config", "enableRSSUserReplies", true),
+ enableRSSUserMedia: masterRss and cfg.get("Config", "enableRSSUserMedia", true),
+ enableRSSUserArticles: masterRss and cfg.get("Config", "enableRSSUserArticles", true),
+ enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true),
+ enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true),
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
- proxyAuth: cfg.get("Config", "proxyAuth", "")
+ proxyAuth: cfg.get("Config", "proxyAuth", ""),
+ apiProxy: cfg.get("Config", "apiProxy", ""),
+ disableTid: cfg.get("Config", "disableTid", false),
+ maxConcurrentReqs: cfg.get("Config", "maxConcurrentReqs", 2),
+ maxRetries: cfg.get("Config", "maxRetries", 1),
+ retryDelayMs: cfg.get("Config", "retryDelayMs", 150)
)
return (conf, cfg)
diff --git a/src/consts.nim b/src/consts.nim
index 792a519..c88ae68 100644
--- a/src/consts.nim
+++ b/src/consts.nim
@@ -1,116 +1,108 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import uri, strutils
+import strutils
const
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
+ bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
+ bearerToken2* = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
- gql = parseUri("https://api.x.com") / "graphql"
+ graphUser* = "Gb-d6r0vxPOADdG62OEBpQ/UserByScreenName"
+ graphUserV2* = "-ZzAG_Bckx16LMbEvHC3lg/UserResultByScreenNameQuery"
+ graphUserById* = "xvmVfRLmnr1alc5f2dib0Q/UserByRestId"
+ graphUserTweetsV2* = "LE3eTyeqhBh2g-fX85O2eQ/UserWithProfileTweetsQueryV2"
+ graphUserTweetsAndRepliesV2* = "AcYHjc_YAx-9_rKWdMsKvA/UserWithProfileTweetsAndRepliesQueryV2"
+ graphUserTweets* = "SXVCYB8XHSS25nzIljNtZA/UserTweets"
+ graphUserTweetsAndReplies* = "qUpkZU6eN8MbtQb7rC_pYg/UserTweetsAndReplies"
+ graphUserMedia* = "VyudDWQnr9vJNw7GasFz2g/UserMedia"
+ graphUserMediaV2* = "WK111rbR0vM0ZX4lyZCYjw/MediaTimelineV2"
+ graphUserArticles* = "ZmMjUyrTpwYfTGAdylEyMw/UserArticlesTweets"
+ graphUserArticlesV2* = "PsGixN38UZz2RheyayNB5Q/UserProfileArticlesTimelineQuery"
+ graphTweet* = "OZMbEnEa96AN8Pq6HyTWdw/ConversationTimeline"
+ graphTweetDetail* = "XMOz5h24KAZ86qKffKTLdQ/TweetDetail"
+ graphTweetResult* = "xYOrBQoTlfKJJPsX76MZEw/TweetResultByIdQuery"
+ graphTweetEditHistory* = "1izbuOcH_QpuMcyCxOXkAg/TweetEditHistory"
+ graphSearchTimeline* = "hyPfJYJ_XAtDYoslQc-Rgg/SearchTimeline"
- graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
- graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
- graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
- graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
- graphUserTweets* = gql / "oRJs8SLCRNRbQzuZG93_oA/UserTweets"
- graphUserTweetsAndReplies* = gql / "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
- graphUserMedia* = gql / "36oKqyQ7E_9CmtONGjJRsA/UserMedia"
- graphUserMediaV2* = gql / "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
- graphTweet* = gql / "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
- graphTweetDetail* = gql / "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"
- graphTweetResult* = gql / "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
- graphSearchTimeline* = gql / "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
- graphListById* = gql / "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
- graphListBySlug* = gql / "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
- graphListMembers* = gql / "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
- graphListTweets* = gql / "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
+ graphListById* = "niz0TtOxL2zIcbq6_NQiNw/ListByRestId"
+ graphListBySlug* = "RqkWNDQpOntlxNtJa4RIoQ/ListBySlug"
+ graphListMembers* = "8rYmkvWQe9jRRZdy_-vkGA/ListMembers"
+ graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
+ graphAboutAccount* = "TzOG2twZEfhr9KmClvVVqA/AboutAccountQuery"
+
+ graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery"
+ graphCommunityTweets* = "EwftYyqQemkckQ0wzGM6uw/CommunityTweetsTimeline"
+ graphCommunityMedia* = "ESJtwnI_apuGesbJncpc0Q/CommunityMediaTimeline"
+ graphCommunityMembers* = "woAp_YdzAdqnWDrqLTNpAw/membersSliceTimeline_Query"
+ graphCommunityModerators* = "0oYT9GRiWUhrz5xoqFE9uw/moderatorsSliceTimeline_Query"
+ graphCommunityHashtags* = "D5EqomOIWeJnSkMhL-FLew/CommunityHashtagsTimeline"
+
+ graphTweetResultByRestId* = "GZsN2Pc4knAoit6pXa4HSA/TweetResultByRestId"
+ graphTweetResultsByRestIds* = "Pho4sg8jLcrVlMeclMayrg/TweetResultsByRestIds"
+
+ graphBroadcast* = "RG6wSogandh6WPIzxW9aag/BroadcastQuery"
+ graphAudioSpace* = "Bh0L6azTQoMs9rJKeCF4wQ/AudioSpaceById"
+ restLiveStream* = "1.1/live_video_stream/status/"
+
+ graphFollowers* = "JNyQdTISpzCkj_1fqxDvFg/Followers"
+ graphFollowing* = "qGZZDF3mp91q7X22s3HxpA/Following"
gqlFeatures* = """{
- "android_ad_formats_media_component_render_overlay_enabled": false,
- "android_graphql_skip_api_media_color_palette": false,
- "android_professional_link_spotlight_display_enabled": false,
- "blue_business_profile_image_shape_enabled": false,
- "commerce_android_shop_module_enabled": false,
- "creator_subscriptions_subscription_count_enabled": false,
- "creator_subscriptions_tweet_preview_api_enabled": true,
- "freedom_of_speech_not_reach_fetch_enabled": true,
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
- "hidden_profile_likes_enabled": false,
- "highlights_tweets_tab_ui_enabled": false,
- "interactive_text_enabled": false,
- "longform_notetweets_consumption_enabled": true,
- "longform_notetweets_inline_media_enabled": true,
- "longform_notetweets_rich_text_read_enabled": true,
- "longform_notetweets_richtext_consumption_enabled": true,
- "mobile_app_spotlight_module_enabled": false,
- "responsive_web_edit_tweet_api_enabled": true,
- "responsive_web_enhance_cards_enabled": false,
- "responsive_web_graphql_exclude_directive_enabled": true,
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
- "responsive_web_graphql_timeline_navigation_enabled": true,
- "responsive_web_media_download_video_enabled": false,
- "responsive_web_text_conversations_enabled": false,
- "responsive_web_twitter_article_tweet_consumption_enabled": true,
- "unified_cards_destination_url_params_enabled": false,
- "responsive_web_twitter_blue_verified_badge_is_enabled": true,
- "rweb_lists_timeline_redesign_enabled": true,
- "spaces_2022_h2_clipping": true,
- "spaces_2022_h2_spaces_communities": true,
- "standardized_nudges_misinfo": true,
- "subscriptions_verification_info_enabled": true,
- "subscriptions_verification_info_reason_enabled": true,
- "subscriptions_verification_info_verified_since_enabled": true,
- "super_follow_badge_privacy_enabled": false,
- "super_follow_exclusive_tweet_notifications_enabled": false,
- "super_follow_tweet_api_enabled": false,
- "super_follow_user_api_enabled": false,
- "tweet_awards_web_tipping_enabled": false,
- "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
- "tweetypie_unmention_optimization_enabled": false,
- "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
+ "rweb_video_screen_enabled": false,
+ "rweb_cashtags_enabled": true,
+ "profile_label_improvements_pcf_label_in_post_enabled": true,
+ "responsive_web_profile_redirect_enabled": false,
+ "rweb_tipjar_consumption_enabled": false,
"verified_phone_label_enabled": false,
- "vibe_api_enabled": false,
- "view_counts_everywhere_api_enabled": true,
+ "creator_subscriptions_tweet_preview_api_enabled": true,
+ "responsive_web_graphql_timeline_navigation_enabled": true,
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
- "responsive_web_jetfuel_frame": true,
+ "c9s_tweet_anatomy_moderator_badge_enabled": true,
+ "c9s_list_members_action_api_enabled": false,
+ "c9s_superc9s_indication_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
+ "responsive_web_grok_analyze_post_followups_enabled": true,
+ "rweb_cashtags_composer_attachment_enabled": true,
+ "responsive_web_jetfuel_frame": true,
+ "responsive_web_grok_share_attachment_enabled": true,
+ "responsive_web_grok_annotations_enabled": true,
+ "articles_preview_enabled": true,
+ "responsive_web_edit_tweet_api_enabled": true,
+ "rweb_conversational_replies_downvote_enabled": false,
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
+ "view_counts_everywhere_api_enabled": true,
+ "longform_notetweets_consumption_enabled": true,
+ "responsive_web_twitter_article_tweet_consumption_enabled": true,
+ "content_disclosure_indicator_enabled": true,
+ "content_disclosure_ai_generated_indicator_enabled": true,
+ "responsive_web_grok_show_grok_translated_post": true,
+ "responsive_web_grok_analysis_button_from_backend": true,
+ "post_ctas_fetch_enabled": true,
+ "freedom_of_speech_not_reach_fetch_enabled": true,
+ "standardized_nudges_misinfo": true,
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
+ "longform_notetweets_rich_text_read_enabled": true,
+ "longform_notetweets_inline_media_enabled": false,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
- "rweb_tipjar_consumption_enabled": true,
- "profile_label_improvements_pcf_label_in_post_enabled": true,
- "creator_subscriptions_quote_tweet_preview_enabled": false,
- "c9s_tweet_anatomy_moderator_badge_enabled": true,
- "responsive_web_grok_analyze_post_followups_enabled": true,
- "rweb_video_timestamps_enabled": false,
- "responsive_web_grok_share_attachment_enabled": true,
- "articles_preview_enabled": true,
- "immersive_video_status_linkable_timestamps": false,
- "articles_api_enabled": false,
- "responsive_web_grok_analysis_button_from_backend": true,
- "rweb_video_screen_enabled": false,
- "payments_enabled": false,
- "responsive_web_profile_redirect_enabled": false,
- "responsive_web_grok_show_grok_translated_post": false,
- "responsive_web_grok_community_note_auto_translation_is_enabled": false,
- "profile_label_improvements_pcf_label_in_profile_enabled": false,
- "grok_android_analyze_trend_fetch_enabled": false,
- "grok_translations_community_note_auto_translation_is_enabled": false,
- "grok_translations_post_auto_translation_is_enabled": false,
- "grok_translations_community_note_translation_is_enabled": false,
- "grok_translations_timeline_user_bio_auto_translation_is_enabled": false
+ "responsive_web_grok_community_note_auto_translation_is_enabled": true,
+ "responsive_web_enhance_cards_enabled": false
}""".replace(" ", "").replace("\n", "")
- tweetVariables* = """{
+ tweetVars* = """{
"postId": "$1",
$2
+ "ranking_mode": "$3",
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
- "withBirdwatchNotes": false,
+ "withBirdwatchNotes": true,
"withVoice": false,
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
- tweetDetailVariables* = """{
+ tweetDetailVars* = """{
"focalTweetId": "$1",
$2
"referrer": "profile",
@@ -123,21 +115,26 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- restIdVariables* = """{
- "rest_id": "$1", $2
- "count": 20
-}"""
+ tweetEditHistoryVars* = """{
+ "tweetId": "$1",
+ "withQuickPromoteEligibilityTweetFields": true
+}""".replace(" ", "").replace("\n", "")
- userMediaVariables* = """{
+ restIdVars* = """{
+ "rest_id": "$1", $2
+ "count": $3
+}""".replace(" ", "").replace("\n", "")
+
+ userMediaVars* = """{
"userId": "$1", $2
- "count": 20,
+ "count": $3,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- userTweetsVariables* = """{
+ userTweetsVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -145,7 +142,7 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- userTweetsAndRepliesVariables* = """{
+ userTweetsAndRepliesVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
@@ -153,5 +150,70 @@ const
"withVoice": true
}""".replace(" ", "").replace("\n", "")
- fieldToggles* = """{"withArticlePlainText":false}"""
+ userArticlesVars* = """{
+ "userId": "$1", $2
+ "count": 20,
+ "includePromotedContent": false,
+ "withVoice": true
+}""".replace(" ", "").replace("\n", "")
+
+ articleVars* = """{
+ "tweetId": "$1",
+ "includePromotedContent": false,
+ "withBirdwatchNotes": true,
+ "withVoice": true,
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ articleBatchVars* = """{
+ "tweetIds": $1,
+ "includePromotedContent": false,
+ "withBirdwatchNotes": true,
+ "withVoice": true,
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}"""
+
+ tweetByRestIdVars* = """{
+ "tweetId": "$1",
+ "includePromotedContent": false,
+ "withBirdwatchNotes": false,
+ "withVoice": false,
+ "withCommunity": false
+}""".replace(" ", "").replace("\n", "")
+
+ userByRestIdVars* = """{
+ "userId": "$1",
+ "withSafetyModeUserFields": true
+}""".replace(" ", "").replace("\n", "")
+
+ communityTweetsVars* = """{
+ "communityId": "$1", $2
+ "count": 20,
+ "displayLocation": "Community",
+ "rankingMode": "$3",
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ communityMediaVars* = """{
+ "communityId": "$1", $2
+ "count": 20,
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ communityMembersVars* = """{
+ "communityId": "$1",
+ "cursor": $2
+}""".replace(" ", "").replace("\n", "")
+
+ communityHashtagsVars* = """{
+ "communityId": "$1", $2
+ "count": 20,
+ "hashtags": ["$3"],
+ "withCommunity": true
+}""".replace(" ", "").replace("\n", "")
+
+ userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
+ userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim
index 40986f5..e22a51f 100644
--- a/src/experimental/parser.nim
+++ b/src/experimental/parser.nim
@@ -1,2 +1,2 @@
-import parser/[user, graphql]
-export user, graphql
+import parser/[user, graphql, article]
+export user, graphql, article
diff --git a/src/experimental/parser/article.nim b/src/experimental/parser/article.nim
new file mode 100644
index 0000000..ae5de7c
--- /dev/null
+++ b/src/experimental/parser/article.nim
@@ -0,0 +1,88 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import std/[strutils, tables, times, options]
+import jsony
+import utils, graphql, ../types/article
+from ../../types import Article, ArticleParagraph, ArticleEntity, ArticleMedia,
+ User, TweetStats
+
+proc parseGraphArticle*(json: string): Article =
+ if json.len == 0 or json[0] != '{':
+ return
+
+ var raw: GraphArticle
+ try:
+ raw = json.fromJson(GraphArticle)
+ except CatchableError:
+ return
+
+ let
+ tweet = raw.data.tweetResult.result
+ article = tweet.article.articleResults.result
+
+ if article.title.len == 0:
+ return
+
+ let publishedAt = article.metadata.firstPublishedAtSecs
+ var articleTime: DateTime
+ if publishedAt > 0:
+ articleTime = publishedAt.int64.fromUnix.utc
+ elif tweet.legacy.createdAt.len > 0:
+ articleTime = parseTwitterDate(tweet.legacy.createdAt)
+
+ result = Article(
+ title: article.title,
+ coverImage: getImageUrl(article.coverMedia.mediaInfo.originalImgUrl),
+ time: articleTime,
+ user: parseUserResult(tweet.core.userResults.result),
+ )
+
+ result.stats = TweetStats(
+ replies: tweet.legacy.replyCount,
+ retweets: tweet.legacy.retweetCount,
+ likes: tweet.legacy.favoriteCount,
+ )
+ if tweet.views.count.len > 0:
+ try: result.stats.views = parseInt(tweet.views.count)
+ except ValueError: discard
+
+ for blk in article.contentState.blocks:
+ result.paragraphs.add ArticleParagraph(
+ text: blk.text,
+ kind: blk.blockKind,
+ inlineStyles: blk.inlineStyleRanges,
+ entityRanges: blk.entityRanges,
+ )
+
+ for entry in article.contentState.entityMap:
+ let key = try: parseInt(entry.key) except ValueError: continue
+ var entity = ArticleEntity(kind: entry.value.entityKind)
+ case entity.kind
+ of "LINK": entity.url = entry.value.data.url
+ of "MEDIA":
+ for mi in entry.value.data.mediaItems:
+ entity.mediaIds.add mi.mediaId
+ entity.caption = entry.value.data.caption
+ of "TWEET": entity.tweetId = entry.value.data.tweetId
+ of "MARKDOWN": entity.markdown = entry.value.data.markdown
+ else: discard
+ result.entities[key] = entity
+
+ for me in article.mediaEntities:
+ let typeName = me.mediaInfo.typeName
+ var media = ArticleMedia(kind: typeName)
+ if me.mediaInfo.videoInfo.isSome:
+ let variants = me.mediaInfo.videoInfo.get.variants
+ case typeName
+ of "ApiGif":
+ if variants.len > 0:
+ media.url = variants[0].url
+ of "ApiVideo":
+ var bestBitrate = -1
+ for v in variants:
+ if v.bitrate > bestBitrate:
+ bestBitrate = v.bitrate
+ media.url = v.url
+ else: discard
+ elif typeName == "ApiImage":
+ media.url = getImageUrl(me.mediaInfo.originalImgUrl)
+ result.media[me.mediaId] = media
diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim
index 045a5d6..85be202 100644
--- a/src/experimental/parser/graphql.nim
+++ b/src/experimental/parser/graphql.nim
@@ -1,6 +1,6 @@
import options, strutils
import jsony
-import user, ../types/[graphuser, graphlistmembers]
+import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User =
@@ -10,27 +10,61 @@ proc parseUserResult*(userResult: UserResult): User =
result.verifiedType = blue
if result.username.len == 0 and userResult.core.screenName.len > 0:
+ # Modern UserByRestId/UserByScreenName shape: `legacy` is empty and the data
+ # lives in typed sub-objects (core/relationship_counts/tweet_counts/etc.).
result.id = userResult.restId
result.username = userResult.core.screenName
result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
+ if userResult.banner.imageUrl.len > 0:
+ result.banner = userResult.banner.imageUrl & "/1500x500"
+
+ if userResult.privacy.isSome:
+ result.protected = userResult.privacy.get.protected
+
+ if userResult.location.isSome:
+ result.location = userResult.location.get.location
+
+ if userResult.core.createdAt.len > 0:
+ result.joinDate = parseTwitterDate(userResult.core.createdAt)
+
if userResult.verification.isSome:
let v = userResult.verification.get
if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType
+ if userResult.relationshipCounts.isSome:
+ let rc = userResult.relationshipCounts.get
+ result.followers = rc.followers
+ result.following = rc.following
+
+ if userResult.tweetCounts.isSome:
+ let tc = userResult.tweetCounts.get
+ result.tweets = tc.tweets
+ result.media = tc.mediaTweets
+
+ if userResult.actionCounts.isSome:
+ result.likes = userResult.actionCounts.get.favoritesCount
+
if userResult.profileBio.isSome:
- result.bio = userResult.profileBio.get.description
+ let bio = userResult.profileBio.get
+ result.bio = bio.description
+ result.expandUserEntities(bio.entities)
proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
return
- let raw = json.fromJson(GraphUser)
- let userResult = raw.data.userResult.result
+ let
+ raw = json.fromJson(GraphUser)
+ userResult =
+ if raw.data.userResult.isSome: raw.data.userResult.get.result
+ elif raw.data.user.isSome: raw.data.user.get.result
+ else: UserResult()
- if userResult.unavailableReason.get("") == "Suspended":
+ if userResult.unavailableReason.get("") == "Suspended" or
+ userResult.reason.get("") == "Suspended":
return User(suspended: true)
result = parseUserResult(userResult)
@@ -53,3 +87,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value
+
+proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
+ result = Result[User](
+ beginning: cursor.len == 0,
+ query: Query(kind: kind)
+ )
+
+ if json.len == 0 or json[0] != '{':
+ return
+
+ let raw = json.fromJson(GraphFollowers)
+ for instruction in raw.data.user.result.timeline.timeline.instructions:
+ if instruction.kind == "TimelineAddEntries":
+ for entry in instruction.entries:
+ case entry.content.entryType
+ of TimelineTimelineItem:
+ let userResult = entry.content.itemContent.userResults.result
+ if userResult.restId.len > 0:
+ result.content.add parseUserResult(userResult)
+ of TimelineTimelineCursor:
+ if entry.content.cursorType == "Bottom":
+ result.bottom = entry.content.value
diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim
index 45e6e1d..db2c98d 100644
--- a/src/experimental/parser/slices.nim
+++ b/src/experimental/parser/slices.nim
@@ -54,7 +54,7 @@ proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice];
let
name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a]
- result.add a(symbol & name, href = "/search?q=%23" & name)
+ result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention:
result.add a($runes[rep.slice], href = rep.url, title = rep.display)
of rkUrl:
diff --git a/src/experimental/parser/tid.nim b/src/experimental/parser/tid.nim
new file mode 100644
index 0000000..28fccea
--- /dev/null
+++ b/src/experimental/parser/tid.nim
@@ -0,0 +1,8 @@
+import jsony
+import ../types/tid
+export TidPair
+
+proc parseTidPairs*(raw: string): seq[TidPair] =
+ result = raw.fromJson(seq[TidPair])
+ if result.len == 0:
+ raise newException(ValueError, "Parsing pairs failed: " & raw)
diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim
index 498757a..866973c 100644
--- a/src/experimental/parser/user.nim
+++ b/src/experimental/parser/user.nim
@@ -9,12 +9,10 @@ let
unReplace = "$1@$2"
htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)"""
- htReplace = "$1$2$3"
+ htReplace = "$1$2$3"
-proc expandUserEntities(user: var User; raw: RawUser) =
- let
- orig = user.bio.toRunes
- ent = raw.entities
+proc expandUserEntities*(user: var User; ent: Entities) =
+ let orig = user.bio.toRunes
if ent.url.urls.len > 0:
user.website = ent.url.urls[0].expandedUrl
@@ -58,15 +56,17 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount,
verifiedType: raw.verifiedType,
protected: raw.protected,
- joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
)
+ if raw.createdAt.len > 0:
+ result.joinDate = parseTwitterDate(raw.createdAt)
+
if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
- result.expandUserEntities(raw)
+ result.expandUserEntities(raw.entities)
proc parseHook*(s: string; i: var int; v: var User) =
var u: RawUser
diff --git a/src/experimental/types/article.nim b/src/experimental/types/article.nim
new file mode 100644
index 0000000..946f8c3
--- /dev/null
+++ b/src/experimental/types/article.nim
@@ -0,0 +1,79 @@
+import std/options
+import graphuser
+from ../../types import ArticleStyle, ArticleEntityRange
+
+type
+ GraphArticle* = object
+ data*: tuple[tweetResult: tuple[result: TweetResultNode]]
+
+ TweetResultNode* = object
+ article*: tuple[articleResults: tuple[result: ArticleResultNode]]
+ legacy*: TweetLegacy
+ core*: tuple[userResults: UserData]
+ views*: tuple[count: string]
+
+ TweetLegacy* = object
+ createdAt*: string
+ replyCount*: int
+ retweetCount*: int
+ favoriteCount*: int
+
+ ArticleResultNode* = object
+ title*: string
+ coverMedia*: tuple[mediaInfo: MediaInfoNode]
+ contentState*: ContentState
+ metadata*: tuple[firstPublishedAtSecs: int]
+ mediaEntities*: seq[RawMediaEntity]
+
+ ContentState* = object
+ blocks*: seq[ContentBlock]
+ entityMap*: seq[EntityMapEntry]
+
+ ContentBlock* = object
+ text*: string
+ blockKind*: string
+ inlineStyleRanges*: seq[ArticleStyle]
+ entityRanges*: seq[ArticleEntityRange]
+
+ EntityMapEntry* = object
+ key*: string
+ value*: EntityMapValue
+
+ EntityMapValue* = object
+ entityKind*: string
+ data*: EntityDataNode
+
+ EntityDataNode* = object
+ url*: string
+ mediaItems*: seq[tuple[mediaId: string]]
+ tweetId*: string
+ markdown*: string
+ caption*: string
+
+ RawMediaEntity* = object
+ mediaId*: string
+ mediaInfo*: MediaInfoNode
+
+ MediaInfoNode* = object
+ typeName*: string
+ originalImgUrl*: string
+ videoInfo*: Option[VideoInfoNode]
+
+ VideoInfoNode* = object
+ variants*: seq[VideoVariant]
+
+ VideoVariant* = object
+ url*: string
+ bitrate*: int
+
+proc renameHook*(v: var ContentBlock; fieldName: var string) =
+ if fieldName == "type":
+ fieldName = "blockKind"
+
+proc renameHook*(v: var EntityMapValue; fieldName: var string) =
+ if fieldName == "type":
+ fieldName = "entityKind"
+
+proc renameHook*(v: var MediaInfoNode; fieldName: var string) =
+ if fieldName == "__typename":
+ fieldName = "typeName"
diff --git a/src/experimental/types/graphfollowers.nim b/src/experimental/types/graphfollowers.nim
new file mode 100644
index 0000000..ba9210b
--- /dev/null
+++ b/src/experimental/types/graphfollowers.nim
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import graphlistmembers
+
+type
+ GraphFollowers* = object
+ data*: tuple[user: UserWrapper]
+
+ UserWrapper = object
+ result*: UserResultWrapper
+
+ UserResultWrapper = object
+ timeline*: tuple[timeline: graphlistmembers.Timeline]
+
+# Hook to normalize snake_case field from API to camelCase used by shared types
+proc renameHook*(v: var Content; fieldName: var string) =
+ if fieldName == "user_results":
+ fieldName = "userResults"
diff --git a/src/experimental/types/graphlistmembers.nim b/src/experimental/types/graphlistmembers.nim
index 4cb3757..9e520d8 100644
--- a/src/experimental/types/graphlistmembers.nim
+++ b/src/experimental/types/graphlistmembers.nim
@@ -7,10 +7,10 @@ type
List = object
membersTimeline*: tuple[timeline: Timeline]
- Timeline = object
+ Timeline* = object
instructions*: seq[Instruction]
- Instruction = object
+ Instruction* = object
kind*: string
entries*: seq[tuple[content: Content]]
@@ -18,7 +18,7 @@ type
TimelineTimelineItem
TimelineTimelineCursor
- Content = object
+ Content* = object
case entryType*: ContentEntryType
of TimelineTimelineItem:
itemContent*: tuple[userResults: UserData]
diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim
index d732b4e..ec41f89 100644
--- a/src/experimental/types/graphuser.nim
+++ b/src/experimental/types/graphuser.nim
@@ -1,9 +1,10 @@
import options, strutils
from ../../types import User, VerifiedType
+import user as userType # Entities, for modern profile_bio parsing
type
GraphUser* = object
- data*: tuple[userResult: UserData]
+ data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object
result*: UserResult
@@ -15,6 +16,7 @@ type
UserBio* = object
description*: string
+ entities*: Entities
UserAvatar* = object
imageUrl*: string
@@ -22,15 +24,41 @@ type
Verification* = object
verifiedType*: VerifiedType
+ Location* = object
+ location*: string
+
+ Privacy* = object
+ protected*: bool
+
+ # Modern UserByRestId/UserByScreenName shape moves the counts out of `legacy`
+ # into these typed sub-objects.
+ RelationshipCounts* = object
+ followers*: int
+ following*: int
+
+ TweetCounts* = object
+ tweets*: int
+ mediaTweets*: int
+
+ ActionCounts* = object
+ favoritesCount*: int
+
UserResult* = object
legacy*: User
restId*: string
isBlueVerified*: bool
- unavailableReason*: Option[string]
core*: UserCore
avatar*: UserAvatar
+ banner*: UserAvatar
+ unavailableReason*: Option[string]
+ reason*: Option[string]
+ privacy*: Option[Privacy]
profileBio*: Option[UserBio]
verification*: Option[Verification]
+ location*: Option[Location]
+ relationshipCounts*: Option[RelationshipCounts]
+ tweetCounts*: Option[TweetCounts]
+ actionCounts*: Option[ActionCounts]
proc enumHook*(s: string; v: var VerifiedType) =
v = try:
diff --git a/src/experimental/types/tid.nim b/src/experimental/types/tid.nim
new file mode 100644
index 0000000..ad036d9
--- /dev/null
+++ b/src/experimental/types/tid.nim
@@ -0,0 +1,4 @@
+type
+ TidPair* = object
+ animationKey*: string
+ verification*: string
diff --git a/src/formatters.nim b/src/formatters.nim
index cafaa4f..958e518 100644
--- a/src/formatters.nim
+++ b/src/formatters.nim
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen
+import strutils, strformat, times, uri, tables, xmltree, htmlparser, htmlgen, math
import std/[enumerate, re]
import types, utils, query
const
cards = "cards.twitter.com/cards"
tco = "https://t.co"
- twitter = parseUri("https://twitter.com")
+ twitter = parseUri("https://x.com")
let
twRegex = re"(?<=(? 0 and "youtu" in result:
- result = result.replace(ytRegex, prefs.replaceYouTube)
+ let youtubeHost = strip(prefs.replaceYouTube, chars={'/'})
+ result = result.replace(ytRegex, youtubeHost)
if prefs.replaceTwitter.len > 0:
+ let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result:
- result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
+ result = result.replace(tco, https & twitterHost & "/t.co")
if "x.com" in result:
- result = result.replace(xRegex, prefs.replaceTwitter)
+ result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a(
- prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
+ twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result:
- result = result.replace(cards, prefs.replaceTwitter & "/cards")
- result = result.replace(twRegex, prefs.replaceTwitter)
+ result = result.replace(cards, twitterHost & "/cards")
+ result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a(
- prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
+ twitterHost & "$2", href = https & twitterHost & "$1"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
- result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
- result = result.replace(rdRegex, prefs.replaceReddit)
- if prefs.replaceReddit in result and "/gallery/" in result:
+ let redditHost = strip(prefs.replaceReddit, chars={'/'})
+ result = result.replace(rdShortRegex, redditHost & "/comments/")
+ result = result.replace(rdRegex, redditHost)
+ if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result:
@@ -88,7 +91,17 @@ proc getM3u8Url*(content: string): string =
if re.find(content, m3u8Regex, matches) != -1:
result = matches[0]
-proc proxifyVideo*(manifest: string; proxy: bool): string =
+proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
+ let (baseUrl, basePath) =
+ if manifestUrl.len > 0:
+ let
+ u = parseUri(manifestUrl)
+ origin = u.scheme & "://" & u.hostname
+ idx = manifestUrl.rfind('/')
+ dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
+ (origin, dirPath)
+ else:
+ ("https://video.twimg.com", "")
var replacements: seq[(string, string)]
for line in manifest.splitLines:
let url =
@@ -96,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line
- if url.startsWith('/'):
- let path = "https://video.twimg.com" & url
- replacements.add (url, if proxy: path.getVidUrl else: path)
+ let resolved =
+ if url.startsWith('/'): baseUrl & url
+ elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
+ not url.startsWith("http") and ('.' in url): basePath & url
+ else: ""
+ if resolved.len > 0:
+ replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
return manifest.multiReplace(replacements)
proc getUserPic*(userPic: string; style=""): string =
@@ -123,25 +140,30 @@ proc pageDesc*(user: User): string =
"The latest tweets from " & user.fullname
proc getJoinDate*(user: User): string =
+ if user.joinDate.year == 0: return ""
user.joinDate.format("'Joined' MMMM YYYY")
proc getJoinDateFull*(user: User): string =
+ if user.joinDate.year == 0: return ""
user.joinDate.format("h:mm tt - d MMM YYYY")
proc getTime*(tweet: Tweet): string =
+ if tweet.time.year == 0: return ""
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
proc getRfc822Time*(tweet: Tweet): string =
+ if tweet.time.year == 0: return ""
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
-proc getShortTime*(tweet: Tweet): string =
+proc getShortTime*(time: DateTime): string =
+ if time.year == 0: return ""
let now = now()
- let since = now - tweet.time
+ let since = now - time
- if now.year != tweet.time.year:
- result = tweet.time.format("d MMM yyyy")
+ if now.year != time.year:
+ result = time.format("d MMM yyyy")
elif since.inDays >= 1:
- result = tweet.time.format("MMM d")
+ result = time.format("MMM d")
elif since.inHours >= 1:
result = $since.inHours & "h"
elif since.inMinutes >= 1:
@@ -151,13 +173,33 @@ proc getShortTime*(tweet: Tweet): string =
else:
result = "now"
+proc getShortTime*(tweet: Tweet): string =
+ getShortTime(tweet.time)
+
+proc getDuration*(ms: int): string =
+ let
+ sec = int(round(ms / 1000))
+ min = floorDiv(sec, 60)
+ hour = floorDiv(min, 60)
+ if hour > 0:
+ &"{hour}:{min mod 60:02}:{sec mod 60:02}"
+ else:
+ &"{min mod 60}:{sec mod 60:02}"
+
+proc getDuration*(video: Video): string =
+ getDuration(video.durationMs)
+
+proc getLink*(id: int64; username="i"; focus=true): string =
+ var username = username
+ if username.len == 0:
+ username = "i"
+ result = &"/{username}/status/{id}"
+ if focus: result &= "#m"
+
proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return
var username = tweet.user.username
- if username.len == 0:
- username = "i"
- result = &"/{username}/status/{tweet.id}"
- if focus: result &= "#m"
+ return getLink(tweet.id, username, focus)
proc getTwitterLink*(path: string; params: Table[string, string]): string =
var
@@ -185,7 +227,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string =
proc getLocation*(u: User | Tweet): (string, string) =
if "://" in u.location: return (u.location, "")
let loc = u.location.split(":")
- let url = if loc.len > 1: "/search?q=place:" & loc[1] else: ""
+ let url = if loc.len > 1: "/search?f=tweets&q=place:" & loc[1] else: ""
(loc[0], url)
proc getSuspended*(username: string): string =
diff --git a/src/http_pool.nim b/src/http_pool.nim
index 664e9a6..2553dd9 100644
--- a/src/http_pool.nim
+++ b/src/http_pool.nim
@@ -27,7 +27,7 @@ proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) =
proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient =
if pool.conns.len == 0:
- result = newAsyncHttpClient(headers=heads, proxy=proxy)
+ result = newAsyncHttpClient(userAgent="", headers=heads, proxy=proxy)
else:
result = pool.conns.pop()
result.headers = heads
diff --git a/src/nitter.nim b/src/nitter.nim
index f81dc1c..a76dda6 100644
--- a/src/nitter.nim
+++ b/src/nitter.nim
@@ -2,15 +2,15 @@
import asyncdispatch, strformat, logging
from net import Port
from htmlgen import a
-from os import getEnv
+from os import getEnv, normalizedPath
import jester
-import types, config, prefs, formatters, redis_cache, http_pool, auth
+import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about]
import routes/[
- preferences, timeline, status, media, search, rss, list, debug,
- unsupported, embed, resolver, router_utils]
+ preferences, timeline, status, media, search, rss, list, community, debug,
+ unsupported, embed, resolver, broadcast, space, article, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -34,39 +34,62 @@ stdout.flushFile
updateDefaultPrefs(fullCfg)
setCacheTimes(cfg)
setHmacKey(cfg.hmacKey)
+if cfg.hmacKey.len == 0 or cfg.hmacKey == "secretkey":
+ stderr.write "WARNING: insecure default 'hmacKey' in nitter.conf; " &
+ "set a unique random value to stop media URL signatures being forgeable.\n"
+ stderr.flushFile
setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth)
+setApiProxy(cfg.apiProxy)
+setDisableTid(cfg.disableTid)
+setMaxConcurrentReqs(cfg.maxConcurrentReqs)
+setMaxRetries(cfg.maxRetries)
+setRetryDelayMs(cfg.retryDelayMs)
initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile
+createArticleRouter(cfg)
createUnsupportedRouter(cfg)
createResolverRouter(cfg)
createPrefRouter(cfg)
createTimelineRouter(cfg)
createListRouter(cfg)
+createCommunityRouter(cfg)
createStatusRouter(cfg)
createSearchRouter(cfg)
createMediaRouter(cfg)
createEmbedRouter(cfg)
createRssRouter(cfg)
+createBroadcastRouter(cfg)
+createSpaceRouter(cfg)
createDebugRouter(cfg)
settings:
port = Port(cfg.port)
- staticDir = cfg.staticDir
+ staticDir = normalizedPath(cfg.staticDir)
bindAddr = cfg.address
reusePort = true
+ maxBody = 64 * 1024
routes:
+ before:
+ # Reject malformed paths
+ if request.path.len == 0 or request.path[0] != '/':
+ halt Http400
+
+ # skip all file URLs (except Twitter widget compatibility)
+ cond "." notin request.path or request.path == "/embed/Tweet.html"
+ applyUrlPrefs()
+
get "/":
- resp renderMain(renderSearch(), request, cfg, themePrefs())
+ resp renderMain(renderSearch(), request, cfg, requestPrefs())
get "/about":
- resp renderMain(renderAbout(), request, cfg, themePrefs())
+ resp renderMain(renderAbout(), request, cfg, requestPrefs())
get "/explore":
redirect("/about")
@@ -77,7 +100,7 @@ routes:
get "/i/redirect":
let url = decodeUrl(@"url")
if url.len == 0: resp Http404
- redirect(replaceUrls(url, cookiePrefs()))
+ redirect(replaceUrls(url, requestPrefs()))
error Http404:
resp Http404, showError("Page not found", cfg)
@@ -102,14 +125,18 @@ routes:
resp Http429, showError(
&"Instance has no auth tokens, or is fully rate limited.
Use {link} or try again later.", cfg)
+ extend articleRoute, ""
extend rss, ""
extend status, ""
extend search, ""
extend timeline, ""
extend media, ""
extend list, ""
+ extend community, ""
extend preferences, ""
extend resolver, ""
extend embed, ""
+ extend broadcastRoute, ""
+ extend spaceRoute, ""
extend debug, ""
extend unsupported, ""
diff --git a/src/parser.nim b/src/parser.nim
index 5bf2b0b..eebca2d 100644
--- a/src/parser.nim
+++ b/src/parser.nim
@@ -1,10 +1,20 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, options, times, math, tables
+import strutils, options, times, math, tables, uri
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/unifiedcard
-proc parseGraphTweet(js: JsonNode): Tweet
+proc parseGraphTweet*(js: JsonNode): Tweet
+
+proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType =
+ try: parseEnum[VerifiedType](s)
+ except ValueError: current
+
+proc parseCommunityNote(js: JsonNode): string =
+ let subtitle = js{"subtitle"}
+ result = subtitle{"text"}.getStr
+ with entities, subtitle{"entities"}:
+ result = expandBirdwatchEntities(result, entities)
proc parseUser(js: JsonNode; id=""): User =
if js.isNull: return
@@ -21,7 +31,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt,
- protected: js{"protected"}.getBool,
+ protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
joinDate: js{"created_at"}.getTime
)
@@ -29,17 +39,17 @@ proc parseUser(js: JsonNode; id=""): User =
result.verifiedType = blue
with verifiedType, js{"verified_type"}:
- result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
+ result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"}
if user.isNull:
- user = ? js{"user_results", "result"}
+ user = js{"user_results", "result"}
if user.isNull:
- if js{"core"}.notNull and js{"legacy"}.notNull:
+ if js{"core"}.notNull:
user = js
else:
return
@@ -51,15 +61,164 @@ proc parseGraphUser(js: JsonNode): User =
# fallback to support UserMedia/recent GraphQL updates
if result.username.len == 0:
+ result.id = user{"rest_id"}.getStr
result.username = user{"core", "screen_name"}.getStr
result.fullname = user{"core", "name"}.getStr
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
- if user{"is_blue_verified"}.getBool(false):
+ if user{"is_blue_verified"}.getBool(
+ user{"verification", "is_blue_verified"}.getBool(false)):
result.verifiedType = blue
with verifiedType, user{"verification", "verified_type"}:
- result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
+ result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
+
+proc parseAboutAccount*(js: JsonNode): AccountInfo =
+ if js.isNull: return
+
+ let user = ? js{"data", "user_result_by_screen_name", "result"}
+
+ if user{"unavailable_reason"}.getStr == "Suspended":
+ result.suspended = true
+ return
+
+ result = AccountInfo(
+ username: user{"core", "screen_name"}.getStr,
+ fullname: user{"core", "name"}.getStr,
+ joinDate: user{"core", "created_at"}.getTime,
+ userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
+ affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
+ )
+
+ if user{"is_blue_verified"}.getBool(false):
+ result.verifiedType = blue
+ with verifiedType, user{"verification", "verified_type"}:
+ result.verifiedType = parseVerifiedType(verifiedType.getStr, result.verifiedType)
+
+ with about, user{"about_profile"}:
+ result.basedIn = about{"account_based_in"}.getStr
+ result.source = about{"source"}.getStr
+ result.affiliateUsername = about{"affiliate_username"}.getStr
+
+ try:
+ result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
+ except ValueError:
+ discard
+
+ with lastChange, about{"username_changes", "last_changed_at_msec"}:
+ result.lastUsernameChange = lastChange.getTimeFromMsStr
+
+ with info, user{"verification_info"}:
+ result.isIdentityVerified = info{"is_identity_verified"}.getBool
+ with reason, info{"reason"}:
+ result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
+ with since, reason{"verified_since_msec"}:
+ result.verifiedSince = since.getTimeFromMsStr
+
+proc parseBroadcastInfo*(js: JsonNode): Broadcast =
+ let bc = ? js{"data", "broadcast"}
+ result = Broadcast(
+ id: bc{"broadcast_id"}.getStr,
+ title: bc{"status"}.getStr,
+ state: bc{"state"}.getStr.toUpperAscii,
+ thumb: bc{"image_url"}.getStr,
+ mediaKey: bc{"media_key"}.getStr,
+ totalWatched: bc{"total_watched"}.getInt,
+ startTime: bc{"start_time"}.getTimeFromMs,
+ endTime: bc{"end_time"}.getTimeFromMs,
+ replayStart: bc{"edited_replay", "start_time"}.getInt,
+ availableForReplay: bc{"available_for_replay"}.getBool,
+ user: parseGraphUser(bc)
+ )
+
+proc parseSpaceParticipant(js: JsonNode): SpaceParticipant =
+ result = SpaceParticipant(
+ userId: js{"user_results", "rest_id"}.getStr,
+ username: js{"twitter_screen_name"}.getStr,
+ displayName: js{"display_name"}.getStr,
+ avatarUrl: js{"avatar_url"}.getStr,
+ isVerified: js{"is_verified"}.getBool or
+ js{"user_results", "result", "is_blue_verified"}.getBool
+ )
+
+proc parseAudioSpace*(js: JsonNode): AudioSpace =
+ let space = ? js{"data", "audioSpace"}
+ let meta = space{"metadata"}
+
+ result = AudioSpace(
+ id: meta{"rest_id"}.getStr,
+ title: meta{"title"}.getStr,
+ state: meta{"state"}.getStr.toUpperAscii,
+ mediaKey: meta{"media_key"}.getStr,
+ totalLiveListeners: meta{"total_live_listeners"}.getInt,
+ totalReplayWatched: meta{"total_replay_watched"}.getInt,
+ availableForReplay: meta{"is_space_available_for_replay"}.getBool
+ )
+
+ let startedAt = meta{"started_at"}.getInt(0)
+ if startedAt > 0:
+ result.startTime = fromUnix(startedAt div 1000).utc()
+
+ let endedAtStr = meta{"ended_at"}.getStr
+ if endedAtStr.len > 0:
+ try:
+ let endedAt = parseBiggestInt(endedAtStr)
+ if endedAt > 0:
+ result.endTime = fromUnix(endedAt div 1000).utc()
+ except ValueError:
+ discard
+
+ result.creator = parseGraphUser(meta{"creator_results", "result"})
+
+ for admin in space{"participants", "admins"}:
+ result.admins.add parseSpaceParticipant(admin)
+
+ for speaker in space{"participants", "speakers"}:
+ result.speakers.add parseSpaceParticipant(speaker)
+
+proc parseGraphCommunity*(js: JsonNode): Community =
+ if js.isNull: return
+ let c = ? js{"data", "communityResults", "result"}
+
+ result = Community(
+ id: c{"rest_id"}.getStr(c{"id_str"}.getStr),
+ name: c{"name"}.getStr,
+ description: c{"description"}.getStr,
+ memberCount: c{"member_count"}.getInt,
+ joinPolicy: c{"join_policy"}.getStr,
+ category: c{"primary_community_topic", "topic_name"}.getStr,
+ banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr,
+ creator: parseGraphUser(c{"creator_results", "result"}),
+ )
+
+ let createdMs = c{"created_at"}.getInt(0)
+ if createdMs > 0:
+ result.createdAt = fromUnix(createdMs div 1000).utc()
+
+ for rule in c{"rules"}:
+ result.rules.add CommunityRule(
+ name: rule{"name"}.getStr,
+ description: rule{"description"}.getStr
+ )
+
+ for item in c{"trending_hashtags_slice", "items"}:
+ let tag = item{"hashtag"}.getStr
+ if tag.len > 0:
+ result.hashtags.add tag
+
+proc parseListObject(js: JsonNode; owner: User): List =
+ List(
+ id: js{"id_str"}.getStr,
+ name: js{"name"}.getStr,
+ username: owner.username,
+ userId: owner.id,
+ description: js{"description"}.getStr,
+ members: js{"member_count"}.getInt,
+ banner: select(
+ js{"custom_banner_media", "media_info", "original_img_url"},
+ js{"default_banner_media", "media_info", "original_img_url"}
+ ).getImageStr
+ )
proc parseGraphList*(js: JsonNode): List =
if js.isNull: return
@@ -70,15 +229,17 @@ proc parseGraphList*(js: JsonNode): List =
if list.isNull:
return
- result = List(
- id: list{"id_str"}.getStr,
- name: list{"name"}.getStr,
- username: list{"user_results", "result", "legacy", "screen_name"}.getStr,
- userId: list{"user_results", "result", "rest_id"}.getStr,
- description: list{"description"}.getStr,
- members: list{"member_count"}.getInt,
- banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr
+ result = parseListObject(list, parseGraphUser(list))
+
+proc parseGraphSearchList(js: JsonNode): ListSearchResult =
+ let owner = parseGraphUser(js)
+ result = ListSearchResult(
+ list: parseListObject(js, owner),
+ owner: owner,
+ followersContext: js{"followers_context"}.getStr
)
+ for url in js{"facepile_urls"}:
+ result.facepiles.add url.getStr
proc parsePoll(js: JsonNode): Poll =
let vals = js{"binding_values"}
@@ -134,60 +295,85 @@ proc parseVideo(js: JsonNode): Video =
result.variants = parseVideoVariants(js{"video_info", "variants"})
+proc addMedia(media: var MediaEntities; photo: Photo) =
+ media.add Media(kind: photoMedia, photo: photo)
+
+proc addMedia(media: var MediaEntities; video: Video) =
+ media.add Media(kind: videoMedia, video: video)
+
+proc addMedia(media: var MediaEntities; gif: Gif) =
+ media.add Media(kind: gifMedia, gif: gif)
+
proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
with jsMedia, js{"extended_entities", "media"}:
for m in jsMedia:
case m.getTypeName:
of "photo":
- result.photos.add m{"media_url_https"}.getImageStr
+ result.media.addMedia(Photo(
+ url: m{"media_url_https"}.getImageStr,
+ altText: m{"ext_alt_text"}.getStr
+ ))
of "video":
- result.video = some(parseVideo(m))
+ result.media.addMedia(parseVideo(m))
with user, m{"additional_media_info", "source_user"}:
if user{"id"}.getInt > 0:
result.attribution = some(parseUser(user))
else:
result.attribution = some(parseGraphUser(user))
+ # Set attribution link from expanded_url (strip /video/N suffix)
+ let expanded = m{"expanded_url"}.getStr
+ if expanded.len > 0:
+ result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
of "animated_gif":
- result.gif = some Gif(
+ result.media.addMedia(Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
- thumb: m{"media_url_https"}.getImageStr
- )
+ thumb: m{"media_url_https"}.getImageStr,
+ altText: m{"ext_alt_text"}.getStr
+ ))
else: discard
- with url, m{"url"}:
- if result.text.endsWith(url.getStr):
- result.text.removeSuffix(url.getStr)
- result.text = result.text.strip()
-
proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}:
+ var parsedMedia: MediaEntities
for mediaEntity in mediaEntities:
with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
case mediaInfo.getTypeName
of "ApiImage":
- result.photos.add mediaInfo{"original_img_url"}.getImageStr
+ parsedMedia.addMedia(Photo(
+ url: mediaInfo{"original_img_url"}.getImageStr,
+ altText: mediaInfo{"alt_text"}.getStr
+ ))
of "ApiVideo":
let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
- result.video = some Video(
+ parsedMedia.addMedia(Video(
available: status.getStr == "Available",
thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
+ title: mediaInfo{"alt_text"}.getStr,
durationMs: mediaInfo{"duration_millis"}.getInt,
variants: parseVideoVariants(mediaInfo{"variants"})
- )
+ ))
+
+ # Parse source user for video attribution
+ with sourceUser, mediaEntity{"source_user_results", "result"}:
+ if result.attribution.isNone:
+ let expanded = mediaEntity{"expanded_url"}.getStr
+ if expanded.len > 0:
+ result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
+ result.attribution = some(User(
+ id: sourceUser{"rest_id"}.getStr,
+ fullname: sourceUser{"core", "name"}.getStr,
+ userPic: sourceUser{"avatar", "image_url"}.getImageStr.replace("_normal", "")
+ ))
of "ApiGif":
- result.gif = some Gif(
+ parsedMedia.addMedia(Gif(
url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
- thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
- )
+ thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
+ altText: mediaInfo{"alt_text"}.getStr
+ ))
else: discard
- # Remove media URLs from text
- with mediaList, js{"legacy", "entities", "media"}:
- for url in mediaList:
- let expandedUrl = url{"expanded_url"}.getStr
- if result.text.endsWith(expandedUrl):
- result.text.removeSuffix(expandedUrl)
- result.text = result.text.strip()
+ if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
+ result.media = parsedMedia
proc parsePromoVideo(js: JsonNode): Video =
result = Video(
@@ -210,14 +396,23 @@ proc parsePromoVideo(js: JsonNode): Video =
result.variants.add variant
proc parseBroadcast(js: JsonNode): Card =
- let image = js{"broadcast_thumbnail_large"}.getImageVal
+ let
+ image = js{"broadcast_thumbnail_large"}.getImageVal
+ broadcastUrl = js{"broadcast_url"}.getStrVal
+ broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
+ streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
result = Card(
kind: broadcast,
- url: js{"broadcast_url"}.getStrVal,
+ url: "/i/broadcasts/" & broadcastId,
title: js{"broadcaster_display_name"}.getStrVal,
text: js{"broadcast_title"}.getStrVal,
image: image,
- video: some Video(thumb: image)
+ video: some Video(
+ thumb: image,
+ available: true,
+ playbackType: m3u8,
+ variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
+ )
)
proc parseCard(js: JsonNode; urls: JsonNode): Card =
@@ -256,7 +451,13 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url = vals{"player_url"}.getStrVal
if "youtube.com" in result.url:
result.url = result.url.replace("/embed/", "/watch?v=")
- of audiospace, unknown:
+ of audiospace:
+ let spaceId = vals{"id"}.getStrVal
+ if spaceId.len > 0:
+ result.url = "/i/spaces/" & spaceId
+ result.title = "Twitter Space"
+ result.text = "Click to view Space"
+ of unknown:
result.title = "This card type is not supported."
else: discard
@@ -267,7 +468,7 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
for u in ? urls:
if u{"url"}.getStr == result.url:
- result.url = u{"expanded_url"}.getStr
+ result.url = u.getExpandedUrl(result.url)
break
if kind in {videoDirectMessage, imageDirectMessage}:
@@ -277,8 +478,9 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
result.url.len == 0 or result.url.startsWith("card://"):
result.url = getPicUrl(result.image)
-proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
- if js.isNull: return
+proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
+ replyId: int64 = 0; hasArticle = false): Tweet =
+ if js.isNull: return Tweet()
let time =
if js{"created_at"}.notNull: js{"created_at"}.getTime
@@ -301,6 +503,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
)
)
+ if result.replyId == 0:
+ result.replyId = replyId
+
# fix for pinned threads
if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId
@@ -318,13 +523,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
# graphql
with rt, js{"retweeted_status_result", "result"}:
# needed due to weird edgecase where the actual tweet data isn't included
- if "legacy" in rt:
+ if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt)
return
with reposts, js{"repostedStatusResults"}:
with rt, reposts{"result"}:
- if "legacy" in rt:
+ if "legacy" in rt or "rest_id" in rt:
result.retweet = some parseGraphTweet(rt)
return
@@ -332,15 +537,17 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr
if "poll" in name:
if "image" in name:
- result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
+ result.media.addMedia(Photo(
+ url: jsCard{"binding_values", "image_large"}.getImageVal
+ ))
result.poll = some parsePoll(jsCard)
elif name == "amplify":
- result.video = some(parsePromoVideo(jsCard{"binding_values"}))
- else:
+ result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
+ elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"entities", "urls"})
- result.expandTweetEntities(js)
+ result.expandTweetEntities(js, hasArticle)
parseLegacyMediaEntities(js, result)
with jsWithheld, js{"withheld_in_countries"}:
@@ -357,7 +564,7 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.")
result.available = false
-proc parseGraphTweet(js: JsonNode): Tweet =
+proc parseGraphTweet*(js: JsonNode): Tweet =
if js.kind == JNull:
return Tweet()
@@ -375,7 +582,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
else:
discard
- if not js.hasKey("legacy"):
+ if "legacy" notin js and "rest_id" notin js:
return Tweet()
var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"})
@@ -394,12 +601,80 @@ proc parseGraphTweet(js: JsonNode): Tweet =
"binding_values": %bindingObj
}
- result = parseTweet(js{"legacy"}, jsCard)
- result.id = js{"rest_id"}.getId
+ var replyId: int64 = 0
+ with restId, js{"reply_to_results", "rest_id"}:
+ replyId = restId.getId
+
+ let hasArticle = js{"article", "article_results", "result", "title"}.getStr.len > 0
+
+ if "details" in js:
+ result = Tweet(
+ id: js{"rest_id"}.getId,
+ available: true,
+ text: js{"details", "full_text"}.getStr,
+ time: js{"details", "created_at_ms"}.getTimeFromMs,
+ replyId: js{"reply_to_results", "rest_id"}.getId,
+ isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool,
+ isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool,
+ stats: TweetStats(
+ replies: js{"counts", "reply_count"}.getInt,
+ retweets: js{"counts", "retweet_count"}.getInt,
+ likes: js{"counts", "favorite_count"}.getInt,
+ )
+ )
+
+ if jsCard.kind != JNull:
+ let name = jsCard{"name"}.getStr
+ if "poll" in name:
+ if "image" in name:
+ result.media.addMedia(Photo(
+ url: jsCard{"binding_values", "image_large"}.getImageVal
+ ))
+
+ result.poll = some parsePoll(jsCard)
+ elif name == "amplify":
+ result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
+ elif name.len > 0 and jsCard{"binding_values"}.notNull:
+ result.card = some parseCard(jsCard, js{"url_entities"})
+
+ parseMediaEntities(js, result)
+ if result.attribution.isNone:
+ parseLegacyMediaEntities(js{"legacy"}, result)
+
+ result.expandTweetEntitiesV2(js, hasArticle)
+
+ # Strip video source URL from text (for videos from other tweets)
+ with mediaEntities, js{"media_entities"}:
+ for m in mediaEntities:
+ if "source_status_id_str" in m:
+ let mediaUrl = m{"url"}.getStr
+ if mediaUrl.len > 0:
+ let idx = result.text.rfind(mediaUrl)
+ if idx >= 0:
+ result.text = result.text[0 ..< idx].strip()
+ break
+ else:
+ result = parseTweet(js{"legacy"}, jsCard, replyId, hasArticle)
+ result.id = js{"rest_id"}.getId
+
+ with artNode, js{"article", "article_results", "result"}:
+ let artTitle = artNode{"title"}.getStr
+ if artTitle.len > 0:
+ result.articlePreview = some ArticlePreview(
+ title: artTitle,
+ previewText: artNode{"preview_text"}.getStr,
+ coverImage: select(
+ artNode{"cover_media_results", "result", "media_info", "original_img_url"},
+ artNode{"cover_media", "media_info", "original_img_url"}
+ ).getImageStr,
+ tweetId: result.id
+ )
+
result.user = parseGraphUser(js{"core"})
- if result.replyId == 0:
- result.replyId = js{"reply_to_results", "rest_id"}.getId
+ if result.reply.len == 0:
+ with replyTo, js{"reply_to_user_results", "result", "core", "screen_name"}:
+ result.reply = @[replyTo.getStr]
with count, js{"views", "count"}:
result.stats.views = count.getStr("0").parseInt
@@ -409,21 +684,58 @@ proc parseGraphTweet(js: JsonNode): Tweet =
parseMediaEntities(js, result)
- if result.quote.isSome:
- result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}))
+ # Hide card if it's redundant with attribution (same video shown via embed)
+ if result.attribution.isSome and result.card.isSome:
+ let cardUri = get(result.card).url.parseUri
+ if cardUri.isTwitterUrl:
+ let cardPath = cardUri.path.replace("/video/1", "")
+ if cardPath.len > 0 and cardPath == result.attributionLink:
+ get(result.card).kind = hidden
- with quoted, js{"quotedPostResults", "result"}:
+ # Handle retweets - check both legacy and top-level paths
+ with reposts, js{"legacy", "repostedStatusResults"}:
+ with rt, reposts{"result"}:
+ if "legacy" in rt or "rest_id" in rt:
+ result.retweet = some parseGraphTweet(rt)
+
+ with quoted, js{"quoted_status_result", "result"}:
result.quote = some(parseGraphTweet(quoted))
+ with quoted, js{"quotedPostResults"}:
+ if "result" in quoted:
+ result.quote = some(parseGraphTweet(quoted{"result"}))
+ else:
+ result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId)
+
+ with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}:
+ for id in ids:
+ result.history.add parseBiggestInt(id.getStr)
+
+ with birdwatch, js{"birdwatch_pivot"}:
+ result.note = parseCommunityNote(birdwatch)
+
+proc getConvSection(js: JsonNode): string =
+ let details = select(
+ js{"item", "client_event_info", "details"},
+ js{"item", "clientEventInfo", "details"}
+ )
+ select(
+ details{"conversation_details", "conversation_section"},
+ details{"conversationDetails", "conversationSection"}
+ ).getStr
+
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
+ var checkedSection = false
for t in ? js{"content", "items"}:
let entryId = t.getEntryId
- if "cursor-showmore" in entryId:
- let cursor = t{"item", "content", "value"}
- result.thread.cursor = cursor.getStr
- result.thread.hasMore = true
- elif "tweet" in entryId and "promoted" notin entryId:
- with tweet, t.getTweetResult("item"):
+ if "tweet-" in entryId and "promoted" notin entryId:
+ if not checkedSection:
+ checkedSection = true
+ if getConvSection(t) == "RelatedTweet":
+ result.thread.related = true
+
+ let tweet = t.getTweetResult("item")
+ if tweet.notNull:
result.thread.content.add parseGraphTweet(tweet)
let tweetDisplayType = select(
@@ -432,11 +744,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
)
if tweetDisplayType.getStr == "SelfThread":
result.self = true
+ else:
+ result.thread.content.add Tweet(id: entryId.getId)
+ elif "cursor-showmore" in entryId:
+ let cursor = t{"item", "content", "value"}
+ result.thread.cursor = cursor.getStr
+ result.thread.hasMore = true
proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweet_result", "result"}:
result = parseGraphTweet(tweet)
+proc parseTweetByRestId*(js: JsonNode): Tweet =
+ with tweet, js{"data", "tweetResult", "result"}:
+ result = parseGraphTweet(tweet)
+
+proc parseGraphTweetResults*(js: JsonNode): seq[Tweet] =
+ let results = js{"data", "tweetResult"}
+ if results.kind != JArray: return
+ for item in results:
+ let tweet = item{"result"}
+ if tweet.isNull: continue
+ let t = parseGraphTweet(tweet)
+ if t != nil:
+ result.add t
+
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result = Conversation(replies: Result[Chain](beginning: true))
@@ -452,7 +784,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if i.getTypeName == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e.getEntryId
- if entryId.startsWith("tweet"):
+ if entryId.startsWith("tweet-"):
let tweetResult = getTweetResult(e)
if tweetResult.notNull:
let tweet = parseGraphTweet(tweetResult)
@@ -460,11 +792,14 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if not tweet.available:
tweet.id = entryId.getId
- if $tweet.id == tweetId:
+ if entryId.endsWith(tweetId):
result.tweet = tweet
else:
result.before.content.add tweet
- elif entryId.startsWith("conversationthread"):
+ elif not entryId.endsWith(tweetId):
+ result.before.content.add Tweet(id: entryId.getId)
+ elif entryId.startsWith("conversationthread") or
+ entryId.startsWith("tweetdetailrelatedtweets"):
let (thread, self) = parseGraphThread(e)
if self:
result.after = thread
@@ -491,20 +826,54 @@ 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):
- var tweet = parseGraphTweet(tweetResult)
+ let tweet = parseGraphTweet(tweetResult)
if not tweet.available:
tweet.id = e.getEntryId.getId
result.add tweet
return
- for item in e{"content", "items"}:
- with tweetResult, item.getTweetResult("item"):
- var tweet = parseGraphTweet(tweetResult)
- if not tweet.available:
- tweet.id = item.getEntryId.getId
- result.add tweet
+ for tweet in extractTweetsFromModuleItems(e{"content", "items"}):
+ result.add tweet
proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0))
@@ -519,12 +888,8 @@ proc parseGraphTimeline*(js: JsonNode; after=""): Profile =
for i in instructions:
if i{"moduleItems"}.notNull:
- for item in i{"moduleItems"}:
- with tweetResult, item.getTweetResult("item"):
- let tweet = parseGraphTweet(tweetResult)
- if not tweet.available:
- tweet.id = item.getEntryId.getId
- result.tweets.content.add tweet
+ for tweet in extractTweetsFromModuleItems(i{"moduleItems"}):
+ result.tweets.content.add tweet
continue
if i{"entries"}.notNull:
@@ -559,18 +924,13 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
for i in instructions:
if i{"moduleItems"}.notNull:
- for item in i{"moduleItems"}:
- with tweetResult, item.getTweetResult("item"):
- let t = parseGraphTweet(tweetResult)
- if not t.available:
- t.id = item.getEntryId.getId
+ for t in extractTweetsFromModuleItems(i{"moduleItems"}):
+ let photo = extractGalleryPhoto(t)
+ if photo.url.len > 0:
+ result.add photo
- let photo = extractGalleryPhoto(t)
- if photo.url.len > 0:
- result.add photo
-
- if result.len == 16:
- return
+ if result.len == 16:
+ return
continue
if i.getTypeName != "TimelineAddEntries":
@@ -587,7 +947,7 @@ proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
if result.len == 16:
return
-proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
+proc parseGraphSearch*[T: User | Tweets | ListSearchResult](js: JsonNode; after=""): Result[T] =
result = Result[T](beginning: after.len == 0)
let instructions = select(
@@ -603,19 +963,73 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
for e in instruction{"entries"}:
let entryId = e.getEntryId
when T is Tweets:
- if entryId.startsWith("tweet"):
- with tweetRes, getTweetResult(e):
- let tweet = parseGraphTweet(tweetRes)
- if not tweet.available:
- tweet.id = entryId.getId
+ if entryId.startsWith("tweet") or entryId.startsWith("search-grid"):
+ for tweet in extractTweetsFromEntry(e):
result.content.add tweet
elif T is User:
if entryId.startsWith("user"):
with userRes, e{"content", "itemContent"}:
result.content.add parseGraphUser(userRes)
+ elif T is ListSearchResult:
+ if entryId.startsWith("list-search"):
+ for list in extractListsFromItems(e{"content", "items"}):
+ result.content.add list
if entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr
+ elif typ == "TimelineAddToModule":
+ when T is Tweets:
+ for tweet in extractTweetsFromModuleItems(instruction{"moduleItems"}):
+ result.content.add tweet
+ elif T is ListSearchResult:
+ for list in extractListsFromItems(instruction{"moduleItems"}):
+ result.content.add list
elif typ == "TimelineReplaceEntry":
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
result.bottom = instruction{"entry", "content", "value"}.getStr
+
+proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline =
+ result = Timeline(beginning: after.len == 0)
+
+ let communityResult = js{"data", "communityResults", "result"}
+ let instructions = ? select(
+ communityResult{"ranked_community_timeline", "timeline", "instructions"},
+ communityResult{"community_media_timeline", "timeline", "instructions"},
+ communityResult{"community_filtered_timeline", "timeline", "instructions"}
+ )
+ if instructions.len == 0:
+ return
+
+ for i in instructions:
+ if i{"entries"}.notNull:
+ for e in i{"entries"}:
+ let entryId = e.getEntryId
+ if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or
+ entryId.startsWith("communities-grid"):
+ for tweet in extractTweetsFromEntry(e):
+ result.content.add tweet
+ elif entryId.startsWith("cursor-bottom"):
+ result.bottom = e{"content", "value"}.getStr
+
+ if after.len == 0 and i.getTypeName == "TimelinePinEntry":
+ var tweets = extractTweetsFromEntry(i{"entry"})
+ for tweet in tweets.mitems:
+ tweet.pinned = true
+ if tweets.len > 0:
+ result.content.insert(tweets, 0)
+
+proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] =
+ result = Result[User](beginning: after.len == 0)
+
+ let r = js{"data", "communityResults", "result"}
+ let slice = if not r{"members_slice"}.isNull: r{"members_slice"}
+ else: r{"moderators_slice"}
+ for item in slice{"items_results"}:
+ let user = parseGraphUser(item{"result"})
+ if user.username.len > 0:
+ result.content.add user
+
+ let cursor = slice{"slice_info", "next_cursor"}.getStr
+ if cursor.len > 0:
+ result.bottom = cursor
+
diff --git a/src/parserutils.nim b/src/parserutils.nim
index 72c50e1..4b8e0b4 100644
--- a/src/parserutils.nim
+++ b/src/parserutils.nim
@@ -17,7 +17,7 @@ let
unReplace = "$1@$2"
htRegex = re"(^|[^\w-_./?])([#$]|#)([\w_]+)"
- htReplace = "$1$2$3"
+ htReplace = "$1$2$3"
type
ReplaceSliceKind = enum
@@ -72,7 +72,6 @@ template getTypeName*(js: JsonNode): string =
template getEntryId*(e: JsonNode): string =
e{"entryId"}.getStr(e{"entry_id"}.getStr)
-
template parseTime(time: string; f: static string; flen: int): DateTime =
if time.len != flen: return
parse(time, f, utc())
@@ -89,11 +88,19 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
let seconds = ms div 1000
return fromUnix(seconds).utc()
+proc getTimeFromMsStr*(js: JsonNode): DateTime =
+ var ms: int64
+ try: ms = parseBiggestInt(js.getStr("0"))
+ except ValueError: return
+ if ms == 0: return
+ let seconds = ms div 1000
+ return fromUnix(seconds).utc()
+
proc getId*(id: string): int64 {.inline.} =
let start = id.rfind("-")
- if start < 0:
- return parseBiggestInt(id)
- return parseBiggestInt(id[start + 1 ..< id.len])
+ try:
+ parseBiggestInt(if start < 0: id else: id[start + 1 ..< id.len])
+ except ValueError: 0'i64
proc getId*(js: JsonNode): int64 {.inline.} =
case js.kind
@@ -112,6 +119,9 @@ proc getImageStr*(js: JsonNode): string =
template getImageVal*(js: JsonNode): string =
js{"image_value", "url"}.getImageStr
+template getExpandedUrl*(js: JsonNode; fallback=""): string =
+ js{"expanded_url"}.getStr(js{"url"}.getStr(fallback))
+
proc getCardUrl*(js: JsonNode; kind: CardKind): string =
result = js{"website_url"}.getStrVal
if kind == promoVideoConvo:
@@ -175,12 +185,16 @@ proc extractSlice(js: JsonNode): Slice[int] =
result = js["indices"][0].getInt ..< js["indices"][1].getInt
proc extractUrls(result: var seq[ReplaceSlice]; js: JsonNode;
- textLen: int; hideTwitter = false) =
+ textLen: int; hideTwitter = false;
+ hideArticle = false) =
let
- url = js["expanded_url"].getStr
+ url = js.getExpandedUrl
slice = js.extractSlice
- if hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
+ if hideArticle and url.isTwitterUrl and "/article/" in url:
+ if slice.a < textLen:
+ result.add ReplaceSlice(kind: rkRemove, slice: slice)
+ elif hideTwitter and slice.b.succ >= textLen and url.isTwitterUrl:
if slice.a < textLen:
result.add ReplaceSlice(kind: rkRemove, slice: slice)
else:
@@ -192,28 +206,41 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
textSlice: Slice[int]): string =
+ let
+ runeLen = runes.len
+ safeStart = max(0, textSlice.a)
+ safeEnd = min(runeLen, textSlice.b)
+
+ var validRepls: seq[ReplaceSlice]
+ for rep in repls:
+ if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
+ validRepls.add rep
+
template extractLowerBound(i: int; idx): int =
- if i > 0: repls[idx].slice.b.succ else: textSlice.a
+ if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
result = newStringOfCap(runes.len)
- for i, rep in repls:
- result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a]
+ for i, rep in validRepls:
+ let lower = extractLowerBound(i, i - 1)
+ if lower < rep.slice.a:
+ result.add $runes[lower ..< rep.slice.a]
case rep.kind
of rkHashtag:
- let
- name = $runes[rep.slice.a.succ .. rep.slice.b]
- symbol = $runes[rep.slice.a]
- result.add a(symbol & name, href = "/search?q=%23" & name)
+ if rep.slice.a.succ <= rep.slice.b:
+ let
+ name = $runes[rep.slice.a.succ .. rep.slice.b]
+ symbol = $runes[rep.slice.a]
+ result.add a(symbol & name, href = "/search?f=tweets&q=%23" & name)
of rkMention:
- result.add a($runes[rep.slice], href = rep.url, title = rep.display)
+ result.add a($runes[rep.slice], href = rep.url, title = escape(rep.display))
of rkUrl:
- result.add a(rep.display, href = rep.url)
+ result.add a(escape(rep.display), href = rep.url)
of rkRemove:
discard
- let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b
- if rest.a <= rest.b:
+ let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
+ if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
result.add $runes[rest]
proc deduplicate(s: var seq[ReplaceSlice]) =
@@ -238,7 +265,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
ent = ? js{"entities"}
with urls, ent{"url", "urls"}:
- user.website = urls[0]{"expanded_url"}.getStr
+ user.website = urls[0].getExpandedUrl
var replacements = newSeq[ReplaceSlice]()
@@ -254,7 +281,7 @@ proc expandUserEntities*(user: var User; js: JsonNode) =
.replacef(htRegex, htReplace)
proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlice: Slice[int];
- replyTo=""; hasRedundantLink=false) =
+ replyTo=""; hasRedundantLink=false; hasArticle=false) =
let hasCard = tweet.card.isSome
var replacements = newSeq[ReplaceSlice]()
@@ -265,10 +292,11 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
if urlStr.len == 0 or urlStr notin text:
continue
- replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink)
+ replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
+ hideArticle = hasArticle)
if hasCard and u{"url"}.getStr == get(tweet.card).url:
- get(tweet.card).url = u{"expanded_url"}.getStr
+ get(tweet.card).url = u.getExpandedUrl
with media, entities{"media"}:
for m in media:
@@ -302,7 +330,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
-proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
+proc expandTweetEntities*(tweet: Tweet; js: JsonNode; hasArticle=false) =
let
entities = ? js{"entities"}
textRange = js{"display_text_range"}
@@ -316,23 +344,99 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
replyTo = reply.getStr
tweet.reply.add replyTo
- tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard)
+ tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
+ hasQuote or hasJobCard, hasArticle)
+
+proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
+ hasRedundantLink=false; hasArticle=false) =
+ let hasCard = tweet.card.isSome
+
+ var replacements = newSeq[ReplaceSlice]()
+
+ with urls, js{"url_entities"}:
+ for u in urls:
+ let urlStr = u["url"].getStr
+ if urlStr.len == 0 or urlStr notin text:
+ continue
+
+ replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink,
+ hideArticle = hasArticle)
+
+ if hasCard and u{"url"}.getStr == get(tweet.card).url:
+ get(tweet.card).url = u.getExpandedUrl
+
+ with hashtags, js{"details", "hashtag_entities"}:
+ for hashtag in hashtags:
+ replacements.extractHashtags(hashtag)
+
+ with cashtags, js{"details", "cashtag_entities"}:
+ for cashtag in cashtags:
+ replacements.extractHashtags(cashtag)
+
+ with mentions, js{"mention_entities"}:
+ for mention in mentions:
+ let
+ name = mention{"screen_name"}.getStr
+ slice = mention.extractSlice
+ idx = tweet.reply.find(name)
+
+ if slice.a >= textSlice.a:
+ replacements.add ReplaceSlice(kind: rkMention, slice: slice,
+ url: "/" & name, display: mention["name"].getStr)
+ elif idx == -1 and tweet.replyId != 0:
+ tweet.reply.add name
+
+ replacements.deduplicate
+ replacements.sort(cmp)
+
+ tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false)
+
+proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode; hasArticle=false) =
+ let
+ textRange = js{"details", "display_text_range"}
+ textSlice = textRange{0}.getInt .. textRange{1}.getInt
+ hasQuote = "quoted_tweet_results" in js
+ hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
+ hasAttribution = tweet.attribution.isSome
+
+ tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
+ hasQuote or hasJobCard or hasAttribution,
+ hasArticle)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let
entities = ? js{"entity_set"}
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
textSlice = 0..text.runeLen
+ hasAttribution = tweet.attribution.isSome
- tweet.expandTextEntities(entities, text, textSlice)
+ tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
+proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
+ let runes = text.toRunes
+ var replacements: seq[ReplaceSlice]
+
+ for entity in entities:
+ let
+ fromIdx = entity{"from_index"}.getInt
+ toIdx = entity{"to_index"}.getInt
+ url = entity{"ref", "url"}.getStr
+ if url.len > 0:
+ replacements.add ReplaceSlice(
+ kind: rkUrl,
+ slice: fromIdx ..< toIdx,
+ url: url,
+ display: $runes[fromIdx ..< min(toIdx, runes.len)]
+ )
+
+ replacements.sort(cmp)
+ result = runes.replacedWith(replacements, 0 ..< runes.len)
+
proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
let url =
- if t.photos.len > 0: t.photos[0]
- elif t.video.isSome: get(t.video).thumb
- elif t.gif.isSome: get(t.gif).thumb
+ if t.media.len > 0: t.media[0].getThumb
elif t.card.isSome: get(t.card).image
else: ""
diff --git a/src/prefs.nim b/src/prefs.nim
index fa40a6d..1a75f75 100644
--- a/src/prefs.nim
+++ b/src/prefs.nim
@@ -1,22 +1,22 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import tables
+import tables, strutils
import types, prefs_impl
from config import get
from parsecfg import nil
-export genUpdatePrefs, genResetPrefs
+export genUpdatePrefs, genResetPrefs, genApplyPrefs
var defaultPrefs*: Prefs
proc updateDefaultPrefs*(cfg: parsecfg.Config) =
genDefaultPrefs()
-proc getPrefs*(cookies: Table[string, string]): Prefs =
+proc getPrefs*(cookies, params: Table[string, string]): Prefs =
result = defaultPrefs
- genCookiePrefs(cookies)
+ genParsePrefs(cookies)
+ genParsePrefs(params)
-template getPref*(cookies: Table[string, string], pref): untyped =
- bind genCookiePref
- var res = defaultPrefs.`pref`
- genCookiePref(cookies, pref, res)
- res
+proc encodePrefs*(prefs: Prefs): string =
+ var encPairs: seq[string]
+ genEncodePrefs(prefs)
+ encPairs.join(",")
diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim
index 8e2ac8f..8519bd5 100644
--- a/src/prefs_impl.nim
+++ b/src/prefs_impl.nim
@@ -60,6 +60,9 @@ genPrefs:
stickyProfile(checkbox, true):
"Make profile sidebar stick to top"
+ stickyNav(checkbox, true):
+ "Keep navbar fixed to top"
+
bidiSupport(checkbox, false):
"Support bidirectional text (makes clicking on tweets harder)"
@@ -75,6 +78,12 @@ genPrefs:
hideReplies(checkbox, false):
"Hide tweet replies"
+ hideRelated(checkbox, true):
+ "Hide related tweets under replies"
+
+ hideCommunityNotes(checkbox, false):
+ "Hide community notes"
+
squareAvatars(checkbox, false):
"Square profile pictures"
@@ -94,6 +103,17 @@ genPrefs:
autoplayGifs(checkbox, true):
"Autoplay gifs"
+ compactGallery(checkbox, false):
+ "Compact media gallery (no profile info or text)"
+
+ gallerySize(select, "Medium"):
+ "Gallery column size"
+ options: @["Small", "Medium", "Large"]
+
+ mediaView(select, "Timeline"):
+ "Default media view"
+ options: @["Timeline", "Grid", "Gallery"]
+
"Link replacements (blank to disable)":
replaceTwitter(input, ""):
"Twitter -> Nitter"
@@ -127,7 +147,7 @@ macro genDefaultPrefs*(): untyped =
result.add quote do:
defaultPrefs.`ident` = cfg.get("Preferences", `name`, `default`)
-macro genCookiePrefs*(cookies): untyped =
+macro genParsePrefs*(prefs): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let
@@ -137,37 +157,17 @@ macro genCookiePrefs*(cookies): untyped =
options = pref.options
result.add quote do:
- if `name` in `cookies`:
+ if `name` in `prefs`:
when `kind` == input or `name` == "theme":
- result.`ident` = `cookies`[`name`]
+ result.`ident` = `prefs`[`name`]
elif `kind` == checkbox:
- result.`ident` = `cookies`[`name`] == "on"
+ result.`ident` = `prefs`[`name`] == "on" or
+ `prefs`[`name`] == "true" or
+ `prefs`[`name`] == "1"
else:
- let value = `cookies`[`name`]
+ let value = `prefs`[`name`]
if value in `options`: result.`ident` = value
-macro genCookiePref*(cookies, prefName, res): untyped =
- result = nnkStmtList.newTree()
- for pref in allPrefs():
- let ident = ident(pref.name)
- if ident != prefName:
- continue
-
- let
- name = pref.name
- kind = newLit(pref.kind)
- options = pref.options
-
- result.add quote do:
- if `name` in `cookies`:
- when `kind` == input or `name` == "theme":
- `res` = `cookies`[`name`]
- elif `kind` == checkbox:
- `res` = `cookies`[`name`] == "on"
- else:
- let value = `cookies`[`name`]
- if value in `options`: `res` = value
-
macro genUpdatePrefs*(): untyped =
result = nnkStmtList.newTree()
let req = ident("request")
@@ -202,6 +202,36 @@ macro genResetPrefs*(): untyped =
result.add quote do:
savePref(`name`, "", `req`, expire=true)
+macro genEncodePrefs*(prefs): untyped =
+ result = nnkStmtList.newTree()
+ for pref in allPrefs():
+ let
+ name = newLit(pref.name)
+ ident = ident(pref.name)
+ kind = newLit(pref.kind)
+ defaultIdent = nnkDotExpr.newTree(ident("defaultPrefs"), ident(pref.name))
+
+ result.add quote do:
+ when `kind` == checkbox:
+ if `prefs`.`ident` != `defaultIdent`:
+ if `prefs`.`ident`:
+ encPairs.add `name` & "=on"
+ else:
+ encPairs.add `name` & "="
+ else:
+ if `prefs`.`ident` != `defaultIdent`:
+ encPairs.add `name` & "=" & `prefs`.`ident`
+
+macro genApplyPrefs*(params, req): untyped =
+ result = nnkStmtList.newTree()
+ for pref in allPrefs():
+ let name = newLit(pref.name)
+ result.add quote do:
+ if `name` in `params`:
+ savePref(`name`, `params`[`name`], `req`)
+ else:
+ savePref(`name`, "", `req`, expire=true)
+
macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do:
diff --git a/src/query.nim b/src/query.nim
index 06e1da2..ecb428d 100644
--- a/src/query.nim
+++ b/src/query.nim
@@ -1,15 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, tables, uri
-import types
+import types, utils
const
validFilters* = @[
"media", "images", "twimg", "videos",
- "native_video", "consumer_video", "pro_video",
+ "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions",
- "replies", "retweets", "nativeretweets",
- "verified", "safe"
+ "replies", "retweets", "nativeretweets"
]
emptyQuery* = "include:nativeretweets"
@@ -21,32 +20,43 @@ template `@`(param: string): untyped =
proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query(
kind: parseEnum[QueryKind](@"f", tweets),
+ view: @"view",
text: @"q",
filters: validFilters.filterIt("f-" & it in pms),
excludes: validFilters.filterIt("e-" & it in pms),
since: @"since",
until: @"until",
- near: @"near"
+ minLikes: validateNumber(@"min_faves")
)
+ # articles is an internal tab kind, not a valid search filter
+ if result.kind == QueryKind.articles:
+ result.kind = tweets
+
if name.len > 0:
result.fromUser = name.split(",")
proc getMediaQuery*(name: string): Query =
Query(
- kind: media,
+ kind: QueryKind.media,
filters: @["twimg", "native_video"],
fromUser: @[name],
sep: "OR"
)
+proc getArticlesQuery*(name: string): Query =
+ Query(
+ kind: QueryKind.articles,
+ fromUser: @[name]
+ )
+
proc getReplyQuery*(name: string): Query =
Query(
kind: replies,
fromUser: @[name]
)
-proc genQueryParam*(query: Query): string =
+proc genQueryParam*(query: Query; maxId=""): string =
var
filters: seq[string]
param: string
@@ -55,15 +65,20 @@ proc genQueryParam*(query: Query): string =
return query.text
for i, user in query.fromUser:
- param &= &"from:{user} "
- if i < query.fromUser.high:
- param &= "OR "
+ if i == 0:
+ param = "("
- if query.fromUser.len > 0 and query.kind in {posts, media}:
- param &= "filter:self_threads OR -filter:replies "
+ param &= &"from:{user}"
+ if i < query.fromUser.high:
+ param &= " OR "
+ else:
+ param &= ")"
+
+ if query.fromUser.len > 0 and query.kind in {posts, QueryKind.media}:
+ param &= " (filter:self_threads OR -filter:replies)"
if "nativeretweets" notin query.excludes:
- param &= "include:nativeretweets "
+ param &= " include:nativeretweets"
for f in query.filters:
filters.add "filter:" & f
@@ -73,38 +88,51 @@ proc genQueryParam*(query: Query): string =
for i in query.includes:
filters.add "include:" & i
- result = strip(param & filters.join(&" {query.sep} "))
+ if filters.len > 0:
+ result = strip(param & " (" & filters.join(&" {query.sep} ") & ")")
+ else:
+ result = strip(param)
+
if query.since.len > 0:
result &= " since:" & query.since
- if query.until.len > 0:
+ if query.until.len > 0 and maxId.len == 0:
result &= " until:" & query.until
- if query.near.len > 0:
- result &= &" near:\"{query.near}\" within:15mi"
+ if query.minLikes.len > 0:
+ result &= " min_faves:" & query.minLikes
if query.text.len > 0:
if result.len > 0:
result &= " " & query.text
else:
result = query.text
+ if result.len > 0 and maxId.len > 0:
+ result &= " max_id:" & maxId
+
proc genQueryUrl*(query: Query): string =
- if query.kind notin {tweets, users}: return
+ var params: seq[string]
- var params = @[&"f={query.kind}"]
- if query.text.len > 0:
- params.add "q=" & encodeUrl(query.text)
- for f in query.filters:
- params.add &"f-{f}=on"
- for e in query.excludes:
- params.add &"e-{e}=on"
- for i in query.includes.filterIt(it != "nativeretweets"):
- params.add &"i-{i}=on"
+ if query.view.len > 0:
+ params.add "view=" & encodeUrl(query.view)
- if query.since.len > 0:
- params.add "since=" & query.since
- if query.until.len > 0:
- params.add "until=" & query.until
- if query.near.len > 0:
- params.add "near=" & query.near
+ # media doubles as the profile media tab, where f isn't part of the URL scheme
+ if query.kind in {tweets, users, lists, top} or
+ (query.kind == QueryKind.media and query.fromUser.len == 0):
+ params.add &"f={query.kind}"
+ if query.text.len > 0:
+ params.add "q=" & encodeUrl(query.text)
+ for f in query.filters:
+ params.add &"f-{f}=on"
+ for e in query.excludes:
+ params.add &"e-{e}=on"
+ for i in query.includes.filterIt(it != "nativeretweets"):
+ params.add &"i-{i}=on"
+
+ if query.since.len > 0:
+ params.add "since=" & query.since
+ if query.until.len > 0:
+ params.add "until=" & query.until
+ if query.minLikes.len > 0:
+ params.add "min_faves=" & query.minLikes
if params.len > 0:
result &= params.join("&")
diff --git a/src/redis_cache.nim b/src/redis_cache.nim
index 559d299..b9ddbcc 100644
--- a/src/redis_cache.nim
+++ b/src/redis_cache.nim
@@ -144,9 +144,10 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
else:
let user = await getGraphUserById(userId)
result = user.username
- await setEx(key, baseCacheTime, result)
- if result.len > 0 and user.id.len > 0:
- await all(cacheUserId(result, user.id), cache(user))
+ if result.len > 0:
+ await setEx(key, baseCacheTime, result)
+ if user.id.len > 0:
+ await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return
@@ -158,6 +159,48 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
# if not result.isNil:
# await cache(result)
+proc cache*(data: Broadcast) {.async.} =
+ if data.id.len == 0: return
+ await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
+
+proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
+ if id.len == 0: return
+ let cached = await get("bc:" & id)
+ if cached != redisNil:
+ cached.deserialize(Broadcast)
+ else:
+ result = await getBroadcastInfo(id)
+ await cache(result)
+ result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
+
+proc cache*(data: AudioSpace) {.async.} =
+ if data.id.len == 0: return
+ let ttl = if data.state == "RUNNING": baseCacheTime div 6 else: baseCacheTime
+ await setEx("sp:" & data.id, ttl, compress(toFlatty(data)))
+
+proc getCachedAudioSpace*(id: string): Future[AudioSpace] {.async.} =
+ if id.len == 0: return
+ let cached = await get("sp:" & id)
+ if cached != redisNil:
+ cached.deserialize(AudioSpace)
+ else:
+ result = await getAudioSpace(id)
+ await cache(result)
+ result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
+
+proc cache*(data: AccountInfo; name: string) {.async.} =
+ await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
+
+proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
+ if username.len == 0: return
+ let name = toLower(username)
+ let cached = await get("ai:" & name)
+ if cached != redisNil:
+ cached.deserialize(AccountInfo)
+ elif fetch:
+ result = await getAboutAccount(username)
+ await cache(result, name)
+
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return
let rail = await get("pr2:" & toLower(id))
@@ -167,6 +210,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
result = await getPhotoRail(id)
await cache(result, id)
+proc cache*(data: Community) {.async.} =
+ if data.id.len == 0: return
+ await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data)))
+
+proc getCachedCommunity*(id: string): Future[Community] {.async.} =
+ if id.len == 0: return
+ let cached = await get("cm:" & id)
+ if cached != redisNil:
+ cached.deserialize(Community)
+ else:
+ result = await getGraphCommunity(id)
+ await cache(result)
+
+proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} =
+ if id.len == 0: return
+ let cached = await get("cmm:" & id)
+ if cached != redisNil:
+ cached.deserialize(seq[User])
+ else:
+ let mods = await getGraphCommunityModerators(id)
+ result = mods.content
+ await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result)))
+
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil
else: await get("l:" & id)
diff --git a/src/routes/article.nim b/src/routes/article.nim
new file mode 100644
index 0000000..0a7d1dc
--- /dev/null
+++ b/src/routes/article.nim
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, tables, strutils
+import jester, karax/vdom
+import ".."/[types, api]
+import ../views/[article, general]
+import router_utils
+
+export api, article, vdom, general, router_utils
+
+proc createArticleRouter*(cfg: Config) =
+ router articleRoute:
+ get "/i/article/@id":
+ cond @"id".allCharsInSet(Digits)
+
+ let article = await getGraphArticle(@"id")
+ if article == nil:
+ resp Http404, showError("Article not found", cfg)
+
+ var tweetIds: seq[string]
+ for e in article.entities.values:
+ if e.kind == "TWEET":
+ tweetIds.add e.tweetId
+
+ var tweets = initTable[int64, Tweet]()
+ if tweetIds.len > 0:
+ try:
+ for t in await getGraphTweetResults(tweetIds):
+ tweets[t.id] = t
+ except CatchableError:
+ discard
+
+ let
+ prefs = requestPrefs()
+ path = getPath()
+ html = renderArticle(article, tweets, path, prefs, @"id")
+ twitterUrl = "https://x.com/" & article.user.username & "/article/" & @"id"
+ resp renderMain(html, request, cfg, prefs, titleText=article.title,
+ twitterLink=twitterUrl)
+
+ get "/@name/article/@id/?":
+ cond '.' notin @"name"
+ cond @"id".allCharsInSet(Digits)
+ redirect("/i/article/" & @"id")
+
+ get "/@name/status/@id/article":
+ cond '.' notin @"name"
+ cond @"id".allCharsInSet(Digits)
+ redirect("/i/article/" & @"id")
diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim
new file mode 100644
index 0000000..d3bb95a
--- /dev/null
+++ b/src/routes/broadcast.nim
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, strutils
+import jester
+
+import router_utils
+import ".."/[types, formatters, redis_cache]
+import ../views/[general, broadcast]
+import media
+
+export broadcast
+
+proc createBroadcastRouter*(cfg: Config) =
+ router broadcastRoute:
+ get "/i/broadcasts/@id":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ var bc: Broadcast
+ try:
+ bc = await getCachedBroadcast(@"id")
+ except:
+ discard
+
+ if bc.id.len == 0:
+ resp Http404, showError("Broadcast not found", cfg)
+
+ let prefs = requestPrefs()
+ resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
+ bc.title, ogTitle=bc.title)
+
+ get "/i/broadcasts/@id/stream":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ var bc: Broadcast
+ try:
+ bc = await getCachedBroadcast(@"id")
+ except:
+ discard
+
+ if bc.m3u8Url.len == 0:
+ resp Http404
+
+ let manifest = await safeFetch(bc.m3u8Url)
+ if manifest.len == 0:
+ resp Http502
+
+ resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime
diff --git a/src/routes/community.nim b/src/routes/community.nim
new file mode 100644
index 0000000..b850b6b
--- /dev/null
+++ b/src/routes/community.nim
@@ -0,0 +1,89 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strformat
+
+import jester
+
+import router_utils
+import ".."/[types, redis_cache, api]
+import ../views/[general, timeline, community]
+
+export community
+
+template respCommunity*(cmty: Community; title: string; nav, vnode: typed) =
+ if cmty.id.len == 0 or cmty.name.len == 0:
+ resp Http404, showError(&"""Community "{@"id"}" not found""", cfg)
+
+ let html = renderCommunity(vnode, nav, cmty)
+ resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner)
+
+proc createCommunityRouter*(cfg: Config) =
+ router community:
+ get "/i/communities/@id/?":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor())
+ respCommunity(cmty, cmty.name,
+ renderCommunityTabs(QueryKind.posts, cmty),
+ renderTimelineTweets(tl, prefs, request.path))
+
+ get "/i/communities/@id/latest":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor())
+ respCommunity(cmty, cmty.name & " - Latest",
+ renderCommunityTabs(QueryKind.replies, cmty),
+ renderTimelineTweets(tl, prefs, request.path))
+
+ get "/i/communities/@id/media":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityMedia(cmty.id, getCursor())
+ respCommunity(cmty, cmty.name & " - Media",
+ renderCommunityTabs(QueryKind.media, cmty),
+ renderTimelineTweets(tl, prefs, request.path))
+
+ get "/i/communities/@id/about":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ mods = await getCachedCommunityModerators(cmty.id)
+ respCommunity(cmty, cmty.name & " - About",
+ renderCommunityTabs(QueryKind.userList, cmty),
+ renderCommunityAbout(cmty, mods))
+
+ get "/i/communities/@id/members":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ members = await getGraphCommunityMembers(cmty.id, getCursor())
+ respCommunity(cmty, cmty.name & " - Members",
+ renderMemberTabs(cmty, false),
+ renderTimelineUsers(members, prefs, request.path))
+
+ get "/i/communities/@id/moderators":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ mods = await getCachedCommunityModerators(cmty.id)
+ respCommunity(cmty, cmty.name & " - Moderators",
+ renderMemberTabs(cmty, true),
+ renderTimelineUsers(Result[User](content: mods), prefs, request.path))
+
+ get "/i/communities/@id/hashtag/@tag":
+ cond '.' notin @"id"
+ let
+ prefs = requestPrefs()
+ cmty = await getCachedCommunity(@"id")
+ tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor())
+ respCommunity(cmty, cmty.name & " - #" & @"tag",
+ renderHashtagHeader(cmty, @"tag"),
+ renderTimelineTweets(tl, prefs, request.path))
diff --git a/src/routes/embed.nim b/src/routes/embed.nim
index 994364b..24bba2d 100644
--- a/src/routes/embed.nim
+++ b/src/routes/embed.nim
@@ -1,29 +1,79 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import asyncdispatch, strutils, strformat, options
+import asyncdispatch, strutils, strformat, json
import jester, karax/vdom
-import ".."/[types, api]
+import ".."/[types, api, formatters]
import ../views/[embed, tweet, general]
+include "../views/oembed.nimf"
import router_utils
export api, embed, vdom, tweet, general, router_utils
+proc parseTweetPath(path: string): tuple[username, id: string] =
+ let parts = path.split('/')
+ if parts.len >= 3 and parts[1] in ["status", "statuses"]:
+ let tweetId = parts[2].split('?')[0].split('#')[0]
+ if tweetId.len > 0 and tweetId.allCharsInSet(Digits):
+ return (parts[0], tweetId)
+ return ("", "")
+
+proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] =
+ var path = url
+ if path.startsWith("https://"):
+ path = path[8..^1]
+ elif path.startsWith("http://"):
+ path = path[7..^1]
+
+ const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/",
+ "www.twitter.com/", "www.x.com/"]
+
+ for prefix in twitterPrefixes:
+ if path.startsWith(prefix):
+ return parseTweetPath(path[prefix.len..^1])
+
+ let nitterPrefix = cfg.hostname & "/"
+ if path.startsWith(nitterPrefix):
+ return parseTweetPath(path[nitterPrefix.len..^1])
+
+ # Fall back: strip any hostname and try to parse as a tweet path.
+ # Handles requests where the URL's host differs from cfg.hostname
+ # (e.g. localhost in dev/CI, or a reverse proxy with a different domain).
+ let slashPos = path.find('/')
+ if slashPos > 0:
+ let afterHost = path[slashPos + 1..^1]
+ let parsed = parseTweetPath(afterHost)
+ if parsed.username.len > 0:
+ return parsed
+
+ return ("", "")
+
proc createEmbedRouter*(cfg: Config) =
router embed:
get "/i/videos/tweet/@id":
- let tweet = await getGraphTweetResult(@"id")
- if tweet == nil or tweet.video.isNone:
- resp Http404
+ let
+ id = @"id"
+ tweet = await getTweetByRestId(id)
+ prefs = requestPrefs()
+
+ if tweet == nil:
+ resp renderErrorEmbed("Tweet not found", prefs, cfg, request, tweetId=id)
+
+ if not tweet.hasVideos:
+ resp renderErrorEmbed("No video in tweet", prefs, cfg, request,
+ tweetId=id, username=tweet.user.username)
resp renderVideoEmbed(tweet, cfg, request)
get "/@user/status/@id/embed":
let
- tweet = await getGraphTweetResult(@"id")
- prefs = cookiePrefs()
+ id = @"id"
+ user = @"user"
+ tweet = await getTweetByRestId(id)
+ prefs = requestPrefs()
path = getPath()
if tweet == nil:
- resp Http404
+ resp renderErrorEmbed("Tweet not found", prefs, cfg, request,
+ tweetId=id, username=user)
resp renderTweetEmbed(tweet, path, prefs, cfg, request)
@@ -34,3 +84,57 @@ proc createEmbedRouter*(cfg: Config) =
redirect(&"/i/status/{id}/embed")
else:
resp Http404
+
+ get "/api/oembed":
+ responseHeaders().get.add(("Access-Control-Allow-Origin", "*"))
+
+ let
+ url = @"url"
+ format = @"format"
+
+ if format.len > 0 and format != "json":
+ resp Http501, "Only JSON format is supported"
+
+ if url.len == 0:
+ resp Http400, "Missing url parameter"
+
+ let (username, tweetId) = parseTweetUrl(url, cfg)
+ if username.len == 0 or tweetId.len == 0:
+ resp Http400, "Invalid tweet URL"
+
+ let tweet = await getTweetByRestId(tweetId)
+ if tweet == nil:
+ resp Http404
+
+ let
+ maxwidthParam = @"maxwidth"
+ maxwidth = if maxwidthParam.len > 0:
+ try: clamp(parseInt(maxwidthParam), 220, 550)
+ except ValueError: 550
+ else: 550
+ embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed"
+ authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username
+ title = stripHtml(tweet.text)
+
+ var response = %*{
+ "version": "1.0",
+ "type": "rich",
+ "provider_name": cfg.title,
+ "provider_url": getUrlPrefix(cfg),
+ "title": title,
+ "author_name": tweet.user.fullname,
+ "author_url": authorUrl,
+ "url": embedUrl,
+ "width": maxwidth,
+ "height": newJNull(),
+ "cache_age": "3153600000",
+ "html": renderOembedIframe(embedUrl, maxwidth)
+ }
+
+ if tweet.media.len > 0:
+ let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb)
+ response["thumbnail_url"] = %thumbUrl
+ response["thumbnail_width"] = %maxwidth
+ response["thumbnail_height"] = %maxwidth
+
+ respJson response
diff --git a/src/routes/list.nim b/src/routes/list.nim
index ac3e97e..b4ab091 100644
--- a/src/routes/list.nim
+++ b/src/routes/list.nim
@@ -13,7 +13,7 @@ template respList*(list, timeline, title, vnode: typed) =
let
html = renderList(vnode, timeline.query, list)
- rss = &"""/i/lists/{@"id"}/rss"""
+ rss = if cfg.enableRSSList: &"""/i/lists/{@"id"}/rss""" else: ""
resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
@@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/?":
cond '.' notin @"id"
let
- prefs = cookiePrefs()
+ prefs = requestPrefs()
list = await getCachedList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path)
@@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
get "/i/lists/@id/members":
cond '.' notin @"id"
let
- prefs = cookiePrefs()
+ prefs = requestPrefs()
list = await getCachedList(id=(@"id"))
members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
diff --git a/src/routes/media.nim b/src/routes/media.nim
index de51061..3442916 100644
--- a/src/routes/media.nim
+++ b/src/routes/media.nim
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import uri, strutils, httpclient, os, hashes, base64, re
import asynchttpserver, asyncstreams, asyncfile, asyncnet
+import asyncdispatch
import jester
@@ -15,7 +16,9 @@ const
maxAge* = "max-age=604800"
proc safeFetch*(url: string): Future[string] {.async.} =
- let client = newAsyncHttpClient()
+ # maxRedirects=0: the caller already validated the host, so never follow a
+ # redirect off the allowlisted host (would re-open the #1411 SSRF).
+ let client = newAsyncHttpClient(maxRedirects = 0)
try: result = await client.getContent(url)
except: discard
finally: client.close()
@@ -30,47 +33,79 @@ template respond*(req: asynchttpserver.Request; headers) =
proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} =
result = Http200
- let
- request = req.getNativeReq()
- client = newAsyncHttpClient()
+ let request = req.getNativeReq()
+ var fetchUrl = url
- try:
- let res = await client.get(url)
- if res.status != "200 OK":
- if res.status != "404 Not Found":
- echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url]
- return Http404
-
- let hashed = $hash(url)
- if request.headers.getOrDefault("If-None-Match") == hashed:
- return Http304
-
- let contentLength =
- if res.headers.hasKey("content-length"):
- res.headers["content-length", 0]
+ for attempt in 0 .. 2:
+ let client = newAsyncHttpClient(maxRedirects = 0)
+ var shouldRetry = false
+ try:
+ let resFut = client.get(fetchUrl)
+ let completed = await withTimeout(resFut, 5000)
+ if not completed:
+ if attempt < 2:
+ echo "[media] Retry $1/2, timeout after 5s, url: $2" % [$(attempt + 1), fetchUrl]
+ shouldRetry = true
+ else:
+ echo "[media] Proxying timeout after 5s, url: $1" % [fetchUrl]
+ return Http504
else:
- ""
+ let res = resFut.read()
+ if res.status != "200 OK":
+ if res.status == "404 Not Found":
+ return Http404
+ if res.status.startsWith("30") and res.headers.hasKey("location"):
+ let location = res.headers["location", 0]
+ if isTwitterUrl(location):
+ fetchUrl = location
+ shouldRetry = true
+ continue
+ else:
+ return Http403
+ if attempt < 2:
+ echo "[media] Retry $1/2, status: $2, url: $3" % [$(attempt + 1), res.status, fetchUrl]
+ shouldRetry = true
+ else:
+ echo "[media] Proxying failed, status: $1, url: $2" % [res.status, fetchUrl]
+ return Http404
+ else:
+ let hashed = $hash(url)
+ if request.headers.getOrDefault("If-None-Match") == hashed:
+ return Http304
- let headers = newHttpHeaders({
- "Content-Type": res.headers["content-type", 0],
- "Content-Length": contentLength,
- "Cache-Control": maxAge,
- "ETag": hashed
- })
+ let contentLength =
+ if res.headers.hasKey("content-length"):
+ res.headers["content-length", 0]
+ else:
+ ""
- respond(request, headers)
+ let headers = newHttpHeaders({
+ "content-type": res.headers["content-type", 0],
+ "content-length": contentLength,
+ "cache-control": maxAge,
+ "etag": hashed
+ })
- var (hasValue, data) = (true, "")
- while hasValue:
- (hasValue, data) = await res.bodyStream.read()
- if hasValue:
- await request.client.send(data)
- data.setLen 0
- except HttpRequestError, ProtocolError, OSError:
- echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url]
- result = Http404
- finally:
- client.close()
+ respond(request, headers)
+
+ var (hasValue, data) = (true, "")
+ while hasValue:
+ (hasValue, data) = await res.bodyStream.read()
+ if hasValue:
+ await request.client.send(data)
+ data.setLen 0
+ return Http200
+ except CatchableError:
+ if attempt < 2:
+ echo "[media] Retry $1/2, error: $2, url: $3" % [$(attempt + 1), getCurrentExceptionMsg(), fetchUrl]
+ shouldRetry = true
+ else:
+ echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), fetchUrl]
+ result = Http404
+ finally:
+ client.close()
+ if not shouldRetry:
+ break
template check*(code): untyped =
if code != Http200:
@@ -86,6 +121,12 @@ proc decoded*(req: jester.Request; index: int): string =
if based: decode(encoded)
else: decodeUrl(encoded)
+proc normalizeImgUrl*(url: var string) =
+ if not url.startsWith("http"):
+ if "twimg.com" notin url:
+ url.insert(twimg)
+ url.insert(https)
+
proc createMediaRouter*(cfg: Config) =
router media:
get "/pic/?":
@@ -93,10 +134,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1)
- if "twimg.com" notin url:
- url.insert(twimg)
- if not url.startsWith(https):
- url.insert(https)
+ cond "/amplify_video/" notin url
+ normalizeImgUrl(url)
url.add("?name=orig")
let uri = parseUri(url)
@@ -107,10 +146,8 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1)
- if "twimg.com" notin url:
- url.insert(twimg)
- if not url.startsWith(https):
- url.insert(https)
+ cond "/amplify_video/" notin url
+ normalizeImgUrl(url)
let uri = parseUri(url)
cond isTwitterUrl(uri) == true
@@ -120,12 +157,12 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
let url = decoded(request, 2)
- cond "http" in url
+ cond isTwitterUrl(url)
if getHmac(url) != request.matches[1]:
resp Http403, showError("Failed to verify signature", cfg)
- if ".mp4" in url or ".ts" in url or ".m4s" in url:
+ if ".mp4" in url or ".ts" in url or ".m4s" in url or ".aac" in url:
let code = await proxyMedia(request, url)
check code
@@ -139,6 +176,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url:
let vid = await safeFetch(url)
- content = proxifyVideo(vid, cookiePref(proxyVideos))
+ content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
resp content, m3u8Mime
diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim
index b8af03d..7f04de2 100644
--- a/src/routes/preferences.nim
+++ b/src/routes/preferences.nim
@@ -19,8 +19,10 @@ proc createPrefRouter*(cfg: Config) =
router preferences:
get "/settings":
let
- prefs = cookiePrefs()
- html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir))
+ prefs = requestPrefs()
+ prefsCode = encodePrefs(prefs)
+ prefsUrl = getUrlPrefix(cfg) & "/?prefs=" & prefsCode
+ html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), prefsUrl)
resp renderMain(html, request, cfg, prefs, "Preferences")
get "/settings/@i?":
@@ -38,3 +40,6 @@ proc createPrefRouter*(cfg: Config) =
savePref("hlsPlayback", "on", request)
redirect(refPath())
+ post "/enablemp4":
+ savePref("mp4Playback", "on", request)
+ redirect(refPath())
diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim
index 1baf873..5f074a5 100644
--- a/src/routes/resolver.nim
+++ b/src/routes/resolver.nim
@@ -18,8 +18,8 @@ proc createResolverRouter*(cfg: Config) =
router resolver:
get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
- respResolved(await resolve(url, cookiePrefs()), "card")
+ respResolved(await resolve(url, requestPrefs()), "card")
get "/t.co/@url":
let url = "https://t.co/" & @"url"
- respResolved(await resolve(url, cookiePrefs()), "t.co")
+ respResolved(await resolve(url, requestPrefs()), "t.co")
diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim
index a071a0d..612a96b 100644
--- a/src/routes/router_utils.nim
+++ b/src/routes/router_utils.nim
@@ -4,26 +4,19 @@ from jester import Request, cookies
import ../views/general
import ".."/[utils, prefs, types]
-export utils, prefs, types, uri
+export utils, prefs, types, uri, json
template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req):
+ let sameSite = if cfg.useHttps: None else: Lax
setCookie(pref, value, daysForward(when expire: -10 else: 360),
- httpOnly=true, secure=cfg.useHttps, sameSite=None)
+ httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
-template cookiePrefs*(): untyped {.dirty.} =
- getPrefs(cookies(request))
-
-template cookiePref*(pref): untyped {.dirty.} =
- getPref(cookies(request), pref)
-
-template themePrefs*(): Prefs =
- var res = defaultPrefs
- res.theme = cookiePref(theme)
- res
+template requestPrefs*(): untyped {.dirty.} =
+ getPrefs(cookies(request), params(request))
template showError*(error: string; cfg: Config): string =
- renderMain(renderError(error), request, cfg, themePrefs(), "Error")
+ renderMain(renderError(error), request, cfg, requestPrefs(), "Error")
template getPath*(): untyped {.dirty.} =
$(parseUri(request.path) ? filterParams(request.params))
@@ -43,5 +36,28 @@ template getCursor*(req: Request): string =
proc getNames*(name: string): seq[string] =
name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
+template applyUrlPrefs*() {.dirty.} =
+ if @"prefs".len > 0:
+ var prefParams = initTable[string, string]()
+ for pair in @"prefs".split(','):
+ let kv = pair.split('=', maxsplit=1)
+ if kv.len == 2:
+ prefParams[kv[0]] = kv[1]
+ elif kv.len == 1 and kv[0].len > 0:
+ prefParams[kv[0]] = ""
+ genApplyPrefs(prefParams, request)
+
+ # Rebuild URL without prefs param
+ var params: seq[(string, string)]
+ for k, v in request.params:
+ if k != "prefs":
+ params.add (k, v)
+
+ if params.len > 0:
+ let cleanUrl = request.getNativeReq.url ? params
+ redirect($cleanUrl)
+ else:
+ redirect(request.path)
+
template respJson*(node: JsonNode) =
resp $node, "application/json"
diff --git a/src/routes/rss.nim b/src/routes/rss.nim
index b0e781d..038096c 100644
--- a/src/routes/rss.nim
+++ b/src/routes/rss.nim
@@ -15,7 +15,7 @@ proc redisKey*(page, name, cursor: string): string =
if cursor.len > 0:
result &= ":" & cursor
-proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
+proc timelineRss*(req: Request; cfg: Config; query: Query; prefs: Prefs): Future[Rss] {.async.} =
var profile: Profile
let
name = req.params.getOrDefault("name")
@@ -39,7 +39,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
return Rss(feed: profile.user.username, cursor: "suspended")
if profile.user.fullname.len > 0:
- let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
+ let rss = renderTimelineRss(profile, cfg, prefs, multi=(names.len > 1))
return Rss(feed: rss, cursor: profile.tweets.bottom)
template respRss*(rss, page) =
@@ -60,12 +60,15 @@ template respRss*(rss, page) =
proc createRssRouter*(cfg: Config) =
router rss:
get "/search/rss":
- cond cfg.enableRss
+ if not cfg.enableRSSSearch:
+ resp Http403, showError("RSS feed is disabled", cfg)
if @"q".len > 200:
resp Http400, showError("Search input too long.", cfg)
- let query = initQuery(params(request))
- if query.kind != tweets:
+ let
+ prefs = requestPrefs()
+ query = initQuery(params(request))
+ if query.kind notin {QueryKind.tweets, QueryKind.top, QueryKind.media}:
resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
let
@@ -78,15 +81,17 @@ proc createRssRouter*(cfg: Config) =
let tweets = await getGraphTweetSearch(query, cursor)
rss.cursor = tweets.bottom
- rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
+ rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg, prefs)
await cacheRss(key, rss)
respRss(rss, "Search")
get "/@name/rss":
- cond cfg.enableRss
cond '.' notin @"name"
+ if not cfg.enableRSSUserTweets:
+ resp Http403, showError("RSS feed is disabled", cfg)
let
+ prefs = requestPrefs()
name = @"name"
key = redisKey("twitter", name, getCursor())
@@ -94,24 +99,23 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
- rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
+ rss = await timelineRss(request, cfg, Query(fromUser: @[name]), prefs)
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/@tab/rss":
- cond cfg.enableRss
cond '.' notin @"name"
- cond @"tab" in ["with_replies", "media", "search"]
+ cond @"tab" in ["with_replies", "media", "search", "articles"]
+ # articles can't be approximated by search, so multi-user is unsupported
+ cond not (@"tab" == "articles" and ',' in @"name")
+ if not cfg.tabRssEnabled(@"tab"):
+ resp Http403, showError("RSS feed is disabled", cfg)
let
+ prefs = requestPrefs()
name = @"name"
tab = @"tab"
- query =
- case tab
- of "with_replies": getReplyQuery(name)
- of "media": getMediaQuery(name)
- of "search": initQuery(params(request), name=name)
- else: Query(fromUser: @[name])
+ query = request.getQuery(tab, name, prefs)
let searchKey = if tab != "search": ""
else: ":" & $hash(genQueryUrl(query))
@@ -122,14 +126,15 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0:
respRss(rss, "User")
- rss = await timelineRss(request, cfg, query)
+ rss = await timelineRss(request, cfg, query, prefs)
await cacheRss(key, rss)
respRss(rss, "User")
get "/@name/lists/@slug/rss":
- cond cfg.enableRss
cond @"name" != "i"
+ if not cfg.enableRSSList:
+ resp Http403, showError("RSS feed is disabled", cfg)
let
slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug)
@@ -145,8 +150,10 @@ proc createRssRouter*(cfg: Config) =
redirect(url)
get "/i/lists/@id/rss":
- cond cfg.enableRss
+ if not cfg.enableRSSList:
+ resp Http403, showError("RSS feed is disabled", cfg)
let
+ prefs = requestPrefs()
id = @"id"
cursor = getCursor()
key = redisKey("lists", id, cursor)
@@ -159,7 +166,7 @@ proc createRssRouter*(cfg: Config) =
list = await getCachedList(id=id)
timeline = await getGraphListTweets(list.id, cursor)
rss.cursor = timeline.bottom
- rss.feed = renderListRss(timeline.content, list, cfg)
+ rss.feed = renderListRss(timeline.content, list, cfg, prefs)
await cacheRss(key, rss)
respRss(rss, "List")
diff --git a/src/routes/search.nim b/src/routes/search.nim
index e9f991d..7c7fd14 100644
--- a/src/routes/search.nim
+++ b/src/routes/search.nim
@@ -19,10 +19,22 @@ proc createSearchRouter*(cfg: Config) =
resp Http400, showError("Search input too long.", cfg)
let
- prefs = cookiePrefs()
- query = initQuery(params(request))
+ prefs = requestPrefs()
title = "Search" & (if q.len > 0: " (" & q & ")" else: "")
+ var query = initQuery(params(request))
+ # x.com URL compat: f=user and f=list map to our kind names
+ # (f=live already falls back to tweets/Latest; f=media matches natively)
+ if @"f" == "user":
+ query.kind = users
+ elif @"f" == "list":
+ query.kind = lists
+
+ # media searches support view modes, defaulting like /user/media
+ if query.kind == QueryKind.media and
+ query.view notin ["timeline", "grid", "gallery"]:
+ query.view = prefs.mediaView.toLowerAscii
+
case query.kind
of users:
if "," in q:
@@ -33,19 +45,24 @@ proc createSearchRouter*(cfg: Config) =
except InternalError:
users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
- of tweets:
+ of tweets, top, QueryKind.media:
let
tweets = await getGraphTweetSearch(query, getCursor())
- rss = "/search/rss?" & genQueryUrl(query)
+ rss = if cfg.enableRSSSearch: "/search/rss?" & genQueryUrl(query) else: ""
resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss)
+ of lists:
+ let listResults = await getGraphListSearch(query, getCursor())
+ resp renderMain(renderListSearch(listResults, prefs, getPath()),
+ request, cfg, prefs, title)
else:
resp Http404, showError("Invalid search", cfg)
get "/hashtag/@hash":
- redirect("/search?q=" & encodeUrl("#" & @"hash"))
+ redirect("/search?f=tweets&q=" & encodeUrl("#" & @"hash"))
get "/opensearch":
- let url = getUrlPrefix(cfg) & "/search?q="
- resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
- generateOpenSearchXML(cfg.title, cfg.hostname, url)
+ let
+ url = getUrlPrefix(cfg) & "/search?f=tweets&q="
+ headers = {"Content-Type": "application/opensearchdescription+xml"}
+ resp Http200, headers, generateOpenSearchXML(cfg.title, cfg.hostname, url)
diff --git a/src/routes/space.nim b/src/routes/space.nim
new file mode 100644
index 0000000..bd956ea
--- /dev/null
+++ b/src/routes/space.nim
@@ -0,0 +1,36 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import asyncdispatch, strutils
+import jester
+
+import router_utils
+import ".."/[types, formatters, redis_cache]
+import ../views/[general, space]
+import media
+
+export space
+
+proc createSpaceRouter*(cfg: Config) =
+ router spaceRoute:
+ get "/i/spaces/@id":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ let sp = await getCachedAudioSpace(@"id")
+
+ if sp.id.len == 0:
+ resp Http404, showError("Space not found", cfg)
+
+ let prefs = requestPrefs()
+ resp renderMain(renderSpace(sp, prefs, request.path), request, cfg, prefs,
+ sp.title, ogTitle=sp.title)
+
+ get "/i/spaces/@id/stream":
+ cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
+ let sp = await getCachedAudioSpace(@"id")
+
+ if sp.m3u8Url.len == 0:
+ resp Http404
+
+ let manifest = await safeFetch(sp.m3u8Url)
+ if manifest.len == 0:
+ resp Http502
+
+ resp proxifyVideo(manifest, requestPrefs().proxyVideos, sp.m3u8Url), m3u8Mime
diff --git a/src/routes/status.nim b/src/routes/status.nim
index 0168dac..32a4447 100644
--- a/src/routes/status.nim
+++ b/src/routes/status.nim
@@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) =
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg)
- let prefs = cookiePrefs()
+ let
+ prefs = requestPrefs()
+ sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance)
# used for the infinite scroll feature
if @"scroll".len > 0:
- let replies = await getReplies(id, getCursor())
+ let replies = await getReplies(id, getCursor(), sort)
if replies.content.len == 0:
- resp Http404, ""
- resp $renderReplies(replies, prefs, getPath())
+ resp Http204
+ resp $renderReplies(replies, prefs, getPath(), sort=sort)
- let conv = await getTweet(id, getCursor())
+ let conv = await getTweet(id, getCursor(), sort)
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found"
@@ -44,15 +46,19 @@ proc createStatusRouter*(cfg: Config) =
desc = conv.tweet.text
var
- images = conv.tweet.photos
+ images = conv.tweet.getPhotos.mapIt(it.url)
video = ""
- if conv.tweet.video.isSome():
- images = @[get(conv.tweet.video).thumb]
+ let
+ firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
+ else: photoMedia
+
+ if firstMediaKind == videoMedia:
+ images = @[conv.tweet.media[0].getThumb]
video = getVideoEmbed(cfg, conv.tweet.id)
- elif conv.tweet.gif.isSome():
- images = @[get(conv.tweet.gif).thumb]
- video = getPicUrl(get(conv.tweet.gif).url)
+ elif firstMediaKind == gifMedia:
+ images = @[conv.tweet.media[0].getThumb]
+ video = getPicUrl(conv.tweet.media[0].gif.url)
elif conv.tweet.card.isSome():
let card = conv.tweet.card.get()
if card.image.len > 0:
@@ -60,13 +66,37 @@ proc createStatusRouter*(cfg: Config) =
elif card.video.isSome():
images = @[card.video.get().thumb]
- let html = renderConversation(conv, prefs, getPath() & "#m")
+ let
+ tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id
+ oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl)
+
+ let html = renderConversation(conv, prefs, getPath() & "#m", sort)
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
- images=images, video=video)
+ images=images, video=video, oembed=oembedUrl)
+
+ get "/@name/status/@id/history/?":
+ cond '.' notin @"name"
+ let id = @"id"
+
+ if id.len > 19 or id.any(c => not c.isDigit):
+ resp Http404, showError("Invalid tweet ID", cfg)
+
+ let edits = await getGraphEditHistory(id)
+ if edits.latest == nil or edits.latest.id == 0:
+ resp Http404, showError("Tweet history not found", cfg)
+
+ let
+ prefs = requestPrefs()
+ title = "History for " & pageTitle(edits.latest)
+ ogTitle = "Edit History for " & pageTitle(edits.latest.user)
+ desc = edits.latest.text
+
+ let html = renderEditHistory(edits, prefs, getPath())
+ resp renderMain(html, request, cfg, prefs, title, desc, ogTitle)
get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"]
- cond @"m" in ["video", "photo", "history"]
+ cond @"m" in ["video", "photo"]
redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?":
diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim
index 7a10e91..c7949a2 100644
--- a/src/routes/timeline.nim
+++ b/src/routes/timeline.nim
@@ -4,20 +4,39 @@ import jester, karax/vdom
import router_utils
import ".."/[types, redis_cache, formatters, query, api]
-import ../views/[general, profile, timeline, status, search]
+import ../views/[general, profile, timeline, status, search, about_account]
export vdom
export uri, sequtils
export router_utils
export redis_cache, formatters, query, api
-export profile, timeline, status
+export profile, timeline, status, about_account
-proc getQuery*(request: Request; tab, name: string): Query =
+proc tabRssEnabled*(cfg: Config; tab: string): bool =
case tab
- of "with_replies": getReplyQuery(name)
- of "media": getMediaQuery(name)
- of "search": initQuery(params(request), name=name)
- else: Query(fromUser: @[name])
+ of "": cfg.enableRSSUserTweets
+ of "with_replies": cfg.enableRSSUserReplies
+ of "media": cfg.enableRSSUserMedia
+ of "articles": cfg.enableRSSUserArticles
+ of "search": cfg.enableRSSSearch
+ else: false
+
+proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
+ let view = request.params.getOrDefault("view")
+ case tab
+ of "with_replies":
+ result = getReplyQuery(name)
+ of "articles":
+ result = getArticlesQuery(name)
+ of "media":
+ result = getMediaQuery(name)
+ result.view =
+ if view in ["timeline", "grid", "gallery"]: view
+ else: prefs.mediaView.toLowerAscii
+ of "search":
+ result = initQuery(params(request), name=name)
+ else:
+ result = Query(fromUser: @[name])
template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
if cond:
@@ -45,20 +64,23 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
let
rail =
- skipIf(skipRail or query.kind == media, @[]):
+ skipIf(skipRail or query.kind == QueryKind.media, @[]):
getCachedPhotoRail(userId)
user = getCachedUser(name)
+ info = getCachedAccountInfo(name, fetch=false)
result =
case query.kind
of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
+ of QueryKind.articles: await getGraphUserTweets(userId, TimelineKind.articles, after)
else: Profile(tweets: await getGraphTweetSearch(query, after))
result.user = await user
result.photoRail = await rail
+ result.accountInfo = await info
result.tweets.query = query
@@ -105,16 +127,72 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user":
respUserId()
+ get "/intent/follow/?":
+ let username = request.params.getOrDefault("screen_name")
+ if username.len == 0:
+ resp Http400, showError("Missing screen_name parameter", cfg)
+ redirect("/" & username)
+
+ get "/@name/about/?":
+ cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
+ let
+ prefs = requestPrefs()
+ name = @"name"
+ info = await getCachedAccountInfo(name)
+ if info.suspended:
+ resp showError(getSuspended(name), cfg)
+ if info.username.len == 0:
+ resp Http404, showError("User \"" & name & "\" not found", cfg)
+ let aboutHtml = renderAboutAccount(info)
+ resp renderMain(aboutHtml, request, cfg, prefs,
+ "About @" & info.username)
+
+ get "/@name/@kind/?":
+ cond @"kind" in ["followers", "following"]
+ cond '.' notin @"name"
+ cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
+ let
+ prefs = requestPrefs()
+ name = @"name"
+ userId = await getUserId(name)
+
+ if userId.len == 0:
+ resp Http404, showError("User \"" & name & "\" not found", cfg)
+ if userId == "suspended":
+ resp showError(getSuspended(name), cfg)
+
+ let
+ cursor = getCursor()
+ isFollowers = @"kind" == "followers"
+ user = await getCachedUser(name)
+ results = if isFollowers: await getGraphFollowers(userId, cursor)
+ else: await getGraphFollowing(userId, cursor)
+
+ if user.protected:
+ resp renderMain(renderProtected(user.username), request, cfg, prefs,
+ "Protected account @" & name)
+
+ let
+ tab = if isFollowers: "Followers" else: "Following"
+ title = (if isFollowers: "People following @" else: "People followed by @") & name
+ html = renderUserList(user, results, prefs, request.path, tab)
+
+ resp renderMain(html, request, cfg, prefs, title,
+ images = @[user.getUserPic("_400x400")])
+
get "/@name/?@tab?/?":
cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
- cond @"tab" in ["with_replies", "media", "search", ""]
+ cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
+ cond @"tab" in ["with_replies", "media", "search", "articles", ""]
+ # articles can't be approximated by search, so multi-user is unsupported
+ cond not (@"tab" == "articles" and ',' in @"name")
let
- prefs = cookiePrefs()
+ prefs = requestPrefs()
after = getCursor()
names = getNames(@"name")
- var query = request.getQuery(@"tab", @"name")
+ var query = request.getQuery(@"tab", @"name", prefs)
if names.len != 1:
query.fromUser = names
@@ -122,7 +200,8 @@ proc createTimelineRouter*(cfg: Config) =
if @"scroll".len > 0:
if query.fromUser.len != 1:
var timeline = await getGraphTweetSearch(query, after)
- if timeline.content.len == 0: resp Http404
+ if timeline.content.len == 0:
+ resp Http204
timeline.beginning = true
resp $renderTweetSearch(timeline, prefs, getPath())
else:
@@ -132,7 +211,9 @@ proc createTimelineRouter*(cfg: Config) =
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
let rss =
- if @"tab".len == 0:
+ if not cfg.tabRssEnabled(@"tab"):
+ ""
+ elif @"tab".len == 0:
"/$1/rss" % @"name"
elif @"tab" == "search":
"/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim
index 0c085d4..345dee7 100644
--- a/src/routes/unsupported.nim
+++ b/src/routes/unsupported.nim
@@ -10,14 +10,14 @@ export feature
proc createUnsupportedRouter*(cfg: Config) =
router unsupported:
template feature {.dirty.} =
- resp renderMain(renderFeature(), request, cfg, themePrefs())
+ resp renderMain(renderFeature(), request, cfg, requestPrefs())
get "/about/feature": feature()
get "/login/?@i?": feature()
get "/@name/lists/?": feature()
get "/intent/?@i?":
- cond @"i" notin ["user"]
+ cond @"i" notin ["user", "follow"]
feature()
get "/i/@i?/?@j?":
diff --git a/src/sass/_article.scss b/src/sass/_article.scss
new file mode 100644
index 0000000..9488658
--- /dev/null
+++ b/src/sass/_article.scss
@@ -0,0 +1,278 @@
+.article-page {
+ max-width: 700px;
+ margin: 0 auto 20px;
+ background-color: var(--bg_panel);
+
+ > .top-ref {
+ padding-top: 20px;
+ }
+
+ .article-cover {
+ width: 100%;
+ display: block;
+ }
+
+ .article-body {
+ padding: 20px;
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+
+ .article-title {
+ display: block;
+ font-size: 2rem;
+ line-height: 1.3;
+ margin: 0 0 10px;
+ color: var(--fg_color);
+ }
+
+ .article-author {
+ margin-bottom: 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--border_grey);
+ font-size: 14px;
+
+ .article-author-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .article-avatar {
+ display: flex;
+ }
+
+ .avatar {
+ width: 40px;
+ height: 40px;
+ }
+
+ .article-author-name {
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+
+ .fullname {
+ font-size: 15px;
+ }
+
+ .verified-icon {
+ margin-left: 2px;
+ }
+ }
+
+ .article-author-meta {
+ display: flex;
+ align-items: center;
+ }
+
+ .fullname {
+ font-weight: 700;
+ color: var(--fg_color);
+ max-width: unset;
+ text-overflow: unset;
+ overflow: visible;
+ white-space: normal;
+ }
+
+ .username,
+ .article-date-sep,
+ .article-date {
+ color: var(--fg_dark);
+ }
+
+ .username {
+ margin-left: 0;
+ }
+
+ .article-date-sep {
+ margin: 0 4px;
+ }
+
+ .article-date {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .tweet-stats {
+ margin-top: 6px;
+
+ .tweet-stat {
+ padding-top: 0;
+ }
+ }
+ }
+
+ > h1 {
+ display: block;
+ font-size: 1.8rem;
+ margin: 25px 0 15px;
+ }
+
+ > h2 {
+ font-size: 1.4rem;
+ font-weight: bold;
+ margin: 20px 0 12px;
+ }
+
+ > h3 {
+ font-size: 1.2rem;
+ font-weight: bold;
+ margin: 18px 0 10px;
+ }
+
+ > p {
+ font-size: 16px;
+ line-height: 1.7;
+ margin: 16px 0;
+ word-wrap: break-word;
+ }
+
+ .blockquote-attribution {
+ display: block;
+ margin-top: 0.5em;
+ }
+
+ > blockquote {
+ border-left: 3px solid var(--accent);
+ padding-left: 16px;
+ margin: 16px 0;
+ color: var(--fg_faded);
+ font-size: 16px;
+ line-height: 1.7;
+ }
+
+ > pre {
+ background-color: var(--bg_elements);
+ padding: 12px 16px;
+ border-radius: 6px;
+ overflow-x: auto;
+ margin: 16px 0;
+
+ code {
+ font-family: monospace;
+ font-size: 14px;
+ color: var(--fg_color);
+ }
+ }
+
+ code {
+ background-color: var(--bg_elements);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-family: monospace;
+ font-size: 0.9em;
+ }
+
+ > ul,
+ > ol {
+ margin: 16px 0;
+ padding-left: 2em;
+
+ li {
+ font-size: 16px;
+ line-height: 1.7;
+ margin: 6px 0;
+ }
+ }
+
+ .article-media {
+ text-align: center;
+ margin: 20px 0;
+
+ img,
+ video {
+ max-width: 100%;
+ border-radius: 12px;
+ }
+
+ .article-media-caption {
+ color: var(--fg_faded);
+ font-size: 0.875rem;
+ margin-top: 6px;
+ }
+ }
+
+ > a,
+ > p a,
+ > h1 a,
+ > h2 a,
+ > h3 a,
+ > blockquote a,
+ > ul a,
+ > ol a {
+ color: var(--accent);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .article-divider {
+ border: none;
+ border-top: 1px solid var(--border_grey);
+ margin: 30px 0;
+ }
+
+ .timeline-item {
+ margin: 20px 0;
+ border: 1px solid var(--border_grey);
+ border-radius: 12px;
+ overflow: hidden;
+ }
+ }
+}
+
+.conversation .article-page {
+ max-width: 100%;
+ margin-bottom: 0;
+}
+
+.article-card {
+ .card-image-container {
+ position: relative;
+ }
+
+ .card-image img {
+ height: auto;
+ }
+
+ .article-card-badge {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 4px;
+ }
+}
+
+.quote .article-card {
+ margin: 0;
+
+ .card-container {
+ border: none;
+ border-radius: 0;
+ border-top: solid 1px var(--dark_grey);
+ }
+}
+
+@media (max-width: 700px) {
+ .article-page {
+ .article-body {
+ padding: 12px 15px 25px;
+
+ .article-title {
+ font-size: 1.6rem;
+ }
+ }
+ }
+}
diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss
new file mode 100644
index 0000000..dd93606
--- /dev/null
+++ b/src/sass/_broadcast.scss
@@ -0,0 +1,75 @@
+.broadcast-page {
+ max-width: 800px;
+ width: 100%;
+ margin: 20px auto 0;
+}
+
+.broadcast-panel {
+ background-color: var(--bg_panel);
+ border: 1px solid var(--border_grey);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.broadcast-player {
+ position: relative;
+ background: black;
+
+ video,
+ img {
+ display: block;
+ width: 100%;
+ }
+}
+
+.broadcast-info {
+ padding: 14px 16px;
+}
+
+.broadcast-title {
+ font-size: 18px;
+ font-weight: bold;
+ margin: 0 0 12px;
+}
+
+.broadcast-user-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.broadcast-user {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--fg_color);
+
+ img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ }
+}
+
+.broadcast-username {
+ color: var(--fg_dark);
+}
+
+.broadcast-meta {
+ color: var(--fg_faded);
+ font-size: 14px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex-shrink: 0;
+ line-height: 1.5em;
+}
+
+.broadcast-live {
+ background: #e0245e;
+ color: white;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: bold;
+ font-size: 12px;
+}
diff --git a/src/sass/_space.scss b/src/sass/_space.scss
new file mode 100644
index 0000000..5fe2e7e
--- /dev/null
+++ b/src/sass/_space.scss
@@ -0,0 +1,149 @@
+.space-page {
+ max-width: 800px;
+ width: 100%;
+ margin: 20px auto 0;
+}
+
+.space-panel {
+ background-color: var(--bg_panel);
+ border: 1px solid var(--border_grey);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.space-player {
+ position: relative;
+ background: linear-gradient(135deg, #7b2a8c 0%, #9b3ab1 100%);
+ min-height: 140px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ audio {
+ width: 100%;
+ padding: 15px;
+ box-sizing: border-box;
+
+ &:not([controls]) {
+ display: none;
+ }
+ }
+
+ .video-overlay {
+ background-color: transparent;
+ }
+}
+
+.space-live {
+ background: #e0245e;
+ color: white;
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 12px;
+ text-transform: uppercase;
+ position: absolute;
+ top: 8px;
+ right: 8px;
+}
+
+.space-info {
+ padding: 16px;
+}
+
+.space-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.space-title {
+ font-size: 18px;
+ font-weight: bold;
+ margin: 0;
+ line-height: 1.3;
+ flex: 1;
+}
+
+.space-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+ font-size: 14px;
+ color: var(--fg_faded);
+}
+
+.listener-count {
+ color: var(--fg_color);
+}
+
+.space-state {
+ color: var(--fg_dark);
+}
+
+.space-participants {
+ border-top: 1px solid var(--border_grey);
+ padding-top: 12px;
+}
+
+.space-participant {
+ margin-bottom: 10px;
+
+ a {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--fg_color);
+ padding: 6px 0;
+ }
+
+ img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+}
+
+.participant-info {
+ min-width: 0;
+}
+
+.participant-name {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ strong {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .verified-icon {
+ margin-bottom: 0;
+ position: relative;
+ top: -2px;
+ }
+}
+
+.host-badge {
+ background: var(--accent);
+ color: white;
+ padding: 2px 7px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ position: relative;
+ top: 1px;
+}
+
+.participant-username {
+ color: var(--fg_dark);
+ font-size: 13px;
+}
diff --git a/src/sass/general.scss b/src/sass/general.scss
index 9feb3d3..e6247d6 100644
--- a/src/sass/general.scss
+++ b/src/sass/general.scss
@@ -1,39 +1,40 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
.panel-container {
- margin: auto;
- font-size: 130%;
+ margin: auto;
+ font-size: 130%;
}
.error-panel {
- @include center-panel(var(--error_red));
- text-align: center;
+ @include center-panel(var(--error_red));
+ text-align: center;
}
.search-bar > form {
- @include center-panel(var(--darkest_grey));
+ @include center-panel(var(--darkest_grey));
- button {
- background: var(--bg_elements);
- color: var(--fg_color);
- border: 0;
- border-radius: 3px;
- cursor: pointer;
- font-weight: bold;
- width: 30px;
- height: 30px;
- }
+ button {
+ background: var(--bg_elements);
+ color: var(--fg_color);
+ border: 0;
+ border-radius: 3px;
+ cursor: pointer;
+ font-weight: bold;
+ width: 30px;
+ height: 30px;
+ padding: 0px 5px 1px 8px;
+ }
- input {
- font-size: 16px;
- width: 100%;
- background: var(--bg_elements);
- color: var(--fg_color);
- border: 0;
- border-radius: 4px;
- padding: 4px;
- margin-right: 8px;
- height: unset;
- }
+ input {
+ font-size: 16px;
+ width: 100%;
+ background: var(--bg_elements);
+ color: var(--fg_color);
+ border: 0;
+ border-radius: 4px;
+ padding: 4px;
+ margin-right: 8px;
+ height: unset;
+ }
}
diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css
index 94e11ee..5fde51a 100644
--- a/src/sass/include/_mixins.css
+++ b/src/sass/include/_mixins.css
@@ -66,18 +66,7 @@
}
#search-panel-toggle:checked ~ .search-panel {
- @if $rows == 6 {
- max-height: 200px !important;
- }
- @if $rows == 5 {
- max-height: 300px !important;
- }
- @if $rows == 4 {
- max-height: 300px !important;
- }
- @if $rows == 3 {
- max-height: 365px !important;
- }
+ max-height: 380px !important;
}
}
}
diff --git a/src/sass/include/_variables.scss b/src/sass/include/_variables.scss
index 0c95ff6..127cccb 100644
--- a/src/sass/include/_variables.scss
+++ b/src/sass/include/_variables.scss
@@ -1,46 +1,43 @@
// colors
-$bg_color: #0F0F0F;
-$fg_color: #F8F8F2;
-$fg_faded: #F8F8F2CF;
-$fg_dark: #FF6C60;
-$fg_nav: #FF6C60;
+$bg_color: #0f0f0f;
+$fg_color: #f8f8f2;
+$fg_faded: #f8f8f2cf;
+$fg_dark: #ff6c60;
+$fg_nav: #ff6c60;
$bg_panel: #161616;
$bg_elements: #121212;
-$bg_overlays: #1F1F1F;
-$bg_hover: #1A1A1A;
+$bg_overlays: #1f1f1f;
+$bg_hover: #1a1a1a;
$grey: #888889;
$dark_grey: #404040;
$darker_grey: #282828;
$darkest_grey: #222222;
-$border_grey: #3E3E35;
+$border_grey: #3e3e35;
-$accent: #FF6C60;
-$accent_light: #FFACA0;
-$accent_dark: #8A3731;
-$accent_border: #FF6C6091;
+$accent: #ff6c60;
+$accent_light: #ffaca0;
+$accent_dark: #8a3731;
+$accent_border: #ff6c6091;
-$play_button: #D8574D;
-$play_button_hover: #FF6C60;
+$play_button: #d8574d;
+$play_button_hover: #ff6c60;
-$more_replies_dots: #AD433B;
-$error_red: #420A05;
+$more_replies_dots: #ad433b;
+$error_red: #420a05;
-$verified_blue: #1DA1F2;
-$verified_business: #FAC82B;
-$verified_government: #C1B6A4;
+$verified_blue: #1da1f2;
+$verified_business: #fac82b;
+$verified_government: #c1b6a4;
$icon_text: $fg_color;
$tab: $fg_color;
$tab_selected: $accent;
-$shadow: rgba(0,0,0,.6);
-$shadow_dark: rgba(0,0,0,.2);
+$shadow: rgba(0, 0, 0, 0.6);
+$shadow_dark: rgba(0, 0, 0, 0.2);
//fonts
-$font_0: Helvetica Neue;
-$font_1: Helvetica;
-$font_2: Arial;
-$font_3: sans-serif;
-$font_4: fontello;
+$font_0: sans-serif;
+$font_1: fontello;
diff --git a/src/sass/index.scss b/src/sass/index.scss
index 6cab48e..404f7d5 100644
--- a/src/sass/index.scss
+++ b/src/sass/index.scss
@@ -1,180 +1,220 @@
-@import '_variables';
+@import "_variables";
-@import 'tweet/_base';
-@import 'profile/_base';
-@import 'general';
-@import 'navbar';
-@import 'inputs';
-@import 'timeline';
-@import 'search';
+@import "tweet/_base";
+@import "profile/_base";
+@import "general";
+@import "navbar";
+@import "inputs";
+@import "timeline";
+@import "search";
+@import "broadcast";
+@import "space";
+@import "_article";
body {
- // colors
- --bg_color: #{$bg_color};
- --fg_color: #{$fg_color};
- --fg_faded: #{$fg_faded};
- --fg_dark: #{$fg_dark};
- --fg_nav: #{$fg_nav};
+ // colors
+ --bg_color: #{$bg_color};
+ --fg_color: #{$fg_color};
+ --fg_faded: #{$fg_faded};
+ --fg_dark: #{$fg_dark};
+ --fg_nav: #{$fg_nav};
- --bg_panel: #{$bg_panel};
- --bg_elements: #{$bg_elements};
- --bg_overlays: #{$bg_overlays};
- --bg_hover: #{$bg_hover};
+ --bg_panel: #{$bg_panel};
+ --bg_elements: #{$bg_elements};
+ --bg_overlays: #{$bg_overlays};
+ --bg_hover: #{$bg_hover};
- --grey: #{$grey};
- --dark_grey: #{$dark_grey};
- --darker_grey: #{$darker_grey};
- --darkest_grey: #{$darkest_grey};
- --border_grey: #{$border_grey};
+ --grey: #{$grey};
+ --dark_grey: #{$dark_grey};
+ --darker_grey: #{$darker_grey};
+ --darkest_grey: #{$darkest_grey};
+ --border_grey: #{$border_grey};
- --accent: #{$accent};
- --accent_light: #{$accent_light};
- --accent_dark: #{$accent_dark};
- --accent_border: #{$accent_border};
+ --accent: #{$accent};
+ --accent_light: #{$accent_light};
+ --accent_dark: #{$accent_dark};
+ --accent_border: #{$accent_border};
- --play_button: #{$play_button};
- --play_button_hover: #{$play_button_hover};
+ --play_button: #{$play_button};
+ --play_button_hover: #{$play_button_hover};
- --more_replies_dots: #{$more_replies_dots};
- --error_red: #{$error_red};
+ --more_replies_dots: #{$more_replies_dots};
+ --error_red: #{$error_red};
- --verified_blue: #{$verified_blue};
- --verified_business: #{$verified_business};
- --verified_government: #{$verified_government};
- --icon_text: #{$icon_text};
+ --verified_blue: #{$verified_blue};
+ --verified_business: #{$verified_business};
+ --verified_government: #{$verified_government};
+ --icon_text: #{$icon_text};
- --tab: #{$fg_color};
- --tab_selected: #{$accent};
+ --tab: #{$fg_color};
+ --tab_selected: #{$accent};
- --profile_stat: #{$fg_color};
+ --profile_stat: #{$fg_color};
- background-color: var(--bg_color);
- color: var(--fg_color);
- font-family: $font_0, $font_1, $font_2, $font_3;
- font-size: 14px;
- line-height: 1.3;
- margin: 0;
+ background-color: var(--bg_color);
+ color: var(--fg_color);
+ font-family: $font_0, $font_1;
+ font-size: 15px;
+ line-height: 1.3;
+ margin: 0;
}
* {
- outline: unset;
- margin: 0;
- text-decoration: none;
+ outline: unset;
+ margin: 0;
+ text-decoration: none;
+}
+
+img {
+ dynamic-range-limit: standard;
}
h1 {
- display: inline;
+ display: inline;
}
-h2, h3 {
- font-weight: normal;
+h2,
+h3 {
+ font-weight: normal;
}
p {
- margin: 14px 0;
+ margin: 14px 0;
}
a {
- color: var(--accent);
+ color: var(--accent);
- &:hover {
- text-decoration: underline;
- }
+ &:hover {
+ text-decoration: underline;
+ }
}
fieldset {
- border: 0;
- padding: 0;
- margin-top: -0.6em;
+ border: 0;
+ padding: 0;
+ margin-top: -0.6em;
}
legend {
- width: 100%;
- padding: .6em 0 .3em 0;
- border: 0;
- font-size: 16px;
- font-weight: 600;
- border-bottom: 1px solid var(--border_grey);
- margin-bottom: 8px;
+ width: 100%;
+ padding: 0.6em 0 0.3em 0;
+ border: 0;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border_grey);
+ margin-bottom: 8px;
}
-.preferences .note {
+.preferences {
+ .note {
border-top: 1px solid var(--border_grey);
border-bottom: 1px solid var(--border_grey);
padding: 6px 0 8px 0;
margin-bottom: 8px;
margin-top: 16px;
+ }
+
+ .bookmark-note {
+ margin: 0;
+ margin-bottom: 10px;
+ }
}
ul {
- padding-left: 1.3em;
+ padding-left: 1.3em;
}
.container {
- display: flex;
- flex-wrap: wrap;
- box-sizing: border-box;
- padding-top: 50px;
- margin: auto;
- min-height: 100vh;
+ display: flex;
+ flex-wrap: wrap;
+ box-sizing: border-box;
+ margin: auto;
+ min-height: 100vh;
+}
+
+body.fixed-nav .container {
+ padding-top: 50px;
}
.icon-container {
- display: inline;
+ display: inline;
}
.overlay-panel {
- max-width: 600px;
- width: 100%;
- margin: 0 auto;
- margin-top: 10px;
- background-color: var(--bg_overlays);
- padding: 10px 15px;
- align-self: start;
+ max-width: 600px;
+ width: 100%;
+ margin: 0 auto;
+ margin-top: 10px;
+ background-color: var(--bg_overlays);
+ padding: 10px 15px;
+ align-self: start;
- ul {
- margin-bottom: 14px;
- }
+ ul {
+ margin-bottom: 14px;
+ }
- p {
- word-break: break-word;
- }
+ p {
+ word-break: break-word;
+ }
}
.verified-icon {
- color: var(--icon_text);
- border-radius: 50%;
- flex-shrink: 0;
- margin: 2px 0 3px 3px;
- padding-top: 3px;
- height: 11px;
- width: 14px;
- font-size: 8px;
- display: inline-block;
- text-align: center;
- vertical-align: middle;
+ display: inline-block;
+ position: relative;
+ width: 14px;
+ height: 14px;
+ margin-bottom: 2px;
- &.blue {
- background-color: var(--verified_blue);
+ .verified-icon-circle {
+ position: absolute;
+ font-size: 15px;
+ }
+
+ .verified-icon-check {
+ position: absolute;
+ font-size: 9px;
+ margin: 5px 3px;
+ }
+
+ &.blue {
+ .verified-icon-circle {
+ color: var(--verified_blue);
}
- &.business {
- color: var(--bg_panel);
- background-color: var(--verified_business);
+ .verified-icon-check {
+ color: var(--icon_text);
+ }
+ }
+
+ &.business {
+ .verified-icon-circle {
+ color: var(--verified_business);
}
- &.government {
- color: var(--bg_panel);
- background-color: var(--verified_government);
+ .verified-icon-check {
+ color: var(--bg_panel);
}
+ }
+
+ &.government {
+ .verified-icon-circle {
+ color: var(--verified_government);
+ }
+
+ .verified-icon-check {
+ color: var(--bg_panel);
+ }
+ }
}
-@media(max-width: 600px) {
- .preferences-container {
- max-width: 95vw;
- }
+@media (max-width: 600px) {
+ .preferences-container {
+ max-width: 95vw;
+ }
- .nav-item, .nav-item .icon-container {
- font-size: 16px;
- }
+ .nav-item,
+ .nav-item .icon-container {
+ font-size: 16px;
+ }
}
diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss
index 17c2a22..2b6016f 100644
--- a/src/sass/inputs.scss
+++ b/src/sass/inputs.scss
@@ -1,185 +1,216 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
button {
- @include input-colors;
- background-color: var(--bg_elements);
- color: var(--fg_color);
- border: 1px solid var(--accent_border);
- padding: 3px 6px;
- font-size: 14px;
- cursor: pointer;
- float: right;
+ @include input-colors;
+ background-color: var(--bg_elements);
+ color: var(--fg_color);
+ border: 1px solid var(--accent_border);
+ padding: 3px 6px;
+ font-size: 14px;
+ cursor: pointer;
+ float: right;
}
input[type="text"],
input[type="date"],
+input[type="number"],
select {
- @include input-colors;
- background-color: var(--bg_elements);
- padding: 1px 4px;
- color: var(--fg_color);
- border: 1px solid var(--accent_border);
- border-radius: 0;
- font-size: 14px;
+ @include input-colors;
+ background-color: var(--bg_elements);
+ padding: 1px 4px;
+ color: var(--fg_color);
+ border: 1px solid var(--accent_border);
+ border-radius: 0;
+ font-size: 14px;
}
-input[type="text"] {
- height: 16px;
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+input[type="text"],
+input[type="number"] {
+ height: 16px;
}
select {
- height: 20px;
- padding: 0 2px;
- line-height: 1;
+ height: 20px;
+ padding: 0 2px;
+ line-height: 1;
}
input[type="date"]::-webkit-inner-spin-button {
- display: none;
+ display: none;
+}
+
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ display: none;
+ -webkit-appearance: none;
+ margin: 0;
}
input[type="date"]::-webkit-clear-button {
- margin-left: 17px;
- filter: grayscale(100%);
- filter: hue-rotate(120deg);
+ margin-left: 17px;
+ filter: grayscale(100%);
+ filter: hue-rotate(120deg);
}
input::-webkit-calendar-picker-indicator {
- opacity: 0;
+ opacity: 0;
}
input::-webkit-datetime-edit-day-field:focus,
input::-webkit-datetime-edit-month-field:focus,
input::-webkit-datetime-edit-year-field:focus {
- background-color: var(--accent);
- color: var(--fg_color);
- outline: none;
+ background-color: var(--accent);
+ color: var(--fg_color);
+ outline: none;
}
.date-range {
- .date-input {
- display: inline-block;
- position: relative;
- }
+ .date-input {
+ display: inline-block;
+ position: relative;
+ }
- .icon-container {
- pointer-events: none;
- position: absolute;
- top: 2px;
- right: 5px;
- }
+ .icon-container {
+ pointer-events: none;
+ position: absolute;
+ top: 2px;
+ right: 5px;
+ }
- .search-title {
- margin: 0 2px;
- }
+ .search-title {
+ margin: 0 2px;
+ }
}
.icon-button button {
- color: var(--accent);
- text-decoration: none;
- background: none;
- border: none;
- float: none;
- padding: unset;
- padding-left: 4px;
+ color: var(--accent);
+ text-decoration: none;
+ background: none;
+ border: none;
+ float: none;
+ padding: unset;
+ padding-left: 4px;
- &:hover {
- color: var(--accent_light);
- }
+ &:hover {
+ color: var(--accent_light);
+ }
}
.checkbox {
- position: absolute;
- top: 1px;
- right: 0;
- height: 17px;
- width: 17px;
- background-color: var(--bg_elements);
- border: 1px solid var(--accent_border);
+ position: absolute;
+ top: 1px;
+ right: 0;
+ height: 17px;
+ width: 17px;
+ background-color: var(--bg_elements);
+ border: 1px solid var(--accent_border);
- &:after {
- content: "";
- position: absolute;
- display: none;
- }
+ &:after {
+ content: "";
+ position: absolute;
+ display: none;
+ }
}
.checkbox-container {
- display: block;
- position: relative;
- margin-bottom: 5px;
+ display: block;
+ position: relative;
+ margin-bottom: 5px;
+ cursor: pointer;
+ user-select: none;
+ padding-right: 22px;
+
+ input {
+ position: absolute;
+ opacity: 0;
cursor: pointer;
- user-select: none;
- padding-right: 22px;
+ height: 0;
+ width: 0;
- input {
- position: absolute;
- opacity: 0;
- cursor: pointer;
- height: 0;
- width: 0;
-
- &:checked ~ .checkbox:after {
- display: block;
- }
+ &:checked ~ .checkbox:after {
+ display: block;
}
+ }
- &:hover input ~ .checkbox {
- border-color: var(--accent);
- }
+ &:hover input ~ .checkbox {
+ border-color: var(--accent);
+ }
- &:active input ~ .checkbox {
- border-color: var(--accent_light);
- }
+ &:active input ~ .checkbox {
+ border-color: var(--accent_light);
+ }
- .checkbox:after {
- left: 2px;
- bottom: 0;
- font-size: 13px;
- font-family: $font_4;
- content: '\e803';
- }
+ .checkbox:after {
+ left: 2px;
+ bottom: 0;
+ font-size: 13px;
+ font-family: $font_1;
+ content: "\e811";
+ }
}
.pref-group {
- display: inline;
+ display: inline;
}
.preferences {
- button {
- margin: 6px 0 3px 0;
- }
+ button {
+ margin: 6px 0 3px 0;
+ }
- label {
- padding-right: 150px;
- }
+ label {
+ padding-right: 150px;
+ }
- select {
- position: absolute;
- top: 0;
- right: 0;
- display: block;
- -moz-appearance: none;
- -webkit-appearance: none;
- appearance: none;
- }
+ select {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: block;
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ appearance: none;
+ min-width: 100px;
+ }
- input[type="text"] {
- position: absolute;
- right: 0;
- max-width: 140px;
- }
+ input[type="text"],
+ input[type="number"] {
+ position: absolute;
+ right: 0;
+ max-width: 140px;
+ }
- .pref-group {
- display: block;
- }
+ .pref-group {
+ display: block;
+ }
- .pref-input {
- position: relative;
- margin-bottom: 6px;
- }
+ .pref-input {
+ position: relative;
+ margin-bottom: 6px;
+ }
- .pref-reset {
- float: left;
- }
+ .pref-reset {
+ float: left;
+ }
+
+ .prefs-code {
+ background-color: var(--bg_elements);
+ border: 1px solid var(--accent_border);
+ color: var(--fg_color);
+ font-size: 13px;
+ padding: 6px 8px;
+ margin: 4px 0;
+ word-break: break-all;
+ white-space: pre-wrap;
+ user-select: all;
+ }
}
diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss
index 47a8765..c999022 100644
--- a/src/sass/navbar.scss
+++ b/src/sass/navbar.scss
@@ -1,89 +1,90 @@
-@import '_variables';
+@import "_variables";
nav {
- display: flex;
- align-items: center;
- position: fixed;
- background-color: var(--bg_overlays);
- box-shadow: 0 0 4px $shadow;
- padding: 0;
- width: 100%;
- height: 50px;
- z-index: 1000;
- font-size: 16px;
+ display: flex;
+ align-items: center;
+ background-color: var(--bg_overlays);
+ box-shadow: 0 0 4px $shadow;
+ padding: 0;
+ width: 100%;
+ height: 50px;
+ z-index: 1000;
+ font-size: 16px;
- a, .icon-button button {
- color: var(--fg_nav);
- }
+ a,
+ .icon-button button {
+ color: var(--fg_nav);
+ }
+
+ body.fixed-nav & {
+ position: fixed;
+ }
}
.inner-nav {
- margin: auto;
- box-sizing: border-box;
- padding: 0 10px;
- display: flex;
- align-items: center;
- flex-basis: 920px;
- height: 50px;
+ margin: auto;
+ box-sizing: border-box;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ flex-basis: 920px;
+ height: 50px;
}
.site-name {
- font-size: 15px;
- font-weight: 600;
- line-height: 1;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1;
- &:hover {
- color: var(--accent_light);
- text-decoration: unset;
- }
+ &:hover {
+ color: var(--accent_light);
+ text-decoration: unset;
+ }
}
.site-logo {
- display: block;
- width: 35px;
- height: 35px;
+ display: block;
+ width: 35px;
+ height: 35px;
}
.nav-item {
- display: flex;
- flex: 1;
- line-height: 50px;
- height: 50px;
- overflow: hidden;
- flex-wrap: wrap;
- align-items: center;
+ display: flex;
+ flex: 1;
+ line-height: 50px;
+ height: 50px;
+ overflow: hidden;
+ flex-wrap: wrap;
+ align-items: center;
- &.right {
- text-align: right;
- justify-content: flex-end;
- }
+ &.right {
+ text-align: right;
+ justify-content: flex-end;
+ }
- &.right a {
- padding-left: 4px;
-
- &:hover {
- color: var(--accent_light);
- text-decoration: unset;
- }
- }
+ &.right a:hover {
+ color: var(--accent_light);
+ text-decoration: unset;
+ }
}
.lp {
- height: 14px;
- display: inline-block;
- position: relative;
- top: 2px;
- fill: var(--fg_nav);
+ height: 14px;
+ display: inline-block;
+ position: relative;
+ top: 2px;
+ fill: var(--fg_nav);
- &:hover {
- fill: var(--accent_light);
- }
+ &:hover {
+ fill: var(--accent_light);
+ }
}
-.icon-info:before {
- margin: 0 -3px;
+.icon-info {
+ margin: 0 -3px;
}
.icon-cog {
- font-size: 15px;
+ font-size: 15px;
+ padding-left: 0 !important;
}
diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss
index b7f33e6..81b3d78 100644
--- a/src/sass/profile/_base.scss
+++ b/src/sass/profile/_base.scss
@@ -1,83 +1,118 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
-@import 'card';
-@import 'photo-rail';
+@import "card";
+@import "about-account";
+@import "photo-rail";
+@import "community";
.profile-tabs {
- @include panel(auto, 900px);
+ @include panel(auto, 900px);
- .timeline-container {
- float: right;
- width: 68% !important;
- max-width: unset;
- }
+ .timeline-container {
+ float: right;
+ width: 68% !important;
+ max-width: unset;
+ }
}
.profile-banner {
- margin-bottom: 4px;
- background-color: var(--bg_panel);
+ margin-bottom: 4px;
+ background-color: var(--bg_panel);
- a {
- display: block;
- position: relative;
- padding: 33.34% 0 0 0;
- }
+ a {
+ display: block;
+ position: relative;
+ padding: 33.34% 0 0 0;
+ }
- img {
- max-width: 100%;
- position: absolute;
- top: 0;
- }
+ img {
+ max-width: 100%;
+ position: absolute;
+ top: 0;
+ }
}
.profile-tab {
- padding: 0 4px 0 0;
- box-sizing: border-box;
- display: inline-block;
- font-size: 14px;
- text-align: left;
- vertical-align: top;
- max-width: 32%;
+ padding: 0 4px 0 0;
+ box-sizing: border-box;
+ display: inline-block;
+ font-size: 14px;
+ text-align: left;
+ vertical-align: top;
+ max-width: 32%;
+ top: 0;
+
+ body.fixed-nav & {
top: 50px;
+ }
}
.profile-result {
- min-height: 54px;
+ min-height: 54px;
- .username {
- margin: 0 !important;
- }
+ .username {
+ margin: 0 !important;
+ }
- .tweet-header {
- margin-bottom: unset;
- }
+ .tweet-header {
+ margin-bottom: unset;
+ }
}
-@media(max-width: 700px) {
- .profile-tabs {
- width: 100vw;
- max-width: 600px;
+.profile-tabs.media-only {
+ max-width: none;
+ width: 100%;
- .timeline-container {
- width: 100% !important;
+ .timeline-container {
+ float: none;
+ width: 100% !important;
+ max-width: none;
+ padding: 0 10px;
+ box-sizing: border-box;
+ }
- .tab-item wide {
- flex-grow: 1.4;
- }
- }
+ .timeline-container > .tab {
+ max-width: 900px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+@media (max-width: 700px) {
+ .profile-tabs {
+ width: 100vw;
+ max-width: 600px;
+
+ .timeline-container {
+ width: 100% !important;
+
+ .tab-item wide {
+ flex-grow: 1.4;
+ }
}
+ }
- .profile-tab {
- width: 100%;
- max-width: unset;
- position: initial !important;
- padding: 0;
+ .profile-tabs.media-only {
+ width: 100%;
+ max-width: none;
+
+ .timeline-container {
+ width: 100vw !important;
+ padding: 0;
}
+ }
+
+ .profile-tab {
+ width: 100%;
+ max-width: unset;
+ position: initial !important;
+ padding: 0;
+ }
}
@media (min-height: 900px) {
- .profile-tab.sticky {
- position: sticky;
- }
+ .profile-tab.sticky {
+ position: sticky;
+ }
}
diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss
new file mode 100644
index 0000000..92d13c9
--- /dev/null
+++ b/src/sass/profile/_community.scss
@@ -0,0 +1,203 @@
+.community-header {
+ padding: 12px 15px;
+ border-bottom: 1px solid var(--border_grey);
+ background-color: var(--bg_panel);
+
+ .community-name {
+ font-size: 22px;
+ margin-bottom: 6px;
+
+ a {
+ color: inherit;
+ }
+ }
+
+ .community-category {
+ display: inline-block;
+ background-color: var(--bg_elements);
+ border: 1px solid var(--border_grey);
+ border-radius: 16px;
+ padding: 2px 12px;
+ font-size: 13px;
+ color: var(--fg_faded);
+ margin-bottom: 8px;
+ }
+
+ .community-description {
+ color: var(--fg_faded);
+ margin-bottom: 8px;
+ line-height: 1.4;
+ }
+
+ .community-member-count {
+ font-weight: bold;
+ color: inherit;
+ }
+
+ .community-stats {
+ color: var(--grey);
+ font-size: 14px;
+ }
+}
+
+.community-about {
+ padding: 16px 15px 15px;
+ background-color: var(--bg_panel);
+
+ h2 {
+ font-size: 18px;
+ margin: 0 0 12px;
+ }
+
+ .community-info {
+ border-bottom: 1px solid var(--border_grey);
+ padding-bottom: 12px;
+ }
+
+ .community-info-item {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ align-items: center;
+
+ .verified-icon {
+ margin-left: 2px;
+ }
+
+ > .icon-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ color: var(--grey);
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ }
+
+ strong {
+ color: var(--fg_color);
+ }
+
+ a {
+ color: var(--accent);
+ }
+ }
+
+ .community-rules {
+ border-bottom: 1px solid var(--border_grey);
+ padding: 16px 0 12px;
+ }
+
+ .community-rules-intro {
+ color: var(--fg_faded);
+ font-size: 14px;
+ margin: 0 0 12px;
+ }
+
+ .community-rule {
+ display: flex;
+ gap: 10px;
+ padding: 10px 0;
+ align-items: flex-start;
+
+ .community-rule-number {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background-color: var(--accent);
+ color: var(--fg_color);
+ font-weight: bold;
+ font-size: 13px;
+ flex-shrink: 0;
+ }
+
+ .community-rule-content p {
+ margin: 4px 0 0;
+ color: var(--fg_faded);
+ font-size: 14px;
+ }
+ }
+
+ .community-moderators {
+ padding-top: 16px;
+
+ h2 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .community-mods-link {
+ font-size: 14px;
+ font-weight: normal;
+ color: var(--accent);
+ }
+ }
+
+ .community-moderator {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ align-items: center;
+
+ .community-mod-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ }
+
+ .community-mod-info {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .community-mod-name {
+ display: flex;
+ align-items: center;
+ font-weight: bold;
+ color: var(--fg_color);
+
+ .verified-icon {
+ margin-left: 2px;
+ }
+ }
+
+ .community-mod-username {
+ color: var(--fg_faded);
+ font-size: 14px;
+ }
+ }
+}
+
+.community-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 15px;
+ border-bottom: 1px solid var(--border_grey);
+
+ .community-tag {
+ display: inline-block;
+ background-color: var(--bg_elements);
+ border: 1px solid var(--border_grey);
+ border-radius: 16px;
+ padding: 4px 12px;
+ font-size: 13px;
+ color: var(--accent);
+ }
+}
+
+.community-hashtag-header {
+ padding: 12px 15px;
+ border-bottom: 1px solid var(--border_grey);
+
+ .community-hashtag-title {
+ font-size: 20px;
+ color: var(--accent);
+ margin: 0;
+ }
+}
diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss
new file mode 100644
index 0000000..aa12f49
--- /dev/null
+++ b/src/sass/profile/about-account.scss
@@ -0,0 +1,71 @@
+@import '_variables';
+
+.about-account {
+ max-width: 500px;
+ width: 100%;
+ margin: 20px auto 0;
+ align-self: flex-start;
+ background: var(--bg_panel);
+ border-radius: 4px;
+ padding: 12px 20px 20px;
+}
+
+.about-account-header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 16px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--border_grey);
+}
+
+.about-account-avatar img {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ margin-bottom: 4px;
+}
+
+.about-account-name {
+ @include breakable;
+ font-weight: bold;
+}
+
+.about-account-body {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.about-account-at {
+ font-size: 18px;
+ font-weight: bold;
+}
+
+.about-account-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ > span:first-child {
+ color: var(--fg_faded);
+ flex-shrink: 0;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.about-account-label {
+ color: var(--fg_faded);
+ font-size: 13px;
+}
+
+@media(max-width: 700px) {
+ .about-account {
+ max-width: none;
+ margin: 10px;
+ }
+}
diff --git a/src/sass/search.scss b/src/sass/search.scss
index f70f7ea..fa2a2d8 100644
--- a/src/sass/search.scss
+++ b/src/sass/search.scss
@@ -1,122 +1,194 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
.search-title {
- font-weight: bold;
- display: inline-block;
- margin-top: 4px;
+ font-weight: bold;
+ display: inline-block;
+ margin-top: 4px;
}
.search-field {
+ display: flex;
+ flex-wrap: wrap;
+
+ button {
+ margin: 0 2px 0 0;
+ padding: 0px 1px 1px 4px;
+ height: 23px;
display: flex;
- flex-wrap: wrap;
+ align-items: center;
+ }
- button {
- margin: 0 2px 0 0;
- height: 23px;
- display: flex;
- align-items: center;
- }
+ .pref-input {
+ margin: 0 4px 0 0;
+ flex-grow: 1;
+ height: 23px;
+ }
- .pref-input {
- margin: 0 4px 0 0;
- flex-grow: 1;
- height: 23px;
- }
+ input[type="text"],
+ input[type="number"] {
+ height: calc(100% - 4px);
+ width: calc(100% - 8px);
+ }
- input[type="text"] {
- height: calc(100% - 4px);
- width: calc(100% - 8px);
- }
+ > label {
+ display: inline;
+ background-color: var(--bg_elements);
+ color: var(--fg_color);
+ border: 1px solid var(--accent_border);
+ padding: 1px 1px 2px 4px;
+ font-size: 14px;
+ cursor: pointer;
+ margin-bottom: 2px;
- > label {
- display: inline;
- background-color: var(--bg_elements);
- color: var(--fg_color);
- border: 1px solid var(--accent_border);
- padding: 1px 6px 2px 6px;
- font-size: 14px;
- cursor: pointer;
- margin-bottom: 2px;
+ @include input-colors;
+ }
- @include input-colors;
- }
-
- @include create-toggle(search-panel, 200px);
+ @include create-toggle(search-panel, 380px);
}
.search-panel {
- width: 100%;
- max-height: 0;
- overflow: hidden;
- transition: max-height 0.4s;
+ width: 100%;
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.4s;
- flex-grow: 1;
- font-weight: initial;
- text-align: left;
+ flex-grow: 1;
+ font-weight: initial;
+ text-align: left;
- > div {
- line-height: 1.7em;
- }
+ .checkbox-container {
+ display: inline;
+ padding-right: unset;
+ margin-bottom: 5px;
+ margin-left: 23px;
+ }
- .checkbox-container {
- display: inline;
- padding-right: unset;
- margin-bottom: unset;
- margin-left: 23px;
- }
+ .checkbox {
+ right: unset;
+ left: -22px;
+ line-height: 1.6em;
+ }
- .checkbox {
- right: unset;
- left: -22px;
- }
-
- .checkbox-container .checkbox:after {
- top: -4px;
- }
+ .checkbox-container .checkbox:after {
+ top: -4px;
+ }
}
.search-row {
- display: flex;
- flex-wrap: wrap;
- line-height: unset;
+ display: flex;
+ flex-wrap: wrap;
+ line-height: unset;
- > div {
- flex-grow: 1;
- flex-shrink: 1;
- }
+ > div {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+
+ input {
+ height: 21px;
+ }
+
+ .pref-input {
+ display: block;
+ padding-bottom: 5px;
input {
- height: 21px;
- }
-
- .pref-input {
- display: block;
- padding-bottom: 5px;
-
- input {
- height: 21px;
- margin-top: 1px;
- }
+ height: 21px;
+ margin-top: 1px;
}
+ }
}
.search-toggles {
- flex-grow: 1;
- display: grid;
- grid-template-columns: repeat(6, auto);
- grid-column-gap: 10px;
+ flex-grow: 1;
+ display: grid;
+ grid-template-columns: repeat(5, auto);
+ grid-column-gap: 10px;
+}
+
+.list-result {
+ display: flex;
+ align-items: flex-start;
+
+ .list-result-banner {
+ flex-shrink: 0;
+ width: 56px;
+ height: 56px;
+ margin-right: 10px;
+ border-radius: 8px;
+ overflow: hidden;
+ background-color: var(--darker_grey);
+ // stay above the tweet-link overlay's hover background
+ z-index: 1;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .list-result-body {
+ min-width: 0;
+ pointer-events: none;
+ z-index: 1;
+
+ a {
+ pointer-events: all;
+ }
+ }
+
+ .list-result-title {
+ align-items: baseline;
+ }
+
+ .list-members {
+ flex-shrink: 0;
+ margin-left: 0.3em;
+ color: var(--fg_faded);
+ }
+
+ .list-result-context {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: 2px;
+ color: var(--fg_faded);
+
+ a {
+ color: var(--fg_dark);
+ }
+
+ a.fullname {
+ color: var(--fg_color);
+ }
+
+ .list-facepile {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ margin-right: 4px;
+ }
+ }
+
+ .list-result-description {
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: all;
+ }
}
.profile-tabs {
- @include search-resize(820px, 5);
- @include search-resize(725px, 4);
- @include search-resize(600px, 6);
- @include search-resize(560px, 5);
- @include search-resize(480px, 4);
- @include search-resize(410px, 3);
+ @include search-resize(820px, 5);
+ @include search-resize(715px, 4);
+ @include search-resize(700px, 5);
+ @include search-resize(485px, 4);
+ @include search-resize(410px, 3);
}
-@include search-resize(560px, 5);
-@include search-resize(480px, 4);
+@include search-resize(700px, 5);
+@include search-resize(485px, 4);
@include search-resize(410px, 3);
diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss
index c8ce309..b7d4a9f 100644
--- a/src/sass/timeline.scss
+++ b/src/sass/timeline.scss
@@ -1,162 +1,505 @@
-@import '_variables';
+@import "_variables";
.timeline-container {
- @include panel(100%, 600px);
+ @include panel(100%, 600px);
}
-.timeline {
- background-color: var(--bg_panel);
+.timeline-container.media-only {
+ max-width: none;
+ width: 100%;
+ padding: 0 10px;
+ box-sizing: border-box;
- > div:not(:first-child) {
- border-top: 1px solid var(--border_grey);
- }
+ > .tab,
+ > .timeline-header {
+ max-width: 900px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+@media (max-width: 700px) {
+ .timeline-container.media-only {
+ padding: 0;
+ }
+}
+
+.timeline > div:not(:first-child) {
+ border-top: 1px solid var(--border_grey);
}
.timeline-header {
- width: 100%;
- background-color: var(--bg_panel);
- text-align: center;
- padding: 8px;
- display: block;
- font-weight: bold;
- margin-bottom: 5px;
- box-sizing: border-box;
+ width: 100%;
+ background-color: var(--bg_panel);
+ text-align: center;
+ padding: 8px;
+ display: block;
+ font-weight: bold;
+ margin-bottom: 4px;
+ box-sizing: border-box;
- button {
- float: unset;
- }
+ button {
+ float: unset;
+ }
}
.timeline-banner img {
- width: 100%;
+ width: 100%;
}
.timeline-description {
- font-weight: normal;
+ font-weight: normal;
}
.tab {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- list-style: none;
- margin: 0 0 5px 0;
- background-color: var(--bg_panel);
- padding: 0;
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ list-style: none;
+ margin: 0 0 4px 0;
+ background-color: var(--bg_panel);
+ padding: 0;
}
.tab-item {
- flex: 1 1 0;
- text-align: center;
- margin-top: 0;
+ flex: 1 1 0;
+ text-align: center;
+ margin-top: 0;
- a {
- border-bottom: .1rem solid transparent;
- color: var(--tab);
- display: block;
- padding: 8px 0;
- text-decoration: none;
- font-weight: bold;
+ a {
+ border-bottom: 0.1rem solid transparent;
+ color: var(--tab);
+ display: block;
+ padding: 8px 0;
+ text-decoration: none;
+ font-weight: bold;
- &:hover {
- text-decoration: none;
- }
-
- &.active {
- border-bottom-color: var(--tab_selected);
- color: var(--tab_selected);
- }
+ &:hover {
+ text-decoration: none;
}
- &.active a {
- border-bottom-color: var(--tab_selected);
- color: var(--tab_selected);
+ &.active {
+ border-bottom-color: var(--tab_selected);
+ color: var(--tab_selected);
}
+ }
- &.wide {
- flex-grow: 1.2;
- flex-basis: 50px;
- }
+ &.active a {
+ border-bottom-color: var(--tab_selected);
+ color: var(--tab_selected);
+ }
+
+ &.wide {
+ flex-grow: 1.2;
+ flex-basis: 50px;
+ }
}
.timeline-footer {
- background-color: var(--bg_panel);
- padding: 6px 0;
+ background-color: var(--bg_panel);
+ padding: 6px 0;
}
.timeline-protected {
- text-align: center;
+ text-align: center;
- p {
- margin: 8px 0;
- }
+ p {
+ margin: 8px 0;
+ }
- h2 {
- color: var(--accent);
- font-size: 20px;
- font-weight: 600;
- }
-}
-
-.timeline-none {
+ h2 {
color: var(--accent);
font-size: 20px;
font-weight: 600;
- text-align: center;
+ }
+}
+
+.timeline-none {
+ color: var(--accent);
+ font-size: 20px;
+ font-weight: 600;
+ text-align: center;
}
.timeline-end {
- background-color: var(--bg_panel);
- color: var(--accent);
- font-size: 16px;
- font-weight: 600;
- text-align: center;
+ background-color: var(--bg_panel);
+ color: var(--accent);
+ font-size: 16px;
+ font-weight: 600;
+ text-align: center;
}
.show-more {
- background-color: var(--bg_panel);
- text-align: center;
- padding: .75em 0;
- display: block !important;
+ background-color: var(--bg_panel);
+ text-align: center;
+ padding: 0.75em 0;
+ display: block !important;
- a {
- background-color: var(--darkest_grey);
- display: inline-block;
- height: 2em;
- padding: 0 2em;
- line-height: 2em;
+ a {
+ background-color: var(--darkest_grey);
+ display: inline-block;
+ height: 2em;
+ padding: 0 2em;
+ line-height: 2em;
- &:hover {
- background-color: var(--darker_grey);
- }
+ &:hover {
+ background-color: var(--darker_grey);
}
+ }
}
.top-ref {
- background-color: var(--bg_color);
- border-top: none !important;
+ background-color: var(--bg_color);
+ border-top: none !important;
- .icon-down {
- font-size: 20px;
- display: flex;
- justify-content: center;
- text-decoration: none;
+ .icon-down {
+ font-size: 20px;
+ display: flex;
+ justify-content: center;
+ text-decoration: none;
- &:hover {
- color: var(--accent_light);
- }
-
- &::before {
- transform: rotate(180deg) translateY(-1px);
- }
+ &:hover {
+ color: var(--accent_light);
}
+
+ &::before {
+ transform: rotate(180deg) translateY(-1px);
+ }
+ }
}
.timeline-item {
- overflow-wrap: break-word;
- border-left-width: 0;
- min-width: 0;
- padding: .75em;
- display: flex;
- position: relative;
+ overflow-wrap: break-word;
+ border-left-width: 0;
+ min-width: 0;
+ padding: 0.75em;
+ display: flex;
+ position: relative;
+ background-color: var(--bg_panel);
+}
+
+.timeline.media-grid-view,
+.timeline.media-gallery-view {
+ > div:not(:first-child) {
+ border-top: none;
+ }
+
+ .timeline-item::before {
+ display: none;
+ }
+}
+
+.timeline.media-grid-view,
+.timeline.media-gallery-view .gallery-masonry.compact {
+ .tweet-header,
+ .replying-to,
+ .retweet-header,
+ .pinned,
+ .tweet-stats,
+ .attribution,
+ .poll,
+ .quote,
+ .community-note,
+ .media-tag-block,
+ .tweet-content,
+ .card-content {
+ display: none;
+ }
+
+ .card {
+ margin: unset;
+
+ .card-container {
+ border: unset;
+ border-radius: unset;
+
+ .card-image-container {
+ width: 100%;
+ min-height: 100%;
+ }
+
+ .card-content-container {
+ display: none;
+ }
+ }
+ }
+}
+
+.timeline.media-grid-view {
+ display: grid;
+ gap: 4px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+
+ > div:not(:first-child) {
+ margin-top: 0;
+ }
+
+ .timeline-item {
+ padding: 0;
+ }
+
+ .tweet-link {
+ z-index: 1000;
+
+ &:hover {
+ background-color: unset;
+ }
+ }
+
+ > .show-more,
+ > .top-ref,
+ > .timeline-footer,
+ > .timeline-header {
+ grid-column: 1 / -1;
+ }
+
+ .tweet-body {
+ height: 100%;
+ margin-left: 0;
+ padding: 0;
+ position: relative;
+ aspect-ratio: 1/1;
+ }
+
+ .gallery-row + .gallery-row {
+ margin-top: 0.25em !important;
+ }
+
+ .attachments {
+ background-color: var(--darkest_grey);
+ border-radius: 0;
+ margin: 0;
+ max-height: none;
+ }
+
+ .attachments,
+ .gallery-row,
+ .still-image {
+ height: 100%;
+ width: 100%;
+ }
+
+ .still-image img,
+ .attachment > video,
+ .attachment > img {
+ object-fit: cover;
+ height: 100%;
+ width: 100%;
+ }
+
+ .attachment {
+ display: flex;
+ align-items: center;
+ }
+
+ .gallery-video {
+ height: 100%;
+ }
+
+ .media-gif {
+ display: flex;
+ }
+
+ .timeline-item:hover {
+ opacity: 0.85;
+ }
+
+ .alt-text {
+ display: none;
+ }
+}
+
+.timeline.media-gallery-view {
+ .gallery-masonry {
+ margin: 10px 0;
+ column-gap: 10px;
+ column-width: unquote("clamp(190px, 22vw, 350px)");
+
+ &[data-col-size="small"] {
+ column-width: unquote("max(130px, 11vw)");
+ }
+
+ &[data-col-size="large"] {
+ column-width: unquote("clamp(350px, 22vw, 480px)");
+ }
+
+ &.masonry-active {
+ column-width: unset;
+ column-gap: unset;
+ position: relative;
+
+ .timeline-item {
+ animation: none;
+ position: absolute;
+ box-sizing: border-box;
+ margin-bottom: 0;
+ }
+ }
+
+ &.compact {
+ .tweet-body {
+ padding: 0;
+
+ > .attachments {
+ margin: 0;
+ }
+ }
+
+ .card-image-container img {
+ max-height: unset;
+ }
+ }
+ }
+
+ @keyframes masonry-init {
+ to {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
+
+ // Start hidden. CSS animation reveals after a delay as a no-JS fallback.
+ // With JS, masonry-active cancels the animation and masonry-visible reveals.
+ .gallery-masonry .timeline-item,
+ > .show-more,
+ > .top-ref,
+ > .timeline-footer {
+ opacity: 0;
+ pointer-events: none;
+ animation: masonry-init 0.2s 0.3s forwards;
+ }
+
+ .gallery-masonry.masonry-active .timeline-item.masonry-visible,
+ > .show-more.masonry-visible,
+ > .top-ref.masonry-visible,
+ > .timeline-footer.masonry-visible {
+ opacity: 1;
+ pointer-events: auto;
+ transition: opacity 0.15s ease;
+ animation: none;
+ }
+
+ .timeline-item {
+ margin-bottom: 10px;
+ break-inside: avoid;
+ flex-direction: column;
+ padding: 0;
+ }
+
+ > .show-more,
+ > .top-ref,
+ > .timeline-footer,
+ > .timeline-header {
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 900px;
+ }
+
+ > .show-more {
+ padding: 0;
+ margin-top: 8px;
+ background-color: unset;
+ }
+
+ .tweet-content {
+ margin: 3px 0;
+ }
+
+ .tweet-body {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ margin-left: 0;
+ padding: 10px;
+
+ > .attachments {
+ align-self: stretch;
+ border-radius: 0;
+ margin: -10px -10px 10px;
+ max-height: none;
+ order: -1;
+ width: auto;
+ background-color: var(--bg_elements);
+
+ .gallery-row {
+ max-height: none;
+ max-width: none;
+ align-items: center;
+ }
+
+ .still-image img,
+ .attachment > video,
+ .attachment > img {
+ max-height: none;
+ width: 100%;
+ }
+
+ .attachment:last-child {
+ max-height: none;
+ }
+
+ .card-container {
+ border: unset;
+ border-radius: unset;
+ }
+ }
+
+ .tweet-stat {
+ padding-top: unset;
+ }
+
+ .quote {
+ margin-bottom: 5px;
+ margin-top: 5px;
+ }
+
+ .replying-to {
+ margin: 0;
+ }
+ }
+
+ .tweet-header {
+ align-items: flex-start;
+ display: flex;
+ gap: 0.75em;
+ margin-bottom: 0;
+
+ .tweet-avatar {
+ img {
+ float: none;
+ height: 42px;
+ margin: 0;
+ width: 42px;
+ }
+ }
+
+ .tweet-name-row {
+ flex: 1;
+ }
+
+ .fullname-and-username {
+ flex-wrap: wrap;
+ }
+
+ .fullname {
+ max-width: calc(100% - 18px);
+ }
+
+ .verified-icon {
+ margin-left: 4px;
+ margin-top: 1px;
+ }
+
+ .username {
+ display: block;
+ flex-basis: 100%;
+ margin-left: 0;
+ }
+ }
+}
+
+@media (max-width: 520px) {
+ .timeline.media-gallery-view {
+ padding: 8px 0;
+ }
}
diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss
index 69f51c0..2f6693e 100644
--- a/src/sass/tweet/_base.scss
+++ b/src/sass/tweet/_base.scss
@@ -1,240 +1,284 @@
-@import '_variables';
-@import '_mixins';
-@import 'thread';
-@import 'media';
-@import 'video';
-@import 'embed';
-@import 'card';
-@import 'poll';
-@import 'quote';
+@import "_variables";
+@import "_mixins";
+@import "thread";
+@import "media";
+@import "video";
+@import "embed";
+@import "card";
+@import "poll";
+@import "quote";
.tweet-body {
- flex: 1;
- min-width: 0;
- margin-left: 58px;
- pointer-events: none;
- z-index: 1;
+ flex: 1;
+ min-width: 0;
+ margin-left: 58px;
+ pointer-events: none;
+ z-index: 1;
}
.tweet-content {
- font-family: $font_3;
- line-height: 1.3em;
- pointer-events: all;
- display: inline;
+ line-height: 1.3em;
+ pointer-events: all;
+ display: inline;
}
.tweet-bidi {
- display: block !important;
+ display: block !important;
}
.tweet-header {
- padding: 0;
- vertical-align: bottom;
- flex-basis: 100%;
- margin-bottom: .2em;
+ padding: 0;
+ vertical-align: bottom;
+ flex-basis: 100%;
+ margin-bottom: 0.2em;
- a {
- display: inline-block;
- word-break: break-all;
- max-width: 100%;
- pointer-events: all;
- }
+ a {
+ display: inline-block;
+ word-break: break-all;
+ max-width: 100%;
+ pointer-events: all;
+ }
}
.tweet-name-row {
- padding: 0;
- display: flex;
- justify-content: space-between;
+ padding: 0;
+ display: flex;
+ justify-content: space-between;
+
+ .verified-icon {
+ margin-left: 2px;
+ }
}
.fullname-and-username {
- display: flex;
- min-width: 0;
+ display: flex;
+ min-width: 0;
}
.fullname {
- @include ellipsis;
- flex-shrink: 2;
- max-width: 80%;
- font-size: 14px;
- font-weight: 700;
- color: var(--fg_color);
+ @include ellipsis;
+ flex-shrink: 2;
+ max-width: 80%;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--fg_color);
}
.username {
- @include ellipsis;
- min-width: 1.6em;
- margin-left: .4em;
- word-wrap: normal;
+ @include ellipsis;
+ min-width: 1.6em;
+ margin-left: 0.4em;
+ word-wrap: normal;
}
.tweet-date {
- display: flex;
- flex-shrink: 0;
- margin-left: 4px;
+ display: flex;
+ flex-shrink: 0;
+ margin-left: 4px;
}
-.tweet-date a, .username, .show-more a {
- color: var(--fg_dark);
+.tweet-date a,
+.username,
+.show-more a {
+ color: var(--fg_dark);
}
.tweet-published {
- margin: 0;
- margin-top: 5px;
- color: var(--grey);
- pointer-events: all;
+ margin-top: 6px;
+ margin-bottom: 0px;
+ color: var(--grey);
}
.tweet-avatar {
- display: contents !important;
+ display: contents !important;
- img {
- float: left;
- margin-top: 3px;
- margin-left: -58px;
- width: 48px;
- height: 48px;
- }
+ img {
+ float: left;
+ margin-top: 3px;
+ margin-left: -58px;
+ width: 48px;
+ height: 48px;
+ }
}
.avatar {
- &.round {
- border-radius: 50%;
- -webkit-user-select: none;
- }
-
- &.mini {
- position: unset;
- margin-right: 5px;
- margin-top: -1px;
- width: 20px;
- height: 20px;
- }
-}
+ &.round {
+ border-radius: 50%;
+ user-select: none;
+ -webkit-user-select: none;
+ }
-.tweet-embed {
- display: flex;
- flex-direction: column;
- justify-content: center;
- height: 100%;
- background-color: var(--bg_panel);
-
- .tweet-content {
- font-size: 18px;
- }
-
- .tweet-body {
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 0.75em * 2);
- }
-
- .card-image img {
- height: auto;
- }
-
- .avatar {
- position: absolute;
- }
+ &.mini {
+ position: unset;
+ margin-right: 5px;
+ margin-top: -1px;
+ width: 20px;
+ height: 20px;
+ }
}
.attribution {
- display: flex;
- pointer-events: all;
- margin: 5px 0;
+ display: flex;
+ pointer-events: all;
+ margin: 5px 0;
- strong {
- color: var(--fg_color);
- }
+ strong {
+ color: var(--fg_color);
+ }
}
.media-tag-block {
- padding-top: 5px;
- pointer-events: all;
+ padding-top: 5px;
+ pointer-events: all;
+ color: var(--fg_faded);
+
+ .icon-container {
+ padding-right: 2px;
+ }
+
+ .media-tag,
+ .icon-container {
color: var(--fg_faded);
-
- .icon-container {
- padding-right: 2px;
- }
-
- .media-tag, .icon-container {
- color: var(--fg_faded);
- }
+ }
}
.timeline-container .media-tag-block {
- font-size: 13px;
+ font-size: 13px;
}
.tweet-geo {
- color: var(--fg_faded);
+ color: var(--fg_faded);
}
.replying-to {
- color: var(--fg_faded);
- margin: -2px 0 4px;
+ color: var(--fg_faded);
+ margin: -2px 0 4px;
- a {
- pointer-events: all;
- }
+ a {
+ pointer-events: all;
+ }
}
-.retweet-header, .pinned, .tweet-stats {
- align-content: center;
- color: var(--grey);
- display: flex;
- flex-shrink: 0;
- flex-wrap: wrap;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
+.retweet-header,
+.pinned,
+.tweet-stats {
+ align-content: center;
+ color: var(--grey);
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 22px;
- span {
- @include ellipsis;
- }
+ span {
+ @include ellipsis;
+ }
}
.retweet-header {
- margin-top: -5px !important;
+ margin-top: -5px !important;
}
.tweet-stats {
- margin-bottom: -3px;
- -webkit-user-select: none;
+ margin-bottom: -3px;
+ user-select: none;
+ -webkit-user-select: none;
}
.tweet-stat {
- padding-top: 5px;
- min-width: 1em;
- margin-right: 0.8em;
+ padding-top: 5px;
+ min-width: 1em;
+ margin-right: 0.8em;
}
.show-thread {
- display: block;
- pointer-events: all;
- padding-top: 2px;
+ display: block;
+ pointer-events: all;
+ padding-top: 2px;
}
.unavailable-box {
- width: 100%;
- height: 100%;
- padding: 12px;
- border: solid 1px var(--dark_grey);
- box-sizing: border-box;
- border-radius: 10px;
- background-color: var(--bg_color);
- z-index: 2;
+ width: 100%;
+ height: 100%;
+ padding: 12px;
+ border: solid 1px var(--dark_grey);
+ box-sizing: border-box;
+ border-radius: 10px;
+ background-color: var(--bg_color);
+ z-index: 2;
}
.tweet-link {
- height: 100%;
- width: 100%;
- left: 0;
- top: 0;
- position: absolute;
- -webkit-user-select: none;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+ position: absolute;
+ user-select: none;
+ -webkit-user-select: none;
- &:hover {
- background-color: var(--bg_hover);
- }
+ &:hover {
+ background-color: var(--bg_hover);
+ }
+}
+
+.latest-post-version {
+ border-bottom: 1px solid var(--dark_grey);
+ border-top: 1px solid var(--dark_grey);
+ padding: 01ch 0px;
+ margin: 1ch 0px;
+ color: var(--grey);
+
+ a {
+ pointer-events: all;
+ }
+}
+
+.community-note {
+ background-color: var(--bg_elements);
+ margin-top: 10px;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ overflow: hidden;
+ pointer-events: all;
+
+ &:hover {
+ background-color: var(--bg_panel);
+ border-color: var(--grey);
+ }
+}
+
+.community-note-header {
+ background-color: var(--bg_hover);
+ font-weight: 700;
+ padding: 8px 10px;
+ padding-top: 6px;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+
+ .icon-container {
+ flex-shrink: 0;
+ color: var(--accent);
+ }
+}
+
+.community-note-text {
+ white-space: pre-line;
+ padding: 10px 10px;
+ padding-top: 6px;
+}
+
+.disclosures {
+ display: flex;
+ flex-direction: column;
+ color: var(--grey);
+ font-size: 14px;
+ margin-top: 4px;
+ margin-bottom: -2px;
+
+ .icon-attention-circled {
+ margin-right: -3px;
+ }
}
diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss
index 5575191..7441d11 100644
--- a/src/sass/tweet/card.scss
+++ b/src/sass/tweet/card.scss
@@ -1,119 +1,119 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
.card {
- margin: 5px 0;
- pointer-events: all;
- max-height: unset;
+ margin: 5px 0;
+ pointer-events: all;
+ max-height: unset;
}
.card-container {
- border-radius: 10px;
- border-width: 1px;
- border-style: solid;
- border-color: var(--dark_grey);
- background-color: var(--bg_elements);
- overflow: hidden;
- color: inherit;
- display: flex;
- flex-direction: row;
- text-decoration: none !important;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ background-color: var(--bg_elements);
+ overflow: hidden;
+ color: inherit;
+ display: flex;
+ flex-direction: row;
+ text-decoration: none !important;
- &:hover {
- border-color: var(--grey);
- }
+ &:hover {
+ border-color: var(--grey);
+ }
- .attachments {
- margin: 0;
- border-radius: 0;
- }
+ .attachments {
+ margin: 0;
+ border-radius: 0;
+ }
}
.card-content {
- padding: 0.5em;
+ padding: 0.5em;
}
.card-title {
- @include ellipsis;
- white-space: unset;
- font-weight: bold;
- font-size: 1.1em;
+ @include ellipsis;
+ white-space: unset;
+ font-weight: bold;
+ font-size: 1.1em;
}
.card-description {
- margin: 0.3em 0;
- white-space: pre-wrap;
+ margin: 0.3em 0;
+ white-space: pre-wrap;
}
.card-destination {
- @include ellipsis;
- color: var(--grey);
- display: block;
+ @include ellipsis;
+ color: var(--grey);
+ display: block;
}
.card-content-container {
- color: unset;
- overflow: auto;
- &:hover {
- text-decoration: none;
- }
+ color: unset;
+ overflow: auto;
+
+ &:hover {
+ text-decoration: none;
+ }
}
.card-image-container {
- width: 98px;
- flex-shrink: 0;
- position: relative;
- overflow: hidden;
- &:before {
- content: "";
- display: block;
- padding-top: 100%;
- }
+ width: 98px;
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+
+ &:before {
+ content: "";
+ display: block;
+ padding-top: 100%;
+ }
}
.card-image {
- position: absolute;
- top: 0;
- left: 0;
- bottom: 0;
- right: 0;
- background-color: var(--bg_overlays);
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background-color: var(--bg_overlays);
- img {
- width: 100%;
- height: 100%;
- max-height: 400px;
- display: block;
- object-fit: cover;
- }
+ img {
+ width: 100%;
+ height: 100%;
+ max-height: 400px;
+ display: block;
+ object-fit: cover;
+ }
}
.card-overlay {
- @include play-button;
- opacity: 0.8;
- display: flex;
- justify-content: center;
- align-items: center;
+ @include play-button;
+ opacity: 0.8;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
.large {
- .card-container {
- display: block;
- }
+ .card-container {
+ display: block;
+ }
- .card-image-container {
- width: unset;
+ .card-image-container {
+ width: unset;
- &:before {
- display: none;
- }
+ &:before {
+ display: none;
}
+ }
- .card-image {
- position: unset;
- border-style: solid;
- border-color: var(--dark_grey);
- border-width: 0;
- border-bottom-width: 1px;
- }
+ .card-image {
+ position: unset;
+ border-style: solid;
+ border-color: var(--dark_grey);
+ border-width: 0;
+ border-bottom-width: 1px;
+ }
}
diff --git a/src/sass/tweet/embed.scss b/src/sass/tweet/embed.scss
index 227fc5e..9ff8403 100644
--- a/src/sass/tweet/embed.scss
+++ b/src/sass/tweet/embed.scss
@@ -1,17 +1,159 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
-.embed-video {
- .gallery-video {
- width: 100%;
- height: 100%;
- position: absolute;
- background-color: black;
- top: 0%;
- left: 0%;
- }
+// Embed page: transparent background, no scrollbars
+html:has(body > .embed-wrapper),
+html:has(body > .embed-video) {
+ background: transparent;
+ overflow: hidden;
- .video-container {
- max-height: unset;
- }
+ body {
+ background: transparent;
+ overflow: hidden;
+ }
+}
+
+// Tweet embed wrapper
+.embed-wrapper {
+ box-sizing: border-box;
+ border: 1px solid var(--border_grey);
+ border-radius: 12px;
+ overflow: hidden;
+
+ .embed-footer {
+ display: block;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border_grey);
+ background: var(--bg_panel);
+ color: var(--accent);
+ font-size: 14px;
+ font-weight: 500;
+ text-align: center;
+ text-decoration: none;
+ transition: background-color 0.15s;
+
+ &:hover {
+ background: var(--bg_hover);
+ }
+ }
+}
+
+// Tweet embed content
+.tweet-embed {
+ position: relative;
+ background-color: var(--bg_panel);
+ transition: background-color 0.15s;
+
+ &:hover {
+ background-color: var(--bg_hover);
+ }
+
+ .timeline-item {
+ pointer-events: none;
+ background-color: transparent;
+ }
+
+ .tweet-link:hover {
+ background-color: transparent;
+ }
+
+ .tweet-content {
+ font-size: 18px;
+ }
+
+ .avatar:not(.mini) {
+ position: absolute;
+ }
+
+ // Cap media height in embeds
+ .still-image img,
+ .quote-media-container img,
+ .quote-media-container video {
+ max-height: 600px;
+ }
+
+ &.error-embed {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 120px;
+ padding: 20px;
+
+ .error-panel {
+ margin: 0;
+ }
+ }
+}
+
+// Video-only embed
+.embed-video {
+ position: relative;
+ min-height: 300px;
+ background-color: black;
+ border: 1px solid var(--border_grey);
+
+ .attachments {
+ margin: 0;
+ border-radius: 0;
+ max-height: 560px;
+ background-color: unset;
+ }
+
+ .card {
+ margin: 0;
+ }
+
+ .gallery-video {
+ width: 100%;
+ }
+
+ .gallery-video>.attachment {
+ max-height: 560px;
+ width: 100%;
+ }
+
+ video {
+ width: 100%;
+ height: auto;
+ max-height: 560px;
+ object-fit: contain;
+ }
+
+ .video-download {
+ display: none;
+ }
+
+ .video-overlay-link {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ padding: 6px 12px;
+ background: rgba(30, 30, 30, 0.75);
+ backdrop-filter: blur(4px);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 700;
+ text-decoration: none;
+ border-radius: 9999px;
+ border: 1px solid transparent;
+ transition:
+ background 0.15s,
+ opacity 0.15s;
+ z-index: 10;
+
+ &:hover {
+ background: rgba(60, 60, 60, 0.9);
+ }
+ }
+
+ // Hide button while playing, show on hover or when paused
+ &.video-playing .video-overlay-link {
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ &.video-playing:hover .video-overlay-link {
+ opacity: 1;
+ pointer-events: auto;
+ }
}
diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss
index 91c9dab..3001a86 100644
--- a/src/sass/tweet/media.scss
+++ b/src/sass/tweet/media.scss
@@ -1,119 +1,168 @@
-@import '_variables';
+@import "_variables";
.gallery-row {
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- align-items: center;
- overflow: hidden;
- flex-grow: 1;
- max-height: 379.5px;
- max-width: 533px;
- pointer-events: all;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ flex-grow: 1;
+ max-height: 379.5px;
+ max-width: 533px;
+ pointer-events: all;
+
+ &.mixed-row {
+ .attachment {
+ min-width: 0;
+ min-height: 0;
+ flex: 1 1 0;
+ max-height: 379.5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: #101010;
+ }
+
+ .still-image,
+ .still-image img,
+ .attachment > video,
+ .attachment > img {
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ }
.still-image {
- width: 100%;
- display: flex;
+ display: flex;
+ align-self: stretch;
}
+
+ .still-image img {
+ flex-basis: auto;
+ flex-grow: 0;
+ object-fit: cover;
+ }
+
+ .attachment > video,
+ .attachment > img {
+ object-fit: cover;
+ }
+
+ .attachment > video {
+ object-fit: contain;
+ }
+ }
}
.attachments {
- margin-top: .35em;
- display: flex;
- flex-direction: row;
- width: 100%;
- max-height: 600px;
- border-radius: 7px;
- overflow: hidden;
- flex-flow: column;
- background-color: var(--bg_color);
- align-items: center;
- pointer-events: all;
-
- .image-attachment {
- width: 100%;
- }
+ margin-top: 0.35em;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ max-height: 600px;
+ border-radius: 7px;
+ overflow: hidden;
+ flex-flow: column;
+ background-color: var(--bg_color);
+ align-items: center;
+ pointer-events: all;
}
.attachment {
- position: relative;
- line-height: 0;
- overflow: hidden;
- margin: 0 .25em 0 0;
- flex-grow: 1;
- box-sizing: border-box;
- min-width: 2em;
+ position: relative;
+ line-height: 0;
+ overflow: hidden;
+ margin: 0 0.25em 0 0;
+ flex-grow: 1;
+ box-sizing: border-box;
+ min-width: 2em;
- &:last-child {
- margin: 0;
- max-height: 530px;
- }
-}
-
-.gallery-gif video {
+ &:last-child {
+ margin: 0;
max-height: 530px;
- background-color: #101010;
-}
-
-.still-image {
- max-height: 379.5px;
- max-width: 533px;
- justify-content: center;
-
- img {
- object-fit: cover;
- max-width: 100%;
- max-height: 379.5px;
- flex-basis: 300px;
- flex-grow: 1;
- }
-}
-
-.image {
- display: inline-block;
-}
-
-// .single-image {
-// display: inline-block;
-// width: 100%;
-// max-height: 600px;
-
-// .attachments {
-// width: unset;
-// max-height: unset;
-// display: inherit;
-// }
-// }
-
-.overlay-circle {
- border-radius: 50%;
- background-color: var(--dark_grey);
- width: 40px;
- height: 40px;
- align-items: center;
- display: flex;
- border-width: 5px;
- border-color: var(--play_button);
- border-style: solid;
-}
-
-.overlay-triangle {
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 12px 0 12px 17px;
- border-color: transparent transparent transparent var(--play_button);
- margin-left: 14px;
+ }
}
.media-gif {
- display: table;
- background-color: unset;
- width: unset;
+ display: table;
+ background-color: unset;
+ width: unset;
+ max-height: unset;
+}
+
+.media-gif video,
+.media-gif img {
+ width: 100%;
+ height: 100%;
+ max-height: 530px;
+ background-color: #101010;
+}
+
+.still-image {
+ max-height: 379.5px;
+ max-width: 533px;
+
+ img {
+ object-fit: cover;
+ max-width: 100%;
+ max-height: 379.5px;
+ flex-basis: 300px;
+ flex-grow: 1;
+ }
+}
+
+.alt-text {
+ margin: 0px;
+ padding: 11px 7px;
+ box-sizing: border-box;
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ width: 2.98em;
+ max-height: 25px;
+ white-space: pre;
+ overflow: hidden;
+ border-radius: 10px;
+ color: var(--fg_color);
+ font-size: 12px;
+ font-weight: bold;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(12px);
+}
+
+.alt-text:hover {
+ padding: 7px;
+ width: Min(230px, calc(100% - 10px * 2));
+ max-height: calc(100% - 10px);
+ line-height: 1.2em;
+ white-space: pre-wrap;
+ transition-duration: 0.4s;
+ transition-property: max-height;
+}
+
+.overlay-circle {
+ border-radius: 50%;
+ background-color: var(--dark_grey);
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ display: flex;
+ border-width: 5px;
+ border-color: var(--play_button);
+ border-style: solid;
+}
+
+.overlay-triangle {
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 12px 0 12px 17px;
+ border-color: transparent transparent transparent var(--play_button);
+ margin-left: 14px;
}
.media-body {
- flex: 1;
- padding: 0;
- white-space: pre-wrap;
+ flex: 1;
+ padding: 0;
+ white-space: pre-wrap;
}
diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss
index 57590c8..6d54e00 100644
--- a/src/sass/tweet/poll.scss
+++ b/src/sass/tweet/poll.scss
@@ -1,42 +1,42 @@
-@import '_variables';
+@import "_variables";
.poll-meter {
- overflow: hidden;
- position: relative;
- margin: 6px 0;
- height: 26px;
- background: var(--bg_color);
- border-radius: 5px;
- display: flex;
- align-items: center;
+ overflow: hidden;
+ position: relative;
+ margin: 6px 0;
+ height: 26px;
+ background: var(--bg_color);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
}
.poll-choice-bar {
- height: 100%;
- position: absolute;
- background: var(--dark_grey);
+ height: 100%;
+ position: absolute;
+ background: var(--dark_grey);
}
.poll-choice-value {
- position: relative;
- font-weight: bold;
- margin-left: 5px;
- margin-right: 6px;
- min-width: 30px;
- text-align: right;
- pointer-events: all;
+ position: relative;
+ font-weight: bold;
+ margin-left: 5px;
+ margin-right: 6px;
+ min-width: 30px;
+ text-align: right;
+ pointer-events: all;
}
.poll-choice-option {
- position: relative;
- pointer-events: all;
+ position: relative;
+ pointer-events: all;
}
.poll-info {
- color: var(--grey);
- pointer-events: all;
+ color: var(--grey);
+ pointer-events: all;
}
.leader .poll-choice-bar {
- background: var(--accent_dark);
+ background: var(--accent_dark);
}
diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss
index b4bc60e..f722455 100644
--- a/src/sass/tweet/quote.scss
+++ b/src/sass/tweet/quote.scss
@@ -1,94 +1,121 @@
-@import '_variables';
+@import "_variables";
.quote {
- margin-top: 10px;
- border: solid 1px var(--dark_grey);
- border-radius: 10px;
- background-color: var(--bg_elements);
+ margin-top: 10px;
+ border: solid 1px var(--dark_grey);
+ border-radius: 10px;
+ background-color: var(--bg_elements);
+ overflow: hidden;
+ pointer-events: all;
+ position: relative;
+ width: 100%;
+
+ &:hover {
+ border-color: var(--grey);
+ }
+
+ &.unavailable:hover {
+ border-color: var(--dark_grey);
+ }
+
+ .tweet-name-row {
+ padding: 8px 10px 6px 10px;
+ }
+
+ .quote-text {
overflow: hidden;
- pointer-events: all;
- position: relative;
- width: 100%;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ padding: 10px;
+ padding-top: 0;
+ }
+
+ .show-thread {
+ padding: 0px 10px 6px 10px;
+ margin-top: -6px;
+ }
+
+ .quote-latest {
+ padding: 0px 10px 6px 10px;
+ color: var(--grey);
+ }
+
+ .replying-to {
+ padding: 0px 10px;
+ padding-bottom: 4px;
+ margin: unset;
+ }
+
+ .community-note {
+ background-color: var(--bg_panel);
+ border: unset;
+ border-top: solid 1px var(--dark_grey);
+ border-radius: unset;
+ margin-top: 0;
&:hover {
- border-color: var(--grey);
+ border-top-color: var(--grey);
}
- &.unavailable:hover {
- border-color: var(--dark_grey);
- }
-
- .tweet-name-row {
- padding: 6px 8px;
- margin-top: 1px;
- }
-
- .quote-text {
- overflow: hidden;
- white-space: pre-wrap;
- word-wrap: break-word;
- padding: 0px 8px 8px 8px;
- }
-
- .show-thread {
- padding: 0px 8px 6px 8px;
- margin-top: -6px;
- }
-
- .replying-to {
- padding: 0px 8px;
- margin: unset;
+ .community-note-header {
+ background-color: var(--bg_panel);
+ padding-bottom: 0;
}
+ }
}
.unavailable-quote {
- padding: 12px;
+ padding: 12px;
+ display: block;
}
.quote-link {
- width: 100%;
- height: 100%;
- left: 0;
- top: 0;
- position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ position: absolute;
}
.quote-media-container {
- max-height: 300px;
+ max-height: 300px;
+ display: flex;
+
+ .card {
+ margin: unset;
+ }
+
+ .attachments {
+ border-radius: 0;
+ }
+
+ .media-gif {
+ width: 100%;
display: flex;
+ justify-content: center;
+ }
- .card {
- margin: unset;
+ .media-gif > .attachment {
+ display: flex;
+ justify-content: center;
+ background-color: var(--bg_color);
+
+ video,
+ img {
+ height: unset;
+ width: unset;
+ max-height: 100%;
+ max-width: 100%;
}
+ }
- .attachments {
- border-radius: 0;
- }
+ .gallery-row .attachment,
+ .gallery-row .attachment > video,
+ .gallery-row .attachment > img {
+ max-height: 300px;
+ }
- .media-gif {
- width: 100%;
- display: flex;
- justify-content: center;
- }
-
- .gallery-gif .attachment {
- display: flex;
- justify-content: center;
- background-color: var(--bg_color);
-
- video {
- height: unset;
- width: unset;
- max-height: 100%;
- max-width: 100%;
- }
- }
-
- .gallery-video, .gallery-gif {
- max-height: 300px;
- }
-
- .still-image img {
- max-height: 250px
- }
+ .still-image img {
+ max-height: 250px;
+ }
}
diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss
index 19fb3e0..134e375 100644
--- a/src/sass/tweet/thread.scss
+++ b/src/sass/tweet/thread.scss
@@ -1,138 +1,196 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
-.conversation {
- @include panel(100%, 600px);
+.conversation,
+.edit-history {
+ @include panel(100%, 600px);
- .show-more {
- margin-bottom: 10px;
- }
+ .show-more {
+ margin-bottom: 10px;
+ }
}
-.main-thread {
- margin-bottom: 20px;
- background-color: var(--bg_panel);
-}
-
-.main-tweet, .replies {
- padding-top: 50px;
- margin-top: -50px;
-}
-
-.main-tweet .tweet-content {
- font-size: 18px;
-}
-
-@media(max-width: 600px) {
- .main-tweet .tweet-content {
- font-size: 16px;
- }
+.main-thread,
+.latest-edit {
+ margin-bottom: 20px;
}
.reply {
- background-color: var(--bg_panel);
- margin-bottom: 10px;
+ margin-bottom: 10px;
+}
+
+.reply-sort {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 2px 14px;
+ margin-bottom: 10px;
+ padding: 8px 12px;
+ background-color: var(--bg_panel);
+ font-size: 14px;
+}
+
+.reply-sort-label {
+ color: var(--fg_faded);
+ margin-right: 2px;
+}
+
+.reply-sort-option {
+ color: var(--tab);
+ font-weight: bold;
+ text-decoration: none;
+ border-bottom: 0.1rem solid transparent;
+
+ &:hover {
+ color: var(--fg_color);
+ text-decoration: none;
+ }
+
+ &.active {
+ color: var(--tab_selected);
+ border-bottom-color: var(--tab_selected);
+ }
+}
+
+.main-tweet,
+.replies,
+.edit-history > div {
+ body.fixed-nav & {
+ padding-top: 50px;
+ margin-top: -50px;
+ }
+}
+
+.edit-history-header {
+ padding: 10px;
+ margin-bottom: 5px;
+ font-size: 16px;
+ font-weight: bold;
+ background-color: var(--bg_panel);
+}
+
+.tweet-edit {
+ margin-bottom: 5px;
+}
+
+.main-tweet .tweet-content {
+ font-size: 18px;
+}
+
+@media (max-width: 600px) {
+ .main-tweet .tweet-content {
+ font-size: 16px;
+ }
}
.thread-line {
- .timeline-item::before,
- &.timeline-item::before {
- background: var(--accent_dark);
- content: '';
- position: relative;
- min-width: 3px;
- width: 3px;
- left: 26px;
- border-radius: 2px;
- margin-left: -3px;
- margin-bottom: 37px;
- top: 56px;
- z-index: 1;
- pointer-events: none;
- }
+ .timeline-item::before,
+ &.timeline-item::before {
+ background: var(--accent_dark);
+ content: "";
+ position: relative;
+ min-width: 3px;
+ width: 3px;
+ left: 26px;
+ border-radius: 2px;
+ margin-left: -3px;
+ margin-bottom: 37px;
+ top: 56px;
+ z-index: 1;
+ pointer-events: none;
+ }
- .with-header:not(:first-child)::after {
- background: var(--accent_dark);
- content: '';
- position: relative;
- float: left;
- min-width: 3px;
- width: 3px;
- right: calc(100% - 26px);
- border-radius: 2px;
- margin-left: -3px;
- margin-bottom: 37px;
- bottom: 10px;
- height: 30px;
- z-index: 1;
- pointer-events: none;
- }
+ .with-header:not(:first-child)::after {
+ background: var(--accent_dark);
+ content: "";
+ position: relative;
+ float: left;
+ min-width: 3px;
+ width: 3px;
+ right: calc(100% - 26px);
+ border-radius: 2px;
+ margin-left: -3px;
+ margin-bottom: 37px;
+ bottom: 10px;
+ height: 30px;
+ z-index: 1;
+ pointer-events: none;
+ }
- .unavailable::before {
- top: 48px;
- margin-bottom: 28px;
- }
+ .unavailable::before {
+ top: 48px;
+ margin-bottom: 28px;
+ }
- .more-replies::before {
- content: '...';
- background: unset;
- color: var(--more_replies_dots);
- font-weight: bold;
- font-size: 20px;
- line-height: 0.25em;
- left: 1.2em;
- width: 5px;
- top: 2px;
- margin-bottom: 0;
- margin-left: -2.5px;
- }
+ .more-replies::before {
+ content: "...";
+ background: unset;
+ color: var(--more_replies_dots);
+ font-weight: bold;
+ font-size: 20px;
+ line-height: 0.25em;
+ left: 1.2em;
+ width: 5px;
+ top: 2px;
+ margin-bottom: 0;
+ margin-left: -2.5px;
+ }
- .earlier-replies {
- padding-bottom: 0;
- margin-bottom: -5px;
- }
+ .earlier-replies {
+ padding-bottom: 0;
+ margin-bottom: -5px;
+ }
}
.timeline-item.thread-last::before {
- background: unset;
- min-width: unset;
- width: 0;
- margin: 0;
+ background: unset;
+ min-width: unset;
+ width: 0;
+ margin: 0;
}
.more-replies {
- padding-top: 0.3em !important;
+ padding-top: 0.3em !important;
}
.more-replies-text {
- @include ellipsis;
- display: block;
- margin-left: 58px;
- padding: 7px 0;
+ @include ellipsis;
+ display: block;
+ margin-left: 58px;
+ padding: 7px 0;
}
.timeline-item.thread.more-replies-thread {
- padding: 0 0.75em;
+ padding: 0 0.75em;
+
+ &::before {
+ top: 40px;
+ margin-bottom: 31px;
+ }
+
+ .more-replies {
+ display: flex;
+ padding-top: unset !important;
+ margin-top: 8px;
&::before {
- top: 40px;
- margin-bottom: 31px;
+ display: inline-block;
+ position: relative;
+ top: -1px;
+ line-height: 0.4em;
}
- .more-replies {
- display: flex;
- padding-top: unset !important;
- margin-top: 8px;
-
- &::before {
- display: inline-block;
- position: relative;
- top: -1px;
- line-height: 0.4em;
- }
-
- .more-replies-text {
- display: inline;
- }
+ .more-replies-text {
+ display: inline;
}
+ }
+}
+
+.related-header {
+ padding: 8px 12px;
+ margin-top: 10px;
+ background-color: var(--bg_panel);
+ color: var(--fg_faded);
+ font-size: 14px;
+ border-bottom: 1px solid var(--border_grey);
}
diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss
index 98a1c29..28fc125 100644
--- a/src/sass/tweet/video.scss
+++ b/src/sass/tweet/video.scss
@@ -1,68 +1,111 @@
-@import '_variables';
-@import '_mixins';
+@import "_variables";
+@import "_mixins";
video {
- max-height: 100%;
- width: 100%;
+ height: 100%;
+ width: 100%;
}
.gallery-video {
- display: flex;
- overflow: hidden;
-}
-
-.gallery-video.card-container {
+ display: flex;
+ overflow: hidden;
+
+ &.card-container {
flex-direction: column;
-}
+ width: 100%;
+ }
-.video-container {
+ > .attachment {
min-height: 80px;
min-width: 200px;
max-height: 530px;
margin: 0;
- display: flex;
- align-items: center;
- justify-content: center;
img {
- max-height: 100%;
- max-width: 100%;
+ max-height: 100%;
+ max-width: 100%;
}
+ }
+}
+
+.video-download {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 2;
+ color: var(--accent);
+ text-decoration: none;
+ opacity: 0;
+ transition: opacity 0.2s;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
+
+ .icon-container {
+ margin: 0;
+ }
+
+ .icon-download-alt {
+ font-size: 18px;
+ }
+}
+
+.attachment:hover .video-download {
+ opacity: 1;
+
+ &:hover {
+ color: var(--fg_color);
+ }
+}
+
+@media (hover: none) {
+ .video-download {
+ opacity: 1;
+ }
}
.video-overlay {
- @include play-button;
- background-color: $shadow;
+ @include play-button;
+ background-color: $shadow;
- p {
- position: relative;
- z-index: 0;
- text-align: center;
- top: calc(50% - 20px);
- font-size: 20px;
- line-height: 1.3;
- margin: 0 20px;
- }
+ p {
+ position: relative;
+ z-index: 0;
+ text-align: center;
+ top: calc(50% - 20px);
+ font-size: 20px;
+ line-height: 1.3;
+ margin: 0 20px;
+ }
- div {
- position: relative;
- z-index: 0;
- top: calc(50% - 20px);
- margin: 0 auto;
- width: 40px;
- height: 40px;
- }
+ .overlay-circle {
+ position: relative;
+ z-index: 0;
+ top: calc(50% - 20px);
+ margin: 0 auto;
+ width: 40px;
+ height: 40px;
+ }
- form {
- width: 100%;
- height: 100%;
- align-items: center;
- justify-content: center;
- display: flex;
- }
+ .overlay-duration {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ background-color: #0000007a;
+ line-height: 1em;
+ padding: 4px 6px 4px 6px;
+ border-radius: 5px;
+ font-weight: bold;
+ }
- button {
- padding: 5px 8px;
- font-size: 16px;
- }
+ form {
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ }
+
+ button {
+ padding: 5px 8px;
+ font-size: 16px;
+ }
}
diff --git a/src/tid.nim b/src/tid.nim
new file mode 100644
index 0000000..ba1f8ec
--- /dev/null
+++ b/src/tid.nim
@@ -0,0 +1,64 @@
+import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
+import nimcrypto
+import experimental/parser/tid
+
+randomize()
+
+const defaultKeyword = "obfiowerehiring";
+const pairsUrl =
+ "https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
+
+var
+ cachedPairs: seq[TidPair] = @[]
+ lastCached = 0
+ # refresh every hour
+ ttlSec = 60 * 60
+
+proc getPair(): Future[TidPair] {.async.} =
+ if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
+ let client = newAsyncHttpClient()
+ defer: client.close()
+
+ let resp = await client.get(pairsUrl)
+ if resp.status == $Http200:
+ cachedPairs = parseTidPairs(await resp.body)
+ lastCached = int(epochTime())
+
+ if cachedPairs.len == 0:
+ raise newException(ValueError, "Failed to fetch x-client-transaction-id pairs")
+
+ return sample(cachedPairs)
+
+proc encodeSha256(text: string): array[32, byte] =
+ let
+ data = cast[ptr byte](addr text[0])
+ dataLen = uint(len(text))
+ digest = sha256.digest(data, dataLen)
+ return digest.data
+
+proc encodeBase64[T](data: T): string =
+ return encode(data).replace("=", "")
+
+proc decodeBase64(data: string): seq[byte] =
+ return cast[seq[byte]](decode(data))
+
+proc genTid*(path: string): Future[string] {.async.} =
+ let
+ pair = await getPair()
+
+ timeNow = int(epochTime() - 1682924400)
+ timeNowBytes = @[
+ byte(timeNow and 0xff),
+ byte((timeNow shr 8) and 0xff),
+ byte((timeNow shr 16) and 0xff),
+ byte((timeNow shr 24) and 0xff)
+ ]
+
+ data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
+ hashBytes = encodeSha256(data)
+ keyBytes = decodeBase64(pair.verification)
+ bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
+ randomNum = byte(rand(256))
+ tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
+
+ return encodeBase64(tid)
diff --git a/src/types.nim b/src/types.nim
index f16fe6f..0a748ba 100644
--- a/src/types.nim
+++ b/src/types.nim
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import times, sequtils, options, tables, uri
+import times, sequtils, options, tables
import prefs_impl
genPrefsType()
@@ -11,21 +11,16 @@ type
BadClientError* = object of CatchableError
TimelineKind* {.pure.} = enum
- tweets, replies, media
+ tweets, replies, media, articles
- Api* {.pure.} = enum
- tweetDetail
- tweetResult
- search
- list
- listBySlug
- listMembers
- listTweets
- userRestId
- userScreenName
- userTweets
- userTweetsAndReplies
- userMedia
+ ApiUrl* = object
+ endpoint*: string
+ params*: seq[(string, string)]
+ skipTid*: bool
+
+ ApiReq* = object
+ oauth*: ApiUrl
+ cookie*: ApiUrl
RateLimit* = object
limit*: int
@@ -42,7 +37,7 @@ type
pending*: int
limited*: bool
limitedAt*: int
- apis*: Table[Api, RateLimit]
+ apis*: Table[string, RateLimit]
case kind*: SessionKind
of oauth:
oauthToken*: string
@@ -51,10 +46,6 @@ type
authToken*: string
ct0*: string
- SessionAwareUrl* = object
- oauthUrl*: Uri
- cookieUrl*: Uri
-
Error* = enum
null = 0
noUserMatches = 17
@@ -70,6 +61,7 @@ type
rateLimited = 88
expiredToken = 89
listIdOrSlug = 112
+ timelineUnavailable = 131
tweetNotFound = 144
tweetNotAuthorized = 179
forbidden = 200
@@ -106,6 +98,59 @@ type
suspended*: bool
joinDate*: DateTime
+ AccountInfo* = object
+ username*: string
+ fullname*: string
+ userPic*: string
+ joinDate*: DateTime
+ verifiedType*: VerifiedType
+ suspended*: bool
+ basedIn*: string
+ source*: string
+ usernameChanges*: int
+ lastUsernameChange*: DateTime
+ affiliateUsername*: string
+ affiliateLabel*: string
+ isIdentityVerified*: bool
+ verifiedSince*: DateTime
+ overrideVerifiedYear*: int
+
+ Broadcast* = object
+ id*: string
+ title*: string
+ state*: string
+ thumb*: string
+ mediaKey*: string
+ m3u8Url*: string
+ totalWatched*: int
+ startTime*: DateTime
+ endTime*: DateTime
+ replayStart*: int
+ availableForReplay*: bool
+ user*: User
+
+ SpaceParticipant* = object
+ userId*: string
+ username*: string
+ displayName*: string
+ avatarUrl*: string
+ isVerified*: bool
+
+ AudioSpace* = object
+ id*: string
+ title*: string
+ state*: string
+ mediaKey*: string
+ m3u8Url*: string
+ totalLiveListeners*: int
+ totalReplayWatched*: int
+ startTime*: DateTime
+ endTime*: DateTime
+ availableForReplay*: bool
+ creator*: User
+ admins*: seq[SpaceParticipant]
+ speakers*: seq[SpaceParticipant]
+
VideoType* = enum
m3u8 = "application/x-mpegURL"
mp4 = "video/mp4"
@@ -129,10 +174,15 @@ type
variants*: seq[VideoVariant]
QueryKind* = enum
- posts, replies, media, users, tweets, userList
+ posts, replies, media, users, tweets, userList, followers, following, lists, top,
+ articles
+
+ RankingMode* = enum
+ Relevance, Recency, Likes
Query* = object
kind*: QueryKind
+ view*: string
text*: string
filters*: seq[string]
includes*: seq[string]
@@ -140,12 +190,33 @@ type
fromUser*: seq[string]
since*: string
until*: string
- near*: string
+ minLikes*: string
sep*: string
Gif* = object
url*: string
thumb*: string
+ altText*: string
+
+ Photo* = object
+ url*: string
+ altText*: string
+
+ MediaKind* = enum
+ photoMedia
+ videoMedia
+ gifMedia
+
+ Media* = object
+ case kind*: MediaKind
+ of photoMedia:
+ photo*: Photo
+ of videoMedia:
+ video*: Video
+ of gifMedia:
+ gif*: Gif
+
+ MediaEntities* = seq[Media]
GalleryPhoto* = object
url*: string
@@ -154,6 +225,44 @@ type
PhotoRail* = seq[GalleryPhoto]
+ Article* = ref object
+ title*: string
+ coverImage*: string
+ user*: User
+ time*: DateTime
+ stats*: TweetStats
+ paragraphs*: seq[ArticleParagraph]
+ entities*: Table[int, ArticleEntity]
+ media*: Table[string, ArticleMedia]
+
+ ArticleParagraph* = object
+ text*: string
+ kind*: string
+ inlineStyles*: seq[ArticleStyle]
+ entityRanges*: seq[ArticleEntityRange]
+
+ ArticleStyle* = object
+ offset*: int
+ length*: int
+ style*: string
+
+ ArticleEntityRange* = object
+ offset*: int
+ length*: int
+ key*: int
+
+ ArticleEntity* = object
+ kind*: string
+ url*: string
+ mediaIds*: seq[string]
+ tweetId*: string
+ markdown*: string
+ caption*: string
+
+ ArticleMedia* = object
+ kind*: string
+ url*: string
+
Poll* = object
options*: seq[string]
values*: seq[int]
@@ -203,6 +312,12 @@ type
likes*: int
views*: int
+ ArticlePreview* = object
+ title*: string
+ previewText*: string
+ coverImage*: string
+ tweetId*: int64
+
Tweet* = ref object
id*: int64
threadId*: int64
@@ -221,13 +336,17 @@ type
stats*: TweetStats
retweet*: Option[Tweet]
attribution*: Option[User]
+ attributionLink*: string
mediaTags*: seq[User]
quote*: Option[Tweet]
card*: Option[Card]
poll*: Option[Poll]
- gif*: Option[Gif]
- video*: Option[Video]
- photos*: seq[string]
+ media*: MediaEntities
+ history*: seq[int64]
+ note*: string
+ isAd*: bool
+ isAI*: bool
+ articlePreview*: Option[ArticlePreview]
Tweets* = seq[Tweet]
@@ -241,6 +360,7 @@ type
content*: Tweets
hasMore*: bool
cursor*: string
+ related*: bool
Conversation* = ref object
tweet*: Tweet
@@ -248,6 +368,10 @@ type
after*: Chain
replies*: Result[Chain]
+ EditHistory* = object
+ latest*: Tweet
+ history*: Tweets
+
Timeline* = Result[Tweets]
Profile* = object
@@ -255,6 +379,7 @@ type
photoRail*: PhotoRail
pinned*: Option[Tweet]
tweets*: Timeline
+ accountInfo*: AccountInfo
List* = object
id*: string
@@ -265,6 +390,29 @@ type
members*: int
banner*: string
+ ListSearchResult* = object
+ list*: List
+ owner*: User
+ followersContext*: string
+ facepiles*: seq[string]
+
+ CommunityRule* = object
+ name*: string
+ description*: string
+
+ Community* = object
+ id*: string
+ name*: string
+ description*: string
+ memberCount*: int
+ banner*: string
+ creator*: User
+ category*: string
+ joinPolicy*: string
+ createdAt*: DateTime
+ rules*: seq[CommunityRule]
+ hashtags*: seq[string]
+
GlobalObjects* = ref object
tweets*: Table[string, Tweet]
users*: Table[string, User]
@@ -281,10 +429,20 @@ type
hmacKey*: string
base64Media*: bool
minTokens*: int
- enableRss*: bool
+ enableRSSUserTweets*: bool
+ enableRSSUserReplies*: bool
+ enableRSSUserMedia*: bool
+ enableRSSUserArticles*: bool
+ enableRSSSearch*: bool
+ enableRSSList*: bool
enableDebug*: bool
proxy*: string
proxyAuth*: string
+ apiProxy*: string
+ disableTid*: bool
+ maxConcurrentReqs*: int
+ maxRetries*: int
+ retryDelayMs*: int
rssCacheTime*: int
listCacheTime*: int
@@ -303,3 +461,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[tweet]
+
+proc getPhotos*(tweet: Tweet): seq[Photo] =
+ tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
+
+proc getVideos*(tweet: Tweet): seq[Video] =
+ tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
+
+proc hasPhotos*(tweet: Tweet): bool =
+ tweet.media.anyIt(it.kind == photoMedia)
+
+proc hasVideos*(tweet: Tweet): bool =
+ tweet.media.anyIt(it.kind == videoMedia)
+
+proc hasGifs*(tweet: Tweet): bool =
+ tweet.media.anyIt(it.kind == gifMedia)
+
+proc getThumb*(media: Media): string =
+ case media.kind
+ of photoMedia: media.photo.url
+ of videoMedia: media.video.thumb
+ of gifMedia: media.gif.thumb
diff --git a/src/utils.nim b/src/utils.nim
index c96a6dd..95b46de 100644
--- a/src/utils.nim
+++ b/src/utils.nim
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import strutils, strformat, uri, tables, base64
+import sequtils, strutils, strformat, uri, tables, base64
import nimcrypto
var
@@ -9,7 +9,7 @@ var
const
https* = "https://"
twimg* = "pbs.twimg.com/"
- nitterParams = ["name", "tab", "id", "list", "referer", "scroll"]
+ nitterParams* = ["name", "tab", "id", "list", "referer", "scroll", "prefs"]
twitterDomains = @[
"twitter.com",
"pic.twitter.com",
@@ -17,7 +17,9 @@ const
"abs.twimg.com",
"pbs.twimg.com",
"video.twimg.com",
- "x.com"
+ "x.com",
+ "pscp.tv",
+ "video.pscp.tv"
]
proc setHmacKey*(key: string) =
@@ -38,12 +40,14 @@ proc getVidUrl*(link: string): string =
&"/video/{sig}/{encodeUrl(link)}"
proc getPicUrl*(link: string): string =
+ if link.len == 0: return
if base64Media:
&"/pic/enc/{encode(link, safe=true)}"
else:
&"/pic/{encodeUrl(link)}"
proc getOrigPicUrl*(link: string): string =
+ if link.len == 0: return
if base64Media:
&"/pic/orig/enc/{encode(link, safe=true)}"
else:
@@ -55,7 +59,13 @@ proc filterParams*(params: Table): seq[(string, string)] =
result.add p
proc isTwitterUrl*(uri: Uri): bool =
- uri.hostname in twitterDomains
+ uri.scheme in ["http", "https"] and
+ (uri.hostname in twitterDomains or uri.hostname.endsWith(".video.pscp.tv"))
proc isTwitterUrl*(url: string): bool =
isTwitterUrl(parseUri(url))
+
+proc validateNumber*(value: string): string =
+ if value.anyIt(not it.isDigit):
+ return ""
+ return value
diff --git a/src/views/about_account.nim b/src/views/about_account.nim
new file mode 100644
index 0000000..aedd444
--- /dev/null
+++ b/src/views/about_account.nim
@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, formatters]
+
+proc renderAboutAccount*(info: AccountInfo): VNode =
+ let user = User(
+ username: info.username,
+ fullname: info.fullname,
+ userPic: info.userPic,
+ verifiedType: info.verifiedType
+ )
+
+ buildHtml(tdiv(class="about-account")):
+ tdiv(class="about-account-header"):
+ a(class="about-account-avatar", href=(&"/{info.username}")):
+ genImg(getUserPic(info.userPic, "_200x200"))
+ tdiv(class="about-account-name"):
+ linkUser(user, class="profile-card-fullname")
+ verifiedIcon(user)
+ linkUser(user, class="profile-card-username")
+
+ tdiv(class="about-account-body"):
+ tdiv(class="about-account-row"):
+ span: icon "calendar"
+ tdiv:
+ span(class="about-account-label"): text "Date joined"
+ span(class="about-account-value"):
+ text info.joinDate.format("MMMM YYYY")
+
+ if info.basedIn.len > 0:
+ tdiv(class="about-account-row"):
+ span: icon "location"
+ tdiv:
+ span(class="about-account-label"): text "Account based in"
+ span(class="about-account-value"): text info.basedIn
+
+ if info.verifiedType != VerifiedType.none:
+ if info.overrideVerifiedYear != 0:
+ tdiv(class="about-account-row"):
+ span: icon "ok"
+ tdiv:
+ span(class="about-account-label"): text "Verified"
+ span(class="about-account-value"):
+ let year = abs(info.overrideVerifiedYear)
+ let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
+ text "Since " & $year & era
+ elif info.verifiedSince.year > 0:
+ tdiv(class="about-account-row"):
+ span: icon "ok"
+ tdiv:
+ span(class="about-account-label"): text "Verified"
+ span(class="about-account-value"):
+ text "Since " & info.verifiedSince.format("MMMM YYYY")
+
+ if info.isIdentityVerified:
+ tdiv(class="about-account-row"):
+ span: icon "ok"
+ tdiv:
+ span(class="about-account-label"): text "ID Verified"
+ span(class="about-account-value"): text "Yes"
+
+ if info.affiliateUsername.len > 0:
+ tdiv(class="about-account-row"):
+ span: icon "group"
+ tdiv:
+ span(class="about-account-label"): text "An affiliate of"
+ span(class="about-account-value"):
+ a(href=(&"/{info.affiliateUsername}")):
+ if info.affiliateLabel.len > 0:
+ text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
+ else:
+ text "@" & info.affiliateUsername
+
+ if info.usernameChanges > 0:
+ tdiv(class="about-account-row"):
+ span(class="about-account-at"): text "@"
+ tdiv:
+ span(class="about-account-label"):
+ text $info.usernameChanges & " username change"
+ if info.usernameChanges > 1: text "s"
+ if info.lastUsernameChange.year > 0:
+ span(class="about-account-value"):
+ text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
+
+ if info.source.len > 0:
+ tdiv(class="about-account-row"):
+ span: icon "link"
+ tdiv:
+ span(class="about-account-label"): text "Connected via"
+ span(class="about-account-value"): text info.source
diff --git a/src/views/article.nim b/src/views/article.nim
new file mode 100644
index 0000000..eedd4cf
--- /dev/null
+++ b/src/views/article.nim
@@ -0,0 +1,248 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, tables, unicode, bitops, uri
+import karax/[karaxdsl, vdom]
+
+import renderutils, tweet, timeline
+import ".."/[types, utils, formatters]
+
+proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article;
+ tweets: Table[int64, Tweet]; path: string;
+ prefs: Prefs): VNode =
+ if paragraph.entityRanges.len == 0:
+ return text ""
+
+ let er = paragraph.entityRanges[0]
+ if er.key notin article.entities:
+ return text ""
+
+ let entity = article.entities[er.key]
+
+ case entity.kind
+ of "MEDIA":
+ buildHtml(tdiv(class="article-media")):
+ for id in entity.mediaIds:
+ let media = article.media.getOrDefault(id)
+ if media.url.len == 0:
+ continue
+ case media.kind
+ of "ApiGif":
+ video(src=getVidUrl(media.url), controls="", autoplay="", loop="",
+ muted="")
+ of "ApiVideo":
+ video(src=getVidUrl(media.url), controls="")
+ else:
+ a(href=getOrigPicUrl(media.url), target="_blank"):
+ img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy")
+ if entity.caption.len > 0:
+ p(class="article-media-caption"): text entity.caption
+ of "TWEET":
+ let tweet = tweets.getOrDefault(
+ try: parseBiggestInt(entity.tweetId)
+ except ValueError: 0, nil)
+ if tweet != nil:
+ renderTweet(tweet, prefs, path)
+ else:
+ text ""
+ of "MARKDOWN":
+ var content = entity.markdown
+ if content.startsWith("```"):
+ let firstNl = content.find('\n')
+ if firstNl >= 0: content = content[firstNl + 1 .. ^1]
+ if content.endsWith("```"): content = content[0 .. ^4]
+ content = content.strip
+ buildHtml(pre()):
+ code(): text content
+ of "DIVIDER":
+ buildHtml(hr(class="article-divider"))
+ else:
+ text ""
+
+proc wrapStyle(node: VNode; style: int): VNode =
+ result = node
+ if style.testBit(4): result = buildHtml(code()): result
+ if style.testBit(0): result = buildHtml(strong()): result
+ if style.testBit(1): result = buildHtml(em()): result
+ if style.testBit(2): result = buildHtml(del()): result
+ if style.testBit(3): result = buildHtml(underlined()): result
+
+proc addContent(target: VNode; content: string; style = 0) =
+ var first = true
+ for line in content.split('\n'):
+ if not first:
+ target.add VNode(kind: VNodeKind.br)
+ first = false
+ var pos = 0
+ while pos < line.len:
+ let atPos = line.find('@', pos)
+ if atPos == -1:
+ target.add wrapStyle(text line[pos .. ^1], style)
+ break
+ if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}:
+ target.add wrapStyle(text line[pos .. atPos], style)
+ pos = atPos + 1
+ continue
+ var j = atPos + 1
+ while j < line.len and j - atPos - 1 < 15 and
+ line[j] in Letters + Digits + {'_'}:
+ inc j
+ if j == atPos + 1:
+ target.add wrapStyle(text line[pos .. atPos], style)
+ pos = atPos + 1
+ continue
+ if atPos > pos:
+ target.add wrapStyle(text line[pos ..< atPos], style)
+ let username = line[atPos + 1 ..< j]
+ let link = a.newVNode()
+ link.setAttr("href", "/" & username)
+ link.add wrapStyle(text ("@" & username), style)
+ target.add link
+ pos = j
+
+proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int;
+ styles: seq[ArticleStyle]) =
+ if styles.len == 0:
+ target.addContent($runes[start ..< start + length])
+ return
+
+ var
+ lastStyle = 0
+ lastStart = start
+ let endPos = start + length
+
+ for i in start ..< endPos:
+ var style = 0
+ for sr in styles:
+ let
+ sStart = sr.offset
+ sEnd = sStart + sr.length
+ if sStart <= i and sEnd > i:
+ case sr.style
+ of "Bold": style.setBit(0)
+ of "Italic": style.setBit(1)
+ of "Strikethrough": style.setBit(2)
+ of "Underline": style.setBit(3)
+ of "Code": style.setBit(4)
+ else: discard
+
+ if style != lastStyle:
+ if i > lastStart:
+ addContent(target, $runes[lastStart ..< i], lastStyle)
+ lastStyle = style
+ lastStart = i
+
+ if lastStart < endPos:
+ addContent(target, $runes[lastStart ..< endPos], lastStyle)
+
+proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode =
+ let text = paragraph.text
+
+ result = case paragraph.kind
+ of "header-one": h1.newVNode()
+ of "header-two": h2.newVNode()
+ of "header-three": h3.newVNode()
+ of "ordered-list-item", "unordered-list-item": li.newVNode()
+ of "blockquote": VNode(kind: VNodeKind.blockquote)
+ of "code-block":
+ let pre = pre.newVNode()
+ let code = code.newVNode()
+ code.add text text
+ pre.add code
+ return pre
+ else: p.newVNode()
+
+ let
+ runes = text.toRunes
+ textLen = runes.len
+ var last = 0
+ for er in paragraph.entityRanges:
+ if er.offset > last:
+ applyInlineStyles(result, runes, last, er.offset - last,
+ paragraph.inlineStyles)
+
+ last = er.offset + er.length
+
+ var target = result
+ if er.key in article.entities:
+ let entity = article.entities[er.key]
+ if entity.kind == "LINK":
+ let parsed = parseUri(entity.url)
+ if parsed.scheme in ["http", "https"]:
+ target = a.newVNode()
+ if parsed.isTwitterUrl:
+ target.setAttr("href", parsed.path)
+ else:
+ target.setAttr("href", entity.url)
+
+ applyInlineStyles(target, runes, er.offset, er.length,
+ paragraph.inlineStyles)
+ if target != result:
+ result.add target
+
+ if last < textLen:
+ applyInlineStyles(result, runes, last, textLen - last,
+ paragraph.inlineStyles)
+
+ if paragraph.kind == "blockquote" and result.len > 0:
+ let lastChild = result[result.len - 1]
+ if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and
+ lastChild[0].kind == VNodeKind.text:
+ lastChild.setAttr("class", "blockquote-attribution")
+
+proc renderArticle*(article: Article; tweets: Table[int64, Tweet];
+ path: string; prefs: Prefs; tweetId=""): VNode =
+ let author = article.user
+
+ let main = buildHtml(article(class="article-body")):
+ h1(class="article-title"): text article.title
+
+ tdiv(class="article-author"):
+ tdiv(class="article-author-row"):
+ a(class="article-avatar", href=("/" & author.username)):
+ genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass)
+ tdiv(class="article-author-info"):
+ tdiv(class="article-author-name"):
+ linkUser(author, class="fullname")
+ verifiedIcon(author)
+ tdiv(class="article-author-meta"):
+ linkUser(author, class="username")
+ span(class="article-date-sep"): text " · "
+ a(class="article-date",
+ href=("/" & author.username & "/status/" & tweetId)):
+ text article.time.getShortTime
+ if not prefs.hideTweetStats:
+ renderStats(article.stats)
+
+ var listKind = ""
+ var list: VNode = nil
+
+ for paragraph in article.paragraphs:
+ let isListItem = paragraph.kind in [
+ "ordered-list-item", "unordered-list-item"]
+
+ if not isListItem and list != nil:
+ main.add list
+ list = nil
+ listKind = ""
+
+ if paragraph.kind == "atomic":
+ main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs)
+ elif isListItem:
+ if paragraph.kind != listKind:
+ if list != nil:
+ main.add list
+ list = if paragraph.kind == "ordered-list-item": ol.newVNode()
+ else: ul.newVNode()
+ listKind = paragraph.kind
+ list.add renderTextParagraph(paragraph, article)
+ else:
+ main.add renderTextParagraph(paragraph, article)
+
+ if list != nil:
+ main.add list
+
+ buildHtml(tdiv(class="article-page")):
+ if article.coverImage.len > 0:
+ a(href=getOrigPicUrl(article.coverImage), target="_blank"):
+ img(class="article-cover", src=getSmallPic(article.coverImage), alt="")
+ main
+ renderToTop()
diff --git a/src/views/broadcast.nim b/src/views/broadcast.nim
new file mode 100644
index 0000000..bfcb9ba
--- /dev/null
+++ b/src/views/broadcast.nim
@@ -0,0 +1,75 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, utils, formatters]
+
+proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
+ let
+ isLive = bc.state == "RUNNING"
+ thumb = getPicUrl(bc.thumb)
+ source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
+ getVidUrl(bc.m3u8Url) else: bc.m3u8Url
+ stateText =
+ if isLive: "LIVE"
+ elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
+ elif bc.state.len > 0: bc.state
+ else: "Ended"
+ durationMs =
+ if bc.startTime.year > 1 and bc.endTime.year > 1:
+ int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
+ else: 0
+ duration = if durationMs > 0: getDuration(durationMs) else: ""
+
+ buildHtml(tdiv(class="broadcast-page")):
+ tdiv(class="broadcast-panel"):
+ tdiv(class="broadcast-player"):
+ if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
+ video(poster=thumb, data-url=source, data-autoload="false",
+ data-start=($bc.replayStart), muted=prefs.muteVideos)
+ verbatim ""
+ tdiv(class="overlay-circle"): span(class="overlay-triangle")
+ if isLive:
+ tdiv(class="broadcast-live"): text "LIVE"
+ elif duration.len > 0:
+ tdiv(class="overlay-duration"): text duration
+ verbatim "
"
+ elif bc.m3u8Url.len > 0:
+ img(src=thumb, alt=bc.title)
+ tdiv(class="video-overlay"):
+ buttonReferer "/enablehls", "Enable hls playback", path
+ if isLive:
+ tdiv(class="broadcast-live"): text "LIVE"
+ elif duration.len > 0:
+ tdiv(class="overlay-duration"): text duration
+ elif bc.thumb.len > 0:
+ img(src=thumb, alt=bc.title)
+ tdiv(class="video-overlay"):
+ if bc.availableForReplay:
+ p: text "Stream unavailable"
+ else:
+ p: text "Replay is not available"
+ else:
+ tdiv(class="video-overlay"):
+ p: text "Broadcast not found"
+
+ tdiv(class="broadcast-info"):
+ h2(class="broadcast-title"): text bc.title
+
+ tdiv(class="broadcast-user-row"):
+ a(class="broadcast-user", href=("/" & bc.user.username)):
+ genImg(getUserPic(bc.user.userPic, "_bigger"))
+ tdiv:
+ tdiv:
+ strong: text bc.user.fullname
+ verifiedIcon(bc.user)
+ span(class="broadcast-username"): text "@" & bc.user.username
+
+ tdiv(class="broadcast-meta"):
+ if bc.totalWatched > 0:
+ span: text insertSep($bc.totalWatched, ',') & " views"
+ if isLive:
+ span(class="broadcast-live"): text stateText
+ else:
+ span: text stateText
diff --git a/src/views/community.nim b/src/views/community.nim
new file mode 100644
index 0000000..52f9041
--- /dev/null
+++ b/src/views/community.nim
@@ -0,0 +1,128 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, utils, formatters]
+
+proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode =
+ let
+ path = &"/i/communities/{community.id}"
+ q = Query(kind: kind)
+ buildHtml(tdiv):
+ ul(class="tab"):
+ li(class=q.getTabClass(posts)):
+ a(href=path): text "Top"
+ li(class=q.getTabClass(replies)):
+ a(href=(path & "/latest")): text "Latest"
+ li(class=q.getTabClass(media)):
+ a(href=(path & "/media")): text "Media"
+ li(class=q.getTabClass(userList)):
+ a(href=(path & "/about")): text "About"
+ if community.hashtags.len > 0:
+ tdiv(class="community-tags"):
+ for tag in community.hashtags:
+ let bare = tag.strip(chars={'#'})
+ a(class="community-tag",
+ href=(&"/i/communities/{community.id}/hashtag/{bare}")):
+ text tag
+
+proc renderMemberTabs*(community: Community; isModerators: bool): VNode =
+ let path = &"/i/communities/{community.id}"
+ buildHtml(ul(class="tab")):
+ li(class=(if not isModerators: "tab-item active" else: "tab-item")):
+ a(href=(path & "/members")): text "All"
+ li(class=(if isModerators: "tab-item active" else: "tab-item")):
+ a(href=(path & "/moderators")): text "Moderators"
+
+proc renderHashtagHeader*(community: Community; tag: string): VNode =
+ buildHtml(tdiv(class="community-hashtag-header")):
+ h2(class="community-hashtag-title"): text "#" & tag
+
+proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode =
+ buildHtml(tdiv(class="community-about")):
+ tdiv(class="community-info"):
+ h2: text "Community Info"
+ tdiv(class="community-info-item"):
+ icon "group"
+ if community.joinPolicy == "Open":
+ text "Anyone can join this Community."
+ else:
+ text "Membership is by approval only."
+
+ tdiv(class="community-info-item"):
+ icon "info"
+ text "All Communities are publicly visible."
+
+ tdiv(class="community-info-item"):
+ icon "calendar"
+ let
+ date = community.createdAt.format("MMMM d, yyyy")
+ creator = community.creator.username
+ span:
+ text &"Created {date} by "
+ a(href=(&"/{creator}")): text &"@{creator}"
+ if community.creator.verifiedType != none:
+ verifiedIcon(community.creator)
+
+ if community.rules.len > 0:
+ tdiv(class="community-rules"):
+ h2: text "Rules"
+ p(class="community-rules-intro"):
+ text "These are set and enforced by Community admins and are in addition to "
+ a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules"
+ text "."
+
+ for i, rule in community.rules:
+ tdiv(class="community-rule"):
+ span(class="community-rule-number"): text $(i + 1)
+ tdiv(class="community-rule-content"):
+ strong: text rule.name
+ if rule.description.len > 0:
+ p: text rule.description
+
+ if moderators.len > 0:
+ tdiv(class="community-moderators"):
+ h2:
+ text "Moderators"
+ a(class="community-mods-link",
+ href=(&"/i/communities/{community.id}/moderators")):
+ text "See all"
+ for user in moderators:
+ tdiv(class="community-moderator"):
+ a(href=(&"/{user.username}")):
+ genImg(user.getUserPic("_bigger"), class="community-mod-avatar")
+ tdiv(class="community-mod-info"):
+ a(href=(&"/{user.username}"), class="community-mod-name"):
+ text user.fullname
+ if user.verifiedType != none:
+ verifiedIcon(user)
+ a(href=(&"/{user.username}"), class="community-mod-username"):
+ text &"@{user.username}"
+
+proc renderCommunity*(body, nav: VNode; community: Community): VNode =
+ buildHtml(tdiv(class="timeline-container")):
+ if community.banner.len > 0:
+ tdiv(class="timeline-banner"):
+ a(href=getPicUrl(community.banner), target="_blank"):
+ genImg(community.banner)
+
+ tdiv(class="community-header"):
+ h1(class="community-name"):
+ a(href=(&"/i/communities/{community.id}")): text community.name
+
+ if community.category.len > 0:
+ span(class="community-category"): text community.category
+
+ if community.description.len > 0:
+ tdiv(class="community-description"):
+ text community.description
+
+ tdiv(class="community-stats"):
+ a(class="community-member-count",
+ href=(&"/i/communities/{community.id}/members")):
+ text insertSep($community.memberCount, ',')
+ text " Members"
+
+ nav
+ body
diff --git a/src/views/embed.nim b/src/views/embed.nim
index ba49f45..5136a35 100644
--- a/src/views/embed.nim
+++ b/src/views/embed.nim
@@ -1,22 +1,76 @@
# SPDX-License-Identifier: AGPL-3.0-only
-import options
import karax/[karaxdsl, vdom]
from jester import Request
-import ".."/[types, formatters]
+import ".."/[types, formatters, prefs]
import general, tweet
-const doctype = "\n"
+const
+ doctype = "\n"
+ embedResizeJs = staticRead("../../public/js/embedResize.js")
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
- let thumb = get(tweet.video).thumb
- let vidUrl = getVideoEmbed(cfg, tweet.id)
- let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
+ let
+ video = tweet.getVideos()[0]
+ thumb = video.thumb
+ vidUrl = getVideoEmbed(cfg, tweet.id)
+ prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos)
+ tweetUrl = getLink(tweet)
+
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
+ base(target="_blank")
body:
tdiv(class="embed-video"):
- renderVideo(get(tweet.video), prefs, "")
+ renderVideo(video, prefs, "")
+ a(class="video-overlay-link", href=tweetUrl):
+ text "Watch on " & cfg.hostname
+
+ script:
+ verbatim embedResizeJs
+
+ result = doctype & $node
+
+proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
+ let node = buildHtml(html(lang="en")):
+ renderHead(prefs, cfg, req)
+ base(target="_blank")
+
+ body:
+ tdiv(class="embed-wrapper"):
+ tdiv(class="tweet-embed"):
+ a(class="tweet-link", href=getLink(tweet))
+ renderTweet(tweet, prefs, path, mainTweet=true)
+ a(class="embed-footer", href=getLink(tweet)):
+ text "Read more on " & cfg.hostname
+
+ script:
+ verbatim embedResizeJs
+
+ result = doctype & $node
+
+proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request;
+ tweetId = ""; username = ""): string =
+ let link = if tweetId.len > 0:
+ if username.len > 0: "/" & username & "/status/" & tweetId
+ else: "/i/status/" & tweetId
+ else: "/"
+
+ let node = buildHtml(html(lang="en")):
+ renderHead(prefs, cfg, req)
+ base(target="_blank")
+
+ body:
+ tdiv(class="embed-wrapper"):
+ tdiv(class="tweet-embed error-embed"):
+ a(class="tweet-link", href=link)
+ tdiv(class="error-panel"):
+ span: text error
+ a(class="embed-footer", href=link):
+ text "Read more on " & cfg.hostname
+
+ script:
+ verbatim embedResizeJs
result = doctype & $node
diff --git a/src/views/general.nim b/src/views/general.nim
index 0091c74..d979898 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -29,19 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
- if cfg.enableRss and rss.len > 0:
+ if rss.len > 0:
icon "rss", title="RSS Feed", href=rss
- icon "bird", title="Open in Twitter", href=canonical
+ icon "bird", title="Open in X", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
- rss=""; canonical=""): VNode =
- var theme = prefs.theme.toTheme
- if "theme" in req.params:
- theme = req.params["theme"].toTheme
+ rss=""; alternate=""; oembed=""): VNode =
+ let theme = prefs.theme.toTheme
let ogType =
if video.len > 0: "video"
@@ -52,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head):
- link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
- link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
+ link(rel="stylesheet", type="text/css", href="/css/style.css?v=106")
+ link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
@@ -66,15 +64,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl)
- if canonical.len > 0:
- link(rel="canonical", href=canonical)
+ if alternate.len > 0:
+ link(rel="alternate", href=alternate, title="View on X")
- if cfg.enableRss and rss.len > 0:
+ if rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
+ if oembed.len > 0:
+ let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
+ link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
+
if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="")
- script(src="/js/hlsPlayback.js", `defer`="")
+ script(src="/js/hlsPlayback.js?v=1", `defer`="")
if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="")
@@ -86,6 +88,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
text cfg.title
meta(name="viewport", content="width=device-width, initial-scale=1.0")
+ meta(name="referrer", content="same-origin")
meta(name="theme-color", content="#1F1F1F")
meta(property="og:type", content=ogType)
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
@@ -98,6 +101,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
for url in images:
+ if url.len == 0: continue
let preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
@@ -119,20 +123,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
# this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason
link(rel="preload", type="font/woff2", `as`="font",
- href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
+ href="/fonts/fontello.woff2?59696369", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
- images: seq[string] = @[]; banner=""): string =
+ images: seq[string] = @[]; banner="";
+ twitterLink=""; oembed=""): string =
- let canonical = getTwitterLink(req.path, req.params)
+ let twitterLink =
+ if twitterLink.len > 0: twitterLink
+ else: getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
- rss, canonical)
+ rss, twitterLink, oembed)
- body:
- renderNavbar(cfg, req, rss, canonical)
+ let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
+ body(class=bodyClass):
+ renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"):
body
diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf
new file mode 100644
index 0000000..4f7f947
--- /dev/null
+++ b/src/views/oembed.nimf
@@ -0,0 +1,7 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+## SPDX-License-Identifier: AGPL-3.0-only
+#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
+# result = ""
+
+# result = result.strip()
+#end proc
diff --git a/src/views/preferences.nim b/src/views/preferences.nim
index 1787704..b051a01 100644
--- a/src/views/preferences.nim
+++ b/src/views/preferences.nim
@@ -32,7 +32,8 @@ macro renderPrefs*(): untyped =
result[2].add stmt
-proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
+proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string];
+ prefsUrl: string): VNode =
buildHtml(tdiv(class="overlay-panel")):
fieldset(class="preferences"):
form(`method`="post", action="/saveprefs", autocomplete="off"):
@@ -40,6 +41,14 @@ proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode
renderPrefs()
+ legend: text "Bookmark"
+ p(class="bookmark-note"):
+ text "Save this URL to restore your preferences (?prefs works on all pages)"
+ pre(class="prefs-code"):
+ text prefsUrl
+ p(class="bookmark-note"):
+ verbatim "You can override preferences with query parameters (e.g. ?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
+
h4(class="note"):
text "Preferences are stored client-side using cookies without any personal information."
diff --git a/src/views/profile.nim b/src/views/profile.nim
index 2b2e410..c9012ed 100644
--- a/src/views/profile.nim
+++ b/src/views/profile.nim
@@ -2,7 +2,7 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
-import renderutils, search
+import renderutils, search, timeline
import ".."/[types, utils, formatters]
proc renderStat(num: int; class: string; text=""): VNode =
@@ -12,7 +12,14 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
-proc renderUserCard*(user: User; prefs: Prefs): VNode =
+proc renderStatLink(num: int; class, href: string): VNode =
+ buildHtml(li(class=class)):
+ a(href=href):
+ span(class="profile-stat-header"): text capitalizeAscii(class)
+ span(class="profile-stat-num"):
+ text insertSep($num, ',')
+
+proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"):
let
@@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname")
+ verifiedIcon(user)
linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"):
@@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
else:
span: text place
+ if info.basedIn.len > 0:
+ tdiv(class="profile-location"):
+ span: icon "location"
+ span: text "Based in " & info.basedIn
+
if user.website.len > 0:
tdiv(class="profile-website"):
span:
@@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(href=url): text url.shortLink
tdiv(class="profile-joindate"):
- span(title=getJoinDateFull(user)):
+ a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
icon "calendar", getJoinDate(user)
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets")
- renderStat(user.following, "following")
- renderStat(user.followers, "followers")
+ renderStatLink(user.following, "following", &"/{user.username}/following")
+ renderStatLink(user.followers, "followers", &"/{user.username}/followers")
renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode =
@@ -93,7 +106,7 @@ proc renderBanner(banner: string): VNode =
else:
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
-proc renderProtected(username: string): VNode =
+proc renderProtected*(username: string): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header timeline-protected"):
h2: text "This account's tweets are protected."
@@ -101,19 +114,52 @@ proc renderProtected(username: string): VNode =
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username]
+ let
+ isGalleryView = profile.tweets.query.kind == QueryKind.media and
+ profile.tweets.query.view == "gallery"
+ viewClass = if isGalleryView: " media-only" else: ""
- buildHtml(tdiv(class="profile-tabs")):
- if not prefs.hideBanner:
+ buildHtml(tdiv(class=("profile-tabs" & viewClass))):
+ if not isGalleryView and not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(profile.user.banner)
- let sticky = if prefs.stickyProfile: " sticky" else: ""
- tdiv(class=("profile-tab" & sticky)):
- renderUserCard(profile.user, prefs)
- if profile.photoRail.len > 0:
- renderPhotoRail(profile)
+ if not isGalleryView:
+ let sticky = if prefs.stickyProfile: " sticky" else: ""
+ tdiv(class=("profile-tab" & sticky)):
+ renderUserCard(profile.user, prefs, profile.accountInfo)
+ if profile.photoRail.len > 0:
+ renderPhotoRail(profile)
if profile.user.protected:
renderProtected(profile.user.username)
else:
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
+
+proc renderFollowTabs(user: User; activeTab: string): VNode =
+ buildHtml(ul(class="tab")):
+ for tab in ["Following", "Followers"]:
+ li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
+ a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
+
+proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
+ path, activeTab: string): VNode =
+ # Check if we've loaded all results (for page 1)
+ var displayResults = results
+ if results.beginning:
+ let expectedCount = if activeTab == "Followers": user.followers else: user.following
+ if results.content.len >= expectedCount:
+ displayResults.bottom = ""
+
+ buildHtml(tdiv(class="profile-tabs")):
+ if not prefs.hideBanner:
+ tdiv(class="profile-banner"):
+ renderBanner(user.banner)
+
+ let sticky = if prefs.stickyProfile: " sticky" else: ""
+ tdiv(class=("profile-tab" & sticky)):
+ renderUserCard(user, prefs, AccountInfo())
+
+ tdiv(class="timeline-container"):
+ renderFollowTabs(user, activeTab)
+ renderTimelineUsers(displayResults, prefs, path)
diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim
index 41ef8df..af2f05d 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils]
const smallWebp* = "?name=small&format=webp"
+const mediumWebp* = "?name=medium&format=webp"
proc getSmallPic*(url: string): string =
+ if url.len == 0: return
result = url
if "?" notin url and not url.endsWith("placeholder.png"):
result &= smallWebp
result = getPicUrl(result)
-proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
+proc getMediumPic*(url: string): string =
+ if url.len == 0: return
+ result = url
+ if "?" notin url and not url.endsWith("placeholder.png"):
+ result &= mediumWebp
+ result = getPicUrl(result)
+
+proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")):
@@ -20,13 +29,15 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
else:
span(class=c, title=title)
- if text.len > 0:
- text " " & text
+ if label.len > 0:
+ text " " & label
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii()
- icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
+ buildHtml(tdiv(class=(&"verified-icon {lower}"))):
+ icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
+ icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
else:
text ""
@@ -40,7 +51,6 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
if isName:
- verifiedIcon(user)
if user.protected:
text " "
icon "lock", title="Protected account"
@@ -64,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
text text
proc genCheckbox*(pref, label: string; state: bool): VNode =
- buildHtml(label(class="pref-group checkbox-container")):
+ buildHtml(label(class="pref-group checkbox-container", title=pref)):
text label
input(name=pref, `type`="checkbox", checked=state)
span(class="checkbox")
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
let p = placeholder
- buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
- buildHtml(tdiv(class="pref-group pref-input")):
+ buildHtml(tdiv(class="pref-group pref-input", title=pref)):
label(`for`=pref): text label
select(name=pref):
for opt in options:
@@ -89,9 +99,16 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state)
icon "calendar"
-proc genImg*(url: string; class=""): VNode =
+proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
+ let p = placeholder
+ buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ if label.len > 0:
+ label(`for`=pref): text label
+ input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
+
+proc genImg*(url: string; class=""; alt=""): VNode =
buildHtml():
- img(src=getPicUrl(url), class=class, alt="", loading="lazy")
+ img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active"
diff --git a/src/views/rss.nimf b/src/views/rss.nimf
index a6f069c..4738705 100644
--- a/src/views/rss.nimf
+++ b/src/views/rss.nimf
@@ -1,26 +1,49 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
-#import strutils, xmltree, strformat, options, unicode
+#import strutils, sequtils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs
+## Snowflake ID cutoff for RSS GUID format transition
+## Corresponds to approximately December 14, 2025 UTC
+#const guidCutoff = 2000000000000000000'i64
#
#proc getTitle(tweet: Tweet; retweet: string): string =
-#if tweet.pinned: result = "Pinned: "
-#elif retweet.len > 0: result = &"RT by @{retweet}: "
-#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
+#var prefix = ""
+#if tweet.pinned: prefix = "Pinned: "
+#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
+#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
#end if
-#var text = stripHtml(tweet.text)
+#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ")
##if unicode.runeLen(text) > 32:
## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if
-#result &= xmltree.escape(text)
-#if result.len > 0: return
+#text = xmltree.escape(text)
+## article tweets' text is just the article link; the title says more
+#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0:
+# result = prefix & xmltree.escape(tweet.articlePreview.get().title)
+# return
#end if
-#if tweet.photos.len > 0:
-# result &= "Image"
-#elif tweet.video.isSome:
-# result &= "Video"
-#elif tweet.gif.isSome:
-# result &= "Gif"
+#if text.len > 0:
+# result = prefix & text
+# return
+#end if
+#if tweet.media.len > 0:
+# result = prefix
+# let firstKind = tweet.media[0].kind
+# if tweet.media.anyIt(it.kind != firstKind):
+# result &= "Media"
+# else:
+# case firstKind
+# of photoMedia: result &= "Image"
+# of videoMedia: result &= "Video"
+# of gifMedia: result &= "Gif"
+# end case
+# end if
+#end if
+#if result.len == 0 and tweet.card.isSome:
+# let card = tweet.card.get()
+# if card.kind notin {hidden, unknown} and card.title.len > 0:
+# result = prefix & xmltree.escape(card.title)
+# end if
#end if
#end proc
#
@@ -28,6 +51,92 @@
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:
@@ -46,27 +155,28 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end if
#end proc
#
-#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
+#proc renderRssTweet(tweet: Tweet; cfg: Config; prefs: Prefs): string =
#let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg)
-#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
+#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
+#if text.len > 0:
${text.replace("\n", "
\n")}
-#if tweet.photos.len > 0:
-# for photo in tweet.photos:
-
+#end if
+#if tweet.media.len > 0:
+# for media in tweet.media:
+${renderRssMedia(media, tweet, urlPrefix)}
# end for
-#elif tweet.video.isSome:
-
-#elif tweet.gif.isSome:
-# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
-# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
-
-#elif tweet.card.isSome:
-# let card = tweet.card.get()
-# if card.image.len > 0:
-
-# end if
+#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}:
+${renderRssCard(tweet.card.get(), prefs, urlPrefix)}
+#end if
+#if tweet.articlePreview.isSome:
+${renderRssArticle(tweet.articlePreview.get(), urlPrefix)}
+#end if
+#if tweet.poll.isSome:
+${renderRssPoll(tweet.poll.get())}
+#end if
+#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
+Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}
#end if
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteTweet = get(tweet.quote)
@@ -75,7 +185,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
${quoteTweet.user.fullname} (@${quoteTweet.user.username})
-${renderRssTweet(quoteTweet, cfg)}
+${renderRssTweet(quoteTweet, cfg, prefs)}