Merge remote-tracking branch 'upstream/master'

This commit is contained in:
PrivacyDev 2023-07-13 20:53:45 -04:00
commit 8bcab11109
24 changed files with 360 additions and 257 deletions

62
.github/workflows/build-docker.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: Docker
on:
push:
paths-ignore:
- "README.md"
branches:
- master
jobs:
tests:
uses: ./.github/workflows/run-tests.yml
build-docker-amd64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
build-docker-arm64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204-arm
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile.arm64
platforms: linux/arm64
push: true
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64

View file

@ -1,9 +1,12 @@
name: Run tests name: Tests
on: on:
push: push:
paths-ignore: paths-ignore:
- "*.md" - "*.md"
branches-ignore:
- master
workflow_call:
jobs: jobs:
test: test:

View file

@ -1,6 +1,7 @@
# Nitter # Nitter
[![Test Matrix](https://github.com/zedeus/nitter/workflows/CI/CD/badge.svg)](https://github.com/zedeus/nitter/actions?query=workflow%3ACI/CD) [![Test Matrix](https://github.com/zedeus/nitter/workflows/Tests/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/run-tests.yml)
[![Test Matrix](https://github.com/zedeus/nitter/workflows/Docker/badge.svg)](https://github.com/zedeus/nitter/actions/workflows/build-docker.yml)
[![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license) [![License](https://img.shields.io/github/license/zedeus/nitter?style=flat)](#license)
A free and open source alternative Twitter front-end focused on privacy and A free and open source alternative Twitter front-end focused on privacy and

View file

@ -7,20 +7,20 @@ import experimental/parser as newParser
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
variables = %*{"screen_name": username} variables = """{"screen_name": "$1"}""" % username
params = {"variables": $variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUser ? params, Api.userScreenName) js = await fetchRaw(graphUser ? params, Api.userScreenName)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let let
variables = %*{"userId": id} variables = """{"rest_id": "$1"}""" % id
params = {"variables": $variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUserById ? params, Api.userRestId) js = await fetchRaw(graphUserById ? params, Api.userRestId)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.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: ""
@ -40,7 +40,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
variables = listTweetsVariables % [id, cursor] variables = listTweetsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphListTweets ? params, Api.listTweets) js = await fetch(graphListTweets ? params, Api.listTweets)
result = parseGraphTimeline(js, "list", after) result = parseGraphTimeline(js, "list", after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
@ -50,8 +50,8 @@ proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
variables = %*{"listId": id} variables = """{"listId": "$1"}""" % id
params = {"variables": $variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
result = parseGraphList(await fetch(graphListById ? params, Api.list)) result = parseGraphList(await fetch(graphListById ? params, Api.list))
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
@ -79,7 +79,7 @@ proc getFavorites*(id: string; cfg: Config; after=""): Future[Timeline] {.async.
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
variables = tweetResultVariables % id variables = """{"rest_id": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures} params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweetResult ? params, Api.tweetResult) js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
@ -138,10 +138,10 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
if after.len > 0: if after.len > 0:
result.replies = await getReplies(id, after) result.replies = await getReplies(id, after)
proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} = proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} =
let q = genQueryParam(query) let q = genQueryParam(query)
if q.len == 0 or q == emptyQuery: if q.len == 0 or q == emptyQuery:
return Result[Tweet](query: query, beginning: true) return Profile(tweets: Timeline(query: query, beginning: true))
var var
variables = %*{ variables = %*{
@ -155,8 +155,24 @@ proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphSearch(await fetch(url, Api.search), after) result = Profile(tweets: parseGraphSearch(await fetch(url, Api.search), after))
result.tweets.query = query
proc getTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
let q = genQueryParam(query)
if q.len == 0 or q == emptyQuery:
return Timeline(query: query, beginning: true)
let url = tweetSearch ? genParams({
"q": q,
"tweet_search_mode": "live",
"max_id": after
})
result = parseTweetSearch(await fetch(url, Api.search))
result.query = query result.query = query
if after.len == 0:
result.beginning = true
proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
if query.text.len == 0: if query.text.len == 0:

View file

@ -2,7 +2,7 @@
import uri, sequtils, strutils import uri, sequtils, strutils
const const
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
api = parseUri("https://api.twitter.com") api = parseUri("https://api.twitter.com")
activate* = $(api / "1.1/guest/activate.json") activate* = $(api / "1.1/guest/activate.json")
@ -12,20 +12,21 @@ const
timelineApi = api / "2/timeline" timelineApi = api / "2/timeline"
favorites* = timelineApi / "favorites" favorites* = timelineApi / "favorites"
userSearch* = api / "1.1/users/search.json" userSearch* = api / "1.1/users/search.json"
tweetSearch* = api / "1.1/search/tweets.json"
graphql = api / "graphql" graphql = api / "graphql"
graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId" graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets" graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies" graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia" graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2"
graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail" graphTweet* = graphql / "83h5UyHZ9wEKBVzALX8R_g/ConversationTimelineV2"
graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId" graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline" graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters" graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters" graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers" graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
@ -56,10 +57,13 @@ const
}.toSeq }.toSeq
gqlFeatures* = """{ gqlFeatures* = """{
"android_graphql_skip_api_media_color_palette": false,
"blue_business_profile_image_shape_enabled": false, "blue_business_profile_image_shape_enabled": false,
"creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true, "creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": false, "freedom_of_speech_not_reach_fetch_enabled": false,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
"hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": false, "highlights_tweets_tab_ui_enabled": false,
"interactive_text_enabled": false, "interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true, "longform_notetweets_consumption_enabled": true,
@ -71,15 +75,25 @@ const
"responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_exclude_directive_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": false,
"responsive_web_media_download_video_enabled": false,
"responsive_web_text_conversations_enabled": false, "responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true, "rweb_lists_timeline_redesign_enabled": true,
"spaces_2022_h2_clipping": true, "spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true, "spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": false, "standardized_nudges_misinfo": false,
"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_awards_web_tipping_enabled": false,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
"tweetypie_unmention_optimization_enabled": false, "tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"verified_phone_label_enabled": false, "verified_phone_label_enabled": false,
"vibe_api_enabled": false, "vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": false "view_counts_everywhere_api_enabled": false
@ -88,43 +102,17 @@ const
tweetVariables* = """{ tweetVariables* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"withBirdwatchNotes": false, "includeHasBirdwatchNotes": false
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false
}"""
tweetResultVariables* = """{
"tweetId": "$1",
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false,
"withCommunity": false
}""" }"""
userTweetsVariables* = """{ userTweetsVariables* = """{
"userId": "$1", $2 "rest_id": "$1", $2
"count": 20, "count": 20
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false,
"withV2Timeline": true
}""" }"""
listTweetsVariables* = """{ listTweetsVariables* = """{
"listId": "$1", $2 "rest_id": "$1", $2
"count": 20, "count": 20
"includePromotedContent": false,
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false,
"withVoice": false
}""" }"""
reactorsVariables* = """{ reactorsVariables* = """{

View file

@ -4,14 +4,17 @@ import user, ../types/[graphuser, graphlistmembers]
from ../../types import User, Result, Query, QueryKind from ../../types import User, Result, Query, QueryKind
proc parseGraphUser*(json: string): User = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{':
return
let raw = json.fromJson(GraphUser) let raw = json.fromJson(GraphUser)
if raw.data.user.result.reason.get("") == "Suspended": if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
return User(suspended: true) return User(suspended: true)
result = toUser raw.data.user.result.legacy result = toUser raw.data.userResult.result.legacy
result.id = raw.data.user.result.restId result.id = raw.data.userResult.result.restId
result.verified = result.verified or raw.data.user.result.isBlueVerified result.verified = result.verified or raw.data.userResult.result.isBlueVerified
proc parseGraphListMembers*(json, cursor: string): Result[User] = proc parseGraphListMembers*(json, cursor: string): Result[User] =
result = Result[User]( result = Result[User](

View file

@ -3,7 +3,7 @@ import user
type type
GraphUser* = object GraphUser* = object
data*: tuple[user: UserData] data*: tuple[userResult: UserData]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
@ -12,4 +12,4 @@ type
legacy*: RawUser legacy*: RawUser
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
reason*: Option[string] unavailableReason*: Option[string]

View file

@ -82,12 +82,16 @@ proc parseVideo(js: JsonNode): Video =
result = Video( result = Video(
thumb: js{"media_url_https"}.getImageStr, thumb: js{"media_url_https"}.getImageStr,
views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt), views: js{"ext", "mediaStats", "r", "ok", "viewCount"}.getStr($js{"mediaStats", "viewCount"}.getInt),
available: js{"ext_media_availability", "status"}.getStr.toLowerAscii == "available", available: true,
title: js{"ext_alt_text"}.getStr, title: js{"ext_alt_text"}.getStr,
durationMs: js{"video_info", "duration_millis"}.getInt durationMs: js{"video_info", "duration_millis"}.getInt
# playbackType: mp4 # playbackType: mp4
) )
with status, js{"ext_media_availability", "status"}:
if status.getStr.len > 0 and status.getStr.toLowerAscii != "available":
result.available = false
with title, js{"additional_media_info", "title"}: with title, js{"additional_media_info", "title"}:
result.title = title.getStr result.title = title.getStr
@ -219,7 +223,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
if result.hasThread and result.threadId == 0: if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId result.threadId = js{"self_thread", "id_str"}.getId
if js{"is_quote_status"}.getBool: if "retweeted_status" in js:
result.retweet = some Tweet()
elif js{"is_quote_status"}.getBool:
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId) result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
# legacy # legacy
@ -265,6 +271,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.gif = some(parseGif(m)) result.gif = some(parseGif(m))
else: discard else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
with jsWithheld, js{"withheld_in_countries"}: with jsWithheld, js{"withheld_in_countries"}:
let withheldInCountries: seq[string] = let withheldInCountries: seq[string] =
if jsWithheld.kind != JArray: @[] if jsWithheld.kind != JArray: @[]
@ -279,6 +290,30 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
result.text.removeSuffix(" Learn more.") result.text.removeSuffix(" Learn more.")
result.available = false result.available = false
proc parseLegacyTweet(js: JsonNode): Tweet =
result = parseTweet(js, js{"card"})
if not result.isNil and result.available:
result.user = parseUser(js{"user"})
if result.quote.isSome:
result.quote = some parseLegacyTweet(js{"quoted_status"})
proc parseTweetSearch*(js: JsonNode): Timeline =
if js.kind == JNull or "statuses" notin js:
return Timeline(beginning: true)
for tweet in js{"statuses"}:
let parsed = parseLegacyTweet(tweet)
if parsed.retweet.isSome:
parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"})
result.content.add @[parsed]
let cursor = js{"search_metadata", "next_results"}.getStr
if cursor.len > 0 and "max_id" in cursor:
result.bottom = cursor[cursor.find("=") + 1 .. cursor.find("&q=")]
proc finalizeTweet(global: GlobalObjects; id: string): Tweet = proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
let intId = if id.len > 0: parseBiggestInt(id) else: 0 let intId = if id.len > 0: parseBiggestInt(id) else: 0
result = global.tweets.getOrDefault(id, Tweet(id: intId)) result = global.tweets.getOrDefault(id, Tweet(id: intId))
@ -297,16 +332,6 @@ proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
else: else:
result.retweet = some Tweet() result.retweet = some Tweet()
proc parsePin(js: JsonNode; global: GlobalObjects): Tweet =
let pin = js{"pinEntry", "entry", "entryId"}.getStr
if pin.len == 0: return
let id = pin.getId
if id notin global.tweets: return
global.tweets[id].pinned = true
return finalizeTweet(global, id)
proc parseGlobalObjects(js: JsonNode): GlobalObjects = proc parseGlobalObjects(js: JsonNode): GlobalObjects =
result = GlobalObjects() result = GlobalObjects()
let let
@ -317,7 +342,7 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects =
result.users[k] = parseUser(v, k) result.users[k] = parseUser(v, k)
for k, v in tweets: for k, v in tweets:
var tweet = parseTweet(v, v{"card"}) var tweet = parseTweet(v, v{"tweet_card"})
if tweet.user.id in result.users: if tweet.user.id in result.users:
tweet.user = result.users[tweet.user.id] tweet.user = result.users[tweet.user.id]
result.tweets[k] = tweet result.tweets[k] = tweet
@ -327,11 +352,6 @@ proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNod
return return
for i in js: for i in js:
when T is Tweet:
if res.beginning and i{"pinEntry"}.notNull:
with pin, parsePin(i, global):
res.content.add pin
with r, i{"replaceEntry", "entry"}: with r, i{"replaceEntry", "entry"}:
if "top" in r{"entryId"}.getStr: if "top" in r{"entryId"}.getStr:
res.top = r.getCursor res.top = r.getCursor
@ -370,9 +390,13 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline =
result.top = cursor{"value"}.getStr result.top = cursor{"value"}.getStr
proc parsePhotoRail*(js: JsonNode): PhotoRail = proc parsePhotoRail*(js: JsonNode): PhotoRail =
with error, js{"error"}:
if error.getStr == "Not authorized.":
return
for tweet in js: for tweet in js:
let let
t = parseTweet(tweet, js{"card"}) t = parseTweet(tweet, js{"tweet_card"})
url = if t.photos.len > 0: t.photos[0] url = if t.photos.len > 0: t.photos[0]
elif t.video.isSome: get(t.video).thumb elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb elif t.gif.isSome: get(t.gif).thumb
@ -390,13 +414,17 @@ proc parseGraphTweet(js: JsonNode): Tweet =
of "TweetUnavailable": of "TweetUnavailable":
return Tweet() return Tweet()
of "TweetTombstone": of "TweetTombstone":
return Tweet(text: js{"tombstone", "text"}.getTombstone) with text, js{"tombstone", "richText"}:
return Tweet(text: text.getTombstone)
with text, js{"tombstone", "text"}:
return Tweet(text: text.getTombstone)
return Tweet()
of "TweetPreviewDisplay": of "TweetPreviewDisplay":
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
of "TweetWithVisibilityResults": of "TweetWithVisibilityResults":
return parseGraphTweet(js{"tweet"}) return parseGraphTweet(js{"tweet"})
var jsCard = copy(js{"card", "legacy"}) var jsCard = copy(js{"tweet_card", "legacy"})
if jsCard.kind != JNull: if jsCard.kind != JNull:
var values = newJObject() var values = newJObject()
for val in jsCard["binding_values"]: for val in jsCard["binding_values"]:
@ -404,6 +432,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
jsCard["binding_values"] = values jsCard["binding_values"] = values
result = parseTweet(js{"legacy"}, jsCard) result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getId
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
@ -417,32 +446,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
for t in js{"content", "items"}: for t in js{"content", "items"}:
let entryId = t{"entryId"}.getStr let entryId = t{"entryId"}.getStr
if "cursor-showmore" in entryId: if "cursor-showmore" in entryId:
let cursor = t{"item", "itemContent", "value"} let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr result.thread.cursor = cursor.getStr
result.thread.hasMore = true result.thread.hasMore = true
elif "tweet" in entryId: elif "tweet" in entryId:
let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"}) let tweet = parseGraphTweet(t{"item", "content", "tweetResult", "result"})
result.thread.content.add tweet result.thread.content.add tweet
if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread": if t{"item", "content", "tweetDisplayType"}.getStr == "SelfThread":
result.self = true result.self = true
proc parseGraphTweetResult*(js: JsonNode): Tweet = proc parseGraphTweetResult*(js: JsonNode): Tweet =
with tweet, js{"data", "tweetResult", "result"}: with tweet, js{"data", "tweet_result", "result"}:
result = parseGraphTweet(tweet) result = parseGraphTweet(tweet)
proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result = Conversation(replies: Result[Chain](beginning: true)) result = Conversation(replies: Result[Chain](beginning: true))
let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"} let instructions = ? js{"data", "timeline_response", "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for e in instructions[0]{"entries"}: for e in instructions[0]{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e{"entryId"}.getStr
# echo entryId
if entryId.startsWith("tweet"): if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: with tweetResult, e{"content", "content", "tweetResult", "result"}:
let tweet = parseGraphTweet(tweetResult) let tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
@ -457,7 +485,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
let tweet = Tweet( let tweet = Tweet(
id: parseBiggestInt(id), id: parseBiggestInt(id),
available: false, available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone text: e{"content", "content", "tombstoneInfo", "richText"}.getTombstone
) )
if id == tweetId: if id == tweetId:
@ -471,34 +499,42 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
else: else:
result.replies.content.add thread result.replies.content.add thread
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", "itemContent", "value"}.getStr result.replies.bottom = e{"content", "content", "value"}.getStr
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline = proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
result = Timeline(beginning: after.len == 0) result = Profile(tweets: Timeline(beginning: after.len == 0))
let instructions = let instructions =
if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"} if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"} else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for i in instructions:
if i{"type"}.getStr == "TimelineAddEntries": if i{"__typename"}.getStr == "TimelineAddEntries":
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"): if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: with tweetResult, e{"content", "content", "tweetResult", "result"}:
let tweet = parseGraphTweet(tweetResult) let tweet = parseGraphTweet(tweetResult)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = parseBiggestInt(entryId.getId())
result.content.add tweet result.tweets.content.add tweet
elif entryId.startsWith("profile-conversation") or entryId.startsWith("homeConversation"): elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
let (thread, self) = parseGraphThread(e) let (thread, self) = parseGraphThread(e)
for tweet in thread.content: result.tweets.content.add thread.content
result.content.add tweet
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr result.tweets.bottom = e{"content", "value"}.getStr
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
let tweet = parseGraphTweet(tweetResult)
tweet.pinned = true
if not tweet.available and tweet.tombstone.len == 0:
let entryId = i{"entry", "entryId"}.getEntryId
if entryId.len > 0:
tweet.id = parseBiggestInt(entryId)
result.pinned = some tweet
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline = proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
result = UsersTimeline(beginning: after.len == 0) result = UsersTimeline(beginning: after.len == 0)

View file

@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
proc createEmbedRouter*(cfg: Config) = proc createEmbedRouter*(cfg: Config) =
router embed: router embed:
get "/i/videos/tweet/@id": get "/i/videos/tweet/@id":
let convo = await getTweet(@"id") let tweet = await getGraphTweetResult(@"id")
if convo == nil or convo.tweet == nil or convo.tweet.video.isNone: if tweet == nil or tweet.video.isNone:
resp Http404 resp Http404
resp renderVideoEmbed(convo.tweet, cfg, request) resp renderVideoEmbed(tweet, cfg, request)
get "/@user/status/@id/embed": get "/@user/status/@id/embed":
let let
convo = await getTweet(@"id") tweet = await getGraphTweetResult(@"id")
prefs = cookiePrefs() prefs = cookiePrefs()
path = getPath() path = getPath()
if convo == nil or convo.tweet == nil: if tweet == nil:
resp Http404 resp Http404
resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request) resp renderTweetEmbed(tweet, path, prefs, cfg, request)
get "/embed/Tweet.html": get "/embed/Tweet.html":
let id = @"id" let id = @"id"

View file

@ -27,15 +27,13 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
else: else:
var q = query var q = query
q.fromUser = names q.fromUser = names
profile = Profile( profile.tweets = await getTweetSearch(q, after)
tweets: await getGraphSearch(q, after),
# this is kinda dumb # this is kinda dumb
user: User( profile.user = User(
username: name, username: name,
fullname: names.join(" | "), fullname: names.join(" | "),
userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
) )
)
if profile.user.suspended: if profile.user.suspended:
return Rss(feed: profile.user.username, cursor: "suspended") return Rss(feed: profile.user.username, cursor: "suspended")
@ -78,7 +76,7 @@ proc createRssRouter*(cfg: Config) =
if rss.cursor.len > 0: if rss.cursor.len > 0:
respRss(rss, "Search") respRss(rss, "Search")
let tweets = await getGraphSearch(query, cursor) let tweets = await getTweetSearch(query, cursor)
rss.cursor = tweets.bottom rss.cursor = tweets.bottom
rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)

View file

@ -35,9 +35,9 @@ proc createSearchRouter*(cfg: Config) =
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
of tweets: of tweets:
let let
tweets = await getGraphSearch(query, getCursor()) tweets = await getTweetSearch(query, getCursor())
rss = "/search/rss?" & genQueryUrl(query) rss = "/search/rss?" & genQueryUrl(query)
resp renderMain(renderTweetSearch(tweets, cfg, prefs, getPath()), resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
request, cfg, prefs, title, rss=rss) request, cfg, prefs, title, rss=rss)
else: else:
resp Http404, showError("Invalid search", cfg) resp Http404, showError("Invalid search", cfg)

View file

@ -46,35 +46,22 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
after.setLen 0 after.setLen 0
let let
timeline =
case query.kind
of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
of media: getGraphUserTweets(userId, TimelineKind.media, after)
of favorites: getFavorites(userId, cfg, after)
else: getGraphSearch(query, after)
rail = rail =
skipIf(skipRail or query.kind == media, @[]): skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(name) getCachedPhotoRail(name)
user = await getCachedUser(name) user = getCachedUser(name)
var pinned: Option[Tweet] result =
if not skipPinned and user.pinnedTweet > 0 and case query.kind
after.len == 0 and query.kind in {posts, replies}: of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
let tweet = await getCachedTweet(user.pinnedTweet) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
if not tweet.isNil: of media: await getGraphUserTweets(userId, TimelineKind.media, after)
tweet.pinned = true of favorites: Profile(tweets: await getFavorites(userId, cfg, after))
tweet.user = user else: Profile(tweets: await getTweetSearch(query, after))
pinned = some tweet
result = Profile( result.user = await user
user: user, result.photoRail = await rail
pinned: pinned,
tweets: await timeline,
photoRail: await rail
)
if result.user.protected or result.user.suspended: if result.user.protected or result.user.suspended:
return return
@ -85,8 +72,8 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
rss, after: string): Future[string] {.async.} = rss, after: string): Future[string] {.async.} =
if query.fromUser.len != 1: if query.fromUser.len != 1:
let let
timeline = await getGraphSearch(query, after) timeline = await getTweetSearch(query, after)
html = renderTweetSearch(timeline, cfg, prefs, getPath()) html = renderTweetSearch(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs, "Multi", rss=rss) return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins) var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins)
@ -147,10 +134,10 @@ proc createTimelineRouter*(cfg: Config) =
# used for the infinite scroll feature # used for the infinite scroll feature
if @"scroll".len > 0: if @"scroll".len > 0:
if query.fromUser.len != 1: if query.fromUser.len != 1:
var timeline = await getGraphSearch(query, after) var timeline = (await getGraphSearch(query, after)).tweets
if timeline.content.len == 0: resp Http404 if timeline.content.len == 0: resp Http404
timeline.beginning = true timeline.beginning = true
resp $renderTweetSearch(timeline, cfg, prefs, getPath()) resp $renderTweetSearch(timeline, prefs, getPath())
else: else:
var profile = await fetchProfile(after, query, cfg, skipRail=true) var profile = await fetchProfile(after, query, cfg, skipRail=true)
if profile.tweets.content.len == 0: resp Http404 if profile.tweets.content.len == 0: resp Http404

View file

@ -110,3 +110,29 @@
margin-left: 58px; margin-left: 58px;
padding: 7px 0; padding: 7px 0;
} }
.timeline-item.thread.more-replies-thread {
padding: 0 0.75em;
&::before {
top: 40px;
margin-bottom: 31px;
}
.more-replies {
display: flex;
padding-top: unset !important;
margin-top: 8px;
&::before {
display: inline-block;
position: relative;
top: -1px;
line-height: 0.4em;
}
.more-replies-text {
display: inline;
}
}
}

View file

@ -41,11 +41,10 @@ proc getPoolJson*(): JsonNode =
let let
maxReqs = maxReqs =
case api case api
of Api.timeline: 187 of Api.timeline, Api.search: 180
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets, of Api.userTweets, Api.userTweetsAndReplies, Api.userRestId,
Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, Api.userScreenName, Api.tweetDetail, Api.tweetResult: 500
Api.userRestId, Api.userScreenName, of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia, Api.favorites, Api.retweeters, Api.favoriters: 500
Api.tweetDetail, Api.tweetResult, Api.search, Api.favorites, Api.retweeters, Api.favoriters: 500
of Api.userSearch: 900 of Api.userSearch: 900
else: 180 else: 180
reqs = maxReqs - token.apis[api].remaining reqs = maxReqs - token.apis[api].remaining

View file

@ -210,6 +210,8 @@ type
video*: Option[Video] video*: Option[Video]
photos*: seq[string] photos*: seq[string]
Tweets* = seq[Tweet]
Result*[T] = object Result*[T] = object
content*: seq[T] content*: seq[T]
top*, bottom*: string top*, bottom*: string
@ -217,7 +219,7 @@ type
query*: Query query*: Query
Chain* = object Chain* = object
content*: seq[Tweet] content*: Tweets
hasMore*: bool hasMore*: bool
cursor*: string cursor*: string
@ -227,7 +229,7 @@ type
after*: Chain after*: Chain
replies*: Result[Chain] replies*: Result[Chain]
Timeline* = Result[Tweet] Timeline* = Result[Tweets]
UsersTimeline* = Result[User] UsersTimeline* = Result[User]
Profile* = object Profile* = object
@ -283,3 +285,6 @@ type
proc contains*(thread: Chain; tweet: Tweet): bool = proc contains*(thread: Chain; tweet: Tweet): bool =
thread.content.anyIt(it.id == tweet.id) thread.content.anyIt(it.id == tweet.id)
proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[tweet]

View file

@ -120,4 +120,4 @@ proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: strin
if profile.user.protected: if profile.user.protected:
renderProtected(profile.user.username) renderProtected(profile.user.username)
else: else:
renderTweetSearch(profile.tweets, cfg, prefs, path, profile.pinned) renderTweetSearch(profile.tweets, prefs, path, profile.pinned)

View file

@ -56,12 +56,16 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#end if #end if
#end proc #end proc
# #
#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string = #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#var links: seq[string] #var links: seq[string]
#for t in tweets: #for thread in tweets:
# let retweet = if t.retweet.isSome: t.user.username else: "" # for tweet in thread:
# let tweet = if retweet.len > 0: t.retweet.get else: t # if userId.len > 0 and tweet.user.id != userId: continue
# end if
#
# let retweet = if tweet.retweet.isSome: tweet.user.username else: ""
# let tweet = if retweet.len > 0: tweet.retweet.get else: tweet
# let link = getLink(tweet) # let link = getLink(tweet)
# if link in links: continue # if link in links: continue
# end if # end if
@ -75,6 +79,7 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<link>${urlPrefix & link}</link> <link>${urlPrefix & link}</link>
</item> </item>
# end for # end for
#end for
#end proc #end proc
# #
#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string = #proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
@ -102,13 +107,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<height>128</height> <height>128</height>
</image> </image>
#if profile.tweets.content.len > 0: #if profile.tweets.content.len > 0:
${renderRssTweets(profile.tweets.content, cfg)} ${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
#end if #end if
</channel> </channel>
</rss> </rss>
#end proc #end proc
# #
#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string = #proc renderListRss*(tweets: seq[Tweets]; list: List; cfg: Config): string =
#let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}" #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
#result = "" #result = ""
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@ -125,7 +130,7 @@ ${renderRssTweets(tweets, cfg)}
</rss> </rss>
#end proc #end proc
# #
#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string = #proc renderSearchRss*(tweets: seq[Tweets]; name, param: string; cfg: Config): string =
#let link = &"{getUrlPrefix(cfg)}/search" #let link = &"{getUrlPrefix(cfg)}/search"
#let escName = xmltree.escape(name) #let escName = xmltree.escape(name)
#result = "" #result = ""

View file

@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils, timeline import renderutils, timeline
import ".."/[types, query] import ".."/[types, query, config]
const toggles = { const toggles = {
"nativeretweets": "Retweets", "nativeretweets": "Retweets",
@ -91,7 +91,7 @@ proc renderSearchPanel*(query: Query): VNode =
span(class="search-title"): text "Near" span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false) genInput("near", "", query.near, "Location...", autofocus=false)
proc renderTweetSearch*(results: Result[Tweet]; cfg: Config; prefs: Prefs; path: string; proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
let query = results.query let query = results.query
buildHtml(tdiv(class="timeline-container")): buildHtml(tdiv(class="timeline-container")):

View file

@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, algorithm, uri, options import strutils, strformat, algorithm, uri, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import ".."/[types, query, formatters] import ".."/[types, query, formatters]
@ -39,24 +39,22 @@ proc renderNoneFound(): VNode =
h2(class="timeline-none"): h2(class="timeline-none"):
text "No items found" text "No items found"
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode = proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="thread-line")): buildHtml(tdiv(class="thread-line")):
let sortedThread = thread.sortedByIt(it.id) let sortedThread = thread.sortedByIt(it.id)
for i, tweet in sortedThread: for i, tweet in sortedThread:
# thread has a gap, display "more replies" link
if i > 0 and tweet.replyId != sortedThread[i - 1].id:
tdiv(class="timeline-item thread more-replies-thread"):
tdiv(class="more-replies"):
a(class="more-replies-text", href=getLink(tweet)):
text "more replies"
let show = i == thread.high and sortedThread[0].id != tweet.threadId let show = i == thread.high and sortedThread[0].id != tweet.threadId
let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), showThread=show)
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
result = @[it]
if it.retweet.isSome or it.replyId in threads: return
for t in tweets:
if t.id == result[0].replyId:
result.insert t
elif t.replyId == result[0].id:
result.add t
proc renderUser(user: User; prefs: Prefs): VNode = proc renderUser(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")): buildHtml(tdiv(class="timeline-item")):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
@ -89,7 +87,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
else: else:
renderNoMore() renderNoMore()
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string; proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
buildHtml(tdiv(class="timeline")): buildHtml(tdiv(class="timeline")):
if not results.beginning: if not results.beginning:
@ -105,26 +103,26 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
else: else:
renderNoneFound() renderNoneFound()
else: else:
var var retweets: seq[int64]
threads: seq[int64]
retweets: seq[int64]
for tweet in results.content: for thread in results.content:
let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 if thread.len == 1:
let
tweet = thread[0]
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
if tweet.id in threads or rt in retweets or tweet.id in retweets or if retweetId in retweets or tweet.id in retweets or
tweet.pinned and prefs.hidePins: continue tweet.pinned and prefs.hidePins:
continue
let thread = results.content.threadFilter(threads, tweet)
if thread.len < 2:
var hasThread = tweet.hasThread var hasThread = tweet.hasThread
if rt != 0: if retweetId != 0 and tweet.retweet.isSome:
retweets &= rt retweets &= retweetId
hasThread = get(tweet.retweet).hasThread hasThread = get(tweet.retweet).hasThread
renderTweet(tweet, prefs, path, showThread=hasThread) renderTweet(tweet, prefs, path, showThread=hasThread)
else: else:
renderThread(thread, prefs, path) renderThread(thread, prefs, path)
threads &= thread.mapIt(it.id)
if results.bottom.len > 0:
renderMore(results.query, results.bottom) renderMore(results.query, results.bottom)
renderToTop() renderToTop()

View file

@ -14,15 +14,14 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
buildHtml(): buildHtml():
img(class=(prefs.getAvatarClass & " mini"), src=url) img(class=(prefs.getAvatarClass & " mini"), src=url)
proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode = proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
buildHtml(tdiv): buildHtml(tdiv):
if retweet.len > 0: if pinned:
tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted"
if tweet.pinned:
tdiv(class="pinned"): tdiv(class="pinned"):
span: icon "pin", "Pinned Tweet" span: icon "pin", "Pinned Tweet"
elif retweet.len > 0:
tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted"
tdiv(class="tweet-header"): tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)): a(class="tweet-avatar", href=("/" & tweet.user.username)):
@ -295,7 +294,10 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome: if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path) renderQuote(tweet.quote.get(), prefs, path)
let fullTweet = tweet let
fullTweet = tweet
pinned = tweet.pinned
var retweet: string var retweet: string
var tweet = fullTweet var tweet = fullTweet
if tweet.retweet.isSome: if tweet.retweet.isSome:
@ -308,7 +310,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
tdiv(class="tweet-body"): tdiv(class="tweet-body"):
var views = "" var views = ""
renderHeader(tweet, retweet, prefs) renderHeader(tweet, retweet, pinned, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and if not afterTweet and index == 0 and tweet.reply.len > 0 and
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):

