Merge pull request #24 from zedeus/user-prefs

User prefs
This commit is contained in:
Zed 2019-08-19 04:46:00 +02:00 committed by GitHub
commit bce76ab8d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 786 additions and 204 deletions

View File

@ -1,6 +1,7 @@
[Server]
address = "0.0.0.0"
port = 8080
https = true # disable to enable cookies when not using https
title = "nitter"
staticDir = "./public"

View File

@ -11,8 +11,8 @@ bin = @["nitter"]
# Dependencies
requires "nim >= 0.19.9"
requires "norm >= 1.0.11"
requires "jester >= 0.4.1"
requires "norm >= 1.0.13"
requires "jester >= 0.4.3"
requires "regex >= 0.11.2"
requires "q >= 0.0.7"
requires "nimcrypto >= 0.3.9"

53
public/css/fontello.css vendored Normal file
View File

@ -0,0 +1,53 @@
@font-face {
font-family: 'fontello';
src: url('/fonts/fontello.eot?39973630');
src: url('/fonts/fontello.eot?39973630#iefix') format('embedded-opentype'),
url('/fonts/fontello.woff2?39973630') format('woff2'),
url('/fonts/fontello.woff?39973630') format('woff'),
url('/fonts/fontello.ttf?39973630') format('truetype'),
url('/fonts/fontello.svg?39973630#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
[class^="icon-"]:before, [class*=" icon-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: none;
display: inline-block;
text-decoration: inherit;
width: 1em;
text-align: center;
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-help-circled:before { content: '\e800'; } /* '' */
.icon-attention:before { content: '\e801'; } /* '' */
.icon-comment:before { content: '\e802'; } /* '' */
.icon-ok:before { content: '\e803'; } /* '' */
.icon-link:before { content: '\e805'; } /* '' */
.icon-calendar:before { content: '\e806'; } /* '' */
.icon-location:before { content: '\e807'; } /* '' */
.icon-down-open-1:before { content: '\e808'; } /* '' */
.icon-picture-1:before { content: '\e809'; } /* '' */
.icon-lock-circled:before { content: '\e80a'; } /* '' */
.icon-down-open:before { content: '\e80b'; } /* '' */
.icon-info-circled:before { content: '\e80c'; } /* '' */
.icon-retweet-1:before { content: '\e80d'; } /* '' */
.icon-search:before { content: '\e80e'; } /* '' */
.icon-pin:before { content: '\e80f'; } /* '' */
.icon-ok-circled:before { content: '\e810'; } /* '' */
.icon-cog-2:before { content: '\e812'; } /* '' */
.icon-thumbs-up-alt:before { content: '\f164'; } /* '' */

View File

@ -7,6 +7,10 @@ body {
line-height: 1.3;
}
* {
outline: unset;
}
#posts {
background-color: #161616;
}
@ -107,29 +111,19 @@ a:hover {
text-overflow: ellipsis;
}
.icon {
.verified-icon {
color: #fff;
background-color: #1da1f2;
border-radius: 50%;
flex-shrink: 0;
margin: 2px 0 3px 3px;
padding-top: 2px;
height: 12px;
width: 14px;
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
flex-shrink: 0;
margin: 2px 0 3px 3px;
}
.verified-icon {
background-color: #1da1f2;
height: 14px;
width: 14px;
font-size: 10px;
}
.protected-icon {
background-color: #353535;
height: 18px;
width: 18px;
font-size: 12px;
font-weight: bold;
}
.tweet-date {
@ -210,19 +204,28 @@ nav {
justify-content: flex-end;
}
.site-name {
font-weight: 600;
}
.site-name:hover {
color: #ffaca0;
text-decoration: unset;
}
.site-logo {
display: block;
width: 35px;
height: 35px;
}
.site-about {
font-size: 17px;
padding-right: 2px;
margin-top: -0.75px;
.item.right a {
padding-left: 4px;
}
.site-settings {
font-size: 18px;
.item.right a:hover {
color: #ffaca0;
text-decoration: unset;
}
.attachments {
@ -277,7 +280,7 @@ nav {
overflow: hidden;
}
video {
video, .video-container img {
height: 100%;
width: 100%;
}
@ -386,10 +389,15 @@ video {
padding: 0 2em;
line-height: 2em;
}
.show-more a:hover {
background-color: #282828;
}
.show-thread {
display: block;
}
.multi-header {
background-color: #161616;
text-align: center;
@ -437,7 +445,6 @@ video {
text-align: left;
vertical-align: top;
max-width: 32%;
position: sticky;
top: 50px;
}
@ -898,12 +905,8 @@ video {
}
.quote-sensitive-icon {
font-size: 25px;
width: 37px;
height: 32px;
background-color: #4e4e4e;
padding-bottom: 5px;
margin: 0;
font-size: 40px;
color: #909090;
}
.card {
@ -1073,3 +1076,132 @@ video {
.poll-info {
color: #868687;
}
.preferences-container {
max-width: 600px;
margin: 0 auto;
width: 100%;
margin-top: 10px;
}
.preferences {
background-color: #1f1f1f;
width: 100%;
padding: 5px 15px 15px 15px;
}
.preferences input[type="text"] {
max-width: 120px;
background-color: #121212;
padding: 1px 4px;
color: #f8f8f2;
margin: 0;
border: 1px solid #ff6c6091;
border-radius: 0px;
position: absolute;
right: 0;
font-size: 14px;
}
.preferences input[type="text"]:hover {
border-color: #ff6c60;
}
fieldset {
margin: .35em 0 .75em;
border: 0;
}
legend {
width: 100%;
padding: .6em 0 .3em 0;
margin: 0;
border: 0;
font-size: 16px;
border-bottom: 1px solid #3e3e35;
margin-bottom: 8px;
}
.pref-input {
position: relative;
margin-bottom: 6px;
}
.pref-submit, .pref-reset button {
background-color: #121212;
color: #f8f8f2;
border: 1px solid #ff6c6091;
padding: 3px 6px;
margin-top: 6px;
font-size: 14px;
cursor: pointer;
float: right;
}
.pref-submit:hover, .pref-reset button:hover {
border-color: #ff6c60;
}
.pref-submit:active, .pref-reset button:active {
border-color: #ff9f97;
}
.pref-reset {
float: left;
}
.icon-container {
display: inline;
}
.checkbox-container {
display: block;
position: relative;
margin-bottom: 5px;
cursor: pointer;
user-select: none;
}
.checkbox-container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox {
position: absolute;
top: 1px;
right: 0;
height: 17px;
width: 17px;
background-color: #121212;
border: 1px solid #ff6c6091;
}
.checkbox-container:hover input ~ .checkbox {
border-color: #ff6c60;
}
.checkbox-container:active input ~ .checkbox {
border-color: #ff9f97;
}
.checkbox:after {
content: "";
position: absolute;
display: none;
}
.checkbox-container input:checked ~ .checkbox:after {
display: block;
}
.checkbox-container .checkbox:after {
left: 2px;
bottom: 0px;
font-size: 13px;
font-family: "fontello";
content: '\e803';
}

39
public/fonts/LICENSE.txt Normal file
View File

@ -0,0 +1,39 @@
Font license info
## Entypo
Copyright (C) 2012 by Daniel Bruce
Author: Daniel Bruce
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://www.entypo.com
## MFG Labs
Copyright (C) 2012 by Daniel Bruce
Author: MFG Labs
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://www.mfglabs.com/
## Font Awesome
Copyright (C) 2016 by Dave Gandy
Author: Dave Gandy
License: SIL ()
Homepage: http://fortawesome.github.com/Font-Awesome/
## Elusive
Copyright (C) 2013 by Aristeides Stathopoulos
Author: Aristeides Stathopoulos
License: SIL (http://scripts.sil.org/OFL)
Homepage: http://aristeides.com/

BIN
public/fonts/fontello.eot Normal file

Binary file not shown.

46
public/fonts/fontello.svg Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2019 by original authors @ fontello.com</metadata>
<defs>
<font id="fontello" horiz-adv-x="1000" >
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="help-circled" unicode="&#xe800;" d="M454 810q190 2 326-130t140-322q2-190-131-327t-323-141q-190-2-327 131t-139 323q-4 190 130 327t324 139z m-2-740q30 0 49 19t19 47q2 30-17 49t-49 19l-2 0q-28 0-47-18t-21-46q0-30 19-49t47-21l2 0z m166 328q26 34 26 78 0 78-54 116-52 38-134 38-64 0-104-26-68-42-72-146l0-4 110 0 0 4q0 26 16 54 16 24 54 24 40 0 52-20 16-20 16-44 0-18-16-40-8-12-20-20l-6-4q-6-4-16-11t-20-15-21-17-17-17q-14-20-18-78l0-8 108 0 0 4q0 12 4 28 6 20 28 36l28 18q46 34 56 50z" horiz-adv-x="920" />
<glyph glyph-name="attention" unicode="&#xe801;" d="M0 350q0 95 37 182t100 149 149 100 183 37q95 0 181-37t150-100 100-149 37-182q0-95-37-182t-100-150-150-100-181-37q-96 0-183 37t-149 100-100 150-37 182z m387 196l27-244q2-21 17-35t36-17q24-3 43 12t21 40l27 244v17q-4 35-31 58t-63 19-58-31-19-63z m12-411q0-30 22-52t51-21 52 21 22 52-22 52q-20 20-52 20t-51-20q-22-22-22-52z" horiz-adv-x="937.5" />
<glyph glyph-name="comment" unicode="&#xe802;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
<glyph glyph-name="ok" unicode="&#xe803;" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
<glyph glyph-name="link" unicode="&#xe805;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
<glyph glyph-name="calendar" unicode="&#xe806;" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
<glyph glyph-name="location" unicode="&#xe807;" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
<glyph glyph-name="down-open-1" unicode="&#xe808;" d="M564 422l-234-224q-18-18-40-18t-40 18l-234 224q-16 16-16 41t16 41q38 38 78 0l196-188 196 188q40 38 78 0 16-16 16-41t-16-41z" horiz-adv-x="580" />
<glyph glyph-name="picture-1" unicode="&#xe809;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
<glyph glyph-name="lock-circled" unicode="&#xe80a;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
<glyph glyph-name="down-open" unicode="&#xe80b;" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
<glyph glyph-name="info-circled" unicode="&#xe80c;" d="M454 810q190 2 326-130t140-322q2-190-131-327t-323-141q-190-2-327 131t-139 323q-4 190 130 327t324 139z m52-152q-42 0-65-24t-23-50q-2-28 15-44t49-16q38 0 61 22t23 54q0 58-60 58z m-120-594q30 0 84 26t106 78l-18 24q-48-36-72-36-14 0-4 38l42 160q26 96-22 96-30 0-89-29t-115-75l16-26q52 34 74 34 12 0 0-34l-36-152q-26-104 34-104z" horiz-adv-x="920" />
<glyph glyph-name="retweet-1" unicode="&#xe80d;" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
<glyph glyph-name="search" unicode="&#xe80e;" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
<glyph glyph-name="pin" unicode="&#xe80f;" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
<glyph glyph-name="ok-circled" unicode="&#xe810;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m182-57l105-105 104-104 103 104 324 324-103 104-324-325-106 106z" horiz-adv-x="1000" />
<glyph glyph-name="cog-2" unicode="&#xe812;" d="M0 272l0 156 150 16q14 45 38 88l-96 117 109 109 117-95q41 23 88 37l16 150 156 0 16-150q45-14 88-37l117 95 109-109-96-117q24-43 38-88l150-16 0-156-150-16q-14-47-38-88l96-117-109-109-117 96q-43-24-88-38l-16-150-156 0-16 150q-47 14-88 38l-117-96-109 109 96 117q-24 41-38 88z m355 78q0-60 42-102t103-42 103 42 42 102-42 103-103 42-103-42-42-103z" horiz-adv-x="1000" />
<glyph glyph-name="thumbs-up-alt" unicode="&#xf164;" d="M143 100q0 15-11 25t-25 11q-15 0-25-11t-11-25q0-15 11-25t25-11q15 0 25 11t11 25z m89 286v-357q0-15-10-25t-26-11h-160q-15 0-25 11t-11 25v357q0 14 11 25t25 10h160q15 0 26-10t10-25z m661 0q0-48-31-83 9-25 9-43 1-42-24-76 9-31 0-66-9-31-31-52 5-62-27-101-36-43-110-44h-72q-37 0-80 9t-68 16-67 22q-69 24-88 25-15 0-25 11t-11 25v357q0 14 10 25t24 11q13 1 42 33t57 67q38 49 56 67 10 10 17 27t10 27 8 34q4 22 7 34t11 29 19 28q10 11 25 11 25 0 46-6t33-15 22-22 14-25 7-28 2-25 1-22q0-21-6-43t-10-33-16-31q-1-4-5-10t-6-13-5-13h155q43 0 75-32t32-75z" horiz-adv-x="928.6" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/fonts/fontello.ttf Normal file

Binary file not shown.

BIN
public/fonts/fontello.woff Normal file

Binary file not shown.

BIN
public/fonts/fontello.woff2 Normal file

Binary file not shown.

View File

@ -52,10 +52,10 @@ macro genMediaGet(media: untyped; token=false) =
var futs: seq[Future[void]]
when `token`:
var token = await getGuestToken(agent)
futs.add `single`(convo.tweet, token, agent)
futs.add `multi`(convo.before, token, agent)
futs.add `multi`(convo.after, token, agent)
futs.add convo.replies.mapIt(`multi`(it, token, agent))
futs.add `single`(convo.tweet, agent, token)
futs.add `multi`(convo.before, agent, token=token)
futs.add `multi`(convo.after, agent, token=token)
futs.add convo.replies.mapIt(`multi`(it, agent, token=token))
else:
futs.add `single`(convo.tweet, agent)
futs.add `multi`(convo.before, agent)
@ -117,7 +117,7 @@ proc getGuestToken(agent: string; force=false): Future[string] {.async.} =
result = json["guest_token"].to(string)
guestToken = result
proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
proc getVideoFetch*(tweet: Tweet; agent, token: string) {.async.} =
if tweet.video.isNone(): return
let headers = newHttpHeaders({
@ -135,7 +135,7 @@ proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
if getTime() - tokenUpdated > initDuration(seconds=1):
tokenUpdated = getTime()
discard await getGuestToken(agent, force=true)
await getVideoFetch(tweet, guestToken, agent)
await getVideoFetch(tweet, agent, guestToken)
return
if tweet.card.isNone:
@ -151,12 +151,12 @@ proc getVideoVar*(tweet: Tweet): var Option[Video] =
else:
return tweet.video
proc getVideo*(tweet: Tweet; token, agent: string; force=false) {.async.} =
proc getVideo*(tweet: Tweet; agent, token: string; force=false) {.async.} =
withDb:
try:
getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
except KeyError:
await getVideoFetch(tweet, token, agent)
await getVideoFetch(tweet, agent, token)
var video = getVideoVar(tweet)
if video.isSome():
get(video).insert()

View File

@ -1,7 +1,7 @@
import asyncdispatch, times
import types, api
withDb:
withCustomDb("cache.db", "", "", ""):
try:
createTables()
except DbError:
@ -13,7 +13,7 @@ proc isOutdated*(profile: Profile): bool =
getTime() - profile.updated > profileCacheTime
proc cache*(profile: var Profile) =
withDb:
withCustomDb("cache.db", "", "", ""):
try:
let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
profile.id = p.id
@ -23,7 +23,7 @@ proc cache*(profile: var Profile) =
profile.insert()
proc hasCachedProfile*(username: string): Option[Profile] =
withDb:
withCustomDb("cache.db", "", "", ""):
try:
let p = Profile.getOne("lower(username) = ?", toLower(username))
doAssert not p.isOutdated
@ -32,7 +32,7 @@ proc hasCachedProfile*(username: string): Option[Profile] =
result = none(Profile)
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
withDb:
withCustomDb("cache.db", "", "", ""):
try:
result.getOne("lower(username) = ?", toLower(username))
doAssert not result.isOutdated

View File

@ -1,5 +1,5 @@
import parsecfg except Config
import os, net, types, strutils
import net, types, strutils
proc get[T](config: parseCfg.Config; s, v: string; default: T): T =
let val = config.getSectionValue(s, v)
@ -15,6 +15,7 @@ proc getConfig*(path: string): Config =
Config(
address: cfg.get("Server", "address", "0.0.0.0"),
port: cfg.get("Server", "port", 8080),
useHttps: cfg.get("Server", "https", true),
title: cfg.get("Server", "title", "Nitter"),
staticDir: cfg.get("Server", "staticDir", "./public"),

View File

@ -11,6 +11,8 @@ const
usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
picRegex = re"pic.twitter.com/[^ ]+"
ellipsisRegex = re" ?…"
ytRegex = re"(www.)?youtu(be.com|.be)"
twRegex = re"(www.)?twitter.com"
nbsp = $Rune(0x000A0)
proc stripText*(text: string): string =
@ -46,7 +48,7 @@ proc reUsernameToLink*(m: RegexMatch; s: string): string =
pretext & toLink("/" & username, "@" & username)
proc linkifyText*(text: string): string =
proc linkifyText*(text: string; prefs: Prefs): string =
result = xmltree.escape(stripText(text))
result = result.replace(ellipsisRegex, "")
result = result.replace(emailRegex, reEmailToLink)
@ -55,6 +57,17 @@ proc linkifyText*(text: string): string =
result = result.replace(re"([^\s\(\n%])<a", "$1 <a")
result = result.replace(re"</a>\s+([;.,!\)'%]|&apos;)", "</a>$1")
result = result.replace(re"^\. <a", ".<a")
if prefs.replaceYouTube.len > 0:
result = result.replace(ytRegex, prefs.replaceYouTube)
if prefs.replaceTwitter.len > 0:
result = result.replace(twRegex, prefs.replaceTwitter)
proc replaceUrl*(url: string; prefs: Prefs): string =
result = url
if prefs.replaceYouTube.len > 0:
result = result.replace(ytRegex, prefs.replaceYouTube)
if prefs.replaceTwitter.len > 0:
result = result.replace(twRegex, prefs.replaceTwitter)
proc stripTwitterUrls*(text: string): string =
result = text

View File

@ -1,15 +1,17 @@
import asyncdispatch, asyncfile, httpclient, sequtils, strutils, strformat, uri, os
import asyncdispatch, asyncfile, httpclient, uri, os
import sequtils, strformat, strutils
from net import Port
import jester, regex
import api, utils, types, cache, formatters, search, config, agents
import views/[general, profile, status]
import api, utils, types, cache, formatters, search, config, prefs, agents
import views/[general, profile, status, preferences]
const configPath {.strdefine.} = "./nitter.conf"
let cfg = getConfig(configPath)
proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Future[string] {.async.} =
proc showSingleTimeline(name, after, agent: string; query: Option[Query];
prefs: Prefs): Future[string] {.async.} =
let railFut = getPhotoRail(name, agent)
var timeline: Timeline
@ -34,33 +36,40 @@ proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Futur
if profile.username.len == 0:
return ""
let profileHtml = renderProfile(profile, timeline, await railFut)
return renderMain(profileHtml, title=cfg.title, titleText=pageTitle(profile), desc=pageDesc(profile))
let profileHtml = renderProfile(profile, timeline, await railFut, prefs)
return renderMain(profileHtml, cfg.title, pageTitle(profile), pageDesc(profile))
proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query]): Future[string] {.async.} =
proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query];
prefs: Prefs): Future[string] {.async.} =
var q = query
if q.isSome:
get(q).fromUser = names
else:
q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
var timeline = renderMulti(await getTimelineSearch(get(q), after, agent), names.join(","))
return renderMain(timeline, title=cfg.title, titleText="Multi")
var timeline = renderMulti(await getTimelineSearch(get(q), after, agent),
names.join(","), prefs)
proc showTimeline(name, after: string; query: Option[Query]): Future[string] {.async.} =
return renderMain(timeline, cfg.title, "Multi")
proc showTimeline(name, after: string; query: Option[Query];
prefs: Prefs): Future[string] {.async.} =
let agent = getAgent()
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
if names.len == 1:
return await showSingleTimeline(names[0], after, agent, query)
return await showSingleTimeline(names[0], after, agent, query, prefs)
else:
return await showMultiTimeline(names, after, agent, query)
return await showMultiTimeline(names, after, agent, query, prefs)
template respTimeline(timeline: typed) =
if timeline.len == 0:
resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title)
resp timeline
template cookiePrefs(): untyped {.dirty.} =
getPrefs(request.cookies.getOrDefault("preferences"))
setProfileCacheTime(cfg.profileCacheTime)
settings:
@ -70,32 +79,56 @@ settings:
routes:
get "/":
resp renderMain(renderSearch(), title=cfg.title)
resp renderMain(renderSearch(), cfg.title)
post "/search":
if @"query".len == 0:
resp Http404, showError("Please enter a username.", cfg.title)
redirect("/" & @"query")
post "/saveprefs":
var prefs = cookiePrefs()
genUpdatePrefs()
setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps)
redirect(decodeUrl(@"referer"))
post "/resetprefs":
var prefs = cookiePrefs()
resetPrefs(prefs)
setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps)
redirect("/settings")
get "/settings":
let refUri = request.headers.getOrDefault("Referer").parseUri()
var path =
if refUri.path.len > 0 and "/settings" notin refUri.path: refUri.path
else: "/"
if refUri.query.len > 0: path &= &"?{refUri.query}"
resp renderMain(renderPreferences(cookiePrefs(), path), cfg.title, "Preferences")
get "/@name/?":
cond '.' notin @"name"
respTimeline(await showTimeline(@"name", @"after", none(Query)))
respTimeline(await showTimeline(@"name", @"after", none(Query), cookiePrefs()))
get "/@name/search":
cond '.' notin @"name"
let prefs = cookiePrefs()
let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
respTimeline(await showTimeline(@"name", @"after", some(query)))
respTimeline(await showTimeline(@"name", @"after", some(query), cookiePrefs()))
get "/@name/replies":
cond '.' notin @"name"
respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name"))))
let prefs = cookiePrefs()
respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), cookiePrefs()))
get "/@name/media":
cond '.' notin @"name"
respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name"))))
let prefs = cookiePrefs()
respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), cookiePrefs()))
get "/@name/status/@id":
cond '.' notin @"name"
let prefs = cookiePrefs()
let conversation = await getTweet(@"name", @"id", getAgent())
if conversation == nil or conversation.tweet.id.len == 0:
@ -103,26 +136,24 @@ routes:
let title = pageTitle(conversation.tweet.profile)
let desc = conversation.tweet.text
let html = renderConversation(conversation)
let html = renderConversation(conversation, prefs)
if conversation.tweet.video.isSome():
let thumb = get(conversation.tweet.video).thumb
let vidUrl = getVideoEmbed(conversation.tweet.id)
resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
images = @[thumb], `type`="video", video=vidUrl)
resp renderMain(html, cfg.title, title, desc, images = @[thumb],
`type`="video", video=vidUrl)
elif conversation.tweet.gif.isSome():
let thumb = get(conversation.tweet.gif).thumb
let vidUrl = getVideoEmbed(conversation.tweet.id)
resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
images = @[thumb], `type`="video", video=vidUrl)
resp renderMain(html, cfg.title, title, desc, images = @[thumb],
`type`="video", video=vidUrl)
else:
resp renderMain(html, title=cfg.title, titleText=title,
desc=desc, images=conversation.tweet.photos)
resp renderMain(html, cfg.title, title, desc, images=conversation.tweet.photos)
get "/pic/@sig/@url":
cond "http" in @"url"
cond "twimg" in @"url"
let
uri = parseUri(decodeUrl(@"url"))
path = uri.path.split("/")[2 .. ^1].join("/")
@ -156,11 +187,10 @@ routes:
if getHmac(url) != @"sig":
resp showError("Failed to verify signature", cfg.title)
let
client = newAsyncHttpClient()
video = await client.getContent(url)
let client = newAsyncHttpClient()
let video = await client.getContent(url)
client.close()
defer: client.close()
resp video, mimetype(url)
runForever()

