initial commit

This commit is contained in:
shim_
2016-05-27 09:16:34 +02:00
parent ff2d56087d
commit 6d368ec20b
32 changed files with 2140 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
package net.shimun.redstream.api
import java.util.concurrent.TimeUnit
import cats.data.Xor
import monix.async.Task
import monix.execution.{Cancelable, Scheduler}
import org.scalajs._
import scala.concurrent.{ExecutionContext}
import scala.scalajs.js
import scala.scalajs.js.{Any, Array, JSON}
/**
* Created by marvin on 3/26/16.
*/
private[api] abstract class DataSourceCompanion{
val Default: DataSource = new DataSource {
def request(url: URL,method: String = "GET",data: Option[String] = None,headers: Seq[(String,String)] = Seq.empty,auth: Option[(String,String)] = None) = Task.create[Xor[dom.ErrorEvent,(Int,String)]]{ (callback,_) =>
val xhr = new dom.XMLHttpRequest()
import js.JSConverters._
val open = xhr.open(method,url.toString,true,_: String,_: String)
auth match {
case Some((user,pass)) => open(user,pass)
case _ => open(null,null)
}
headers.foreach{
case (k,v) => xhr.setRequestHeader(k,v)
}
xhr.onload = { (e: dom.Event) =>
callback.onSuccess(Xor.Right((xhr.status,xhr.responseText)))
}
xhr.onerror = { (e: dom.ErrorEvent) =>
callback.onSuccess(Xor.Left(e))
}
data.fold(xhr.send())(data => xhr.send(data))
new Cancelable {
override def cancel(): Unit = xhr.abort()
}
}
override implicit def executionContext: ExecutionContext = ExecutionContext.Implicits.global
override def httpRequest(url: URL, method: String, data: Option[String],headers: Seq[(String,String)] = Seq.empty, auth: Option[(String, String)]): Task[(Int, String)] = request(url,method,data,headers,auth).flatMap{
case Xor.Right((status: Int,text: String)) =>
Task.now((status,text))
case Xor.Left(err) => Task.error(new Exception("xhr request failed"))
}
override implicit def scheduler: Scheduler = Scheduler.trampoline()
//JsonP
override def json(url: URL, auth: Option[(String, String)],callbackParameter: String = "jsonp"): Task[(Int, String)] = Task.create{ (reg,scheduler: Scheduler) =>
val tag = dom.document.createElement("script")
tag.setAttribute("async","true")
tag.setAttribute("type","text/javascript")
val functionName = s"jsonp_${scala.util.Random.alphanumeric.take(6).mkString}"
val window = dom.window.asInstanceOf[js.Dynamic]
window.updateDynamic(functionName)(((json: js.Object) => {
window.updateDynamic(functionName)(null)
tag.parentNode.removeChild(tag)
val text: String = JSON.stringify(json)
reg.onSuccess((200,text))
}): js.Function1[js.Object,Unit])
scheduler.scheduleOnce(30,TimeUnit.SECONDS,new Runnable {
override def run(): Unit = if(window.selectDynamic(functionName) != null){
window.updateDynamic(functionName)(null)
tag.parentNode.removeChild(tag)
reg.onError(new Exception("JsonP request failed!"))
}
})
tag.setAttribute("src",s"$url${if(url.toString.contains("?")) "&" else "?"}$callbackParameter=$functionName")
dom.document.body.appendChild(tag)
new Cancelable {
override def cancel(): Unit = ()
}
}
}
}

View File

@@ -0,0 +1,12 @@
package net.shimun.redstream.api
/**
* Created by marvin on 3/26/16.
*/
object JsonParser {
//def parse(json: String) = io.circe.parser.parse(json)
}

View File

@@ -0,0 +1,14 @@
package net.shimun.redstream.api
import org.scalajs.dom
import scala.scalajs.js.URIUtils
/**
* Created by marvin on 3/27/16.
*/
object UrlEncoder {
def encode(s: String,enc: String) = URIUtils.encodeURIComponent(s)
}

View File

@@ -0,0 +1,50 @@
package net.shimun.redstream.api
import java.net.HttpURLConnection
import java.util.Base64
import java.util.concurrent.Executors
import monix.async.Task
import monix.execution.{Cancelable, Scheduler}
import scala.concurrent.ExecutionContext
import scala.io.Source
/**
* Created by marvin on 3/26/16.
*/
private[api] abstract class DataSourceCompanion{
val Default: DataSource = new DataSource {
override implicit def executionContext: ExecutionContext = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(4))
override def httpRequest(url: URL, method: String, data: Option[String],headers: Seq[(String,String)] = Seq.empty, auth: Option[(String, String)]): Task[(Int, String)] = Task.create[(Int,String)]{ (callback,_) =>
println(s"Req: $method $url")
val con = url.toURL.openConnection().asInstanceOf[HttpURLConnection]
con.setRequestMethod(method)
con.setRequestProperty("User-Agent",
"Mozilla/5.0 (Windows NT 5.1; rv:19.0) Gecko/20100101 Firefox/19.0")
headers.foreach{
case (k,v) => con.setRequestProperty(k,v)
}
auth.foreach{ auth =>
con.addRequestProperty("Authorization",Base64.getEncoder.encodeToString(s"${auth._1}:${auth._2}".getBytes("UTF-8")))
}
data.foreach(_ => con.setDoOutput(true))
con.setDoInput(true)
con.connect()
data.foreach{ data =>
val out = con.getOutputStream
out.write(data.getBytes("UTF-8"))
out.close()
}
callback.onSuccess((con.getResponseCode,Source.fromInputStream(con.getInputStream).getLines().mkString))
new Cancelable {
override def cancel(): Unit = con.disconnect()
}
}
override implicit def scheduler: Scheduler = Scheduler(executionContext)
}
}

View File

@@ -0,0 +1,12 @@
package net.shimun.redstream.api
/**
* Created by marvin on 3/26/16.
*/
object JsonParser {
// def parse(json: String) = io.circe.parser.parse(json)
}

View File

@@ -0,0 +1,10 @@
package net.shimun.redstream.api
/**
* Created by marvin on 3/27/16.
*/
object UrlEncoder {
def encode(s: String,enc: String) = java.net.URLEncoder.encode(s,enc)
}

View File

@@ -0,0 +1,57 @@
package net.shimun.redstream.api
import java.net.URI
import cats.data.Xor
import monix.async.Task
import monix.execution.Scheduler
import scala.concurrent.{Promise, ExecutionContext, Future}
import scala.util.{Try, Failure, Success}
/**
* Created by marvin on 1/5/16.
*/
trait DataSource extends RedditLocation{
private implicit def task2future[T](task: Task[T]): Future[T] = {
val p = Promise[T]
task.runAsync{ t: Try[T] =>
t match {
case Success(res) => p.success(res)
case Failure(t) => p.failure(t)
}
}
p.future
}
@deprecated
def get(url: URI): Future[(Int,Option[String])] = httpRequest(url).map(t => (t._1,Some(t._2)))
@deprecated
def post(url: URI,body: String): Future[(Int,Option[String])] = httpRequest(url,"POST").map(t => (t._1,Some(t._2)))
def httpRequest(url: URL,method: String = "GET",data: Option[String] = None,headers: Seq[(String,String)] = Seq.empty,auth: Option[(String,String)] = None): Task[(Int,String)]
def json(url: URL,auth: Option[(String,String)] = None,callbackParameter: String = "jsonp"): Task[(Int,String)] = httpRequest(url,auth=auth)
implicit def executionContext: ExecutionContext
implicit def scheduler: Scheduler
}
trait RedditLocation {
def secure = true
def domain: String = "reddit.com"
def authenticatedDomain: String = "oauth.reddit.com"
}
object DataSource extends DataSourceCompanion

View File