View file

@ -16,7 +16,12 @@ card = [
['FluentAI/status/1116417904831029248', ['FluentAI/status/1116417904831029248',
'Amazons Alexa isnt just AI — thousands of humans are listening', 'Amazons Alexa isnt just AI — thousands of humans are listening',
'One of the only ways to improve Alexa is to have human beings check it for errors', 'One of the only ways to improve Alexa is to have human beings check it for errors',
'theverge.com', True] 'theverge.com', True],
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'There were several big news in the Nim world in 2018 two new major releases, partnership with Status, and much more. But let us go chronologically.',
'nim-lang.org', True]
] ]
no_thumb = [ no_thumb = [
@ -33,17 +38,7 @@ no_thumb = [
['voidtarget/status/1133028231672582145', ['voidtarget/status/1133028231672582145',
'sinkingsugar/nimqt-example', 'sinkingsugar/nimqt-example',
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.', 'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
'github.com'], 'github.com']
['mobile_test/status/490378953744318464',
'Nantasket Beach',
'Explore this photo titled Nantasket Beach by Ben Sandofsky (@sandofsky) on 500px',
'500px.com'],
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'Posted by u/miran1 - 36 votes and 46 comments',
'reddit.com']
] ]
playable = [ playable = [
@ -58,17 +53,6 @@ playable = [
'youtube.com'] 'youtube.com']
] ]
# promo = [
# ['BangOlufsen/status/1145698701517754368',
# 'Upgrade your journey', '',
# 'www.bang-olufsen.com'],
# ['BangOlufsen/status/1154934429900406784',
# 'Learn more about Beosound Shape', '',
# 'www.bang-olufsen.com']
# ]
class CardTest(BaseTestCase): class CardTest(BaseTestCase):
@parameterized.expand(card) @parameterized.expand(card)
def test_card(self, tweet, title, description, destination, large): def test_card(self, tweet, title, description, destination, large):
@ -76,7 +60,7 @@ class CardTest(BaseTestCase):
c = Card(Conversation.main + " ") c = Card(Conversation.main + " ")
self.assert_text(title, c.title) self.assert_text(title, c.title)
self.assert_text(destination, c.destination) self.assert_text(destination, c.destination)
self.assertIn('_img', self.get_image_url(c.image + ' img')) self.assertIn('/pic/', self.get_image_url(c.image + ' img'))
if len(description) > 0: if len(description) > 0:
self.assert_text(description, c.description) self.assert_text(description, c.description)
if large: if large:
@ -99,17 +83,7 @@ class CardTest(BaseTestCase):
c = Card(Conversation.main + " ") c = Card(Conversation.main + " ")
self.assert_text(title, c.title) self.assert_text(title, c.title)
self.assert_text(destination, c.destination) self.assert_text(destination, c.destination)
self.assertIn('_img', self.get_image_url(c.image + ' img')) self.assertIn('/pic/', self.get_image_url(c.image + ' img'))
self.assert_element_visible('.card-overlay') self.assert_element_visible('.card-overlay')
if len(description) > 0: if len(description) > 0:
self.assert_text(description, c.description) self.assert_text(description, c.description)
# @parameterized.expand(promo)
# def test_card_promo(self, tweet, title, description, destination):
# self.open_nitter(tweet)
# c = Card(Conversation.main + " ")
# self.assert_text(title, c.title)
# self.assert_text(destination, c.destination)
# self.assert_element_visible('.video-overlay')
# if len(description) > 0:
# self.assert_text(description, c.description)

