initial commit
This commit is contained in:
@@ -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 = ()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
@@ -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
|
||||
|
142
api/shared/src/main/scala/net/shimun/redstream/api/Page.scala
Normal file
142
api/shared/src/main/scala/net/shimun/redstream/api/Page.scala
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
@@ -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
|
||||
}
|
@@ -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")
|
||||
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
???
|
||||
}
|
||||
|
||||
}
|
@@ -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]
|
||||
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -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
|
||||
|
||||
}
|
@@ -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
106
build.sbt
Normal 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 ?
|
||||
|
||||
|
43
client-macros/src/main/scala/Mappable.scala
Normal file
43
client-macros/src/main/scala/Mappable.scala
Normal 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)
|
||||
}
|
||||
""" }
|
||||
}
|
||||
}
|
||||
}
|
102
client/src/main/scala/net/shimun/redstream/ui/Filter.scala
Normal file
102
client/src/main/scala/net/shimun/redstream/ui/Filter.scala
Normal 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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -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)
|
||||
}
|
@@ -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]
|
||||
|
||||
}
|
49
client/src/main/scala/net/shimun/redstream/ui/UI.scala
Normal file
49
client/src/main/scala/net/shimun/redstream/ui/UI.scala
Normal 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")))
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
55
client/src/main/scala/net/shimun/redstream/ui/Util.scala
Normal file
55
client/src/main/scala/net/shimun/redstream/ui/Util.scala
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -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
|
||||
|
||||
}
|
@@ -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
|
||||
|
||||
}
|
@@ -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))
|
||||
}
|
||||
|
||||
}
|
@@ -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(
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
@@ -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)
|
||||
)
|
||||
|
||||
}
|
@@ -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
1
project/build.properties
Normal file
@@ -0,0 +1 @@
|
||||
sbt.version = 0.13.8
|
3
project/plugins.sbt
Normal file
3
project/plugins.sbt
Normal file
@@ -0,0 +1,3 @@
|
||||
logLevel := Level.Warn
|
||||
|
||||
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.8")
|
Reference in New Issue
Block a user