47
src/prefs.nim Normal file
View File

@ -0,0 +1,47 @@
import sequtils, macros
import types
import prefs_impl
export genUpdatePrefs
static:
var pFields: seq[string]
for id in getTypeImpl(Prefs)[2]:
if $id[0] == "id": continue
pFields.add $id[0]
let pDefs = toSeq(allPrefs()).mapIt(it.name)
let missing = pDefs.filterIt(it notin pFields)
if missing.len > 0:
raiseAssert("{$1} missing from the Prefs type" % missing.join(", "))
withCustomDb("prefs.db", "", "", ""):
try:
createTables()
except DbError:
discard
proc cache*(prefs: var Prefs) =
withCustomDb("prefs.db", "", "", ""):
try:
doAssert prefs.id != 0
discard Prefs.getOne("id = ?", prefs.id)
prefs.update()
except AssertionError, KeyError:
prefs.insert()
proc getPrefs*(id: string): Prefs =
if id.len == 0: return genDefaultPrefs()
withCustomDb("prefs.db", "", "", ""):
try:
result.getOne("id = ?", id)
except KeyError:
result = genDefaultPrefs()
cache(result)
proc resetPrefs*(prefs: var Prefs) =
var defPrefs = genDefaultPrefs()
defPrefs.id = prefs.id
cache(defPrefs)
prefs = defPrefs