@@ -0,0 +1,142 @@
package net.shimun.redstream.api
import java.net.URI
import cats.Monad
import cats.data.Xor
import io.circe._
import io.iteratee.{Enumeratee, Enumerator}
import monix.async.Task
import io.circe.parser.{parse => parseJSON}
import net.shimun.redstream.api.models.Submission
/**
* Created by marvin on 1/5/16.
*/
case class Page(url: QueryURL, before: Option[String], after: Option[String], children: Seq[JsonObject],count: Int = 0) {
def next(implicit ds: DataSource): Enumerator[Task,Page] = after match {
case Some(token) => Page.crawl(url,Some(Right(token)))
case _ => Enumerator.empty[Task,Page]
}
def previous(implicit ds: DataSource): Enumerator[Task,Page] = before match {
case Some(token) => Page.crawl(url,Some(Left(token)))
case _ => Enumerator.empty[Task,Page]
}
override def toString: String = s"${getClass.getSimpleName}($url,$before,$after,..[${children.size}])"
}
object Page {
private def defaultExtractor(obj: JsonObject): Seq[JsonObject] = (for{
node <- obj("children").toSeq
children <- node.asArray.toSeq
child <- children
childObj <- child.asObject
} yield childObj)
def crawl(url: QueryURL, direction: Option[Either[String, String]] = None, limit: Int = 25,
entrypoint: (Option[JsonObject]) => Option[JsonObject] = (obj) => obj ,extractor: (JsonObject) => Seq[JsonObject] = defaultExtractor)(implicit ds: DataSource): Enumerator[Task, Page] = {
val baseURL = url.copy(parameters = Seq("limit" -> limit.toString) ++ (direction match {
case Some(Left(token)) => Seq("before" -> token)
case Some(Right(token)) => Seq("after" -> token)
case _ => Seq.empty[(String, String)]
}) ++ url.parameters.filterNot(p => Seq("count", "before", "after").contains(p._1)))
import ds._
def process(url: QueryURL,count: Int = 0): Task[Option[Page]] = {
ds.json(url).flatMap {
case (200, text) =>
parseJSON(text) match {
case Xor.Right(json: Json) =>
Task.now {
for {
root <- entrypoint(json.asObject)
kind <- root("kind").flatMap(_.asString)
data <- root("data").flatMap(_.asObject) if kind == "Listing"
_ <- data("modhash").flatMap(_.asString)
after <- data("after").map(_.asString)
before <- data("before").map(_.asString)
} yield {
val extracted = extractor(data)
Page(url, before, after, extracted,count + extracted.size)
}
}
case Xor.Left(failure) => Task.error(failure.underlying)
}
case (status, _) => Task.error(new RuntimeException(s"Failed to fetch page - $status"))
}
}
import taskModule._
val relativeDirection = direction match {
case Some(Left(_)) => Left(())
case _ => Right(())
}
liftToEnumerator(process(baseURL))
.flatMap(_.fold(Enumerator.empty[Task,Page])(Enumerator.enumOne(_)))
.flatMap{ page =>
iterateUntilM(page){ prev =>
((relativeDirection,prev) match {
case (Right(()),Page(_,_,Some(tkn),_,_)) => Some(baseURL.withParameters((baseURL.parameters :+ ("after" -> tkn)):_*))
case (Left(()),Page(_,Some(tkn),_,_,_)) => Some(baseURL.withParameters((baseURL.parameters :+ ("before" -> tkn)):_*))
case _ => None
}).map(u=>u.withParameters((u.parameters :+ ("count" -> prev.count.toString)):_*)).map(process(_,prev.count)).getOrElse(Task.now(None))
}
}
}
}
trait Paged {
def url(implicit rl: RedditLocation): QueryURL
def pages(implicit ds: DataSource): Enumerator[Task,Page] = Page.crawl(url(ds))(ds)
}
trait Owned[R] {
def parent: Either[User,R]
}
trait Rateable {
def id: String
def vote(up: Boolean)(implicit session: Session,ds: DataSource) = {
import io.circe.syntax._
import io.circe.generic.auto._
case class Vote(dir: Int,id: String,rank: Int)
session.authenticated(s"https://${ds.authenticatedDomain}/api/vote","POST",Some(Vote(if(up) 1 else -1,id,2).asJson.noSpaces))
}
def up(implicit userSession: Session,ds: DataSource): Task[_ <: Rateable]
def down(implicit userSession: Session,ds: DataSource): Task[_ <: Rateable]
def ups: Int
//def downs: Int = ups - (ups * (upvoted / 100))
}
case class FormattedText(plain: String,html: String)
object FormattedText {
val empty = FormattedText("","")
}
case class Score(ups: Int,downs: Int) {
def score = ups - downs
}

View File

@@ -0,0 +1,21 @@
package net.shimun.redstream.api
/**
* Created by marvin on 2/14/16.
*/
case class QueryURL(baseURL: URL,parameters: Seq[(String,String)] = Seq.empty) {
def toURL: URL = {
import baseURL._
new URL(getScheme,getUserInfo,getHost,getPort,getPath,parameters.toMap.toSeq.map{ kv =>
val (key,value) = kv
val encode = UrlEncoder.encode(_ : String,"UTF-8")
s"${encode(key)}=${encode(value)}"
}.mkString("","&",""),getFragment)
}
def withParameters(parameters: (String,String)*) = copy(parameters = parameters)
override def toString: String = toURL.toString
}

View File

@@ -0,0 +1,33 @@
package net.shimun.redstream.api
import monix.async.Task
/**
* Created by marvin on 4/1/16.
*/
trait Session {
val api: Boolean
def authenticated(url: URL,method: String = "GET",data: Option[String] = None,headers: Seq[(String,String)] = Seq.empty)(implicit ds: DataSource): Task[(Int,String)]
}
/*
* {
"access_token": Your access token,
"token_type": "bearer",
"expires_in": Unix Epoch Seconds,
"scope": A scope string,
"refresh_token": Your refresh token
}
* */
case class ApiSession(token: String,tokenType: String,expires: Long,scope: String,refreshToken: String) extends Session{
override val api: Boolean = true
override def authenticated(url: URL, method: String,data: Option[String],headers: Seq[(String,String)] = Seq.empty)(implicit ds: DataSource) =
ds.httpRequest(url,method,data,headers :+ "Authorization" -> s"$tokenType $token")
}

View File

@@ -0,0 +1,53 @@
package net.shimun.redstream.api
import monix.async.Task
import net.shimun.redstream.api.models.{Comment, Submission}
//import scalaz.Id
//import upickle.default._
//
//import scalaz.iteratee.Iteratee._
//import scalaz.iteratee._
/**
* Created by marvin on 1/5/16.
*/
case class Subreddit(name: String,sorting: String = "hot") extends Paged {
override def url(implicit rl: RedditLocation): QueryURL = QueryURL(new URL(s"http${if(rl.secure) "s" else ""}://${rl.domain}/r/$name/$sorting.json"))
def posts(implicit ds: DataSource) = {
import ds._
Submission(pages,this)
}
def meta: Task[Option[Nothing]] = Task.now(None)
}
case class User(name: String) extends Paged {
override def url(implicit rl: RedditLocation): QueryURL = QueryURL(new URL(s"http${if(rl.secure) "s" else ""}://${rl.domain}/u/$name.json"))
def posts(implicit ds: DataSource) = {
import ds._
Submission(pages,this)
}
def comments(implicit ds: DataSource) = {
import ds._
Comment(pages,this)
}
def meta: Task[Option[Nothing]] = Task.now(None)
override def equals(obj: scala.Any): Boolean = obj match {
case User(otherName) => otherName == name
case other => other == name
}
}

View File

@@ -0,0 +1,86 @@
package net.shimun.redstream.api
package models
import cats.{Applicative, Monad}
import io.circe.JsonObject
import io.iteratee.{Enumeratee, Enumerator}
import monix.async.Task
/**
* Created by marvin on 4/6/16.
*/
case class Comment(parent: Either[User,Submission],id: String,text: FormattedText,author: User, score: Int = 0,comments: Enumerator[Task,Comment] = Enumerator.empty) extends Rateable with Owned[Submission] {
override def ups: Int = score
override def up(implicit userSession: Session, ds: DataSource): Task[_ <: Rateable] = ???
override def down(implicit userSession: Session, ds: DataSource): Task[_ <: Rateable] = ???
def sub = parent match {
case Right(sub) => Some(sub)
case _ => None
}
def profile = parent match {
case Left(profile) => Some(profile)
case _ => None
}
}
object Comment {
def apply(pages: Enumerator[Task, Page],parent: Submission): Enumerator[Task, Comment] =
pages.flatMap(page => Enumerator.enumList[Task,JsonObject](page.children.toList)).mapE(parser(Right(parent)))
def apply(pages: Enumerator[Task, Page],parent: User): Enumerator[Task, Comment] =
pages.flatMap(page => Enumerator.enumList[Task,JsonObject](page.children.toList)).mapE(parser(Left(parent)))
def parser(parent: Either[User,Submission]): Enumeratee[Task,JsonObject,Comment] = Enumeratee.flatMap[Task,JsonObject,Comment]{ obj =>
trait Extractor[T] {
def apply(o: JsonObject,name: String): Option[T]
}
implicit val str = new Extractor[String] {
override def apply(o: JsonObject,name: String): Option[String] = o(name).flatMap(_.asString)
}
implicit val int = new Extractor[Int] {
override def apply(o: JsonObject,name: String): Option[Int] = o(name).flatMap(_.asNumber.flatMap(_.toInt))
}
implicit def optStr = new Extractor[Option[String]] {
override def apply(o: JsonObject,name: String): Option[Option[String]] = o(name).map(_.asString)
}
implicit val bool = new Extractor[Boolean] {
override def apply(o: JsonObject,name: String): Option[Boolean] = o(name).flatMap(_.asBoolean)
}
def get[T](o: JsonObject,name: String)(implicit E: Extractor[T]) = E(o,name)
def parseReplies(replies: Option[JsonObject]): Enumerator[Task,Comment] =
replies.fold(Enumerator.empty[Task,Comment]){ replies=>
Enumerator.enumOne(replies).mapE(parser(parent))
}
// val comments = for {
// kind <- get[String](obj, "kind").toIndexedSeq
// data <- obj("data").flatMap(_.asObject).toSeq if kind == "Listing"
// before <- get[Option[String]](obj, "before")
// after <- get[Option[String]](obj, "after")
// _ <- get[Option[String]](obj, "modhash")
// replies <- obj("replies").map(_.asObject)
// children <- obj("children").flatMap(_.asArray)
// childRoot <- children.flatMap(_.asObject.toSeq)
// kind <- get[String](childRoot,"kind")
// child <- childRoot("data").flatMap(_.asObject) if kind != "more"
// id <- get[String](child, "id")
// author <- get[String](child, "author")
// body <- get[String](child, "body")
// bodyHtml <- get[String](child, "body_html")
// score <- get[Int](child, "score")
// ups <- get[Int](child, "ups")
// downs <- get[Int](child, "downs")
// } yield Comment(parent, id, FormattedText(body, bodyHtml), User(author), score, parseReplies(replies))
// Enumerator.enumIndexedSeq[Task,Comment](comments)
???
}
}

