From 6d368ec20b7fef27409a0c768360e85f7499ac55 Mon Sep 17 00:00:00 2001 From: shim_ Date: Fri, 27 May 2016 09:16:34 +0200 Subject: [PATCH] initial commit --- .../net/shimun/redstream/api/DataSource.scala | 84 ++++ .../net/shimun/redstream/api/JsonParser.scala | 12 + .../net/shimun/redstream/api/UrlEncoder.scala | 14 + .../net/shimun/redstream/api/DataSource.scala | 50 ++ .../net/shimun/redstream/api/JsonParser.scala | 12 + .../net/shimun/redstream/api/UrlEncoder.scala | 10 + .../net/shimun/redstream/api/DataSource.scala | 57 +++ .../scala/net/shimun/redstream/api/Page.scala | 142 ++++++ .../net/shimun/redstream/api/QueryURL.scala | 21 + .../net/shimun/redstream/api/Session.scala | 33 ++ .../net/shimun/redstream/api/Subreddit.scala | 53 +++ .../shimun/redstream/api/models/Comment.scala | 86 ++++ .../shimun/redstream/api/models/Parsing.scala | 42 ++ .../redstream/api/models/Submission.scala | 138 ++++++ .../shimun/redstream/api/models/package.scala | 14 + .../net/shimun/redstream/api/package.scala | 52 +++ build.sbt | 106 +++++ client-macros/src/main/scala/Mappable.scala | 43 ++ .../net/shimun/redstream/ui/Filter.scala | 102 ++++ .../shimun/redstream/ui/HttpDataScource.scala | 36 ++ .../redstream/ui/SubmissionBodySelector.scala | 15 + .../scala/net/shimun/redstream/ui/UI.scala | 49 ++ .../scala/net/shimun/redstream/ui/Util.scala | 55 +++ .../redstream/ui/components/Navigation.scala | 85 ++++ .../shimun/redstream/ui/components/Post.scala | 434 ++++++++++++++++++ .../redstream/ui/components/package.scala | 292 ++++++++++++ .../redstream/ui/style/NavigationStyles.scala | 20 + .../net/shimun/redstream/ui/style/Post.scala | 53 +++ .../shimun/redstream/ui/style/Styles.scala | 16 + project/build.properties | 1 + project/plugins.sbt | 3 + readme.md | 10 + 32 files changed, 2140 insertions(+) create mode 100644 api/js/src/main/scala/net/shimun/redstream/api/DataSource.scala create mode 100644 api/js/src/main/scala/net/shimun/redstream/api/JsonParser.scala create mode 100644 api/js/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala create mode 100644 api/jvm/src/main/scala/net/shimun/redstream/api/DataSource.scala create mode 100644 api/jvm/src/main/scala/net/shimun/redstream/api/JsonParser.scala create mode 100644 api/jvm/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/DataSource.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/Page.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/QueryURL.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/Session.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/Subreddit.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/models/Comment.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/models/Submission.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/models/package.scala create mode 100644 api/shared/src/main/scala/net/shimun/redstream/api/package.scala create mode 100644 build.sbt create mode 100644 client-macros/src/main/scala/Mappable.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/Filter.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/HttpDataScource.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/SubmissionBodySelector.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/UI.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/Util.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/components/Navigation.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/components/Post.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/components/package.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/style/NavigationStyles.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/style/Post.scala create mode 100644 client/src/main/scala/net/shimun/redstream/ui/style/Styles.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 readme.md diff --git a/api/js/src/main/scala/net/shimun/redstream/api/DataSource.scala b/api/js/src/main/scala/net/shimun/redstream/api/DataSource.scala new file mode 100644 index 0000000..2602f3d --- /dev/null +++ b/api/js/src/main/scala/net/shimun/redstream/api/DataSource.scala @@ -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 = () + } + } + + } +} diff --git a/api/js/src/main/scala/net/shimun/redstream/api/JsonParser.scala b/api/js/src/main/scala/net/shimun/redstream/api/JsonParser.scala new file mode 100644 index 0000000..4c08a2c --- /dev/null +++ b/api/js/src/main/scala/net/shimun/redstream/api/JsonParser.scala @@ -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) + +} diff --git a/api/js/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala b/api/js/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala new file mode 100644 index 0000000..e12c725 --- /dev/null +++ b/api/js/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala @@ -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) + +} diff --git a/api/jvm/src/main/scala/net/shimun/redstream/api/DataSource.scala b/api/jvm/src/main/scala/net/shimun/redstream/api/DataSource.scala new file mode 100644 index 0000000..12ab08d --- /dev/null +++ b/api/jvm/src/main/scala/net/shimun/redstream/api/DataSource.scala @@ -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) + + } +} diff --git a/api/jvm/src/main/scala/net/shimun/redstream/api/JsonParser.scala b/api/jvm/src/main/scala/net/shimun/redstream/api/JsonParser.scala new file mode 100644 index 0000000..f4e51a6 --- /dev/null +++ b/api/jvm/src/main/scala/net/shimun/redstream/api/JsonParser.scala @@ -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) + +} diff --git a/api/jvm/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala b/api/jvm/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala new file mode 100644 index 0000000..63c32e6 --- /dev/null +++ b/api/jvm/src/main/scala/net/shimun/redstream/api/UrlEncoder.scala @@ -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) + +} diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/DataSource.scala b/api/shared/src/main/scala/net/shimun/redstream/api/DataSource.scala new file mode 100644 index 0000000..57b66ef --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/DataSource.scala @@ -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 + diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/Page.scala b/api/shared/src/main/scala/net/shimun/redstream/api/Page.scala new file mode 100644 index 0000000..038dfa8 --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/Page.scala @@ -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 +} + + + + diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/QueryURL.scala b/api/shared/src/main/scala/net/shimun/redstream/api/QueryURL.scala new file mode 100644 index 0000000..70537da --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/QueryURL.scala @@ -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 +} diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/Session.scala b/api/shared/src/main/scala/net/shimun/redstream/api/Session.scala new file mode 100644 index 0000000..13dcd39 --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/Session.scala @@ -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") + +} diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/Subreddit.scala b/api/shared/src/main/scala/net/shimun/redstream/api/Subreddit.scala new file mode 100644 index 0000000..a41ee63 --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/Subreddit.scala @@ -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 + } +} + diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/models/Comment.scala b/api/shared/src/main/scala/net/shimun/redstream/api/models/Comment.scala new file mode 100644 index 0000000..737cc9c --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/models/Comment.scala @@ -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) + ??? + } + +} diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/models/Parsing.scala b/api/shared/src/main/scala/net/shimun/redstream/api/models/Parsing.scala index 14e4d39..8eadd5b 100644 --- a/api/shared/src/main/scala/net/shimun/redstream/api/models/Parsing.scala +++ b/api/shared/src/main/scala/net/shimun/redstream/api/models/Parsing.scala @@ -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] + } diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/models/Submission.scala b/api/shared/src/main/scala/net/shimun/redstream/api/models/Submission.scala new file mode 100644 index 0000000..95a0c15 --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/models/Submission.scala @@ -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)) + } + } + +} \ No newline at end of file diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/models/package.scala b/api/shared/src/main/scala/net/shimun/redstream/api/models/package.scala new file mode 100644 index 0000000..3333c67 --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/models/package.scala @@ -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 + +} diff --git a/api/shared/src/main/scala/net/shimun/redstream/api/package.scala b/api/shared/src/main/scala/net/shimun/redstream/api/package.scala new file mode 100644 index 0000000..56c139b --- /dev/null +++ b/api/shared/src/main/scala/net/shimun/redstream/api/package.scala @@ -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 + } + + +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..8b296aa --- /dev/null +++ b/build.sbt @@ -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 ? + + diff --git a/client-macros/src/main/scala/Mappable.scala b/client-macros/src/main/scala/Mappable.scala new file mode 100644 index 0000000..cbc7591 --- /dev/null +++ b/client-macros/src/main/scala/Mappable.scala @@ -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) + } + """ } + } + } +} \ No newline at end of file diff --git a/client/src/main/scala/net/shimun/redstream/ui/Filter.scala b/client/src/main/scala/net/shimun/redstream/ui/Filter.scala new file mode 100644 index 0000000..27e5ab7 --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/Filter.scala @@ -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)) + } + } + + } + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/HttpDataScource.scala b/client/src/main/scala/net/shimun/redstream/ui/HttpDataScource.scala new file mode 100644 index 0000000..3555875 --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/HttpDataScource.scala @@ -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) +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/SubmissionBodySelector.scala b/client/src/main/scala/net/shimun/redstream/ui/SubmissionBodySelector.scala new file mode 100644 index 0000000..e4970aa --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/SubmissionBodySelector.scala @@ -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] + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/UI.scala b/client/src/main/scala/net/shimun/redstream/ui/UI.scala new file mode 100644 index 0000000..7d68f9a --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/UI.scala @@ -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"))) + ) + + } + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/Util.scala b/client/src/main/scala/net/shimun/redstream/ui/Util.scala new file mode 100644 index 0000000..a4bca7b --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/Util.scala @@ -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) + } + } + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/components/Navigation.scala b/client/src/main/scala/net/shimun/redstream/ui/components/Navigation.scala new file mode 100644 index 0000000..4cb0e02 --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/components/Navigation.scala @@ -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 + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/components/Post.scala b/client/src/main/scala/net/shimun/redstream/ui/components/Post.scala new file mode 100644 index 0000000..6cb105f --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/components/Post.scala @@ -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 + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/components/package.scala b/client/src/main/scala/net/shimun/redstream/ui/components/package.scala new file mode 100644 index 0000000..512edd0 --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/components/package.scala @@ -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)) + } + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/style/NavigationStyles.scala b/client/src/main/scala/net/shimun/redstream/ui/style/NavigationStyles.scala new file mode 100644 index 0000000..faecd5c --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/style/NavigationStyles.scala @@ -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( + + ) + + + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/style/Post.scala b/client/src/main/scala/net/shimun/redstream/ui/style/Post.scala new file mode 100644 index 0000000..3e3428b --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/style/Post.scala @@ -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) + ) + +} diff --git a/client/src/main/scala/net/shimun/redstream/ui/style/Styles.scala b/client/src/main/scala/net/shimun/redstream/ui/style/Styles.scala new file mode 100644 index 0000000..6f9757b --- /dev/null +++ b/client/src/main/scala/net/shimun/redstream/ui/style/Styles.scala @@ -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") + ) + + + +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..d638b4f --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 0.13.8 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..6e58810 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,3 @@ +logLevel := Level.Warn + +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.8") diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..08e5f7f --- /dev/null +++ b/readme.md @@ -0,0 +1,10 @@ +Stream subreddits like 9gag + + +Inspired by 9gag, scrolldit + +API: + +https://www.reddit.com/r/.json + +https://www.reddit.com/dev/api \ No newline at end of file