103
src/prefs_impl.nim Normal file
View File

@ -0,0 +1,103 @@
import macros, tables, strutils, xmltree
const hostname {.strdefine.} = "nitter.net"
type
PrefKind* = enum
checkbox, select, input
Pref* = object
name*: string
label*: string
case kind*: PrefKind
of checkbox:
defaultState*: bool
of select:
defaultOption*: string
options*: seq[string]
of input:
defaultInput*: string
placeholder*: string
# TODO: write DSL to simplify this
const prefList*: Table[string, seq[Pref]] = {
"Privacy": @[
Pref(kind: input, name: "replaceTwitter",
label: "Replace Twitter links with Nitter (blank to disable)",
defaultInput: hostname, placeholder: "Nitter hostname"),
Pref(kind: input, name: "replaceYouTube",
label: "Replace YouTube links with Invidious (blank to disable)",
defaultInput: "invidio.us", placeholder: "Invidious hostname")
],
"Media": @[
Pref(kind: checkbox, name: "mp4Playback",
label: "Enable mp4 video playback",
defaultState: true),
Pref(kind: checkbox, name: "hlsPlayback",
label: "Enable hls video streaming (requires JavaScript)",
defaultState: false),
Pref(kind: checkbox, name: "muteVideos",
label: "Mute videos by default",
defaultState: false),
Pref(kind: checkbox, name: "autoplayGifs", label: "Autoplay gifs",
defaultState: true)
],
"Display": @[
Pref(kind: checkbox, name: "hideTweetStats",
label: "Hide tweet stats (replies, retweets, likes)",
defaultState: false),
Pref(kind: checkbox, name: "hideBanner", label: "Hide profile banner",
defaultState: false),
Pref(kind: checkbox, name: "stickyProfile",
label: "Make profile sidebar stick to top",
defaultState: true)
]
}.toTable
iterator allPrefs*(): Pref =
for k, v in prefList:
for pref in v:
yield pref
macro genDefaultPrefs*(): untyped =
result = nnkObjConstr.newTree(ident("Prefs"))
for pref in allPrefs():
let default =
case pref.kind
of checkbox: newLit(pref.defaultState)
of select: newLit(pref.defaultOption)
of input: newLit(pref.defaultInput)
result.add nnkExprColonExpr.newTree(ident(pref.name), default)
macro genUpdatePrefs*(): untyped =
result = nnkStmtList.newTree()
for pref in allPrefs():
let ident = ident(pref.name)
let value = nnkPrefix.newTree(ident("@"), newLit(pref.name))
case pref.kind
of checkbox:
result.add quote do: prefs.`ident` = `value` == "on"
of input:
result.add quote do: prefs.`ident` = xmltree.escape(strip(`value`))
of select:
let options = pref.options
let default = pref.defaultOption
result.add quote do:
if `value` in `options`: prefs.`ident` = `value`
else: prefs.`ident` = `default`
result.add quote do:
cache(prefs)