View File

@@ -5,4 +5,46 @@ package net.shimun.redstream.api.models
*/
object Parsing {
import java.net.URL
import shapeless._
trait Convert[I <: HList, O <: HList] { def apply(i: I): O }
object Convert extends LowPriorityConvertInstances {
implicit val convertHNil: Convert[HNil, HNil] = new Convert[HNil, HNil] {
def apply(i: HNil): HNil = i
}
implicit def convertHConsURL[T <: HList, TO <: HList](implicit
c: Convert[T, TO]
) = new Convert[String :: T, URL :: TO] {
def apply(i: String :: T): URL :: TO = new URL(i.head) :: c(i.tail)
}
implicit def convertHConsOptionalURL[T <: HList, TO <: HList](implicit
c: Convert[T, TO]
) = new Convert[Option[String] :: T, Option[URL] :: TO] {
def apply(i: Option[String] :: T): Option[URL] :: TO = i.head.map(new URL(_)) :: c(i.tail)
}
}
sealed class LowPriorityConvertInstances {
implicit def convertHCons[H, T <: HList, TO <: HList](implicit
c: Convert[T, TO]
): Convert[H :: T, H :: TO] = new Convert[H :: T, H :: TO] {
def apply(i: H :: T): H :: TO = i.head :: c(i.tail)
}
}
class PartiallyAppliedConvert[O <: HList] {
import Convert._
def apply[I <: HList](i: I)(implicit c: Convert[I, O]): O = c(i)
}
def convert[O <: HList]: PartiallyAppliedConvert[O] =
new PartiallyAppliedConvert[O]
}

View File

@@ -0,0 +1,138 @@
package net.shimun.redstream.api.models
import cats.Monad
import io.circe.JsonObject
import io.iteratee.{Enumeratee, Enumerator}
import monix.async.Task
import net.shimun.redstream.api._
/**
* Created by marvin on 4/6/16.
*/
case class Submission(parent: Either[User,Subreddit],title: String,id: String, author: User, domain: String, permalink: URL,self: Boolean, selfText: Option[FormattedText],thumbnail: Option[URL],url: Option[URL], nsfw: Boolean, commentCount: Int, score: Int) extends Rateable with Owned[Subreddit]{
override def ups: Int = score
def comments: Enumerator[Task, Comment] = ???
override def up(implicit userSession: Session, ds: DataSource): Task[_ <: Rateable] = vote(true).map{
case (200,_) => copy(score=score+1)
}
override def down(implicit userSession: Session, ds: DataSource): Task[_ <: Rateable] = vote(false).map{
case (200,_) => copy(score=score-1)
}
def sub = parent match {
case Right(sub) => Some(sub)
case _ => None
}
def profile = parent match {
case Left(profile) => Some(profile)
case _ => None
}
}
object Submission {
case class RawSubmission(
domain: String,
banned_by: Option[String],
subreddit: String,
selftext_html: Option[String],
selftext: Option[String],
likes: Option[String],
suggested_sort: Option[String],
//user_reports: List[UserReport],
secure_media: Option[String],
link_flair_text: Option[String],
id: String,
from_kind: Option[String],
gilded: Double,
archived: Option[Boolean],
clicked: Boolean,
report_reasons: Option[String],
author: String,
media: Option[String],
score: Double,
approved_by: Option[String],
over_18: Boolean,
hidden: Boolean,
num_comments: Int,
thumbnail: String,
subreddit_id: String,
hide_score: Boolean,
edited: Boolean,
link_flair_css_class: Option[String],
author_flair_css_class: Option[String],
downs: Int,
saved: Boolean,
removal_reason: Option[String],
stickied: Boolean,
from: Option[String],
is_self: Boolean,
from_id: Option[String],
permalink: String,
locked: Boolean,
name: String,
created: Long,
url: String,
author_flair_text: Option[String],
quarantine: Boolean,
title: String,
created_utc: Long,
distinguished: Option[Boolean],
//mod_reports: List[ModReport],
visited: Boolean,
num_reports: Option[Int],
ups: Int
)
def lift(raw: RawSubmission) = {
import shapeless._
import Parsing.convert
val rawGen = Generic[RawSubmission]
val gen = Generic[Submission]
//gen.from(convert[gen.Repr](rawGen.to(raw)))
}
def apply[F[_]](pages: Enumerator[F, Page],parent: Subreddit)(implicit F: Monad[F],rl: RedditLocation): Enumerator[F, Submission] =
pages.flatMap(page => Enumerator.enumList[F,JsonObject](page.children.toList)).mapE(parser[F](Right(parent)))
def apply[F[_]](pages: Enumerator[F, Page],parent: User)(implicit F: Monad[F],rl: RedditLocation): Enumerator[F, Submission] =
pages.flatMap(page => Enumerator.enumList[F,JsonObject](page.children.toList)).mapE(parser[F](Left(parent)))
//TODO: use circe + http://json2caseclass.cleverapps.io/ for full submission repr
import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
def parser[F[_]](parent: Either[User,Subreddit])(implicit F: Monad[F],rl: RedditLocation) = Enumeratee.flatMap[F,JsonObject,Submission]{ obj =>
Enumerator.enumList[F,Submission]((for {
postData <- obj("data").flatMap(_.asObject)
title <- postData("title").flatMap(_.asString)
id <- postData("id").flatMap(_.asString)
author <- postData("author").flatMap(_.asString)
domain <- postData("domain").flatMap(_.asString)
nsfw <- postData("over_18").flatMap(_.asBoolean)
comments <- postData("num_comments").flatMap(_.asNumber)
selftext <- postData("selftext").map(_.asString.filterNot(_ == ""))
selftextHtml <- postData("selftext_html").map(_.asString.filterNot(_ == ""))
url <- postData("url").map(_.asString)
link <- postData("permalink").flatMap(_.asString).map(p=>s"http${if(rl.secure) "s" else ""}://${rl.domain}$p")
isSelf <-postData("is_self").flatMap(_.asBoolean)
thumbnail <- postData("thumbnail").map(_.asString.filterNot(_ == ""))
score <- postData("score").flatMap(_.asNumber)
} yield Submission(parent,title,id, User(author), domain, link,isSelf,selftext.flatMap(plain=>selftextHtml.map(FormattedText(plain,_))),thumbnail.map(url => url),url.map(url => url), nsfw, comments.truncateToInt, score.truncateToInt)).toList)
}
object Common{
def unapplySeq(submission: Submission) = {
import submission._
Some((parent,title,id,author,domain,permalink,self,selfText,thumbnail,url,nsfw,commentCount,score))
}
}
}

View File

@@ -0,0 +1,14 @@
package net.shimun.redstream.api
/**
* Created by marvin on 4/6/16.
*/
package object models {
sealed trait Report //TODO: populate
case class UserReport() extends Report
case class ModReport() extends Report
}

View File

@@ -0,0 +1,52 @@
package net.shimun.redstream
import java.net.{URI, URLDecoder}
import cats.Monad
import io.iteratee.{Enumeratee, Iteratee, Module}
import monix.async.Task
import net.shimun.redstream.api.models.Submission
/**
* Created by marvin on 1/5/16.
*/
package object api {
type URL = java.net.URI
implicit def queryURL2URL(queryURL: QueryURL) = queryURL.toURL
implicit def URL2queryURL(url: URL) = {
val decode = URLDecoder.decode(_: String,"UTF_8")
QueryURL(url,url.getQuery.dropWhile(_ == '?').split("=").map(_.split("&").toSeq).collect{
case Seq(key,value) => decode(key) -> decode(value)
case Seq(key) => decode(key) -> ""
})
}
implicit def string2uri(str: String) = new URI(str)
implicit def TaskMonad = new Monad[Task] {
override def flatMap[A, B](fa: Task[A])(f: (A) => Task[B]): Task[B] = fa.flatMap(f)
override def pure[A](x: A): Task[A] = Task.now(x)
}
private[api] val taskModule = new Module[Task] {}
def subreddit(name: String)(implicit ds: DataSource) = Subreddit(name)
def user(name: String)(implicit source: DataSource) = User(name)
def test = {
implicit val j = DataSource.Default
import j._
subreddit("scalajs").posts.mapE(Enumeratee.take[Task,Submission](3)).run(Iteratee.foreach[Task,Submission]{ p =>
println(s"Page: $p")
}).runAsync
}
}