View file

@ -66,8 +66,8 @@ class ProfileTest(BaseTestCase):
self.assert_text(f'User "{username}" not found') self.assert_text(f'User "{username}" not found')
def test_suspended(self): def test_suspended(self):
self.open_nitter('user') self.open_nitter('suspendme')
self.assert_text('User "user" has been suspended') self.assert_text('User "suspendme" has been suspended')
@parameterized.expand(banner_image) @parameterized.expand(banner_image)
def test_banner_image(self, username, url): def test_banner_image(self, username, url):

View file

@ -2,8 +2,8 @@ from base import BaseTestCase
from parameterized import parameterized from parameterized import parameterized
class SearchTest(BaseTestCase): #class SearchTest(BaseTestCase):
@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
def test_username_search(self, username): #def test_username_search(self, username):
self.search_username(username) #self.search_username(username)
self.assert_text(f'{username}') #self.assert_text(f'{username}')

View file

@ -74,9 +74,9 @@ retweet = [
[3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr'] [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
] ]
reply = [ # reply = [
['mobile_test/with_replies', 15] # ['mobile_test/with_replies', 15]
] # ]
class TweetTest(BaseTestCase): class TweetTest(BaseTestCase):
@ -137,8 +137,8 @@ class TweetTest(BaseTestCase):
self.open_nitter(tweet) self.open_nitter(tweet)
self.assert_text('Tweet not found', '.error-panel') self.assert_text('Tweet not found', '.error-panel')
@parameterized.expand(reply) # @parameterized.expand(reply)
def test_thread(self, tweet, num): # def test_thread(self, tweet, num):
self.open_nitter(tweet) # self.open_nitter(tweet)
thread = self.find_element(f'.timeline > div:nth-child({num})') # thread = self.find_element(f'.timeline > div:nth-child({num})')
self.assertIn(thread.get_attribute('class'), 'thread-line') # self.assertIn(thread.get_attribute('class'), 'thread-line')