View File

@ -1,5 +1,6 @@
import times, sequtils, options
import norm/sqlite
import prefs_impl
export sqlite, options
@ -22,25 +23,17 @@ db("cache.db", "", "", ""):
tweets*: string
likes*: string
media*: string
verified* {.
dbType: "STRING",
parseIt: parseBool(it.s)
formatIt: $it
.}: bool
protected* {.
dbType: "STRING",
parseIt: parseBool(it.s)
formatIt: $it
.}: bool
verified*: bool
protected*: bool
joinDate* {.
dbType: "INTEGER",
parseIt: it.i.fromUnix(),
formatIt: it.toUnix()
dbType: "INTEGER"
parseIt: it.i.fromUnix()
formatIt: dbValue(it.toUnix())
.}: Time
updated* {.
dbType: "INTEGER",
parseIt: it.i.fromUnix(),
formatIt: getTime().toUnix()
dbType: "INTEGER"
parseIt: it.i.fromUnix()
formatIt: dbValue(getTime().toUnix())
.}: Time
Video* = object
@ -50,16 +43,23 @@ db("cache.db", "", "", ""):
url*: string
thumb*: string
views*: string
available*: bool
playbackType* {.
dbType: "STRING",
parseIt: parseEnum[VideoType](it.s),
formatIt: $it,
dbType: "STRING"
parseIt: parseEnum[VideoType](it.s)
formatIt: dbValue($it)
.}: VideoType
available* {.
dbType: "STRING",
parseIt: parseBool(it.s)
formatIt: $it
.}: bool
Prefs* = object
hlsPlayback*: bool
mp4Playback*: bool
muteVideos*: bool
autoplayGifs*: bool
hideTweetStats*: bool
hideBanner*: bool
stickyProfile*: bool
replaceYouTube*: string
replaceTwitter*: string
type
QueryKind* = enum
@ -169,6 +169,7 @@ type
Config* = ref object
address*: string
port*: int
useHttps*: bool
title*: string
staticDir*: string
cacheDir*: string

