1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-01-31 07:42:51 -05:00

13 Commits

Author SHA1 Message Date
Zed
b83227aaf5 Implement temp fix for cookie sessions
Fixes #1319
2025-11-26 01:03:27 +01:00
Zed
404b06b5f3 Include "Video" and link for video tweets in RSS (#1315)
Fixes #836
2025-11-25 01:03:45 +01:00
Zed
2b922c049a Embed quote tweet in RSS (#1316)
Fixes #132
Closes #820
2025-11-25 01:02:45 +01:00
Zed
78101df2cc Style number input field 2025-11-24 23:04:25 +01:00
Zed
12bbddf204 Update search panel grid layout and animation 2025-11-24 23:04:25 +01:00
Zed
4979d07f2e Add spaces filter, remove broken filters 2025-11-24 23:04:25 +01:00
Zed
f038b53fa2 Fix body font size to match x.com
Fixes #711
2025-11-24 23:04:25 +01:00
Zed
4748311f8d Fix intent/follow URL redirect
Fixes #629
2025-11-24 23:04:25 +01:00
Zed
d47eb8f0eb Fix double slashes in url replacements
Fixes #520
2025-11-24 23:04:25 +01:00
Zed
1657eeb769 Fix canonical link causing redirects to Twitter
Fixes #526
2025-11-24 23:04:25 +01:00
Zed
25df682094 Expose username as HTML attribute
Fixes #551
2025-11-24 23:04:25 +01:00
Zed
53edbbc4e9 Fix broken tweet pagination ("Load more" button)
Fixes #1277
2025-11-23 20:00:10 +01:00
Zed
5b4a3fe691 Redirect /i/status/id/history to /i/status/id
Fixes #1231
2025-11-23 19:27:13 +01:00
23 changed files with 182 additions and 110 deletions

View File

@@ -13,47 +13,54 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
proc mediaUrl(id: string; cursor: string): SessionAwareUrl = proc mediaUrl(id: string; cursor: string): SessionAwareUrl =
let let
cookieVariables = userMediaVariables % [id, cursor] cookieVars = userMediaVars % [id, cursor]
oauthVariables = restIdVariables % [id, cursor] oauthVars = restIdVars % [id, cursor]
result = SessionAwareUrl( result = SessionAwareUrl(
cookieUrl: graphUserMedia ? genParams(cookieVariables), cookieUrl: graphUserMedia ? genParams(cookieVars),
oauthUrl: graphUserMediaV2 ? genParams(oauthVariables) oauthUrl: graphUserMediaV2 ? genParams(oauthVars)
) )
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl =
let let
cookieVariables = userTweetsVariables % [id, cursor] cookieVars = userTweetsVars % [id, cursor]
oauthVariables = restIdVariables % [id, cursor] oauthVars = restIdVars % [id, cursor]
result = SessionAwareUrl( result = SessionAwareUrl(
# cookieUrl: graphUserTweets ? genParams(cookieVariables, fieldToggles), # cookieUrl: graphUserTweets ? genParams(cookieVars, userTweetsFieldToggles),
oauthUrl: graphUserTweetsV2 ? genParams(oauthVariables) oauthUrl: graphUserTweetsV2 ? genParams(oauthVars)
) )
# might change this in the future pending testing # might change this in the future pending testing
result.cookieUrl = result.oauthUrl result.cookieUrl = result.oauthUrl
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl = proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl =
let let
cookieVariables = userTweetsAndRepliesVariables % [id, cursor] cookieVars = userTweetsAndRepliesVars % [id, cursor]
oauthVariables = restIdVariables % [id, cursor] oauthVars = restIdVars % [id, cursor]
result = SessionAwareUrl( result = SessionAwareUrl(
cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVariables, fieldToggles), cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVars, userTweetsFieldToggles),
oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVariables) oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVars)
) )
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl = proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl =
let let
cookieVariables = tweetDetailVariables % [id, cursor] cookieVars = tweetDetailVars % [id, cursor]
oauthVariables = tweetVariables % [id, cursor] oauthVars = tweetVars % [id, cursor]
result = SessionAwareUrl( result = SessionAwareUrl(
cookieUrl: graphTweetDetail ? genParams(cookieVariables, tweetDetailFieldToggles), cookieUrl: graphTweetDetail ? genParams(cookieVars, tweetDetailFieldToggles),
oauthUrl: graphTweet ? genParams(oauthVariables) oauthUrl: graphTweet ? genParams(oauthVars)
)
proc userUrl(username: string): SessionAwareUrl =
let
cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
oauthVars = """{"screen_name": "$1"}""" % username
result = SessionAwareUrl(
cookieUrl: graphUser ? genParams(cookieVars, tweetDetailFieldToggles),
oauthUrl: graphUserV2 ? genParams(oauthVars)
) )
proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let let js = await fetchRaw(userUrl(username), Api.userScreenName)
url = graphUser ? genParams("""{"screen_name": "$1"}""" % username)
js = await fetchRaw(url, Api.userScreenName)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
@@ -80,7 +87,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = graphListTweets ? genParams(restIdVariables % [id, cursor]) url = graphListTweets ? genParams(restIdVars % [id, cursor])
result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =

