diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 1189083a7..59395877b 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -11,16 +11,14 @@ jobs: build-docker-amd64: runs-on: self-hosted steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + - uses: actions/checkout@v6 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index f4639a4db..000000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Tests - -on: - push: - paths-ignore: - - "*.md" - branches-ignore: - - master - workflow_call: - -# Ensure that multiple runs on the same branch do not overlap. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - build-test: - name: Build and test - runs-on: buildjet-2vcpu-ubuntu-2204 - strategy: - matrix: - nim: ["2.0.x", "2.2.x", "devel"] - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Cache Nimble Dependencies - id: cache-nimble - uses: buildjet/cache@v4 - with: - path: ~/.nimble - key: ${{ matrix.nim }}-nimble-v2-${{ hashFiles('*.nimble') }} - restore-keys: | - ${{ matrix.nim }}-nimble-v2- - - - name: Setup Nim - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: ${{ matrix.nim }} - use-nightlies: true - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Build Project - run: nimble build -d:release -Y - - integration-test: - needs: [build-test] - name: Integration test - runs-on: buildjet-2vcpu-ubuntu-2204 - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Cache Nimble Dependencies - id: cache-nimble - uses: buildjet/cache@v4 - with: - path: ~/.nimble - key: devel-nimble-v2-${{ hashFiles('*.nimble') }} - restore-keys: | - devel-nimble-v2- - - - name: Setup Python (3.10) with pip cache - uses: buildjet/setup-python@v4 - with: - python-version: "3.10" - cache: pip - - - name: Setup Nim - uses: jiro4989/setup-nim-action@v2 - with: - nim-version: devel - use-nightlies: true - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Build Project - run: nimble build -d:release -Y - - - name: Install SeleniumBase and Chromedriver - run: | - pip install seleniumbase - seleniumbase install chromedriver - - - name: Start Redis Service - uses: supercharge/redis-github-action@1.5.0 - - - name: Prepare Nitter Environment - run: | - sudo apt-get update && sudo apt-get install -y libsass-dev - cp nitter.example.conf nitter.conf - sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf - nimble md - nimble scss - echo '${{ secrets.SESSIONS }}' | head -n1 - echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl - - - name: Run Tests - run: | - ./nitter & - pytest -n1 tests diff --git a/.gitignore b/.gitignore index 7a960fb71..c323872da 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ nitter.conf guest_accounts.json* sessions.json* dump.rdb +*.bak +/tools/*.json* node_modules debug_responses diff --git a/nitter.example.conf b/nitter.example.conf index 4f88d20ed..b5ebaa4d3 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -1,34 +1,42 @@ [Server] -hostname = "nitter.net" # for generating links, change this to your own domain/ip +hostname = "nitter.net" # for generating links, change this to your own domain/ip title = "nitter" address = "0.0.0.0" port = 8080 -https = false # disable to enable cookies when not using https +https = false # disable to enable cookies when not using https httpMaxConnections = 100 staticDir = "./public" [Cache] -listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) -rssMinutes = 10 # how long to cache rss queries -redisHost = "localhost" # Change to "nitter-redis" if using docker-compose +listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) +rssMinutes = 10 # how long to cache rss queries +redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisPort = 6379 redisPassword = "" -redisConnections = 20 # minimum open connections in pool +redisConnections = 20 # minimum open connections in pool redisMaxConnections = 30 # new connections are opened when none are available, but if the pool size # goes above this, they're closed when released. don't worry about this unless # you receive tons of requests per second [Config] -hmacKey = "secretkey" # random key for cryptographic signing of video urls -base64Media = false # use base64 encoding for proxied media urls -enableRSS = true # set this to false to disable RSS feeds -enableJsonApi = true # set this to false to disable the JSON API -enableDebug = false # enable request logs and debug endpoints (/.sessions) -proxy = "" # http/https url, SOCKS proxies are not supported +hmacKey = "secretkey" # random key for cryptographic signing of video urls +base64Media = false # use base64 encoding for proxied media urls +enableRSS = true # master switch, set to false to disable all RSS feeds +enableRSSUserTweets = true # /@user/rss +enableRSSUserReplies = true # /@user/with_replies/rss +enableRSSUserMedia = true # /@user/media/rss +enableRSSSearch = true # /search/rss and /@user/search/rss +enableRSSList = true # list RSS feeds +enableJsonApi = true # set this to false to disable the JSON API +enableDebug = false # enable request logs and debug endpoints (/.sessions) +proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" -apiProxy = "" # nitter-proxy host, e.g. localhost:7000 -disableTid = false # enable this if cookie-based auth is failing +apiProxy = "" # nitter-proxy host, e.g. localhost:7000 +disableTid = false # enable this if cookie-based auth is failing +maxConcurrentReqs = 2 # max requests at a time per session to avoid race conditions +maxRetries = 1 # max number of retries on rate limit errors +retryDelayMs = 150 # delay in ms between retries # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] diff --git a/nitter.nimble b/nitter.nimble index 7ff819642..c206105a9 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -28,7 +28,7 @@ requires "oauth#b8c163b" # Tasks task scss, "Generate css": - exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" + exec "nim r --hint[Processing]:off tools/gencss" task md, "Render md": - exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" + exec "nim r --hint[Processing]:off tools/rendermd" diff --git a/public/css/fontello.css b/public/css/fontello.css index 52362d8f4..eb41de7d7 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,12 +1,12 @@ @font-face { font-family: "fontello"; - src: url("/fonts/fontello.eot?77185648"); + src: url("/fonts/fontello.eot?49059696"); src: - url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"), - url("/fonts/fontello.woff2?77185648") format("woff2"), - url("/fonts/fontello.woff?77185648") format("woff"), - url("/fonts/fontello.ttf?77185648") format("truetype"), - url("/fonts/fontello.svg?77185648#fontello") format("svg"); + url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?49059696") format("woff2"), + url("/fonts/fontello.woff?49059696") format("woff"), + url("/fonts/fontello.ttf?49059696") format("truetype"), + url("/fonts/fontello.svg?49059696#fontello") format("svg"); font-weight: normal; font-style: normal; } @@ -56,6 +56,11 @@ } /* '' */ +.icon-group:before { + content: "\e804"; +} + +/* '' */ .icon-play:before { content: "\e805"; } @@ -121,6 +126,11 @@ } /* '' */ +.icon-attention:before { + content: "\e812"; +} + +/* '' */ .icon-circle:before { content: "\f111"; } diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 8671134d7..ed1dba857 100644 Binary files a/public/fonts/fontello.eot and b/public/fonts/fontello.eot differ diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 31bd38c71..19db3bd74 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,7 +1,7 @@ -Copyright (C) 2025 by original authors @ fontello.com +Copyright (C) 2026 by original authors @ fontello.com @@ -14,6 +14,8 @@ + + @@ -40,6 +42,8 @@ + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index 0c04c6c93..be34c4eb2 100644 Binary files a/public/fonts/fontello.ttf and b/public/fonts/fontello.ttf differ diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index e4582ad0e..699b2ba8d 100644 Binary files a/public/fonts/fontello.woff and b/public/fonts/fontello.woff differ diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index d2c246d1a..cfe8dfb8c 100644 Binary files a/public/fonts/fontello.woff2 and b/public/fonts/fontello.woff2 differ diff --git a/public/js/hlsPlayback.js b/public/js/hlsPlayback.js index 5cd46a6b5..9919fec55 100644 --- a/public/js/hlsPlayback.js +++ b/public/js/hlsPlayback.js @@ -3,6 +3,7 @@ function playVideo(overlay) { const video = overlay.parentElement.querySelector('video'); const url = video.getAttribute("data-url"); + const startTime = parseFloat(video.getAttribute("data-start") || "0"); video.setAttribute("controls", ""); overlay.style.display = "none"; @@ -12,12 +13,13 @@ function playVideo(overlay) { hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.loadLevel = hls.levels.length - 1; - hls.startLoad(); + hls.startLoad(startTime); video.play(); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.addEventListener('canplay', function() { + if (startTime > 0) video.currentTime = startTime; video.play(); }); } diff --git a/public/js/infiniteScroll.js b/public/js/infiniteScroll.js index be27e0cfa..f79912fa5 100644 --- a/public/js/infiniteScroll.js +++ b/public/js/infiniteScroll.js @@ -1,77 +1,225 @@ // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 // SPDX-License-Identifier: AGPL-3.0-only + function insertBeforeLast(node, elem) { - node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); + node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); } function getLoadMore(doc) { - return doc.querySelector(".show-more:not(.timeline-item)"); + return doc.querySelector(".show-more:not(.timeline-item)"); } -function isDuplicate(item, itemClass) { - const tweet = item.querySelector(".tweet-link"); - if (tweet == null) return false; - const href = tweet.getAttribute("href"); - return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; +function getHrefs(selector) { + return new Set([...document.querySelectorAll(selector)].map(el => el.getAttribute("href"))); } -window.onload = function () { - const url = window.location.pathname; - const isTweet = url.indexOf("/status/") !== -1; - const containerClass = isTweet ? ".replies" : ".timeline"; - const itemClass = containerClass + " > div:not(.top-ref)"; - - var html = document.querySelector("html"); - var container = document.querySelector(containerClass); - var loading = false; - - function handleScroll(failed) { - if (loading) return; - - if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { - loading = true; - var loadMore = getLoadMore(document); - if (loadMore == null) return; - - loadMore.children[0].text = "Loading..."; - - var url = new URL(loadMore.children[0].href); - url.searchParams.append("scroll", "true"); - - fetch(url.toString()).then(function (response) { - if (response.status === 404) throw "error"; - - return response.text(); - }).then(function (html) { - var parser = new DOMParser(); - var doc = parser.parseFromString(html, "text/html"); - loadMore.remove(); - - for (var item of doc.querySelectorAll(itemClass)) { - if (item.className == "timeline-item show-more") continue; - if (isDuplicate(item, itemClass)) continue; - if (isTweet) container.appendChild(item); - else insertBeforeLast(container, item); - } - - loading = false; - const newLoadMore = getLoadMore(doc); - if (newLoadMore == null) return; - if (isTweet) container.appendChild(newLoadMore); - else insertBeforeLast(container, newLoadMore); - }).catch(function (err) { - console.warn("Something went wrong.", err); - if (failed > 3) { - loadMore.children[0].text = "Error"; - return; - } - - loading = false; - handleScroll((failed || 0) + 1); - }); - } +function getTweetId(item) { + const m = item.querySelector(".tweet-link")?.getAttribute("href")?.match(/\/status\/(\d+)/); + return m ? m[1] : ""; +} + +function isDuplicate(item, hrefs) { + return hrefs.has(item.querySelector(".tweet-link")?.getAttribute("href")); +} + +const GAP = 10; + +class Masonry { + constructor(container) { + this.container = container; + const colSizes = { + small: w => Math.max(130, w * 0.11), + medium: w => Math.max(190, Math.min(350, w * 0.22)), + large: w => Math.max(350, Math.min(480, w * 0.22)), + }; + const size = container.dataset.colSize || "medium"; + this._targetWidth = colSizes[size] || colSizes.medium; + this.colHeights = []; + this.colCounts = []; + this.colCount = 0; + this._lastWidth = 0; + this._colWidthCache = 0; + this._items = []; + this._revealTimer = null; + this.container.classList.add("masonry-active"); + + let resizeTimer; + window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => this._rebuild(), 50); + }); + + // Re-sync positions whenever images finish loading and items grow taller. + // Must be set up before _rebuild() so initial items get observed on first pass. + let syncTimer; + this._observer = window.ResizeObserver ? new ResizeObserver(() => { + clearTimeout(syncTimer); + syncTimer = setTimeout(() => this.syncHeights(), 100); + }) : null; + + this._rebuild(); + } + + // Reveal all items and gallery siblings (show-more, top-ref). Idempotent. + _revealAll() { + clearTimeout(this._revealTimer); + for (const item of this._items) item.classList.add("masonry-visible"); + for (const el of this.container.parentElement.querySelectorAll(":scope > .show-more, :scope > .top-ref, :scope > .timeline-footer")) + el.classList.add("masonry-visible"); + } + + // Height-primary, count-as-tiebreaker: handles both tall tweets and unloaded images. + _pickCol() { + return this.colHeights.reduce((min, h, i) => { + const m = this.colHeights[min]; + return (h < m || (h === m && this.colCounts[i] < this.colCounts[min])) ? i : min; + }, 0); + } + + // Position items using current column state. Updates colHeights, colCounts, container height. + _position(items, heights, colWidth) { + for (let i = 0; i < items.length; i++) { + const col = this._pickCol(); + items[i].style.left = `${col * (colWidth + GAP)}px`; + items[i].style.top = `${this.colHeights[col]}px`; + this.colHeights[col] += heights[i] + GAP; + this.colCounts[col]++; + } + this.container.style.height = `${Math.max(0, ...this.colHeights)}px`; + } + + // Full reset and re-place all items. + _place(items, heights, n, colWidth) { + this.colHeights = new Array(n).fill(0); + this.colCounts = new Array(n).fill(0); + this.colCount = n; + this._position(items, heights, colWidth); + } + + _rebuild() { + const w = this.container.clientWidth; + const n = Math.max(1, Math.floor(w / this._targetWidth(w))); + if (n === this.colCount && w === this._lastWidth) return; + + const isFirst = this.colCount === 0; + + if (isFirst) { + this._items = [...this.container.querySelectorAll(".timeline-item")]; } - window.addEventListener("scroll", () => handleScroll()); -}; + // Sort newest-first by tweet ID (snowflake IDs exceed Number precision, compare as strings). + this._items.sort((a, b) => { + const idA = getTweetId(a), idB = getTweetId(b); + if (idA.length !== idB.length) return idB.length - idA.length; + return idB < idA ? -1 : idB > idA ? 1 : 0; + }); + + // Pre-set widths BEFORE reading heights so measurements reflect the new column width. + const colWidth = this._colWidthCache = Math.floor((w - GAP * (n - 1)) / n); + for (const item of this._items) item.style.width = `${colWidth}px`; + + this._place(this._items, this._items.map(item => item.offsetHeight), n, colWidth); + this._lastWidth = w; + + if (isFirst) { + if (this._observer) this._items.forEach(item => this._observer.observe(item)); + // Reveal immediately if all images are cached, else wait for syncHeights. + const hasUnloaded = this._items.some(item => + [...item.querySelectorAll("img")].some(img => !img.complete)); + if (hasUnloaded) { + this._revealTimer = setTimeout(() => this._revealAll(), 1000); + } else { + this._revealAll(); + } + } + } + + // Re-read actual heights and re-place all items. Fixes drift after images load. + syncHeights() { + this._place(this._items, this._items.map(item => item.offsetHeight), this.colCount, this._colWidthCache); + this._revealAll(); + } + + // Batch-add items in three phases to avoid O(N) reflows: + // 1. writes: set widths, append all — no reads, no reflows + // 2. one read: batch offsetHeight + // 3. writes: assign columns, set left/top + addAll(newItems) { + if (!newItems.length) return; + const colWidth = this._colWidthCache; + + for (const item of newItems) { + item.style.width = `${colWidth}px`; + this.container.appendChild(item); + } + + this._position(newItems, newItems.map(item => item.offsetHeight), colWidth); + this._items.push(...newItems); + + if (this._observer) newItems.forEach(item => this._observer.observe(item)); + } +} + +document.addEventListener("DOMContentLoaded", function () { + const isTweet = location.pathname.includes("/status/"); + const containerClass = isTweet ? ".replies" : ".timeline"; + const itemClass = containerClass + " > div:not(.top-ref)"; + const html = document.documentElement; + const container = document.querySelector(containerClass); + const masonryEl = container?.querySelector(".gallery-masonry"); + const masonry = masonryEl ? new Masonry(masonryEl) : null; + let loading = false; + + function handleScroll(failed) { + if (loading || html.scrollTop + html.clientHeight < html.scrollHeight - 3000) return; + + const loadMore = getLoadMore(document); + if (!loadMore) return; + loading = true; + loadMore.children[0].text = "Loading..."; + + const url = new URL(loadMore.children[0].href); + url.searchParams.append("scroll", "true"); + + fetch(url) + .then(r => { + if (r.status > 299) throw new Error("error"); + return r.text(); + }) + .then(responseText => { + const doc = new DOMParser().parseFromString(responseText, "text/html"); + loadMore.remove(); + + if (masonry) { + masonry.syncHeights(); + const newMasonry = doc.querySelector(".gallery-masonry"); + if (newMasonry) { + const knownHrefs = getHrefs(".gallery-masonry .tweet-link"); + masonry.addAll([...newMasonry.querySelectorAll(".timeline-item")].filter(item => !isDuplicate(item, knownHrefs))); + } + } else { + const knownHrefs = getHrefs(`${itemClass} .tweet-link`); + for (const item of doc.querySelectorAll(itemClass)) { + if (item.className === "timeline-item show-more" || isDuplicate(item, knownHrefs)) continue; + isTweet ? container.appendChild(item) : insertBeforeLast(container, item); + } + } + + loading = false; + const newLoadMore = getLoadMore(doc); + if (newLoadMore) { + isTweet ? container.appendChild(newLoadMore) : insertBeforeLast(container, newLoadMore); + if (masonry) newLoadMore.classList.add("masonry-visible"); + } + }) + .catch(err => { + console.warn("Something went wrong.", err); + if (failed > 3) { loadMore.children[0].text = "Error"; return; } + loading = false; + handleScroll((failed || 0) + 1); + }); + } + + window.addEventListener("scroll", () => handleScroll()); +}); // @license-end diff --git a/src/api.nim b/src/api.nim index acd25f1f1..f3c6c12de 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import asyncdispatch, httpclient, strutils, sequtils, sugar import packedjson -import types, query, formatters, consts, apiutils, parser +import types, query, formatters, consts, apiutils, parser, utils import experimental/parser as newParser # Helper to generate params object for GraphQL requests @@ -18,16 +18,16 @@ proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = let url = apiUrl(endpoint, variables, fieldToggles) return ApiReq(cookie: url, oauth: url) -proc mediaUrl(id: string; cursor: string): ApiReq = +proc mediaUrl(id, cursor: string; count=20): ApiReq = result = ApiReq( - cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]), - oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor]) + cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor, $count]), + oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor, $count]) ) proc userTweetsUrl(id: string; cursor: string): ApiReq = result = ApiReq( # cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor]) + oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor, "20"]) ) # might change this in the future pending testing result.cookie = result.oauth @@ -36,7 +36,7 @@ proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = let cookieVars = userTweetsAndRepliesVars % [id, cursor] result = ApiReq( cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles), - oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor]) + oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) ) proc tweetDetailUrl(id: string; cursor: string): ApiReq = @@ -66,6 +66,32 @@ proc getGraphUserById*(id: string): Future[User] {.async.} = js = await fetchRaw(url) result = parseGraphUser(js) +proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} = + if username.len == 0: return + let + url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username) + js = await fetch(url) + result = parseAboutAccount(js) + +proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq = + let url = ApiUrl(endpoint: endpoint, params: params) + ApiReq(cookie: url, oauth: url) + +proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} = + if id.len == 0: return + let + req = apiReq(graphBroadcast, """{"id":"$1"}""" % id) + js = await fetch(req) + result = parseBroadcastInfo(js) + +proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} = + if mediaKey.len == 0: return + let + streamReq = restReq(restLiveStream & mediaKey) + streamJs = await fetch(streamReq) + result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr( + streamJs{"source", "location"}.getStr) + proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = if id.len == 0: return let @@ -73,7 +99,7 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi url = case kind of TimelineKind.tweets: userTweetsUrl(id, cursor) of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) - of TimelineKind.media: mediaUrl(id, cursor) + of TimelineKind.media: mediaUrl(id, cursor, 100) js = await fetch(url) result = parseGraphTimeline(js, after) @@ -81,7 +107,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - url = apiReq(graphListTweets, restIdVars % [id, cursor]) + url = apiReq(graphListTweets, restIdVars % [id, cursor, "20"]) js = await fetch(url) result = parseGraphTimeline(js, after).tweets @@ -138,8 +164,20 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) +proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id) + js = await fetch(url) + result = parseGraphEditHistory(js, id) + proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = - let q = genQueryParam(query) + # workaround for #1372 + let maxId = + if not after.startsWith("maxid:"): "" + else: validateNumber(after[6..^1]) + + let q = genQueryParam(query, maxId) if q.len == 0 or q == emptyQuery: return Timeline(query: query, beginning: true) @@ -153,14 +191,20 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = "withReactionsMetadata": false, "withReactionsPerspective": false } - if after.len > 0: + if after.len > 0 and maxId.len == 0: variables["cursor"] = % after - let + let url = apiReq(graphSearchTimeline, $variables) js = await fetch(url) result = parseGraphSearch[Tweets](js, after) result.query = query + # when no more items are available the API just returns the last page in + # full. this detects that and clears the page instead. + if after.len > 0 and result.bottom.len > 0 and maxId.len == 0 and + after[0..<64] == result.bottom[0..<64]: + result.content.setLen(0) + proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = if query.text.len == 0: return Result[User](query: query, beginning: true) @@ -187,7 +231,7 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = if id.len == 0: return - let js = await fetch(mediaUrl(id, "")) + let js = await fetch(mediaUrl(id, "", 30)) result = parseGraphPhotoRail(js) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = diff --git a/src/apiutils.nim b/src/apiutils.nim index ddb5027ca..d6952e8ca 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -10,28 +10,38 @@ const rlLimit = "x-rate-limit-limit" errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest} -var +var pool: HttpPool disableTid: bool apiProxy: string + maxRetries: int + retryDelayMs: int proc setDisableTid*(disable: bool) = disableTid = disable +proc setMaxRetries*(n: int) = + maxRetries = n + +proc setRetryDelayMs*(ms: int) = + retryDelayMs = ms + proc setApiProxy*(url: string) = + apiProxy = "" if url.len > 0: apiProxy = url.strip(chars={'/'}) & "/" if "http" notin apiProxy: apiProxy = "http://" & apiProxy proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = - case sessionKind - of oauth: - let o = req.oauth - parseUri("https://api.x.com/graphql") / o.endpoint ? o.params - of cookie: - let c = req.cookie - parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params + let url = case sessionKind + of oauth: req.oauth + of cookie: req.cookie + let base = case sessionKind + of oauth: "https://api.x.com" + of cookie: "https://x.com/i/api" + let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/" + parseUri(base) / (prefix & url.endpoint) ? url.params proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = let @@ -80,7 +90,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = result["sec-fetch-dest"] = "empty" result["sec-fetch-mode"] = "cors" result["sec-fetch-site"] = "same-site" - if disableTid: + if disableTid or "/1.1/" in url.path: result["authorization"] = bearerToken2 else: result["authorization"] = bearerToken @@ -107,7 +117,7 @@ template fetchImpl(result, fetchBody) {.dirty.} = pool.use(await genHeaders(session, url)): template getContent = # TODO: this is a temporary simple implementation - if apiProxy.len > 0: + if apiProxy.len > 0 and "/1.1/" notin url.path: resp = await c.get(($url).replace("https://", apiProxy)) else: resp = await c.get($url) @@ -119,6 +129,10 @@ template fetchImpl(result, fetchBody) {.dirty.} = badClient = true raise newException(BadClientError, "Bad client") + if resp.status == $Http404 and result.len == 0: + echo "[sessions] transient 404 (empty body), retrying: ", url.path + raise rateLimitError() + if resp.headers.hasKey(rlRemaining): let remaining = parseInt(resp.headers[rlRemaining]) @@ -164,11 +178,15 @@ template fetchImpl(result, fetchBody) {.dirty.} = release(session) template retry(bod) = - try: - bod - except RateLimitError: - echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..." - bod + for i in 0 ..< maxRetries: + try: + bod + break + except RateLimitError: + echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, + " request (", i, "/", maxRetries, ")..." + if retryDelayMs > 0: + await sleepAsync(retryDelayMs) proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = retry: diff --git a/src/auth.nim b/src/auth.nim index 5d7ef0eb4..d801489a9 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -3,14 +3,17 @@ import std/[asyncdispatch, times, json, random, strutils, tables, packedsets, os import types, consts import experimental/parser/session -# max requests at a time per session to avoid race conditions -const - maxConcurrentReqs = 2 - hourInSeconds = 60 * 60 +const hourInSeconds = 60 * 60 var sessionPool: seq[Session] enableLogging = false + # max requests at a time per session to avoid race conditions + maxConcurrentReqs = 2 + +proc setMaxConcurrentReqs*(reqs: int) = + if reqs > 0: + maxConcurrentReqs = reqs template log(str: varargs[string, `$`]) = echo "[sessions] ", str.join("") diff --git a/src/config.nim b/src/config.nim index f34c5abab..43abcbc7b 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,13 +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), + enableRSSSearch: masterRss and cfg.get("Config", "enableRSSSearch", true), + enableRSSList: masterRss and cfg.get("Config", "enableRSSList", true), enableJsonApi: cfg.get("Config", "enableJsonApi", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""), apiProxy: cfg.get("Config", "apiProxy", ""), - disableTid: cfg.get("Config", "disableTid", false) + 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 6456efccf..29a582b93 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -16,48 +16,85 @@ const graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" + graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" + graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery" + + graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery" + restLiveStream* = "1.1/live_video_stream/status/" 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, + "articles_api_enabled": false, + "articles_preview_enabled": true, "blue_business_profile_image_shape_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": true, "commerce_android_shop_module_enabled": false, + "communities_web_enable_tweet_community_results_fetch": true, + "creator_subscriptions_quote_tweet_preview_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, + "grok_android_analyze_trend_fetch_enabled": false, + "grok_translations_community_note_auto_translation_is_enabled": false, + "grok_translations_community_note_translation_is_enabled": false, + "grok_translations_post_auto_translation_is_enabled": false, + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, "hidden_profile_likes_enabled": false, "highlights_tweets_tab_ui_enabled": false, + "immersive_video_status_linkable_timestamps": 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, + "longform_notetweets_rich_text_read_enabled": true, "mobile_app_spotlight_module_enabled": false, + "payments_enabled": false, + "post_ctas_fetch_enabled": true, + "premium_content_api_read_enabled": false, + "profile_label_improvements_pcf_label_in_post_enabled": true, + "profile_label_improvements_pcf_label_in_profile_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_grok_analysis_button_from_backend": true, + "responsive_web_grok_analyze_button_fetch_trends_enabled": false, + "responsive_web_grok_analyze_post_followups_enabled": true, + "responsive_web_grok_annotations_enabled": true, + "responsive_web_grok_community_note_auto_translation_is_enabled": false, + "responsive_web_grok_image_annotation_enabled": true, + "responsive_web_grok_imagine_annotation_enabled": true, + "responsive_web_grok_share_attachment_enabled": true, + "responsive_web_grok_show_grok_translated_post": false, + "responsive_web_jetfuel_frame": true, "responsive_web_media_download_video_enabled": false, + "responsive_web_profile_redirect_enabled": false, "responsive_web_text_conversations_enabled": false, + "responsive_web_twitter_article_notes_tab_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, + "rweb_tipjar_consumption_enabled": true, + "rweb_video_screen_enabled": false, + "rweb_video_timestamps_enabled": false, "spaces_2022_h2_clipping": true, "spaces_2022_h2_spaces_communities": true, "standardized_nudges_misinfo": true, + "subscriptions_feature_can_gift_premium": false, "subscriptions_verification_info_enabled": true, + "subscriptions_verification_info_is_identity_verified_enabled": false, "subscriptions_verification_info_reason_enabled": true, "subscriptions_verification_info_verified_since_enabled": true, "super_follow_badge_privacy_enabled": false, @@ -68,40 +105,10 @@ const "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, + "unified_cards_destination_url_params_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, "view_counts_everywhere_api_enabled": true, - "premium_content_api_read_enabled": false, - "communities_web_enable_tweet_community_results_fetch": true, - "responsive_web_jetfuel_frame": true, - "responsive_web_grok_analyze_button_fetch_trends_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, - "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", "") @@ -110,7 +117,7 @@ const $2 "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withBirdwatchNotes": false, + "withBirdwatchNotes": true, "withVoice": false, "withV2Timeline": true }""".replace(" ", "").replace("\n", "") @@ -128,14 +135,19 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") + tweetEditHistoryVars* = """{ + "tweetId": "$1", + "withQuickPromoteEligibilityTweetFields": true +}""".replace(" ", "").replace("\n", "") + restIdVars* = """{ "rest_id": "$1", $2 - "count": 20 + "count": $3 }""" userMediaVars* = """{ "userId": "$1", $2 - "count": 20, + "count": $3, "includePromotedContent": false, "withClientEventToken": false, "withBirdwatchNotes": false, diff --git a/src/experimental/parser/slices.nim b/src/experimental/parser/slices.nim index 45e6e1d8d..db2c98d0c 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/user.nim b/src/experimental/parser/user.nim index 8517bdc37..8b98e7f68 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -9,7 +9,7 @@ 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 diff --git a/src/formatters.nim b/src/formatters.nim index fc0e1972e..aef1c12c6 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -91,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 = @@ -99,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 = @@ -154,25 +168,31 @@ proc getShortTime*(tweet: Tweet): string = else: result = "now" -proc getDuration*(video: Video): string = - let - ms = video.durationMs +proc getDuration*(ms: int): string = + let sec = int(round(ms / 1000)) min = floorDiv(sec, 60) hour = floorDiv(min, 60) if hour > 0: - return &"{hour}:{min mod 60}:{sec mod 60:02}" + &"{hour}:{min mod 60:02}:{sec mod 60:02}" else: - return &"{min mod 60}:{sec mod 60:02}" + &"{min mod 60}:{sec mod 60:02}" -proc getLink*(tweet: Tweet; focus=true): string = - if tweet.id == 0: return - var username = tweet.user.username +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/{tweet.id}" + result = &"/{username}/status/{id}" if focus: result &= "#m" +proc getLink*(tweet: Tweet; focus=true): string = + if tweet.id == 0: return + var username = tweet.user.username + return getLink(tweet.id, username, focus) + proc getTwitterLink*(path: string; params: Table[string, string]): string = var username = params.getOrDefault("name") @@ -199,7 +219,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/jsons/health.nim b/src/jsons/health.nim index 2a0ee7dd1..e74fcf4d0 100644 --- a/src/jsons/health.nim +++ b/src/jsons/health.nim @@ -11,5 +11,11 @@ proc createJsonApiHealthRouter*(cfg: Config) = router jsonapi_health: get "/api/health": cond cfg.enableJsonApi - let headers = {"Content-Type": "application/json; charset=utf-8"} + let origin = corsOrigin() + let headers = { + "Content-Type": "application/json; charset=utf-8", + "Vary": "Origin", + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true" + } resp Http200, headers, """{"message": "OK"}""" diff --git a/src/jsons/search.nim b/src/jsons/search.nim index 2e0a5562f..4216bb708 100644 --- a/src/jsons/search.nim +++ b/src/jsons/search.nim @@ -16,9 +16,7 @@ proc createJsonApiSearchRouter*(cfg: Config) = if q.len > 500: respJsonError("Search input too long.", "invalid_input", Http400) - let - prefs = cookiePrefs() - query = initQuery(params(request)) + let query = initQuery(params(request)) case query.kind of users: diff --git a/src/jsons/timeline.nim b/src/jsons/timeline.nim index e6a61e90e..d1acdd986 100644 --- a/src/jsons/timeline.nim +++ b/src/jsons/timeline.nim @@ -1,9 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only import json, asyncdispatch, strutils, sequtils, uri, options, times -import options -import times - import jester, karax/vdom import ".."/routes/[router_utils, timeline] @@ -32,6 +29,33 @@ proc formatUserAsJson*(user: User): JsonNode = "joinDate": user.joinDate.toTime.toUnix() } +proc formatMediaAsJson*(m: Media): JsonNode = + case m.kind + of photoMedia: + return %*{"type": "photo", "url": m.photo.url, "altText": m.photo.altText} + of videoMedia: + var variants = newJArray() + for v in m.video.variants: + variants.add %*{ + "contentType": $v.contentType, + "url": v.url, + "bitrate": v.bitrate, + "resolution": v.resolution + } + return %*{ + "type": "video", + "durationMs": m.video.durationMs, + "url": m.video.url, + "thumb": m.video.thumb, + "available": m.video.available, + "reason": m.video.reason, + "title": m.video.title, + "description": m.video.description, + "variants": variants + } + of gifMedia: + return %*{"type": "gif", "url": m.gif.url, "thumb": m.gif.thumb, "altText": m.gif.altText} + proc formatTweetAsJson*(tweet: Tweet): JsonNode = return %*{ "id": $tweet.id, @@ -51,7 +75,8 @@ proc formatTweetAsJson*(tweet: Tweet): JsonNode = "replies": tweet.stats.replies, "retweets": tweet.stats.retweets, "likes": tweet.stats.likes, - "quotes": tweet.stats.quotes + "quotes": tweet.stats.quotes, + "views": tweet.stats.views }, "retweet": if tweet.retweet.isSome: formatTweetAsJson(get( tweet.retweet)) else: newJNull(), @@ -63,10 +88,11 @@ proc formatTweetAsJson*(tweet: Tweet): JsonNode = tweet.quote)) else: newJNull(), "card": if tweet.card.isSome: %*get(tweet.card) else: newJNull(), "poll": if tweet.poll.isSome: %*get(tweet.poll) else: newJNull(), - "gif": if tweet.gif.isSome: %*get(tweet.gif) else: newJNull(), - "gifs": if tweet.gifs.len > 0: %tweet.gifs else: newJNull(), - "video": if tweet.video.isSome: %*get(tweet.video) else: newJNull(), - "photos": if tweet.photos.len > 0: %tweet.photos else: newJNull() + "media": (if tweet.media.len > 0: %tweet.media.map(formatMediaAsJson) else: newJNull()), + "history": (if tweet.history.len > 0: %tweet.history else: newJNull()), + "note": (if tweet.note.len > 0: %tweet.note else: newJNull()), + "isAd": %tweet.isAd, + "isAI": %tweet.isAI } proc formatTimelineAsJson*(results: Timeline): JsonNode = @@ -127,10 +153,10 @@ proc createJsonApiTimelineRouter*(cfg: Config) = cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] let - prefs = cookiePrefs() + prefs = requestPrefs() names = getNames(@"name") - var query = request.getQuery("", @"name") + var query = request.getQuery("", @"name", prefs) if names.len != 1: query.fromUser = names @@ -145,11 +171,11 @@ proc createJsonApiTimelineRouter*(cfg: Config) = cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"tab" in ["with_replies", "media", "search", ""] 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 diff --git a/src/nitter.nim b/src/nitter.nim index 42ea3359a..2e26245d3 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strformat, logging +import asyncdispatch, strformat, logging, re from net import Port from htmlgen import a from os import getEnv @@ -10,7 +10,7 @@ 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] + unsupported, embed, resolver, broadcast, router_utils] import jsons/[health, timeline, list, search, status] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" @@ -40,6 +40,9 @@ 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) @@ -56,6 +59,7 @@ createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) +createBroadcastRouter(cfg) createDebugRouter(cfg) createJsonApiHealthRouter(cfg) @@ -71,11 +75,27 @@ settings: reusePort = true routes: + options re"/api/.*": + let origin = if request.headers.hasKey("Origin"): request.headers["Origin"] else: "*" + resp Http204, { + "Vary": "Origin", + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, DNT", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "300" + }, "" + + before: + # skip all file URLs + cond "." notin request.path + 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") @@ -86,7 +106,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) @@ -125,5 +145,6 @@ routes: extend preferences, "" extend resolver, "" extend embed, "" + extend broadcastRoute, "" extend debug, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index b1b518405..2384b426f 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -6,6 +6,16 @@ import experimental/parser/unifiedcard proc parseGraphTweet(js: JsonNode): Tweet +proc parseVerifiedType(s: string; current: VerifiedType): VerifiedType = + try: parseEnum[VerifiedType](s) + except ValueError: current + +proc parseCommunityNote(js: JsonNode): string = + let subtitle = js{"subtitle"} + result = subtitle{"text"}.getStr + with entities, subtitle{"entities"}: + result = expandBirdwatchEntities(result, entities) + proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( @@ -29,7 +39,7 @@ 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) @@ -55,11 +65,70 @@ proc parseGraphUser(js: JsonNode): User = 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 parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -134,24 +203,37 @@ 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)) 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"}: @@ -161,26 +243,41 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = 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"}) - ) + )) 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 + if "expanded_url" in mediaEntity: + let expandedUrl = js.getExpandedUrl + 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 + # Remove media URLs from text with mediaList, js{"legacy", "entities", "media"}: for url in mediaList: @@ -210,14 +307,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 = @@ -277,8 +383,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): Tweet = + if js.isNull: return Tweet() let time = if js{"created_at"}.notNull: js{"created_at"}.getTime @@ -302,6 +409,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 @@ -333,11 +443,13 @@ 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"})) + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) else: result.card = some parseCard(jsCard, js{"entities", "urls"}) @@ -376,7 +488,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"}) @@ -395,12 +507,50 @@ proc parseGraphTweet(js: JsonNode): Tweet = "binding_values": %bindingObj } - result = parseTweet(js{"legacy"}, jsCard) - result.id = js{"rest_id"}.getId + var replyId = 0 + with restId, js{"reply_to_results", "rest_id"}: + replyId = restId.getId + + 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"})) + else: + result.card = some parseCard(jsCard, js{"url_entities"}) + + result.expandTweetEntitiesV2(js) + else: + result = parseTweet(js{"legacy"}, jsCard, replyId) + result.id = js{"rest_id"}.getId + 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 @@ -410,21 +560,28 @@ proc parseGraphTweet(js: JsonNode): Tweet = parseMediaEntities(js, result) - if result.quote.isSome: - result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"})) - - with quoted, js{"quotedPostResults", "result"}: + 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 parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = 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: + let tweet = t.getTweetResult("item") + if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -433,6 +590,12 @@ 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"}: @@ -453,7 +616,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) @@ -461,10 +624,12 @@ 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 not entryId.endsWith(tweetId): + result.before.content.add Tweet(id: entryId.getId) elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: @@ -492,6 +657,29 @@ 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) + proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): var tweet = parseGraphTweet(tweetResult) diff --git a/src/parserutils.nim b/src/parserutils.nim index b6ccd52cc..8d6ea2ead 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,6 +88,14 @@ 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: @@ -207,7 +214,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: @@ -321,6 +328,58 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) +proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; + hasRedundantLink=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) + + 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) = + 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 + + tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard) + proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} @@ -331,11 +390,29 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = 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 fa40a6da6..1a75f753a 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 8e2ac8f8d..699ec4c9f 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,9 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideCommunityNotes(checkbox, false): + "Hide community notes" + squareAvatars(checkbox, false): "Square profile pictures" @@ -94,6 +100,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 +144,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 +154,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 +199,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 c77bf5f53..38fe6f4e3 100644 --- a/src/query.nim +++ b/src/query.nim @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import strutils, strformat, sequtils, tables, uri -import types +import types, utils const validFilters* = @[ @@ -17,14 +17,10 @@ template `@`(param: string): untyped = if param in pms: pms[param] else: "" -proc validateNumber(value: string): string = - if value.anyIt(not it.isDigit): - return "" - return value - 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), @@ -50,7 +46,7 @@ proc getReplyQuery*(name: string): Query = fromUser: @[name] ) -proc genQueryParam*(query: Query): string = +proc genQueryParam*(query: Query; maxId=""): string = var filters: seq[string] param: string @@ -59,15 +55,20 @@ proc genQueryParam*(query: Query): string = return query.text for i, user in query.fromUser: - param &= &"from:{user} " + if i == 0: + param = "(" + + param &= &"from:{user}" if i < query.fromUser.high: - param &= "OR " + param &= " OR " + else: + param &= ")" if query.fromUser.len > 0 and query.kind in {posts, media}: - param &= "filter:self_threads OR -filter:replies " + 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 @@ -77,10 +78,14 @@ 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.minLikes.len > 0: result &= " min_faves:" & query.minLikes @@ -90,25 +95,32 @@ proc genQueryParam*(query: Query): string = else: result = query.text -proc genQueryUrl*(query: Query): string = - if query.kind notin {tweets, users}: return - - 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 result.len > 0 and maxId.len > 0: + result &= " max_id:" & maxId - 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 +proc genQueryUrl*(query: Query): string = + var params: seq[string] + + if query.view.len > 0: + params.add "view=" & encodeUrl(query.view) + + if query.kind in {tweets, users}: + 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 559d29951..bfd271f44 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -158,6 +158,33 @@ 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: 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)) diff --git a/src/routes/broadcast.nim b/src/routes/broadcast.nim new file mode 100644 index 000000000..d3bb95aab --- /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/embed.nim b/src/routes/embed.nim index 994364b97..bdca60f2a 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -11,7 +11,7 @@ proc createEmbedRouter*(cfg: Config) = router embed: get "/i/videos/tweet/@id": let tweet = await getGraphTweetResult(@"id") - if tweet == nil or tweet.video.isNone: + if tweet == nil or not tweet.hasVideos: resp Http404 resp renderVideoEmbed(tweet, cfg, request) @@ -19,7 +19,7 @@ proc createEmbedRouter*(cfg: Config) = get "/@user/status/@id/embed": let tweet = await getGraphTweetResult(@"id") - prefs = cookiePrefs() + prefs = requestPrefs() path = getPath() if tweet == nil: diff --git a/src/routes/list.nim b/src/routes/list.nim index ac3e97eca..b4ab0915d 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 186b8d8d3..df30d5f5d 100644 --- a/src/routes/media.nim +++ b/src/routes/media.nim @@ -86,6 +86,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 +99,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 +111,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 @@ -139,6 +141,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 b8af03db2..5886c0ea8 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?": diff --git a/src/routes/resolver.nim b/src/routes/resolver.nim index 1baf873df..5f074a55b 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 cb2ead0a2..15ef87489 100644 --- a/src/routes/router_utils.nim +++ b/src/routes/router_utils.nim @@ -9,21 +9,13 @@ export utils, prefs, types, uri template savePref*(pref, value: string; req: Request; expire=false) = if not expire or pref in cookies(req): setCookie(pref, value, daysForward(when expire: -10 else: 360), - httpOnly=true, secure=cfg.useHttps, sameSite=None) + httpOnly=true, secure=cfg.useHttps, sameSite=None, 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,25 +35,74 @@ 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 corsOrigin*(): string {.dirty.} = + if request.headers.hasKey("Origin"): request.headers["Origin"] else: "*" + template respJson*(node: JsonNode) = - resp $node, "application/json" + let origin = corsOrigin() + resp Http200, { + "Content-Type": "application/json", + "Vary": "Origin", + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true" + }, $node template respJsonSuccess*(data: JsonNode) = + let origin = corsOrigin() let successResponse = %*{ "code": 0, "data": data } - resp $successResponse, "application/json" + resp Http200, { + "Content-Type": "application/json", + "Vary": "Origin", + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true" + }, $successResponse template respJsonError*(message: string, errorType: string = "", httpCode: HttpCode = Http200) = + let origin = corsOrigin() var errorResponse = %*{ "code": -1, "error": message } if errorType.len > 0: errorResponse["error_type"] = %errorType - resp httpCode, $errorResponse, "application/json" + resp httpCode, { + "Content-Type": "application/json", + "Vary": "Origin", + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true" + }, $errorResponse template respJsonNull*() = - let nullResponse = newJNull() - resp $nullResponse, "application/json" + let origin = corsOrigin() + resp Http200, { + "Content-Type": "application/json", + "Vary": "Origin", + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true" + }, $newJNull() diff --git a/src/routes/rss.nim b/src/routes/rss.nim index b0e781d6c..444dc704e 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,11 +60,14 @@ 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)) + let + prefs = requestPrefs() + query = initQuery(params(request)) if query.kind != tweets: resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg) @@ -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,16 +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"] + let rssEnabled = case @"tab" + of "with_replies": cfg.enableRSSUserReplies + of "media": cfg.enableRSSUserMedia + of "search": cfg.enableRSSSearch + else: false + if not rssEnabled: + resp Http403, showError("RSS feed is disabled", cfg) let + prefs = requestPrefs() name = @"name" tab = @"tab" query = @@ -122,14 +134,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 +158,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 +174,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 e9f991dc5..7d72f34c9 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -19,7 +19,7 @@ proc createSearchRouter*(cfg: Config) = resp Http400, showError("Search input too long.", cfg) let - prefs = cookiePrefs() + prefs = requestPrefs() query = initQuery(params(request)) title = "Search" & (if q.len > 0: " (" & q & ")" else: "") @@ -36,16 +36,16 @@ proc createSearchRouter*(cfg: Config) = of tweets: 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) 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=" + let url = getUrlPrefix(cfg) & "/search?f=tweets&q=" resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, generateOpenSearchXML(cfg.title, cfg.hostname, url) diff --git a/src/routes/status.nim b/src/routes/status.nim index 0168dac80..f7fb1bb39 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -21,13 +21,13 @@ 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() # used for the infinite scroll feature if @"scroll".len > 0: let replies = await getReplies(id, getCursor()) if replies.content.len == 0: - resp Http404, "" + resp Http204 resp $renderReplies(replies, prefs, getPath()) let conv = await getTweet(id, getCursor()) @@ -44,15 +44,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: @@ -64,9 +68,29 @@ proc createStatusRouter*(cfg: Config) = resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, images=images, video=video) + 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 2ac87bbaf..0eb48e2bd 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -4,20 +4,28 @@ 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 getQuery*(request: Request; tab, name: string; prefs: Prefs): Query = + let view = request.params.getOrDefault("view") case tab - of "with_replies": getReplyQuery(name) - of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) - else: Query(fromUser: @[name]) + of "with_replies": + result = getReplyQuery(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: @@ -49,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] getCachedPhotoRail(userId) user = getCachedUser(name) + info = getCachedAccountInfo(name, fetch=false) result = case query.kind @@ -59,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile] result.user = await user result.photoRail = await rail + result.accountInfo = await info result.tweets.query = query @@ -111,17 +121,31 @@ proc createTimelineRouter*(cfg: Config) = 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/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','}) cond @"tab" in ["with_replies", "media", "search", ""] 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 @@ -129,7 +153,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: @@ -138,8 +163,17 @@ proc createTimelineRouter*(cfg: Config) = profile.tweets.beginning = true resp $renderTimelineTweets(profile.tweets, prefs, getPath()) + let rssEnabled = + if @"tab".len == 0: cfg.enableRSSUserTweets + elif @"tab" == "with_replies": cfg.enableRSSUserReplies + elif @"tab" == "media": cfg.enableRSSUserMedia + elif @"tab" == "search": cfg.enableRSSSearch + else: false + let rss = - if @"tab".len == 0: + if not rssEnabled: + "" + 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 362b36b2e..345dee72d 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -10,7 +10,7 @@ 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() diff --git a/src/sass/_broadcast.scss b/src/sass/_broadcast.scss new file mode 100644 index 000000000..dd93606b5 --- /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/index.scss b/src/sass/index.scss index 3f4b123d7..e60b9c4c6 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -7,6 +7,7 @@ @import "inputs"; @import "timeline"; @import "search"; +@import "broadcast"; body { // colors @@ -62,6 +63,10 @@ body { text-decoration: none; } +img { + dynamic-range-limit: standard; +} + h1 { display: inline; } @@ -99,12 +104,19 @@ legend { margin-bottom: 8px; } -.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; +.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 { @@ -115,11 +127,14 @@ ul { display: flex; flex-wrap: wrap; box-sizing: border-box; - padding-top: 50px; margin: auto; min-height: 100vh; } +body.fixed-nav .container { + padding-top: 50px; +} + .icon-container { display: inline; } @@ -146,7 +161,7 @@ ul { display: inline-block; width: 14px; height: 14px; - margin-left: 2px; + margin-bottom: 2px; .verified-icon-circle { position: absolute; diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss index aafa5b8c1..2b6016f50 100644 --- a/src/sass/inputs.scss +++ b/src/sass/inputs.scss @@ -179,6 +179,7 @@ input::-webkit-datetime-edit-year-field:focus { -moz-appearance: none; -webkit-appearance: none; appearance: none; + min-width: 100px; } input[type="text"], @@ -200,4 +201,16 @@ input::-webkit-datetime-edit-year-field:focus { .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 86bfbe707..c99902250 100644 --- a/src/sass/navbar.scss +++ b/src/sass/navbar.scss @@ -3,7 +3,6 @@ nav { display: flex; align-items: center; - position: fixed; background-color: var(--bg_overlays); box-shadow: 0 0 4px $shadow; padding: 0; @@ -16,6 +15,10 @@ nav { .icon-button button { color: var(--fg_nav); } + + body.fixed-nav & { + position: fixed; + } } .inner-nav { diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index b7f33e67c..248246055 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -1,83 +1,117 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; -@import 'card'; -@import 'photo-rail'; +@import "card"; +@import "about-account"; +@import "photo-rail"; .profile-tabs { - @include panel(auto, 900px); + @include panel(auto, 900px); - .timeline-container { - float: right; - width: 68% !important; - max-width: unset; - } + .timeline-container { + float: right; + width: 68% !important; + max-width: unset; + } } .profile-banner { - margin-bottom: 4px; - background-color: var(--bg_panel); + margin-bottom: 4px; + background-color: var(--bg_panel); - a { - display: block; - position: relative; - padding: 33.34% 0 0 0; - } + a { + display: block; + position: relative; + padding: 33.34% 0 0 0; + } - img { - max-width: 100%; - position: absolute; - top: 0; - } + img { + max-width: 100%; + position: absolute; + top: 0; + } } .profile-tab { - padding: 0 4px 0 0; - box-sizing: border-box; - display: inline-block; - font-size: 14px; - text-align: left; - vertical-align: top; - max-width: 32%; + 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; + } +} + +.profile-tabs.media-only { + max-width: none; + width: 100%; + + .timeline-container { + float: none; + width: 100% !important; + max-width: none; + padding: 0 10px; + box-sizing: border-box; + } + + .timeline-container > .tab { + max-width: 900px; + margin-left: auto; + margin-right: auto; + } } -@media(max-width: 700px) { - .profile-tabs { - width: 100vw; - max-width: 600px; +@media (max-width: 700px) { + .profile-tabs { + width: 100vw; + max-width: 600px; - .timeline-container { - width: 100% !important; + .timeline-container { + width: 100% !important; - .tab-item wide { - flex-grow: 1.4; - } - } + .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/about-account.scss b/src/sass/profile/about-account.scss new file mode 100644 index 000000000..aa12f4987 --- /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/timeline.scss b/src/sass/timeline.scss index 40882b2f2..e167448ca 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -4,12 +4,8 @@ @include panel(100%, 600px); } -.timeline { - background-color: var(--bg_panel); - - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } +.timeline > div:not(:first-child) { + border-top: 1px solid var(--border_grey); } .timeline-header { @@ -19,7 +15,7 @@ padding: 8px; display: block; font-weight: bold; - margin-bottom: 5px; + margin-bottom: 4px; box-sizing: border-box; button { @@ -40,7 +36,7 @@ display: flex; flex-wrap: wrap; list-style: none; - margin: 0 0 5px 0; + margin: 0 0 4px 0; background-color: var(--bg_panel); padding: 0; } @@ -159,4 +155,331 @@ 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 7f2d931b3..7606c3106 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -44,6 +44,10 @@ padding: 0; display: flex; justify-content: space-between; + + .verified-icon { + margin-left: 2px; + } } .fullname-and-username { @@ -80,8 +84,8 @@ } .tweet-published { - margin: 0; - margin-top: 5px; + margin-top: 6px; + margin-bottom: 0px; color: var(--grey); pointer-events: all; } @@ -101,6 +105,7 @@ .avatar { &.round { border-radius: 50%; + user-select: none; -webkit-user-select: none; } @@ -204,6 +209,7 @@ .tweet-stats { margin-bottom: -3px; + user-select: none; -webkit-user-select: none; } @@ -236,9 +242,70 @@ left: 0; top: 0; position: absolute; + user-select: none; -webkit-user-select: none; &:hover { background-color: var(--bg_hover); } } + +.latest-post-version { + border-bottom: 1px solid var(--dark_grey); + border-top: 1px solid var(--dark_grey); + padding: 01ch 0px; + margin: 1ch 0px; + color: var(--grey); + + a { + pointer-events: all; + } +} + +.community-note { + background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + overflow: hidden; + pointer-events: all; + + &:hover { + background-color: var(--bg_panel); + border-color: var(--grey); + } +} + +.community-note-header { + background-color: var(--bg_hover); + font-weight: 700; + padding: 8px 10px; + padding-top: 6px; + display: flex; + align-items: center; + gap: 2px; + + .icon-container { + flex-shrink: 0; + color: var(--accent); + } +} + +.community-note-text { + white-space: pre-line; + padding: 10px 10px; + padding-top: 6px; +} + +.disclosures { + display: flex; + flex-direction: column; + color: var(--grey); + font-size: 14px; + margin-top: 4px; + margin-bottom: -2px; + + .icon-attention { + margin-right: -3px; + } +} diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 5575191c4..7441d11d5 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; - - &:hover { - border-color: var(--grey); - } - - .attachments { - margin: 0; - border-radius: 0; - } + 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); + } + + .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); - - img { - width: 100%; - height: 100%; - max-height: 400px; - display: block; - object-fit: cover; - } + 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; + } } .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 fbdbd4163..bee23d3a2 100644 --- a/src/sass/tweet/embed.scss +++ b/src/sass/tweet/embed.scss @@ -11,7 +11,7 @@ left: 0%; } - .video-container { + .gallery-video > .attachment { max-height: unset; } } diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 66a300f9d..d7b443e14 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -4,16 +4,53 @@ display: flex; flex-direction: row; flex-wrap: nowrap; - align-items: center; overflow: hidden; flex-grow: 1; max-height: 379.5px; max-width: 533px; pointer-events: all; - .still-image { - width: 100%; - display: flex; + &.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 { + 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; + } } } @@ -29,10 +66,6 @@ background-color: var(--bg_color); align-items: center; pointer-events: all; - - .image-attachment { - width: 100%; - } } .attachment { @@ -50,7 +83,14 @@ } } -.gallery-gif video { +.media-gif { + display: table; + background-color: unset; + width: unset; + max-height: unset; +} + +.media-gif video { max-height: 530px; background-color: #101010; } @@ -58,7 +98,6 @@ .still-image { max-height: 379.5px; max-width: 533px; - justify-content: center; img { object-fit: cover; @@ -69,21 +108,34 @@ } } -.image { - display: inline-block; +.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); } -// .single-image { -// display: inline-block; -// width: 100%; -// max-height: 600px; - -// .attachments { -// width: unset; -// max-height: unset; -// display: inherit; -// } -// } +.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%; @@ -106,12 +158,6 @@ margin-left: 14px; } -.media-gif { - display: table; - background-color: unset; - width: unset; -} - .media-body { flex: 1; padding: 0; diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 1db4f7ea7..d6a75a954 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -19,30 +19,54 @@ } .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; + padding: 8px 10px 6px 10px; } .quote-text { overflow: hidden; white-space: pre-wrap; word-wrap: break-word; - padding: 0px 8px 8px 8px; + padding: 10px; + padding-top: 0; } .show-thread { - padding: 0px 8px 6px 8px; + padding: 0px 10px 6px 10px; margin-top: -6px; } + .quote-latest { + padding: 0px 10px 6px 10px; + color: var(--grey); + } + .replying-to { - padding: 0px 8px; + 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-top-color: var(--grey); + } + + .community-note-header { + background-color: var(--bg_panel); + padding-bottom: 0; + } + } } .unavailable-quote { padding: 12px; + display: block; } .quote-link { @@ -71,7 +95,7 @@ justify-content: center; } - .gallery-gif .attachment { + .media-gif > .attachment { display: flex; justify-content: center; background-color: var(--bg_color); @@ -84,8 +108,9 @@ } } - .gallery-video, - .gallery-gif { + .gallery-row .attachment, + .gallery-row .attachment > video, + .gallery-row .attachment > img { max-height: 300px; } diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index 9d2fb649c..c5165d771 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,7 +1,8 @@ @import "_variables"; @import "_mixins"; -.conversation { +.conversation, +.edit-history { @include panel(100%, 600px); .show-more { @@ -9,15 +10,34 @@ } } -.main-thread { +.main-thread, +.latest-edit { margin-bottom: 20px; - background-color: var(--bg_panel); +} + +.reply { + margin-bottom: 10px; } .main-tweet, -.replies { - padding-top: 50px; - margin-top: -50px; +.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 { @@ -30,11 +50,6 @@ } } -.reply { - background-color: var(--bg_panel); - margin-bottom: 10px; -} - .thread-line { .timeline-item::before, &.timeline-item::before { diff --git a/src/sass/tweet/video.scss b/src/sass/tweet/video.scss index ba77b14a4..c20d348bf 100644 --- a/src/sass/tweet/video.scss +++ b/src/sass/tweet/video.scss @@ -9,22 +9,22 @@ video { .gallery-video { display: flex; overflow: hidden; -} - -.gallery-video.card-container { - flex-direction: column; - width: 100%; -} + + &.card-container { + flex-direction: column; + width: 100%; + } -.video-container { - min-height: 80px; - min-width: 200px; - max-height: 530px; - margin: 0; + > .attachment { + min-height: 80px; + min-width: 200px; + max-height: 530px; + margin: 0; - img { - max-height: 100%; - max-width: 100%; + img { + max-height: 100%; + max-width: 100%; + } } } diff --git a/src/types.nim b/src/types.nim index 02e58cd31..90e4b634f 100644 --- a/src/types.nim +++ b/src/types.nim @@ -96,6 +96,37 @@ 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 + VideoType* = enum m3u8 = "application/x-mpegURL" mp4 = "video/mp4" @@ -123,6 +154,7 @@ type Query* = object kind*: QueryKind + view*: string text*: string filters*: seq[string] includes*: seq[string] @@ -136,6 +168,27 @@ type 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 @@ -219,10 +272,11 @@ type quote*: Option[Tweet] card*: Option[Card] poll*: Option[Poll] - gif*: Option[Gif] - gifs*: seq[Gif] - video*: Option[Video] - photos*: seq[string] + media*: MediaEntities + history*: seq[int64] + note*: string + isAd*: bool + isAI*: bool Tweets* = seq[Tweet] @@ -243,6 +297,10 @@ type after*: Chain replies*: Result[Chain] + EditHistory* = object + latest*: Tweet + history*: Tweets + Timeline* = Result[Tweets] Profile* = object @@ -250,6 +308,7 @@ type photoRail*: PhotoRail pinned*: Option[Tweet] tweets*: Timeline + accountInfo*: AccountInfo List* = object id*: string @@ -276,13 +335,20 @@ type hmacKey*: string base64Media*: bool minTokens*: int - enableRss*: bool + enableRSSUserTweets*: bool + enableRSSUserReplies*: bool + enableRSSUserMedia*: bool + enableRSSSearch*: bool + enableRSSList*: bool enableJsonApi*: bool enableDebug*: bool proxy*: string proxyAuth*: string apiProxy*: string disableTid*: bool + maxConcurrentReqs*: int + maxRetries*: int + retryDelayMs*: int rssCacheTime*: int listCacheTime*: int @@ -301,3 +367,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 c96a6ddc7..391e2a39a 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) = @@ -55,7 +57,13 @@ proc filterParams*(params: Table): seq[(string, string)] = result.add p proc isTwitterUrl*(uri: Uri): bool = - uri.hostname in twitterDomains + 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 000000000..aedd444a2 --- /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/broadcast.nim b/src/views/broadcast.nim new file mode 100644 index 000000000..bfcb9baee --- /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/embed.nim b/src/views/embed.nim index ba49f4530..62cc76fa2 100644 --- a/src/views/embed.nim +++ b/src/views/embed.nim @@ -9,14 +9,17 @@ import general, tweet const doctype = "\n" 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) + let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) body: tdiv(class="embed-video"): - renderVideo(get(tweet.video), prefs, "") + renderVideo(video, prefs, "") result = doctype & $node diff --git a/src/views/general.nim b/src/views/general.nim index 252584117..4110bdcf3 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -29,7 +29,7 @@ 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 X", href=canonical a(href="https://liberapay.com/zedeus"): verbatim lp @@ -39,9 +39,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; rss=""; alternate=""): VNode = - var theme = prefs.theme.toTheme - if "theme" in req.params: - theme = req.params["theme"].toTheme + 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=22") - link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=35") + link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) @@ -69,7 +67,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; 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 prefs.hlsPlayback: @@ -131,7 +129,8 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, rss, twitterLink) - body: + let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" + body(class=bodyClass): renderNavbar(cfg, req, rss, twitterLink) tdiv(class="container"): diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 178770487..b051a018b 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 2b2e4102b..5b751d606 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode = span(class="profile-stat-num"): text insertSep($num, ',') -proc renderUserCard*(user: User; prefs: Prefs): VNode = +proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode = buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let @@ -26,6 +26,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 +46,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,7 +59,7 @@ 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"): @@ -101,17 +107,22 @@ 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 == 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) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 377a44382..0bd9789e2 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -4,6 +4,7 @@ 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 = result = url @@ -11,6 +12,12 @@ proc getSmallPic*(url: string): string = result &= smallWebp result = getPicUrl(result) +proc getMediumPic*(url: string): string = + result = url + if "?" notin url and not url.endsWith("placeholder.png"): + result &= mediumWebp + result = getPicUrl(result) + proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = var c = "icon-" & icon if class.len > 0: c = &"{c} {class}" @@ -42,7 +49,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" @@ -66,20 +72,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: @@ -98,9 +104,9 @@ proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocu 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=""; 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 717ad99e3..4c1a86f9a 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -1,29 +1,38 @@ #? 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) ##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) +#if text.len > 0: +# result = prefix & text +# return #end if -#if tweet.photos.len > 0: -# result &= "Image" -#elif tweet.video.isSome: -# result &= "Video" -#elif tweet.gif.isSome: -# result &= "Gif" +#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 #end proc # @@ -31,6 +40,26 @@ 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 getTweetsWithPinned(profile: Profile): seq[Tweets] = #result = profile.tweets.content #if profile.pinned.isSome and result.len > 0: @@ -49,31 +78,24 @@ 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)

${text.replace("\n", "
\n")}

-#if tweet.photos.len > 0: -# for photo in tweet.photos: - +#if tweet.media.len > 0: +# for media in tweet.media: +${renderRssMedia(media, tweet, urlPrefix)} # end for -#elif tweet.video.isSome: - -
Video
- -
-#elif tweet.gif.isSome: -# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" -# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" - #elif tweet.card.isSome: # let card = tweet.card.get() # if card.image.len > 0: # end if #end if +#if tweet.note.len > 0 and not prefs.hideCommunityNotes: +

Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}

+#end if #if tweet.quote.isSome and get(tweet.quote).available: # let quoteTweet = get(tweet.quote) # let quoteLink = urlPrefix & getLink(quoteTweet) @@ -81,7 +103,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
${quoteTweet.user.fullname} (@${quoteTweet.user.username})

-${renderRssTweet(quoteTweet, cfg)} +${renderRssTweet(quoteTweet, cfg, prefs)}