View File

@ -1,6 +1,7 @@
import karax/[karaxdsl, vdom]
import ../utils
import renderutils
import ../utils, ../types
const doctype = "<!DOCTYPE html>\n"
@ -13,14 +14,15 @@ proc renderNavbar*(title: string): VNode =
a(href="/"): img(class="site-logo", src="/logo.png")
tdiv(class="item right"):
a(class="site-about", href="/about"): text "🛈"
a(class="site-settings", href="/settings"): text ""
icon "info-circled", title="About", href="/about"
icon "cog-2", title="Preferences", href="/settings"
proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc="";
`type`="article"; video=""; images: seq[string] = @[]): string =
let node = buildHtml(html(lang="en")):
head:
link(rel="stylesheet", `type`="text/css", href="/style.css")
link(rel="stylesheet", `type`="text/css", href="/css/style.css")
link(rel="stylesheet", `type`="text/css", href="/css/fontello.css")
title:
if titleText.len > 0:
@ -53,12 +55,12 @@ proc renderSearch*(): VNode =
tdiv(class="search-panel"):
form(`method`="post", action="search"):
input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
button(`type`="submit"): text "🔎"
button(`type`="submit"): icon "search"
proc renderError*(error: string): VNode =
buildHtml(tdiv(class="panel")):
tdiv(class="error-panel"):
span: text error
proc showError*(error: string; title: string): string =
renderMain(renderError(error), title=title, titleText="Error")
proc showError*(error, title: string): string =
renderMain(renderError(error), title, "Error")

67
src/views/preferences.nim Normal file
View File

@ -0,0 +1,67 @@
import tables, macros, strformat, xmltree
import karax/[karaxdsl, vdom, vstyles]
import ../types, ../prefs_impl
proc genCheckbox(pref, label: string; state: bool): VNode =
buildHtml(tdiv(class="pref-group")):
label(class="checkbox-container"):
text label
if state: input(name=pref, `type`="checkbox", checked="")
else: input(name=pref, `type`="checkbox")
span(class="checkbox")
proc genSelect(pref, label, state: string; options: seq[string]): VNode =
buildHtml(tdiv(class="pref-group")):
label(`for`=pref): text label
select(name=pref):
for opt in options:
if opt == state:
option(value=opt, selected=""): text opt
else:
option(value=opt): text opt
proc genInput(pref, label, state, placeholder: string): VNode =
let s = xmltree.escape(state)
let p = xmltree.escape(placeholder)
buildHtml(tdiv(class="pref-group pref-input")):
label(`for`=pref): text label
verbatim &"<input name={pref} type=\"text\" placeholder=\"{p}\" value=\"{s}\"/>"
macro renderPrefs*(): untyped =
result = nnkCall.newTree(
ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
for header, options in prefList:
result[2].add nnkCall.newTree(
ident("legend"),
nnkStmtList.newTree(
nnkCommand.newTree(ident("text"), newLit(header))))
for pref in options:
let procName = ident("gen" & capitalizeAscii($pref.kind))
let state = nnkDotExpr.newTree(ident("prefs"), ident(pref.name))
var stmt = nnkStmtList.newTree(
nnkCall.newTree(procName, newLit(pref.name), newLit(pref.label), state))
case pref.kind
of checkbox: discard
of select: stmt[0].add newLit(pref.options)
of input: stmt[0].add newLit(pref.placeholder)
result[2].add stmt
proc renderPreferences*(prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="preferences-container")):
fieldset(class="preferences"):
form(`method`="post", action="saveprefs"):
verbatim "<input name=\"referer\" style=\"display: none\" value=\"$1\"/>" % path
renderPrefs()
button(`type`="submit", class="pref-submit"):
text "Save preferences"
form(`method`="post", action="resetprefs", class="pref-reset"):
button(`type`="submit"):
text "Reset preferences"

View File