106
build.sbt Normal file
View File

@@ -0,0 +1,106 @@
import sbt.Keys._
name := "redstream"
resolvers in Scope.GlobalScope += Resolver.sonatypeRepo("snapshots")
libraryDependencies += "com.lihaoyi" %%% "utest" % "0.3.0" % "test"
testFrameworks += new TestFramework("utest.runner.Framework")
lazy val commonSettings = Seq{
version := "1.0"
scalaVersion := "2.11.8"
}
lazy val root = project.in(file("."))
.aggregate(apiJS, apiJVM)
.settings(commonSettings:_*)
lazy val IterateeVersion = "0.4.0-SNAPSHOT"
lazy val CirceVersion = "0.4.0-RC1"
lazy val MonixVersion = "2.0-M1"
lazy val ShapelessVersion = "2.3.0"
lazy val api = crossProject
.settings(commonSettings:_*)
.settings(
name := "api",
initialCommands in console :=
"""
|import net.shimun.redstream._
|import api._
|import io.iteratee._
|import scala.concurrent._
|import monix.async._
|implicit val ds = api.DataSource.Default
|import ds._
""".stripMargin.split("\n").mkString(";")
).jvmSettings(
libraryDependencies ++= Seq(
"io.circe" %% "circe-parser" % CirceVersion,
"io.circe" %% "circe-generic" % CirceVersion,
"io.iteratee" %% "iteratee-core" % IterateeVersion,
"io.monix" %% "monix-async" % MonixVersion,
"com.chuusai" %% "shapeless" % ShapelessVersion
)
).jsSettings(
libraryDependencies ++= Seq(
"io.circe" %%% "circe-parser" % CirceVersion,
"io.circe" %%% "circe-generic" % CirceVersion,
"io.iteratee" %%% "iteratee-core" % IterateeVersion,
"io.monix" %%% "monix-async" % MonixVersion,
"com.chuusai" %%% "shapeless" % ShapelessVersion,
"org.scala-js" %%% "scalajs-dom" % "0.8.0"
)
).enablePlugins(ScalaJSPlugin)
lazy val apiJS = api.js
lazy val apiJVM = api.jvm
lazy val client = project
.settings(commonSettings:_*)
.settings(
libraryDependencies ++= Seq(
"org.scala-js" %%% "scalajs-dom" % "0.8.0",
"com.github.japgolly.scalajs-react" %%% "core" % "0.10.4",
"com.github.japgolly.scalacss" %%% "ext-react" % "0.4.0",
"com.chuusai" %%% "shapeless" % "2.2.5"
),
persistLauncher := true,
jsDependencies += RuntimeDOM,
jsDependencies ++= Seq(
"org.webjars.bower" % "react" % "0.14.3"
/ "react-with-addons.js"
minified "react-with-addons.min.js"
commonJSName "React",
"org.webjars.bower" % "react" % "0.14.3"
/ "react-dom.js"
minified "react-dom.min.js"
dependsOn "react-with-addons.js"
commonJSName "ReactDOM")
)
.dependsOn(apiJS)
.dependsOn(`client-macros`)
.enablePlugins(ScalaJSPlugin)
lazy val `client-macros` = project
.settings(commonSettings:_*)
.settings(
libraryDependencies <+= scalaVersion("org.scala-lang" % "scala-compiler" % _)
)
.enablePlugins(ScalaJSPlugin)
skip in packageJSDependencies := false
//jsDependencies += "org.webjars" % "ractive" % "0.7.1" / "ractive.js" minified "ractive.min.js"
//which one ?

View File

@@ -0,0 +1,43 @@
package net.shimun.redstream.ui
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
object Mappable {
trait Mappable[T] {
def toMap(t: T): Map[String, Any]
def fromMap(map: Map[String, Any]): T
}
object Mappable {
implicit def materializeMappable[T]: Mappable[T] = macro materializeMappableImpl[T]
def materializeMappableImpl[T: c.WeakTypeTag](c: Context): c.Expr[Mappable[T]] = {
import c.universe._
val tpe = weakTypeOf[T]
val companion = tpe.typeSymbol.companion
val fields = tpe.decls.collectFirst {
case m: MethodSymbol if m.isPrimaryConstructor => m
}.get.paramLists.head
val (toMapParams, fromMapParams) = fields.map { field =>
val name = field.asTerm.name
val key = name.decodedName.toString
val returnType = tpe.decl(name).typeSignature
(q"$key -> t.$name", q"map($key).asInstanceOf[$returnType]")
}.unzip
c.Expr[Mappable[T]] { q"""
new Mappable[$tpe] {
def toMap(t: $tpe): Map[String, Any] = Map(..$toMapParams)
def fromMap(map: Map[String, Any]): $tpe = $companion(..$fromMapParams)
}
""" }
}
}
}

View File

@@ -0,0 +1,102 @@
package net.shimun.redstream.ui
import io.iteratee.{Enumeratee, Enumerator}
import monix.async.Task
import net.shimun.redstream.api._
import net.shimun.redstream.api.models.Submission
import shapeless.{Poly1, HList, LabelledGeneric}
import scala.util.matching.Regex
object Filter {
type PostType = String //TODO enum
sealed trait Filter {
def apply(post: Submission): Task[Boolean]
}
sealed trait TopLevelFilter extends Filter
case class Subreddits(subreddits: Seq[Subreddit]) extends TopLevelFilter {
override def apply(post: Submission): Task[Boolean] = Task.now(post.sub.fold(false)(s=>subreddits.find(_.name == s.name).isDefined))
}
case class Users(users: Seq[User]) extends TopLevelFilter {
override def apply(post: Submission): Task[Boolean] = Task.now(users.find(_.name == post.author.name).isDefined)
}
// case class PostMeta(attribute: String,valueRegex: Regex) extends Filter {
//
// override def apply(post: Submission): Task[Boolean] = Task.now{
// //TODO: use shapeless
// val gen: LabelledGeneric.Aux[Submission,HList] = LabelledGeneric[Submission]
// gen.to(post).map(new Poly1 {
// implicit def optCase[T](opt: Option[T]) = opt.fold("")(_.toString)
// implicit def anyCase[T](t: T) = t.toString
// })
// Mappable.materializeMappable[Submission].toMap(post).get(attribute)
// .map{
// case opt: Option[Any] => opt.fold(false)(value=>valueRegex.findFirstMatchIn(value.toString).isDefined)
// case value: Any => valueRegex.findFirstMatchIn(value.toString).isDefined
// }.getOrElse(false)
// }
// }
case class PostContent(regex: Option[Regex], words: Option[Seq[String]]) extends Filter {
override def apply(post: Submission): Task[Boolean] = {
val text = post.selfText.getOrElse(FormattedText.empty)
Task.now(
regex.fold(false)(_.findFirstMatchIn(text.plain).isDefined) ||
words.fold(false)(_.forall(text.plain.contains))
)
}
}
sealed trait Operator extends Filter
object Operator {
case class Not(condition: Filter) extends Operator {
override def apply(post: Submission): Task[Boolean] = condition(post).map(!_)
}
case class And(conditions: Filter*) extends Operator {
override def apply(post: Submission): Task[Boolean] =
Task.sequence(conditions.map(_(post))).map(_.forall(b=>b))
}
case class Or(conditions: Filter*) extends Operator {
override def apply(post: Submission): Task[Boolean] = {
def check(conditions: Seq[Filter]): Task[Boolean] = conditions match {
case head :: tail => head(post).flatMap{
case true => Task.now(true)
case _ => check(tail)
}
case _ => Task.now(false)
}
check(conditions)
}
}
}
object Filter{
def compile(top: TopLevelFilter,filters: Filter*)(implicit ds: DataSource): Enumerator[Task,Submission] = {
val src = top match {
case Subreddits(subs) => subs.foldLeft(Enumerator.empty[Task,Submission]){ (posts,sub) =>
posts.append(sub.posts) //TODO: mix
}
case Users(users) => users.foldLeft(Enumerator.empty[Task,Submission]){ (posts, user) =>
posts.append(user.posts) //TODO: mix
}
}
filters.foldLeft(src){ (filtered,filter) =>
filtered.mapE(Enumeratee.filterF(filter.apply))
}
}
}
}

View File