View File

@@ -50,7 +50,7 @@ proc genHeaders*(session: Session, url: string): HttpHeaders =
of SessionKind.oauth: of SessionKind.oauth:
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret) result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret)
of SessionKind.cookie: of SessionKind.cookie:
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF" result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
result["x-twitter-auth-type"] = "OAuth2Session" result["x-twitter-auth-type"] = "OAuth2Session"
result["x-csrf-token"] = session.ct0 result["x-csrf-token"] = session.ct0
result["cookie"] = getCookieHeader(session.authToken, session.ct0) result["cookie"] = getCookieHeader(session.authToken, session.ct0)

View File

@@ -7,7 +7,8 @@ const
gql = parseUri("https://api.x.com") / "graphql" gql = parseUri("https://api.x.com") / "graphql"
graphUser* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" graphUser* = gql / "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
graphUserV2* = gql / "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery" graphUserById* = gql / "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"
graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" graphUserTweetsV2* = gql / "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2" graphUserTweetsAndRepliesV2* = gql / "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
@@ -97,10 +98,14 @@ const
"grok_translations_community_note_auto_translation_is_enabled": false, "grok_translations_community_note_auto_translation_is_enabled": false,
"grok_translations_post_auto_translation_is_enabled": false, "grok_translations_post_auto_translation_is_enabled": false,
"grok_translations_community_note_translation_is_enabled": false, "grok_translations_community_note_translation_is_enabled": false,
"grok_translations_timeline_user_bio_auto_translation_is_enabled": false "grok_translations_timeline_user_bio_auto_translation_is_enabled": false,
"subscriptions_feature_can_gift_premium": false,
"responsive_web_twitter_article_notes_tab_enabled": false,
"subscriptions_verification_info_is_identity_verified_enabled": false,
"hidden_profile_subscriptions_enabled": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVars* = """{
"postId": "$1", "postId": "$1",
$2 $2
"includeHasBirdwatchNotes": false, "includeHasBirdwatchNotes": false,
@@ -110,7 +115,7 @@ const
"withV2Timeline": true "withV2Timeline": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetDetailVariables* = """{ tweetDetailVars* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"referrer": "profile", "referrer": "profile",
@@ -123,12 +128,12 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
restIdVariables* = """{ restIdVars* = """{
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": 20 "count": 20
}""" }"""
userMediaVariables* = """{ userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -137,7 +142,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{ userTweetsVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -145,7 +150,7 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVariables* = """{ userTweetsAndRepliesVars* = """{
"userId": "$1", $2 "userId": "$1", $2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false,
@@ -153,5 +158,6 @@ const
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
fieldToggles* = """{"withArticlePlainText":false}""" userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

View File

@@ -1,6 +1,6 @@
import options, strutils import options, strutils
import jsony import jsony
import user, ../types/[graphuser, graphlistmembers] import user, utils, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User = proc parseUserResult*(userResult: UserResult): User =
@@ -15,22 +15,36 @@ proc parseUserResult*(userResult: UserResult): User =
result.fullname = userResult.core.name result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "") result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
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: if userResult.verification.isSome:
let v = userResult.verification.get let v = userResult.verification.get
if v.verifiedType != VerifiedType.none: if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType result.verifiedType = v.verifiedType
if userResult.profileBio.isSome: if userResult.profileBio.isSome and result.bio.len == 0:
result.bio = userResult.profileBio.get.description result.bio = userResult.profileBio.get.description
proc parseGraphUser*(json: string): User = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{': if json.len == 0 or json[0] != '{':
return return
let raw = json.fromJson(GraphUser) let
let userResult = raw.data.userResult.result 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) return User(suspended: true)
result = parseUserResult(userResult) result = parseUserResult(userResult)

View File

@@ -58,11 +58,13 @@ proc toUser*(raw: RawUser): User =
media: raw.mediaCount, media: raw.mediaCount,
verifiedType: raw.verifiedType, verifiedType: raw.verifiedType,
protected: raw.protected, protected: raw.protected,
joinDate: parseTwitterDate(raw.createdAt),
banner: getBanner(raw), banner: getBanner(raw),
userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "")
) )
if raw.createdAt.len > 0:
result.joinDate = parseTwitterDate(raw.createdAt)
if raw.pinnedTweetIdsStr.len > 0: if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])

View File

@@ -3,7 +3,7 @@ from ../../types import User, VerifiedType
type type
GraphUser* = object GraphUser* = object
data*: tuple[userResult: UserData] data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
@@ -22,15 +22,24 @@ type
Verification* = object Verification* = object
verifiedType*: VerifiedType verifiedType*: VerifiedType
Location* = object
location*: string
Privacy* = object
protected*: bool
UserResult* = object UserResult* = object
legacy*: User legacy*: User
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
unavailableReason*: Option[string]
core*: UserCore core*: UserCore
avatar*: UserAvatar avatar*: UserAvatar
unavailableReason*: Option[string]
reason*: Option[string]
privacy*: Option[Privacy]
profileBio*: Option[UserBio] profileBio*: Option[UserBio]
verification*: Option[Verification] verification*: Option[Verification]
location*: Option[Location]
proc enumHook*(s: string; v: var VerifiedType) = proc enumHook*(s: string; v: var VerifiedType) =
v = try: v = try:

View File

@@ -6,7 +6,7 @@ import types, utils, query
const const
cards = "cards.twitter.com/cards" cards = "cards.twitter.com/cards"
tco = "https://t.co" tco = "https://t.co"
twitter = parseUri("https://twitter.com") twitter = parseUri("https://x.com")
let let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
@@ -59,25 +59,28 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
result = body result = body
if prefs.replaceYouTube.len > 0 and "youtu" in result: if prefs.replaceYouTube.len > 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: if prefs.replaceTwitter.len > 0:
let twitterHost = strip(prefs.replaceTwitter, chars={'/'})
if tco in result: 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: if "x.com" in result:
result = result.replace(xRegex, prefs.replaceTwitter) result = result.replace(xRegex, twitterHost)
result = result.replacef(xLinkRegex, a( result = result.replacef(xLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) twitterHost & "$2", href = https & twitterHost & "$1"))
if "twitter.com" in result: if "twitter.com" in result:
result = result.replace(cards, prefs.replaceTwitter & "/cards") result = result.replace(cards, twitterHost & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter) result = result.replace(twRegex, twitterHost)
result = result.replacef(twLinkRegex, a( 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): if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") let redditHost = strip(prefs.replaceReddit, chars={'/'})
result = result.replace(rdRegex, prefs.replaceReddit) result = result.replace(rdShortRegex, redditHost & "/comments/")
if prefs.replaceReddit in result and "/gallery/" in result: result = result.replace(rdRegex, redditHost)
if redditHost in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/") result = result.replace("/gallery/", "/comments/")
if absolute.len > 0 and "href" in result: if absolute.len > 0 and "href" in result:

View File

@@ -21,7 +21,7 @@ proc parseUser(js: JsonNode; id=""): User =
tweets: js{"statuses_count"}.getInt, tweets: js{"statuses_count"}.getInt,
likes: js{"favourites_count"}.getInt, likes: js{"favourites_count"}.getInt,
media: js{"media_count"}.getInt, media: js{"media_count"}.getInt,
protected: js{"protected"}.getBool, protected: js{"protected"}.getBool(js{"privacy", "protected"}.getBool),
joinDate: js{"created_at"}.getTime joinDate: js{"created_at"}.getTime
) )
@@ -485,6 +485,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
var cursorValue = select( var cursorValue = select(
e{"content", "value"},
e{"content", "content", "value"}, e{"content", "content", "value"},
e{"content", "itemContent", "value"} e{"content", "itemContent", "value"}
) )

View File

@@ -6,10 +6,9 @@ import types
const const
validFilters* = @[ validFilters* = @[
"media", "images", "twimg", "videos", "media", "images", "twimg", "videos",
"native_video", "consumer_video", "pro_video", "native_video", "consumer_video", "spaces",
"links", "news", "quote", "mentions", "links", "news", "quote", "mentions",
"replies", "retweets", "nativeretweets", "replies", "retweets", "nativeretweets"
"verified", "safe"
] ]
emptyQuery* = "include:nativeretweets" emptyQuery* = "include:nativeretweets"
@@ -18,6 +17,11 @@ template `@`(param: string): untyped =
if param in pms: pms[param] if param in pms: pms[param]
else: "" else: ""
proc validateNumber(value: string): string =
if value.anyIt(not it.isDigit):
return ""
return value
proc initQuery*(pms: Table[string, string]; name=""): Query = proc initQuery*(pms: Table[string, string]; name=""): Query =
result = Query( result = Query(
kind: parseEnum[QueryKind](@"f", tweets), kind: parseEnum[QueryKind](@"f", tweets),
@@ -26,7 +30,7 @@ proc initQuery*(pms: Table[string, string]; name=""): Query =
excludes: validFilters.filterIt("e-" & it in pms), excludes: validFilters.filterIt("e-" & it in pms),
since: @"since", since: @"since",
until: @"until", until: @"until",
near: @"near" minLikes: validateNumber(@"min_faves")
) )
if name.len > 0: if name.len > 0:
@@ -78,8 +82,8 @@ proc genQueryParam*(query: Query): string =
result &= " since:" & query.since result &= " since:" & query.since
if query.until.len > 0: if query.until.len > 0:
result &= " until:" & query.until result &= " until:" & query.until
if query.near.len > 0: if query.minLikes.len > 0:
result &= &" near:\"{query.near}\" within:15mi" result &= " min_faves:" & query.minLikes
if query.text.len > 0: if query.text.len > 0:
if result.len > 0: if result.len > 0:
result &= " " & query.text result &= " " & query.text
@@ -103,8 +107,8 @@ proc genQueryUrl*(query: Query): string =
params.add "since=" & query.since params.add "since=" & query.since
if query.until.len > 0: if query.until.len > 0:
params.add "until=" & query.until params.add "until=" & query.until
if query.near.len > 0: if query.minLikes.len > 0:
params.add "near=" & query.near params.add "min_faves=" & query.minLikes
if params.len > 0: if params.len > 0:
result &= params.join("&") result &= params.join("&")

View File

@@ -31,8 +31,6 @@ proc createStatusRouter*(cfg: Config) =
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
let conv = await getTweet(id, getCursor()) let conv = await getTweet(id, getCursor())
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found" var error = "Tweet not found"
@@ -68,7 +66,7 @@ proc createStatusRouter*(cfg: Config) =
get "/@name/@s/@id/@m/?@i?": get "/@name/@s/@id/@m/?@i?":
cond @"s" in ["status", "statuses"] cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo"] cond @"m" in ["video", "photo", "history"]
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?": get "/@name/statuses/@id/?":

View File

@@ -105,6 +105,12 @@ proc createTimelineRouter*(cfg: Config) =
get "/intent/user": get "/intent/user":
respUserId() 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/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]

View File

@@ -17,7 +17,7 @@ proc createUnsupportedRouter*(cfg: Config) =
get "/@name/lists/?": feature() get "/@name/lists/?": feature()
get "/intent/?@i?": get "/intent/?@i?":
cond @"i" notin ["user"] cond @"i" notin ["user", "follow"]
feature() feature()
get "/i/@i?/?@j?": get "/i/@i?/?@j?":

View File

@@ -66,18 +66,7 @@
} }
#search-panel-toggle:checked ~ .search-panel { #search-panel-toggle:checked ~ .search-panel {
@if $rows == 6 { max-height: 380px !important;
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;
}
} }
} }
} }

View File

@@ -51,7 +51,7 @@ body {
background-color: var(--bg_color); background-color: var(--bg_color);
color: var(--fg_color); color: var(--fg_color);
font-family: $font_0, $font_1, $font_2, $font_3; font-family: $font_0, $font_1, $font_2, $font_3;
font-size: 14px; font-size: 15px;
line-height: 1.3; line-height: 1.3;
margin: 0; margin: 0;
} }

View File

@@ -14,6 +14,7 @@ button {
input[type="text"], input[type="text"],
input[type="date"], input[type="date"],
input[type="number"],
select { select {
@include input-colors; @include input-colors;
background-color: var(--bg_elements); background-color: var(--bg_elements);
@@ -24,7 +25,12 @@ select {
font-size: 14px; font-size: 14px;
} }
input[type="text"] { input[type="number"] {
-moz-appearance: textfield;
}
input[type="text"],
input[type="number"] {
height: 16px; height: 16px;
} }
@@ -38,6 +44,17 @@ 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 { input[type="date"]::-webkit-clear-button {
margin-left: 17px; margin-left: 17px;
filter: grayscale(100%); filter: grayscale(100%);
@@ -164,7 +181,8 @@ input::-webkit-datetime-edit-year-field:focus {
appearance: none; appearance: none;
} }
input[type="text"] { input[type="text"],
input[type="number"] {
position: absolute; position: absolute;
right: 0; right: 0;
max-width: 140px; max-width: 140px;

View File

@@ -24,7 +24,8 @@
height: 23px; height: 23px;
} }
input[type="text"] { input[type="text"],
input[type="number"] {
height: calc(100% - 4px); height: calc(100% - 4px);
width: calc(100% - 8px); width: calc(100% - 8px);
} }
@@ -42,7 +43,7 @@
@include input-colors; @include input-colors;
} }
@include create-toggle(search-panel, 200px); @include create-toggle(search-panel, 380px);
} }
.search-panel { .search-panel {
@@ -104,19 +105,18 @@
.search-toggles { .search-toggles {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(6, auto); grid-template-columns: repeat(5, auto);
grid-column-gap: 10px; grid-column-gap: 10px;
} }
.profile-tabs { .profile-tabs {
@include search-resize(820px, 5); @include search-resize(820px, 5);
@include search-resize(725px, 4); @include search-resize(715px, 4);
@include search-resize(600px, 6); @include search-resize(700px, 5);
@include search-resize(560px, 5); @include search-resize(485px, 4);
@include search-resize(480px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);
} }
@include search-resize(560px, 5); @include search-resize(700px, 5);
@include search-resize(480px, 4); @include search-resize(485px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);

View File

@@ -140,7 +140,7 @@ type
fromUser*: seq[string] fromUser*: seq[string]
since*: string since*: string
until*: string until*: string
near*: string minLikes*: string
sep*: string sep*: string
Gif* = object Gif* = object

View File

@@ -31,14 +31,14 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
icon "search", title="Search", href="/search" icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0: if cfg.enableRss and rss.len > 0:
icon "rss", title="RSS Feed", href=rss 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 a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about" icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle=""; video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""): VNode = rss=""; alternate=""): VNode =
var theme = prefs.theme.toTheme var theme = prefs.theme.toTheme
if "theme" in req.params: if "theme" in req.params:
theme = req.params["theme"].toTheme theme = req.params["theme"].toTheme
@@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/style.css?v=21")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
if theme.len > 0: if theme.len > 0:
@@ -66,8 +66,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
href=opensearchUrl) href=opensearchUrl)
if canonical.len > 0: if alternate.len > 0:
link(rel="canonical", href=canonical) link(rel="alternate", href=alternate, title="View on X")
if cfg.enableRss and rss.len > 0: if cfg.enableRss and rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
@@ -125,14 +125,14 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""): string = images: seq[string] = @[]; banner=""): string =
let canonical = getTwitterLink(req.path, req.params) let twitterLink = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical) rss, twitterLink)
body: body:
renderNavbar(cfg, req, rss, canonical) renderNavbar(cfg, req, rss, twitterLink)
tdiv(class="container"): tdiv(class="container"):
body body

View File

@@ -89,6 +89,13 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state) input(name=pref, `type`="date", value=state)
icon "calendar" icon "calendar"
proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
let p = placeholder
buildHtml(tdiv(class=("pref-group pref-input " & class))):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
proc genImg*(url: string; class=""): VNode = proc genImg*(url: string; class=""): VNode =
buildHtml(): buildHtml():
img(src=getPicUrl(url), class=class, alt="", loading="lazy") img(src=getPicUrl(url), class=class, alt="", loading="lazy")

View File

@@ -25,7 +25,7 @@
#end proc #end proc
# #
#proc getDescription(desc: string; cfg: Config): string = #proc getDescription(desc: string; cfg: Config): string =
Twitter feed for: ${desc}. Generated by ${cfg.hostname} Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc #end proc
# #
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] = #proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
@@ -51,10 +51,6 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) #let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
<p>${text.replace("\n", "<br>\n")}</p> <p>${text.replace("\n", "<br>\n")}</p>
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote))
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
#end if
#if tweet.photos.len > 0: #if tweet.photos.len > 0:
# for photo in tweet.photos: # for photo in tweet.photos:
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
@@ -75,6 +71,20 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
# end if # end if
#end if #end if
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteTweet = get(tweet.quote)
# let quoteLink = urlPrefix & getLink(quoteTweet)
<hr/>
<blockquote>
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
<p>
${renderRssTweet(quoteTweet, cfg)}
</p>
<footer>
— <cite><a href="${quoteLink}">${quoteLink}</a>
</footer>
</blockquote>
#end if
#end proc #end proc
# #
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =

View File

@@ -10,14 +10,12 @@ const toggles = {
"media": "Media", "media": "Media",
"videos": "Videos", "videos": "Videos",
"news": "News", "news": "News",
"verified": "Verified",
"native_video": "Native videos", "native_video": "Native videos",
"replies": "Replies", "replies": "Replies",
"links": "Links", "links": "Links",
"images": "Images", "images": "Images",
"safe": "Safe",
"quote": "Quotes", "quote": "Quotes",
"pro_video": "Pro videos" "spaces": "Spaces"
}.toOrderedTable }.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
@@ -53,7 +51,7 @@ proc renderSearchTabs*(query: Query): VNode =
proc isPanelOpen(q: Query): bool = proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
@[q.near, q.until, q.since].anyIt(it.len > 0)) @[q.minLikes, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode = proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",") let user = query.fromUser.join(",")
@@ -85,8 +83,8 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "-" span(class="search-title"): text "-"
genDate("until", query.until) genDate("until", query.until)
tdiv: tdiv:
span(class="search-title"): text "Near" span(class="search-title"): text "Minimum likes"
genInput("near", "", query.near, "Location...", autofocus=false) genNumberInput("min_faves", "", query.minLikes, "Number...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =

View File

@@ -56,7 +56,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), showThread=show)
proc renderUser(user: User; prefs: Prefs): VNode = proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")): buildHtml(tdiv(class="timeline-item", data-username=user.username)):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"): tdiv(class="tweet-body profile-result"):
tdiv(class="tweet-header"): tdiv(class="tweet-header"):

View File

@@ -272,7 +272,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
divClass = "thread-last " & class divClass = "thread-last " & class
if not tweet.available: if not tweet.available:
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")): return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
tdiv(class="unavailable-box"): tdiv(class="unavailable-box"):
if tweet.tombstone.len > 0: if tweet.tombstone.len > 0:
text tweet.tombstone text tweet.tombstone
@@ -294,7 +294,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tweet = tweet.retweet.get tweet = tweet.retweet.get
retweet = fullTweet.user.fullname retweet = fullTweet.user.fullname
buildHtml(tdiv(class=("timeline-item " & divClass))): buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
if not mainTweet: if not mainTweet:
a(class="tweet-link", href=getLink(tweet)) a(class="tweet-link", href=getLink(tweet))