@ -1,8 +1,8 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
import ../types, ../utils, ../formatters
import tweet, timeline, renderutils
import ../types, ../utils, ../formatters
proc renderStat(num, class: string; text=""): VNode =
let t = if text.len > 0: text else: class
@ -11,7 +11,7 @@ proc renderStat(num, class: string; text=""): VNode =
span(class="profile-stat-num"):
text if num.len == 0: "?" else: num
proc renderProfileCard*(profile: Profile): VNode =
proc renderProfileCard*(profile: Profile; prefs: Prefs): VNode =
buildHtml(tdiv(class="profile-card")):
a(class="profile-card-avatar", href=profile.getUserPic().getSigUrl("pic")):
genImg(profile.getUserpic("_200x200"))
@ -23,21 +23,21 @@ proc renderProfileCard*(profile: Profile): VNode =
tdiv(class="profile-card-extra"):
if profile.bio.len > 0:
tdiv(class="profile-bio"):
p: verbatim linkifyText(profile.bio)
p: verbatim linkifyText(profile.bio, prefs)
if profile.location.len > 0:
tdiv(class="profile-location"):
span: text "📍 " & profile.location
span: icon "location", profile.location
if profile.website.len > 0:
tdiv(class="profile-website"):
span:
text "🔗 "
icon "link"
linkText(profile.website)
tdiv(class="profile-joindate"):
span(title=getJoinDateFull(profile)):
text "📅 " & getJoinDate(profile)
icon "calendar", getJoinDate(profile)
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
@ -50,7 +50,7 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
buildHtml(tdiv(class="photo-rail-card")):
tdiv(class="photo-rail-header"):
a(href=(&"/{profile.username}/media")):
text &"🖼 {profile.media} Photos and videos"
icon "picture-1", $profile.media & " Photos and videos"
tdiv(class="photo-rail-grid"):
for i, photo in photoRail:
@ -68,20 +68,22 @@ proc renderBanner(profile: Profile): VNode =
genImg(profile.banner)
proc renderProfile*(profile: Profile; timeline: Timeline;
photoRail: seq[GalleryPhoto]): VNode =
photoRail: seq[GalleryPhoto]; prefs: Prefs): VNode =
buildHtml(tdiv(class="profile-tabs")):
if not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(profile)
tdiv(class="profile-tab"):
renderProfileCard(profile)
let sticky = if prefs.stickyProfile: "sticky" else: "unset"
tdiv(class="profile-tab", style={position: sticky}):
renderProfileCard(profile, prefs)
if photoRail.len > 0:
renderPhotoRail(profile, photoRail)
tdiv(class="timeline-tab"):
renderTimeline(timeline, profile.username, profile.protected)
renderTimeline(timeline, profile.username, profile.protected, prefs)
proc renderMulti*(timeline: Timeline; usernames: string): VNode =
proc renderMulti*(timeline: Timeline; usernames: string; prefs: Prefs): VNode =
buildHtml(tdiv(class="multi-timeline")):
tdiv(class="timeline-tab"):
renderTimeline(timeline, usernames, false, multi=true)
renderTimeline(timeline, usernames, false, prefs, multi=true)

View File