@@ -0,0 +1,36 @@
package net.shimun.redstream.ui
import monix.async.Task
import monix.execution.{UncaughtExceptionReporter, Scheduler}
import monix.execution.schedulers.TrampolineScheduler
import net.shimun.redstream.api._
import java.net.URI
import org.scalajs.dom
import scala.concurrent._
import scala.concurrent.{ExecutionContext, Future}
/**
* Created by marvin on 1/27/16.
*/
object HttpDataSource extends DataSource{
private val ds: DataSource = DataSource.Default
override def httpRequest(url: URL, method: String, data: Option[String], headers: Seq[(String, String)], auth: Option[(String, String)]): Task[(Int, String)] =
ds.httpRequest(url,method,data,headers,auth)
override implicit def executionContext: ExecutionContext = new ExecutionContext {
override def reportFailure(cause: Throwable): Unit = cause.printStackTrace()
override def execute(runnable: Runnable): Unit = dom.setTimeout(() => runnable.run(),0.0)
}
override def json(url: URL, auth: Option[(String, String)],callbackParameter: String = "jsonp"): Task[(Int, String)] = ds.json(url,auth,callbackParameter)
override implicit def scheduler: Scheduler = TrampolineScheduler(UncaughtExceptionReporter.LogExceptionsToStandardErr)
}

View File

@@ -0,0 +1,15 @@
package net.shimun.redstream.ui
import japgolly.scalajs.react
import japgolly.scalajs.react.{ReactElement, CompScope, ReactComponentC}
import monix.async.Task
import net.shimun.redstream.api.models.Submission
/**
* Created by marvin on 3/27/16.
*/
trait SubmissionBodySelector {
def apply(submission: Submission): Task[ReactElement]
}

View File

@@ -0,0 +1,49 @@
package net.shimun.redstream.ui
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom._
import japgolly.scalajs.react.vdom.prefix_<^._
import monix.async.{Callback => TaskCallback}
import net.shimun.redstream.api.{ApiSession, Session}
import net.shimun.redstream.ui.components._
import org.scalajs.dom
import scala.scalajs.js
import scala.scalajs.js.JSApp
import scala.scalajs.js.annotation.JSExport
import scalacss.Defaults._
import scalacss.ScalaCssReact._
/**
* Created by marvin on 3/27/16.
*/
@JSExport
object UI extends JSApp{
implicit val ds = HttpDataSource
@JSExport
def setSession(token: String,tokenType: String,expires: String,scope: String,refreshToken: String) = {
session = Some(ApiSession(token,tokenType,expires.toLong,scope,refreshToken))
}
var session: Option[Session] = None
@JSExport
override def main(): Unit = dom.setTimeout(() => delayed,100.0)
def delayed = {
val render = ReactDOM.render(_: ReactElement,dom.document.body)
style.Styles.addToDocument()
style.PostStyle.addToDocument()
style.NavigationStyles.addToDocument()
render(
Navigation.NavBar(Option(dom.document.location.hash.drop(1)).filterNot(_ == "").map((_,"hot")).getOrElse(("all","hot")))
)
}
}

View File

@@ -0,0 +1,55 @@
package net.shimun.redstream.ui
import io.iteratee.{Iteratee, Enumerator}
import monix.async.Task
import net.shimun.redstream.api._
import scala.concurrent.Promise
import scala.util._
/**
* Created by marvin on 4/5/16.
*/
object Util {
case class EnumeratorIterator[E](enum: Enumerator[Task,E],onValue: (E) => Task[Unit])(ds: DataSource){
import ds._
def promise[T] = {
val p = Promise[T]
((t: Try[T]) => t match {
case Success(v) => p.success(v)
case Failure(f) => p.failure(f)
},Task.fromFuture(p.future))
}
def iterate = enum.run(Iteratee.foreachM[Task,E](onValue))
}
import shapeless._
trait Convert[I <: HList, O <: HList] { def apply(i: I): O }
object Convert extends LowPriorityConvertInstances {
implicit val convertHNil: Convert[HNil, HNil] = new Convert[HNil, HNil] {
def apply(i: HNil): HNil = i
}
implicit def convertHConsURL[T <: HList, TO <: HList](implicit
c: Convert[T, TO]
): Convert[String :: T, URL :: TO] = new Convert[String :: T, URL :: TO] {
def apply(i: String :: T): URL :: TO = new URL(i.head) :: c(i.tail)
}
}
sealed class LowPriorityConvertInstances {
implicit def convertHCons[H, T <: HList, TO <: HList](implicit
c: Convert[T, TO]
): Convert[H :: T, H :: TO] = new Convert[H :: T, H :: TO] {
def apply(i: H :: T): H :: TO = i.head :: c(i.tail)
}
}
}

View File

@@ -0,0 +1,85 @@
package net.shimun.redstream.ui.components
import japgolly.scalajs.react._
import monix.async.Task
import net.shimun.redstream.ui.SubmissionBodySelector
import net.shimun.redstream.ui.style._
import org.scalajs.dom.html
import vdom._
import prefix_<^._
import Post._
import scalacss.Defaults._
import scalacss.ScalaCssReact._
object Navigation {
class NavbarBackend($: BackendScope[(String,String),(String,String,String)]){
def update(sub: (String,String,String)) =
$.setState(sub)//.flatMap(_ => $.forceUpdate)
def render(s: (String,String,String)) = {
val (sub,sorting,typed) = s
<.div(
NavigationStyles.navbar,
Header(
<.h2(s"/r/$sub"),
<.input(
^.ref := "input",
^.`type` := "text",
^.defaultValue := sub,
^.onChange ==> { (e: ReactEventI) =>
update(sub,sorting,e.target.value)
},
^.onKeyDown ==> { (e: ReactKeyboardEventI) =>
if(e.keyCode == 13){
val sub = e.target.value
update((sub,sorting,sub))
}else{
Callback.empty
}
}
),
SquareButton(
update((typed,sorting,typed)),
<.span(">")
),
<.div(
^.cls := "sorting",
(Seq("hot" -> true,"new" -> false,"rising" -> false,"controversial" -> false,"top" -> false).map{
case (k,v) => SquareButton(update((sub,k,typed)),
<.span(
^.cls := "btn" + (if(v) " active" else ""),
k.capitalize
)
): TagMod
})
)
),
Posts(PostsProps(sub,sorting)),
Footer()
).render
}
}
val NavBar = ReactComponentB[(String,String)]("Sub")
.initialState_P(s => (s._1,s._2,""))
.renderBackend[NavbarBackend]
.shouldComponentUpdate(s => s.nextProps != s.currentProps || s.nextState._1 != s.currentState._1 || s.nextState._2 != s.currentState._2)
.build
val Header = ReactComponentB[Unit]("Header")
.render_C(c =>
<.div(
NavigationStyles.header,
c
)
)
.buildU
val Footer = ReactComponentB[Unit]("Footer")
.render_C(c => <.div(c))
.buildU
}

View File

@@ -0,0 +1,434 @@
package net.shimun.redstream.ui
package components
import java.util.concurrent.TimeUnit
import cats.Monad
import cats.data.Xor
import io.circe.JsonObject
import io.iteratee.internal.Step
import io.iteratee.{Iteratee, Enumeratee, Enumerator}
import japgolly.scalajs.react.ReactComponentB._defaultBuildStep_noBackend
import japgolly.scalajs.react._
import monix.async.{Task,Callback => TaskCallback}
import monix.execution.Cancelable
import net.shimun.redstream.api.models.Submission
import net.shimun.redstream.ui.style.{PostStyle}
import org.scalajs
import org.scalajs.dom
import org.scalajs.dom.html
import org.scalajs.dom.raw.Element
import vdom._
import prefix_<^._
import net.shimun.redstream.api._
import scalacss.Defaults._
import scalacss.ScalaCssReact._
import scalajs.dom
import scala.concurrent.duration.Duration
import scala.util.Random
import io.circe.parser.{parse => parseJSON}
import HttpDataSource._
/**
* Created by marvin on 3/27/16.
*/
object Post {
import UI._
val Title = ReactComponentB[String]("Title")
.render(c => <.h2(^.cls := "textual",c.props))
.build
val Upvotes = ReactComponentB[Int]("Ups")
.render(c => <.span(^.title := "upvotes",^.cls := "upvotes numeric",c.props))
.build
val Header = ReactComponentB[Submission]("Header")
.render{ c =>
<.div(Title(c.props.title),Upvotes(c.props.ups))
}.build
val TextBody = ReactComponentB[FormattedText]("TextBody")
.render(c => <.div(c.props.plain)) //TODO: safe html
.build
val ImageBody = ReactComponentB[(URL,Thumbnail)]("ImageBody")
.render { c =>
c.props match {
case (url, thumbnail) =>
VerticalExpander(
(
(e: OnLoaded) =>
VerticallyScaledImage((Task.now(url.toString),thumbnail,500,e))
,500,600
)
)
}
}.build
val ErrorBody = ReactComponentB[Unit]("ErrorBody")
.render{ c =>
<.div(c.propsChildren)
}.buildU
object MediaBody {
def imageBody(url: ImageUrl,thumbnail: Thumbnail) =
VerticalExpander(
(
(e: OnLoaded) =>
VerticallyScaledImage((url,thumbnail,500,e))
,500,600
)
)
def multipleImages(urls: Task[Seq[ReactElement]],thumbnail: Thumbnail) =
VerticalSlider(urls,thumbnail.map(t=>imageBody(Task.now(t),None)).toSeq)
def videoBody(url: VideoUrl,thumbnail: Thumbnail,silent: Boolean = true) =
VerticalExpander(
(
(e: OnLoaded) =>
HeadlessVideo(
(url,true,true,(width,height) =>
//TODO scaler component
e(width,height),0,0),
ImageBody(("",thumbnail)) //TODO: prettier
),
500,600
)
)
val ImgurBody: ReactComponentC.ReqProps[(URL,Thumbnail),Unit,Unit,TopNode] = ReactComponentB[(URL,Thumbnail)]("ImgurBody")
.render { c =>
c.props match {
case (url, thumbnail) =>
import Imgur._
val image = imageBody(_: ImageUrl,thumbnail)
val video = videoBody(_: VideoUrl,thumbnail)
def multiple(host: String,url: String) = multipleImages(
json(url).map{
case (200,json) => parseJSON(json) match {
case Xor.Right(root) => (for {
data <- root.asObject.flatMap(_.apply("data").flatMap(_.asObject)).toSeq
image <- data("image").flatMap(_.asObject).toSeq
albumImages <- image("album_images").flatMap(_.asObject).toSeq
images <- albumImages("images").flatMap(_.asArray.map(_.toSeq)).toSeq
albumImage <- images.flatMap(_.asObject)
title <- albumImage("title").flatMap(_.asString)
hash <- albumImage("hash").flatMap(_.asString)
ext <- albumImage("ext").flatMap(_.asString)
} yield (s"$host/$hash.$ext" -> Some(title)))
case _ => throw new Exception("Malformed json")
}
}.map(_.map{
case (url,title) =>
ImgurBody((url,None))
})
,thumbnail)
println(s"$url g:${gallery.unapplySeq(url.toString)} a:${album.unapplySeq(url.toString)}")
url.toString match {
case gallery(host,id) if host != null && id != null => ErrorBody(<.div("Failed to display Imgur ",<.a(^.href := url.toString,^.target := "_blank","Gallery")))//multiple(host,s"$host/gallery/$id.json") //TODO: no jsonp available
case album(host,id) if host != null && id != null => ErrorBody(<.div("Failed to display Imgur ",<.a(^.href := url.toString,^.target := "_blank","Album"))) //multiple(host,s"$host/album/$id.json") //TODO: suitable api
case regular(host,id,"gifv" | "gif" | "mp4" | "webm") if host != null && id != null => video(Task.now(Seq(s"$host/$id").flatMap(p=>Seq("mp4","webm").map(f=>s"$p.$f" -> f))))
case regular(host,id,format) if host != null && id != null => image(Task.now(s"$host/$id.${Option(format).getOrElse("jpg")}"))
case _ => ErrorBody(<.div("Failed to display Imgur ",<.a(^.href := url.toString,^.target := "_blank","Image")))
}
}
}.build
object Imgur {
val regular = """(.*imgur.com)\/(\w{7})\.?(.{3,4})?""".r.unanchored
val gallery = """(.*imgur.com)\/gallery/(\w{5})""".r.unanchored
val album = """(.*imgur.com)\/(?:a|album)/(\w{5})""".r.unanchored
}
val GfycatBody = ReactComponentB[(URL,Thumbnail)]("GfycatBody")
.render { c =>
c.props match {
case (url, thumbnail) =>
import Gfycat._
val hosts = Map(
"webm" -> "fat.gfycat.com",
"mp4" -> "giant.gfycat.com"
)
val video = videoBody(_: VideoUrl,thumbnail)
url.toString match {
case regular(host,id,_) if host != null && id != null => video(
json(new URL(s"https://gfycat.com/cajax/get/$id"),callbackParameter="callback").map{
case (200,json) => parseJSON(json) match {
case Xor.Right(root) => (for {
gfyItem <- root.asObject.flatMap(_.apply("gfyItem").flatMap(_.asObject))
mp4 <- gfyItem("mp4Url").flatMap(_.asString)
webm <- gfyItem("webmUrl").flatMap(_.asString)
} yield Seq(mp4 -> "mp4",webm -> "webm")).toSeq.flatten
case _ => throw new Exception("Malformed json")
}
}
)
case _ => ErrorBody("Failed to display Gfycat image")
}
}
}.build
object Gfycat {
val regular = """(https?://gfycat\.com)/(.+)\.?(gif)?""".r.unanchored
}
}
val LinkBody = ReactComponentB[(URL,Thumbnail)]("LinkBody")
.render{ c =>
c.props match {
case (link, thumbnail) => <.a(
^.href := link.toString,
<.p(link.toString),
thumbnail.filter(_.matches("""^([a-z][a-z0-9\*\-\.]*):\/\/(?:(?:(?:[\w\.\-\+!$&'\(\)*\+,;=]|%[0-9a-f]{2})+:)*(?:[\w\.\-\+%!$&'\(\)*\+,;=]|%[0-9a-f]{2})+@)?(?:(?:[a-z0-9\-\.]|%[0-9a-f]{2})+|(?:\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\]))(?::[0-9]+)?(?:[\/|\?](?:[\w#!:\.\?\+=&@!$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2})*)?$""")).fold(Seq.empty[TagMod]){ thumbnail =>
Seq{
VerticallyScaledImage((Task.now(thumbnail),None,350,(_,_) => Callback.empty))
}
}
)
}
}.build
val PlaceholderBody = ReactComponentB[Submission]("PlaceholderBody")
.render(c =>
<.img(^.src := "/img/loading.svg",^.alt := "Loading")//ScaledImage((c.props.toString,400,350))
).build
val Footer = ReactComponentB[Submission]("Footer")
.render{ c =>
val post = c.props
<.div(
^.cls := "footer",
session.fold(Seq.empty[TagMod]){ implicit session =>
Seq(
SquareButton(runTask(post.up)(_ => Callback.TODO("rerender votes")),<.span("Up")),
SquareButton(runTask(post.down)(_ => Callback.TODO("rerender votes")),<.span("Down"))
)
},
<.span(^.cls := "reddit-link",
<.a(^.href := post.permalink.toString,^.target := "_blank","Orginal")
)
)
}.build
// def apply(submission: Submission)(implicit sel: SubmissionBodySelector = basicBodySelector): Task[ReactElement] = sel(submission).map{ body =>
// ReactComponentB[Submission]("Post")
// .render{c =>
// val post = c.props
// <.div(
// ^.cls := "post",
// Header(post),
// <.div(^.cls := "postbody",body),
// Footer(post)
// )
// }.build.apply(submission)
// }
class PostBackend($: BackendScope[(Submission,SubmissionBodySelector),(Submission,ReactElement)]){
def load = $.props.flatMap{
case (post,sel) =>
runTask(sel(post))(body=>$.setState((post,body)))
}
def render(s: (Submission,ReactElement)) = {
val (post,body) = s
<.div(
^.cls := "post",
PostStyle.single,
Header(post),
<.div(
^.cls := "postbody",
PostStyle.body,
body
),
Footer(post)
)
}
}
val SinglePost = ReactComponentB[(Submission,SubmissionBodySelector)]("Post")
.initialState_P[(Submission,ReactElement)](p=>(p._1,PlaceholderBody(p._1)))
.renderBackend[PostBackend]
.componentDidMount(_.backend.load)
.build
val basicBodySelector = new SubmissionBodySelector {
val imgur = """^https?:\/\/(\w+\.)?imgur.com\/""".r
override def apply(submission: Submission): Task[ReactElement] = (submission match {
case Submission(_,_,_,_,_,_,_,_,thumbnail,Some(url),_,_,_) if imgur.findFirstIn(url.toString).isDefined => Task.now(MediaBody.ImgurBody((url,thumbnail.map(_.toString))))
case Submission(_,_,_,_,_,_,_,_,thumbnail,Some(url),_,_,_) if MediaBody.Gfycat.regular.findFirstIn(url.toString).isDefined => Task.now(MediaBody.GfycatBody((url,thumbnail.map(_.toString))))
case Submission(_,_,_,_,_,_,_,_,thumbnail,Some(url),_,_,_) if url.toString.matches("(je?pg|png|gif)$") => Task.now(ImageBody((url,thumbnail.map(_.toString))))
case Submission(_,_,_,_,_,_,true,Some(text),_,_,_,_,_) => Task.now(TextBody(text))
case Submission(_,_,_,_,_,_,true,_,_,_,_,_,_) => Task.now(TextBody(FormattedText.empty))
case Submission(_,_,_,_,_,_,false,_,thumbnail,Some(url),_,_,_) => Task.now(LinkBody((url,thumbnail.map(_.toString))))
case Submission(_,_,_,_,_,link,_,_,thumbnail,_,_,_,_) => Task.now(LinkBody((link,thumbnail.map(_.toString))))
})//.delayExecution(Duration(Random.nextInt(10),TimeUnit.SECONDS))
}
case class PostsProps(
sub: String,//posts: Enumerator[Task,Submission],
sorting: String = "hot",
nsfw: Boolean = false,
bodyFactory: SubmissionBodySelector = basicBodySelector
)
case class PostsState(page: Option[Page] = None,
posts: Vector[ReactElement] = Vector.empty,
state: PostsState.State = PostsState.Ready
)
object PostsState {
sealed trait State
case object Loading extends State
case object Ready extends State
case class Error(t: Throwable) extends State
}
class PostsBackend(val $: BackendScope[PostsProps,PostsState]) {
def loadNext = load($.props,$.state)
def load(props: CallbackTo[PostsProps],state: CallbackTo[PostsState]): Callback = props.flatMap{
case PostsProps(sub,sorting,nsfw,bf) =>
def buildState(s: PostsState,enum: Enumerator[Task,Page],n: Int = 1) = enum.mapE(Enumeratee.take(n)).reducedK(s){ (state,page) =>
Submission(Enumerator.enumOne[Task,Page](page),Subreddit(sub)).map(s=>(if(!nsfw && s.nsfw){
<.div(
PostStyle.nsfw,
SinglePost((s,bf))
): ReactElement
}else{
SinglePost((s,bf)): ReactElement
})).run(Iteratee.consume).map(posts => state.copy(posts=state.posts++posts,page=Some(page)))
}
state.flatMap{
case s@PostsState(Some(page),_,_) => CallbackTo(buildState(s,page.next)) << $.setState(s.copy(state = PostsState.Loading))
case s@PostsState(None,_,_) => CallbackTo(buildState(s,Subreddit(sub,sorting).pages)) << $.setState(s.copy(state = PostsState.Loading))
}.flatMap{ enum =>
CallbackTo[Unit]{
enum.run(Iteratee.foreachM{ s =>
Task.create[Unit]{ (reg,_) =>
$.setState(s.copy(state = PostsState.Ready),Callback(reg.onSuccess(()))).runNow()
new Cancelable {
override def cancel(): Unit = ()
}
}
}).runAsync.failed.map(t => $.modState(_.copy(state = PostsState.Error(t))).runNow())
}
}
}
//
// def load2(props: CallbackTo[PostsProps],state: CallbackTo[PostsState],n: Int = 50): Callback = props.flatMap{
// case PostsProps(sub,sorting,bf) =>
// state.map{
// case s@PostsState(_,Some(next),_) => s
// case s@PostsState(_,None,_) =>
// val next = Subreddit(sub,sorting).posts.map(s=>SinglePost((s,bf))).nextBatched(50) //TODO: Iterator for Enumerator
// s.copy(next = Some(next))
// }.flatMap{
// case s@PostsState(_,Some(next),_) =>
// runTask(next(n))(posts => Callback.log(posts.toString()) >> $.setState(s.copy(posts=s.posts ++ posts)))
// case _ => Callback.log("Failed next")
// }
// }
def reset: Callback = $.modState(s => PostsState())
def render(s: PostsState,p: PostsProps) = {
<.ul(
PostStyle.listing,
^.ref := "posts",
^.cls := "posts",
s.posts.dropRight(1).map{ p =>
<.li(
PostStyle.item,
p
)
},
if(s.posts.size == 1) None: Option[TagMod] else s.posts.lastOption.map[TagMod]{ p =>
<.li(
PostStyle.item,
^.ref := "last",
p
)
}
,
<.li(
<.div(
s.state match {
case PostsState.Loading =>
Seq[TagMod](
^.backgroundColor := "blue",
^.height := 50
)
case PostsState.Ready =>
Seq[TagMod](
^.backgroundColor := "yellow",
^.onMouseEnter ==> {(e: ReactEvent) => loadNext},
^.height := 50,
SquareButton(loadNext,<.a(^.href := "#","load more"))
)
case PostsState.Error(t) =>
Seq[TagMod](
^.backgroundColor := "red",
^.onMouseEnter ==> {(e: ReactEvent) => loadNext},
^.height := 50,
SquareButton(loadNext,<.a(^.href := "#","retry"))
)
}
)
) //Only display if there where previous posts
).render
}
}
val Posts = ReactComponentB[PostsProps]("Posts")
.initialState_P(s=>PostsState())
.renderBackend[PostsBackend]
.componentDidMount{ c =>
c.backend.loadNext
}
.componentWillReceiveProps(p=>
p.component.backend.reset.flatMap(_ => p.component.backend.load(CallbackTo(p.nextProps),CallbackTo(PostsState())))
)
.componentDidUpdate{f =>
import f._
Callback{
$.refs[dom.Element]("last").toOption.map(_.getDOMNode()).foreach{ elem =>
def loop(first:Boolean = true): Callback = {
def loopFurther = $.backend.$.state.flatMap{ s =>
val rightPage = (for{
prev <- $.state.page
cur <- s.page
} yield (prev.url == cur.url)).getOrElse(false)
s.state match {
case PostsState.Loading | PostsState.Error(_) => Callback.empty
case _ if rightPage => Callback(dom.setTimeout(() => loop(false).runNow(),1000.0))
case _ => Callback.empty
}
}
(if(scrolledIn(elem) && !first) f.$.backend.loadNext
else loopFurther)
}
(Callback.log(s"waiting for scroll ${$.state.page.map(_.url)}") >> loop()).runNow()
}
}
}
.build
}