@ -2,6 +2,18 @@ import karax/[karaxdsl, vdom, vstyles]
import ../types, ../utils
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = c & " " & class
buildHtml(tdiv(class="icon-container")):
if href.len > 0:
a(class=c, title=title, href=href)
else:
span(class=c, title=title)
if text.len > 0:
text " " & text
proc linkUser*(profile: Profile, class=""): VNode =
let
isName = "username" notin class
@ -12,9 +24,10 @@ proc linkUser*(profile: Profile, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
if isName and profile.verified:
span(class="icon verified-icon", title="Verified account"): text ""
icon "ok", class="verified-icon", title="Verified account"
if isName and profile.protected:
span(class="icon protected-icon", title="Protected account"): text "🔒"
text " "
icon "lock-circled", title="Protected account"
proc genImg*(url: string; class=""): VNode =
buildHtml():

View File

@ -4,11 +4,11 @@ import karax/[karaxdsl, vdom]
import ../types
import tweet, renderutils
proc renderReplyThread(thread: Thread): VNode =
proc renderReplyThread(thread: Thread; prefs: Prefs): VNode =
buildHtml(tdiv(class="reply thread thread-line")):
for i, tweet in thread.tweets:
let last = (i == thread.tweets.high and thread.more == 0)
renderTweet(tweet, index=i, last=last)
renderTweet(tweet, prefs, index=i, last=last)
if thread.more != 0:
let num = if thread.more != -1: $thread.more & " " else: ""
@ -17,26 +17,26 @@ proc renderReplyThread(thread: Thread): VNode =
a(class="more-replies-text", title="Not implemented yet"):
text $num & "more " & reply
proc renderConversation*(conversation: Conversation): VNode =
proc renderConversation*(conversation: Conversation; prefs: Prefs): VNode =
let hasAfter = conversation.after != nil
buildHtml(tdiv(class="conversation", id="posts")):
tdiv(class="main-thread"):
if conversation.before != nil:
tdiv(class="before-tweet thread-line"):
for i, tweet in conversation.before.tweets:
renderTweet(tweet, index=i)
renderTweet(tweet, prefs, index=i)
tdiv(class="main-tweet"):
let afterClass = if hasAfter: "thread thread-line" else: ""
renderTweet(conversation.tweet, class=afterClass)
renderTweet(conversation.tweet, prefs, class=afterClass)
if hasAfter:
tdiv(class="after-tweet thread-line"):
let total = conversation.after.tweets.high
for i, tweet in conversation.after.tweets:
renderTweet(tweet, index=i, total=total)
renderTweet(tweet, prefs, index=i, total=total)
if conversation.replies.len > 0:
tdiv(class="replies"):
for thread in conversation.replies:
renderReplyThread(thread)
renderReplyThread(thread, prefs)

View File

@ -54,28 +54,28 @@ proc renderProtected(username: string): VNode =
h2: text "This account's tweets are protected."
p: text &"Only confirmed followers have access to @{username}'s tweets."
proc renderThread(thread: seq[Tweet]): VNode =
proc renderThread(thread: seq[Tweet]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-tweet thread-line")):
for i, threadTweet in thread.sortedByIt(it.time):
renderTweet(threadTweet, "thread", index=i, total=thread.high)
renderTweet(threadTweet, prefs, class="thread", index=i, total=thread.high)
proc threadFilter(it: Tweet; tweetThread: string): bool =
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
proc renderTweets(timeline: Timeline): VNode =
proc renderTweets(timeline: Timeline; prefs: Prefs): VNode =
buildHtml(tdiv(id="posts")):
var threads: seq[string]
for tweet in timeline.tweets:
if tweet.threadId in threads: continue
let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
if thread.len < 2:
renderTweet(tweet, "timeline-tweet")
renderTweet(tweet, prefs, class="timeline-tweet")
else:
renderThread(thread)
renderThread(thread, prefs)
threads &= tweet.threadId
proc renderTimeline*(timeline: Timeline; username: string;
protected: bool; multi=false): VNode =
proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
prefs: Prefs; multi=false): VNode =
buildHtml(tdiv):
if multi:
tdiv(class="multi-header"):
@ -91,7 +91,7 @@ proc renderTimeline*(timeline: Timeline; username: string;
elif timeline.tweets.len == 0:
renderNoneFound()
else:
renderTweets(timeline)
renderTweets(timeline, prefs)
if timeline.hasMore or timeline.query.isSome:
renderOlder(timeline, username)
else:

View File

@ -1,17 +1,18 @@
import strutils, sequtils
import karax/[karaxdsl, vdom, vstyles]
import ../types, ../utils, ../formatters
import renderutils
import ../types, ../utils, ../formatters
proc renderHeader(tweet: Tweet): VNode =
buildHtml(tdiv):
if tweet.retweet.isSome:
tdiv(class="retweet"):
span: text "🔄 " & get(tweet.retweet).by & " retweeted"
span: icon "retweet-1", get(tweet.retweet).by & " retweeted"
if tweet.pinned:
tdiv(class="pinned"):
span: text "📌 Pinned Tweet"
span: icon "pin", "Pinned Tweet"
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.profile.username)):
@ -44,26 +45,55 @@ proc renderAlbum(tweet: Tweet): VNode =
target="_blank", style={display: flex}):
genImg(photo)
proc renderVideo(video: Video): VNode =
proc isPlaybackEnabled(prefs: Prefs; video: Video): bool =
case video.playbackType
of mp4: prefs.mp4Playback
of m3u8, vmap: prefs.hlsPlayback
proc renderVideoDisabled(video: Video): VNode =
buildHtml(tdiv):
img(src=video.thumb.getSigUrl("pic"))
tdiv(class="video-overlay"):
case video.playbackType
of mp4:
p: text "mp4 playback disabled in preferences"
of m3u8, vmap:
p: text "hls playback disabled in preferences"
proc renderVideo(video: Video; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments")):
tdiv(class="gallery-video"):
tdiv(class="attachment video-container"):
if prefs.isPlaybackEnabled(video):
let thumb = video.thumb.getSigUrl("pic")
let source = video.url.getSigUrl("video")
case video.playbackType
of mp4:
video(poster=video.thumb.getSigUrl("pic"), controls=""):
source(src=video.url.getSigUrl("video"), `type`="video/mp4")
if prefs.muteVideos:
video(poster=thumb, controls="", muted=""):
source(src=source, `type`="video/mp4")
else:
video(poster=thumb, controls=""):
source(src=source, `type`="video/mp4")
of m3u8, vmap:
video(poster=video.thumb.getSigUrl("pic"))
video(poster=thumb)
tdiv(class="video-overlay"):
p: text "Video playback not supported"
p: text "Video playback not supported yet"
else:
renderVideoDisabled(video)
proc renderGif(gif: Gif): VNode =
proc renderGif(gif: Gif; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments media-gif")):
tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
tdiv(class="attachment"):
video(class="gif", poster=gif.thumb.getSigUrl("pic"),
autoplay="", muted="", loop=""):
source(src=gif.url.getSigUrl("video"), `type`="video/mp4")
let thumb = gif.thumb.getSigUrl("pic")
let url = gif.url.getSigUrl("video")
if prefs.autoplayGifs:
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
source(src=url, `type`="video/mp4")
else:
video(class="gif", poster=thumb, controls="", muted="", loop=""):
source(src=url, `type`="video/mp4")
proc renderPoll(poll: Poll): VNode =
buildHtml(tdiv(class="poll")):
@ -80,22 +110,22 @@ proc renderPoll(poll: Poll): VNode =
proc renderCardImage(card: Card): VNode =
buildHtml(tdiv(class="card-image-container")):
tdiv(class="card-image"):
img(src=get(card.image).getSigUrl("pic"))
img(src=getSigUrl(get(card.image), "pic"))
if card.kind == player:
tdiv(class="card-overlay"):
tdiv(class="card-overlay-circle"):
span(class="card-overlay-triangle")
proc renderCard(card: Card): VNode =
proc renderCard(card: Card; prefs: Prefs): VNode =
const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
let large = if card.kind in largeCards: " large" else: ""
buildHtml(tdiv(class=("card" & large))):
a(class="card-container", href=card.url):
a(class="card-container", href=replaceUrl(card.url, prefs)):
if card.image.isSome:
renderCardImage(card)
elif card.video.isSome:
renderVideo(get(card.video))
renderVideo(get(card.video), prefs)
tdiv(class="card-content-container"):
tdiv(class="card-content"):
@ -105,9 +135,9 @@ proc renderCard(card: Card): VNode =
proc renderStats(stats: TweetStats): VNode =
buildHtml(tdiv(class="tweet-stats")):
span(class="tweet-stat"): text "💬 " & $stats.replies
span(class="tweet-stat"): text "🔄 " & $stats.retweets
span(class="tweet-stat"): text "👍 " & $stats.likes
span(class="tweet-stat"): icon "comment", $stats.replies
span(class="tweet-stat"): icon "retweet-1", $stats.retweets
span(class="tweet-stat"): icon "thumbs-up-alt", $stats.likes
proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")):
@ -133,9 +163,9 @@ proc renderQuoteMedia(quote: Quote): VNode =
tdiv(class="quote-badge-text"): text quote.badge
elif quote.sensitive:
tdiv(class="quote-sensitive"):
span(class="icon quote-sensitive-icon"): text ""
icon "attention", class="quote-sensitive-icon"
proc renderQuote(quote: Quote): VNode =
proc renderQuote(quote: Quote; prefs: Prefs): VNode =
if not quote.available:
return buildHtml(tdiv(class="quote unavailable")):
tdiv(class="unavailable-quote"):
@ -155,13 +185,14 @@ proc renderQuote(quote: Quote): VNode =
renderReply(quote)
tdiv(class="quote-text"):
verbatim linkifyText(quote.text)
verbatim linkifyText(quote.text, prefs)
if quote.hasThread:
a(href=getLink(quote)):
a(class="show-thread", href=getLink(quote)):
text "Show this thread"
proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNode =
proc renderTweet*(tweet: Tweet; prefs: Prefs; class="";
index=0; total=(-1); last=false): VNode =
var divClass = class
if index == total or last:
divClass = "thread-last " & class
@ -181,24 +212,25 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod
renderReply(tweet)
tdiv(class="status-content media-body"):
verbatim linkifyText(tweet.text)
verbatim linkifyText(tweet.text, prefs)
if tweet.quote.isSome:
renderQuote(tweet.quote.get())
renderQuote(tweet.quote.get(), prefs)
if tweet.card.isSome:
renderCard(tweet.card.get())
renderCard(tweet.card.get(), prefs)
elif tweet.photos.len > 0:
renderAlbum(tweet)
elif tweet.video.isSome:
renderVideo(tweet.video.get())
renderVideo(tweet.video.get(), prefs)
elif tweet.gif.isSome:
renderGif(tweet.gif.get())
renderGif(tweet.gif.get(), prefs)
elif tweet.poll.isSome:
renderPoll(tweet.poll.get())
if not prefs.hideTweetStats:
renderStats(tweet.stats)
if tweet.hasThread and "timeline" in class:
a(href=getLink(tweet)):
a(class="show-thread", href=getLink(tweet)):
text "Show this thread"

View File

@ -39,7 +39,7 @@ class Tweet(object):
class Profile(object):
fullname = '.profile-card-fullname'
username = '.profile-card-username'
protected = '.protected-icon'
protected = '.icon-lock-circled'
verified = '.verified-icon'
banner = '.profile-banner'
bio = '.profile-bio'

View File

@ -6,68 +6,68 @@ card = [
['voidtarget/status/1133028231672582145',
'sinkingsugar/nimqt-example',
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
'github.com', '-tb6lD-A', False],
'github.com', False],
['Bountysource/status/1141879700639215617',
'$1,000 Bounty on kivy/plyer',
'Automation and Screen Reader Support',
'bountysource.com', 'TF5vo84K', False],
'bountysource.com', False],
['lorenlugosch/status/1115440394148487168',
'lorenlugosch/pretrain_speech_model',
'Speech Model Pre-training for End-to-End Spoken Language Understanding - lorenlugosch/pretrain_speech_model',
'github.com', 'VwMnYBVh', False],
'github.com', False],
['PyTorch/status/1123379369672450051',
'PyTorch',
'An open source deep learning platform that provides a seamless path from research prototyping to production deployment.',
'pytorch.org', 'lAc4aESh', False],
'pytorch.org', False],
['Thom_Wolf/status/1122466524860702729',
'pytorch/fairseq',
'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - pytorch/fairseq',
'github.com', '1SVn24P6', False],
'github.com', False],
['TheTwoffice/status/558685306090946561',
'Eternity: a moment standing still forever…',
'- James Montgomery. | facebook | 500px | ferpectshotz | I dusted off this one from my old archives, it was taken while I was living in mighty new York city working at Wall St. I think this was the 11...',
'flickr.com', '1LT6fSLU', True],
'flickr.com', True],
['nim_lang/status/1136652293510717440',
'Version 0.20.0 released',
'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!',
'nim-lang.org', 'Q0aJrdMZ', True],
'nim-lang.org', True],
['Tesla/status/1141041022035623936',
'Experience the Tesla Arcade',
'',
'www.tesla.com', '40H36baw', True],
'www.tesla.com', True],
['mobile_test/status/490378953744318464',
'Nantasket Beach',
'Rocks on the beach.',
'500px.com', 'FVUU4YDwN', True],
'500px.com', True],
['voidtarget/status/1094632512926605312',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
'gist.github.com', '37n4WuBF', True],
'gist.github.com', True],
['AdsAPI/status/1110272721005367296',
'Conversation Targeting',
'',
'view.highspot.com', 'FrVMLWJH', True],
'view.highspot.com', True],
['FluentAI/status/1116417904831029248',
'Amazons Alexa isnt just AI — thousands of humans are listening',
'One of the only ways to improve Alexa is to have human beings check it for errors',
'theverge.com', 'HOW73fOB', True]
'theverge.com', True]
]
no_thumb = [
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
'Posted in r/programming by u/miran1 • 38 points and 46 comments',
'Posted in r/programming by u/miran1 • 36 points and 46 comments',
'reddit.com'],
['brent_p/status/1088857328680488961',
@ -80,17 +80,17 @@ playable = [
['nim_lang/status/1118234460904919042',
'Nim development blog 2019-03',
'Arne (aka Krux02) * debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g * bugs: * forwarding of .pure. * sizeof union * fea...',
'youtube.com', 'rJkABhGF'],
'youtube.com'],
['nim_lang/status/1121090879823986688',
'Nim - First natively compiled language w/ hot code-reloading at...',
'#nim #c++ #ACCUConf Nim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming ca...',
'youtube.com', 'FuFgnQ9PA'],
'youtube.com'],
['lele/status/819930645145288704',
'Eurocrash presents Open Decks - emerging dj #4: E-Musik',
"OPEN DECKS is Eurocrash's new project about discovering new and emerging dj talents. Every selected dj will have the chance to perform the first dj-set in front of an actual audience. The best dj...",
'mixcloud.com', 'FdM8jyi04']
'mixcloud.com']
]
promo = [
@ -106,12 +106,12 @@ promo = [
class CardTest(BaseTestCase):
@parameterized.expand(card)
def test_card(self, tweet, title, description, destination, image, large):
def test_card(self, tweet, title, description, destination, large):
self.open_nitter(tweet)
card = Card(Conversation.main + " ")
self.assert_text(title, card.title)
self.assert_text(destination, card.destination)
self.assertIn(image, self.get_image_url(card.image + ' img'))
self.assertIn('_img', self.get_image_url(card.image + ' img'))
if len(description) > 0:
self.assert_text(description, card.description)
if large:
@ -129,12 +129,12 @@ class CardTest(BaseTestCase):
self.assert_text(description, card.description)
@parameterized.expand(playable)
def test_card_playable(self, tweet, title, description, destination, image):
def test_card_playable(self, tweet, title, description, destination):
self.open_nitter(tweet)
card = Card(Conversation.main + " ")
self.assert_text(title, card.title)
self.assert_text(destination, card.destination)
self.assertIn(image, self.get_image_url(card.image + ' img'))
self.assertIn('_img', self.get_image_url(card.image + ' img'))
self.assert_element_visible('.card-overlay')
if len(description) > 0:
self.assert_text(description, card.description)

View File

@ -4,15 +4,15 @@ from parameterized import parameterized
profiles = [
['mobile_test', 'Test account',
'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
'📍 San Francisco, CA', '🔗 example.com/foobar', '📅 Joined October 2009', '100'],
['mobile_test_2', 'mobile test 2', '', '', '', '📅 Joined January 2011', '13']
'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '100'],
['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13']
]
verified = [['jack'], ['elonmusk']]
protected = [
['mobile_test_7', 'mobile test 7🔒', ''],
['Poop', 'Randy🔒', 'Social media fanatic.']
['mobile_test_7', 'mobile test 7', ''],
['Poop', 'Randy', 'Social media fanatic.']
]
invalid = [['thisprofiledoesntexist'], ['%']]
@ -39,7 +39,7 @@ class ProfileTest(BaseTestCase):
(location, Profile.location),
(website, Profile.website),
(joinDate, Profile.joinDate),
(f"🖼 {mediaCount} Photos and videos", Profile.mediaCount)
(mediaCount + " Photos and videos", Profile.mediaCount)
]
for text, selector in tests:

View File

@ -16,7 +16,7 @@ timeline = [
]
status = [
[20, 'jack 🌍🌏🌎', 'jack', '21 Mar 2006', 'just setting up my twttr'],
[20, 'jack 🌍🌏🌎', 'jack', '21 Mar 2006', 'just setting up my twttr'],
[134849778302464000, 'The Twoffice', 'TheTwoffice', '10 Nov 2011', 'test'],
[105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
[572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test']
@ -77,7 +77,7 @@ emoji = [
retweet = [
[7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'],
[3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎', '@jack', 'twttr']
[3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎', '@jack', 'twttr']
]
reply = [

View File

@ -92,7 +92,7 @@ class MediaTest(BaseTestCase):
self.assert_element_visible(Media.container)
self.assert_element_visible(Media.video)
video_thumb = self.get_attribute('video', 'poster')
video_thumb = self.get_attribute(Media.video + ' img', 'src')
self.assertIn(thumb, video_thumb)
@parameterized.expand(gallery)