View File

@@ -0,0 +1,292 @@
package net.shimun.redstream.ui
import java.util.concurrent.TimeUnit
import japgolly.scalajs.react._
import monix.async.Task
import monix.async.{Task,Callback => TaskCallback}
import japgolly.scalajs.react.vdom._
import net.shimun.redstream.ui.style.Styles
import org.scalajs.dom
import prefix_<^._
import scalacss.ScalaCssReact._
import scala.concurrent.duration.FiniteDuration
import scalacss.Media.Height
import HttpDataSource._
package object components {
type Width = Int
type Height = Int
type Thumbnail = Option[String]
type OnLoaded = (Width,Height) => Callback
type ImageUrl = Task[String]
type VideoUrl = Task[Seq[(String,String)]]
type Silent = Boolean
type Autostart = Boolean
def runTask[T](task: Task[T])(f: T => Callback): Callback = Callback{
task.runAsync(new TaskCallback[T] {
override def onError(ex: Throwable): Unit = throw ex
override def onSuccess(value: T): Unit = f(value).runNow()
})
}
val SquareButton = ReactComponentB[Callback]("SqButton")
.render{ c =>
<.div(Styles.button,^.onClick ==> ((e: ReactEvent) => {
e.preventDefault()
c.props
}),c.propsChildren)
}.build
class VerticallyScaledImageBackend($: BackendScope[(ImageUrl,Thumbnail,Width,OnLoaded),(Option[String],Boolean,Height)]){
def reset: Callback = $.props.flatMap(p=>$.setState(VerticallyScaledImageBackend.reset(p)))
def loadImage = $.props.flatMap{
case (url,_,dw,_) =>
runTask(url)(url => $.modState(_.copy(_1 = Some(url))))
}
def scale(width: Width,height: Height,thumbnail: Boolean = false) = $.props.flatMap{
case (_,_,dw,_) =>
val h = (dw.toDouble/width*height).toInt
$.modState{
case s@ (_,wasThumbnail,_) => s.copy(_2 = wasThumbnail == thumbnail,_3 = h)
} >> CallbackTo(h)
}
def render(s: (Option[String],Boolean,Height),p: (ImageUrl,Thumbnail,Width,OnLoaded)) = {
val (url,showThumbnail,height) = s
val (_,thumbOpt,width,onLoaded) = p
//val onLoaded = (_:Int,_:Int) => Callback.empty
def wh = if(width > 0 && height > 0){
Seq[TagMod](
"width".reactAttr := width,
"height".reactAttr := height
)
}else Seq("width".reactAttr := p._3)
<.div(
thumbOpt.filter(
_.matches("""^([a-z][a-z0-9\*\-\.]*):\/\/(?:(?:(?:[\w\.\-\+!$&'\(\)*\+,;=]|%[0-9a-f]{2})+:)*(?:[\w\.\-\+%!$&'\(\)*\+,;=]|%[0-9a-f]{2})+@)?(?:(?:[a-z0-9\-\.]|%[0-9a-f]{2})+|(?:\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\]))(?::[0-9]+)?(?:[\/|\?](?:[\w#!:\.\?\+=&@!$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2})*)?$""")
).fold(Seq.empty[TagMod]){ thumbnail =>
Seq[TagMod](
<.img(
wh,
if(showThumbnail) ^.display.block else ^.display.none,
^.cls := "thumbnail",
^.onLoad ==> {(e: ReactEventH) => scale(e.target.offsetWidth.toInt,e.target.offsetHeight.toInt,true).void},
^.onError ==> {(_: ReactEvent) => Callback.warn("Failed to retrieve thumbnail",thumbnail)},
^.src := thumbnail
)
)
},
url.fold(Seq.empty[TagMod]){ url =>
Seq(
<.img(
wh,
^.cls := "orginal",
if(!showThumbnail) ^.display.block else ^.display.none,
^.onLoad ==> {(e: ReactEventH) =>
val img = e.target.asInstanceOf[dom.html.Image]
val wh @ (width,height) = (img.naturalWidth,img.naturalHeight)
scale(width,height,false).flatMap{ height =>
onLoaded(width,height)
}// >> Callback.log(wh.toString())
},
^.onError ==> {(_: ReactEvent) => onLoaded(width,height) >> Callback.warn("Failed to retrieve image",url)},
^.src := url
)
)
}
).render
}
}
object VerticallyScaledImageBackend {
def reset(p : (ImageUrl,Thumbnail,Width,OnLoaded)) = p match {
case (url,thumb,_,_) => (None: Option[String],thumb.isDefined,0)
}
}
val VerticallyScaledImage = ReactComponentB[(ImageUrl,Thumbnail,Width,OnLoaded)]("VerticallyScaledImage")
.initialState_P{println("init"); VerticallyScaledImageBackend.reset}
.renderBackend[VerticallyScaledImageBackend]
.componentDidMount(_.backend.loadImage)
// .componentWillReceiveProps(f=>f.$.backend.reset >> f.$.backend.loadImage)
.build
class VerticalExpanderBackend($: BackendScope[(OnLoaded => ReactNode,Width,Height),(Boolean,Option[Boolean])]){
def reset = $.props.flatMap(s => update(false,None))
def update(expanded: Boolean,needsExpander: Option[Boolean]) = $.setState((expanded,needsExpander))
def toggle = $.modState(s=>(!s._1,s._2))
def render(s: (Boolean,Option[Boolean]),p: (OnLoaded => ReactNode,Width,Height),c: PropsChildren) = {
val (expanded,needsExpander) = s
val (component,width,maxHeight) = p
//println(needsExpander.map(_ => maxHeight).filterNot(_ => expanded))
<.div(
LimitedDimensions(
(Some(width),Some(maxHeight).filterNot(_ => expanded)),
component(
(width,height) => {
update(expanded,Some(height >= maxHeight))// >> Callback.log(s"$height > $maxHeight = ${height >= maxHeight}")
})
),
if(!expanded && needsExpander.getOrElse(false)) Seq(
c
) else Seq.empty[TagMod]
).render
}
}
val VerticalExpander = ReactComponentB[(OnLoaded => ReactNode,Width,Height)]("VerticalExpander")
.initialState((false,None: Option[Boolean]))
.renderBackend[VerticalExpanderBackend]
.build
val LimitedDimensions = ReactComponentB[(Option[Width],Option[Height])]("LimitedDimensions")
.render{ c =>
val (width,height) = c.props
<.div(
width.toSeq.flatMap{ w =>
Seq(
^.maxWidth := w,
^.overflowX.hidden
)
},
height.toSeq.flatMap{ h =>
Seq(
^.maxHeight := h,
^.overflowY.hidden
)
},
c.propsChildren
)
}
.build
class VerticalSliderBackend($: BackendScope[Task[Seq[ReactElement]],(Option[Seq[ReactElement]],Int)]){
def load = $.props.flatMap(runTask(_)(items => Callback.log(items) >> $.setState((Some(items),0))))
def render(s: (Option[Seq[ReactElement]],Int),c: PropsChildren) = {
val (items,current) = s
<.div(
items.filterNot(_.isEmpty).fold(c.render){ items =>
items.applyOrElse(current,(_:Int) => c.render)
}
)
}
}
val VerticalSlider = ReactComponentB[Task[Seq[ReactElement]]]("VerticalSlider")
.initialState((None: Option[Seq[ReactElement]],0))
.renderBackend[VerticalSliderBackend]
.componentDidMount(_.backend.load)
.build
class HeadlessVideoBackend($: BackendScope[(VideoUrl,Silent,Autostart,OnLoaded,Width,Height),(Option[Seq[(String,String)]],Boolean)]) {
def update(url: Option[Seq[(String,String)]],play: Boolean) = $.setState((url,play))
def loadVideo(props: CallbackTo[(VideoUrl,Silent,Autostart,OnLoaded,Width,Height)] = $.props) = props.flatMap{
case (url,_,start,_,_,_) =>
runTask(url)(url => $.modState(_.copy(_1 = Some(url),_2 = false))) >> (if(start) {
$.state.flatMap{
case (_,playing) => if(!playing) togglePlay else Callback.empty
}
}else Callback.empty)
}
def togglePlay = $.modState{ s=>
val next = s.copy(_2 = !s._2)
$.refs[dom.html.Video]("video").map(_.getDOMNode()).foreach{ v =>
if(next._2){
v.play()
}else{
v.pause()
}
}
next
}
def render(s: (Option[Seq[(String,String)]],Boolean), p: (VideoUrl,Silent,Autostart,OnLoaded,Width,Height), c: PropsChildren) = {
val (url,play) = s
val (_,silent,_,onLoaded,width,height) = p
<.div(
^.onClick ==> {(_: ReactEvent) => togglePlay},
url.fold(c: TagMod){ urls =>
<.video(
^.preload := "metadata",
^.muted := silent,
^.loop := true,
^.ref := "video",
if(width > 0) Seq(^.width := width) else Seq.empty[TagMod],
if(height > 0) Seq(^.height := height) else Seq.empty[TagMod],
^.onLoad ==> {(e: SyntheticEvent[dom.html.Video]) =>
val video = e.target
val (width,height) = (video.videoWidth,video.videoHeight)
onLoaded(width,height)
},
urls.map{
case (url,format) => <.source(
^.src := url,
^.`type` := s"video/$format"
)
}
)
}
)
}
}
val HeadlessVideo = ReactComponentB[(VideoUrl,Silent,Autostart,OnLoaded,Width,Height)]("HeadlessVideo")
.initialState((None: Option[Seq[(String,String)]],false))
.renderBackend[HeadlessVideoBackend]
.componentDidMount(_.backend.loadVideo())
.componentWillReceiveProps(s=>s.$.backend.loadVideo(CallbackTo(s.nextProps)))
.build
/*
* function isScrolledIntoView(elem)
{
var $elem = $(elem);
var $window = $(window);
var docViewTop = $window.scrollTop();
var docViewBottom = docViewTop + $window.height();
var elemTop = $elem.offset().top;
var elemBottom = elemTop + $elem.height();
return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
}
* */
def scrolledIn(elem: dom.Element,parent: dom.Element = dom.document.body) = {
val docViewTop = parent.scrollTop
val docViewBottom = docViewTop + parent.clientHeight
val elemTop = elem.getBoundingClientRect().top
val elemBottom = elemTop + elem.clientHeight
println(((elemBottom <= docViewTop) , (elemTop <= docViewBottom) , (elemBottom <= docViewBottom) , (elemTop <= docViewTop)))
((elemBottom <= docViewTop) && (elemTop <= docViewBottom) && (elemBottom <= docViewBottom) && (elemTop <= docViewTop))
}
}

View File

@@ -0,0 +1,20 @@
package net.shimun.redstream.ui.style
import scalacss.ClassNameHint
import scalacss.Defaults._
object NavigationStyles extends StyleSheet.Inline{
import dsl._
val header = style(
width(100 %%)
)
val navbar = style(
)
}

View File

@@ -0,0 +1,53 @@
package net.shimun.redstream.ui.style
import scalacss.{Length, ClassNameHint}
import scalacss.Defaults._
import scalacss.DslBase.DslNum
object PostStyle extends StyleSheet.Inline{
import dsl._
val verticallyCentered = mixin(
marginLeft(auto),
marginRight(auto)
)
def verticalLimit[N](limit: Length[N]) = mixin(
maxWidth(limit),
overflowX.hidden
)
def horizontalLimit[N](limit: Length[N]) = mixin(
maxHeight(limit),
overflowY.hidden
)
val listing = style(
verticallyCentered,
overflowY.visible,
listStyleType := "none",
width(530 px)
)
val single = style(
verticallyCentered,
width(520 px), //TODO: mobile
backgroundColor.lightblue
//maxHeight(600 px)
)
val item = style(
marginBottom(10 px)
)
val nsfw = style(
backgroundColor.red
)
val body = style(
verticallyCentered,
verticalLimit(500 px)
)
}

View File

@@ -0,0 +1,16 @@
package net.shimun.redstream.ui.style
import scalacss.ClassNameHint
import scalacss.Defaults._
object Styles extends StyleSheet.Inline{
import dsl._
val button = style(
addClassNames("btn","sqbtn")
)
}

1
project/build.properties Normal file
View File

@@ -0,0 +1 @@
sbt.version = 0.13.8

3
project/plugins.sbt Normal file
View File

@@ -0,0 +1,3 @@
logLevel := Level.Warn
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.8")

10
readme.md Normal file
View File

@@ -0,0 +1,10 @@
Stream subreddits like 9gag
Inspired by 9gag, scrolldit
API:
https://www.reddit.com/r/<sub>.json
https://www.reddit.com/dev/api