├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── src └── main ├── java └── codes │ └── monkey │ └── reactivechat │ ├── ChatSocketHandler.java │ ├── Event.java │ ├── EventBuilder.java │ ├── Payload.java │ ├── ReactiveChatApplication.java │ ├── User.java │ └── UserStats.java ├── resources ├── application.properties └── public │ └── .gitkeep └── ui ├── .babelrc ├── .editorconfig ├── .eslintrc.yml ├── .gitignore ├── .jshintrc ├── app ├── actions │ ├── chat.js │ └── time.js ├── app.jsx ├── components │ ├── chat.jsx │ ├── humanized_time.jsx │ ├── login.jsx │ ├── message_input.jsx │ ├── messages.jsx │ ├── nav.jsx │ ├── online_users.jsx │ ├── require_user.jsx │ ├── time_ticker.jsx │ └── user_profile.jsx ├── index.jsx ├── middleware │ └── websocket.js ├── reducers │ ├── index.js │ ├── messages.js │ ├── stats.js │ ├── time.js │ └── user.js ├── styles │ ├── index.scss │ ├── login.scss │ ├── main.scss │ ├── messages.scss │ ├── profile.scss │ └── users.scss └── template.html ├── package.json ├── postcss.config.js ├── webpack.config.js ├── webpack.loaders.js ├── webpack.production.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | /out/ 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | 14 | ### IntelliJ IDEA ### 15 | .idea 16 | *.iws 17 | *.iml 18 | *.ipr 19 | 20 | ### NetBeans ### 21 | nbproject/private/ 22 | build/ 23 | nbbuild/ 24 | dist/ 25 | nbdist/ 26 | .nb-gradle/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebFlux Chat App 2 | 3 | Sample code accompanying [How To Build a Chat App Using WebFlux, WebSockets & React](https://johanzietsman.com/how-to-build-a-chat-app-using-webflux-websockets-react/). 4 | 5 | ![Screenshot](https://res.cloudinary.com/monkey-codes/image/upload/v1505618654/chat-app-screenshot.png) 6 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '2.0.0.M2' 4 | } 5 | repositories { 6 | mavenCentral() 7 | maven { url "https://repo.spring.io/snapshot" } 8 | maven { url "https://repo.spring.io/milestone" } 9 | } 10 | dependencies { 11 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 12 | } 13 | } 14 | 15 | apply plugin: 'java' 16 | apply plugin: 'eclipse' 17 | apply plugin: 'org.springframework.boot' 18 | apply plugin: 'io.spring.dependency-management' 19 | 20 | version = '0.0.1-SNAPSHOT' 21 | sourceCompatibility = 1.8 22 | 23 | repositories { 24 | mavenCentral() 25 | maven { url "https://repo.spring.io/snapshot" } 26 | maven { url "https://repo.spring.io/milestone" } 27 | } 28 | 29 | 30 | dependencies { 31 | compile('org.springframework.boot:spring-boot-starter-webflux') 32 | testCompile('org.springframework.boot:spring-boot-starter-test') 33 | } 34 | 35 | 36 | 37 | task npmDependencies(type: Exec) { 38 | workingDir 'src/main/ui' 39 | commandLine 'npm' 40 | args = ["install"] 41 | } 42 | 43 | task buildUI(type: Exec, dependsOn: npmDependencies) { 44 | logger.lifecycle("-" * 100) 45 | logger.lifecycle(""" 46 | THIS BUILD HAS BEEN TESTED ON OSX WITH NPM 4.1.2 AND NODE v7.7.1 AVAILABLE ON THE PATH 47 | """) 48 | logger.lifecycle("-" * 100) 49 | workingDir 'src/main/ui' 50 | commandLine 'npm' 51 | args = ["run", "build"] 52 | } 53 | 54 | processResources.dependsOn buildUI 55 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monkey-codes/java-reactive-chat/9b246d37242556669e146ba246001a989ae17291/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-3.4.1-bin.zip 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save ( ) { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/ChatSocketHandler.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import org.springframework.web.reactive.socket.WebSocketHandler; 6 | import org.springframework.web.reactive.socket.WebSocketMessage; 7 | import org.springframework.web.reactive.socket.WebSocketSession; 8 | import reactor.core.publisher.Flux; 9 | import reactor.core.publisher.Mono; 10 | import reactor.core.publisher.UnicastProcessor; 11 | 12 | import java.io.IOException; 13 | import java.util.Optional; 14 | 15 | import static codes.monkey.reactivechat.Event.Type.USER_LEFT; 16 | 17 | public class ChatSocketHandler implements WebSocketHandler { 18 | 19 | private UnicastProcessor eventPublisher; 20 | private Flux outputEvents; 21 | private ObjectMapper mapper; 22 | 23 | public ChatSocketHandler(UnicastProcessor eventPublisher, Flux events) { 24 | this.eventPublisher = eventPublisher; 25 | this.mapper = new ObjectMapper(); 26 | this.outputEvents = Flux.from(events).map(this::toJSON); 27 | } 28 | 29 | @Override 30 | public Mono handle(WebSocketSession session) { 31 | WebSocketMessageSubscriber subscriber = new WebSocketMessageSubscriber(eventPublisher); 32 | return session.receive() 33 | .map(WebSocketMessage::getPayloadAsText) 34 | .map(this::toEvent) 35 | .doOnNext(subscriber::onNext) 36 | .doOnError(subscriber::onError) 37 | .doOnComplete(subscriber::onComplete) 38 | .zipWith(session.send(outputEvents.map(session::textMessage))) 39 | .then(); 40 | } 41 | 42 | 43 | private Event toEvent(String json) { 44 | try { 45 | return mapper.readValue(json, Event.class); 46 | } catch (IOException e) { 47 | throw new RuntimeException("Invalid JSON:" + json, e); 48 | } 49 | } 50 | 51 | private String toJSON(Event event) { 52 | try { 53 | return mapper.writeValueAsString(event); 54 | } catch (JsonProcessingException e) { 55 | throw new RuntimeException(e); 56 | } 57 | } 58 | 59 | private static class WebSocketMessageSubscriber { 60 | private UnicastProcessor eventPublisher; 61 | private Optional lastReceivedEvent = Optional.empty(); 62 | 63 | public WebSocketMessageSubscriber(UnicastProcessor eventPublisher) { 64 | this.eventPublisher = eventPublisher; 65 | } 66 | 67 | public void onNext(Event event) { 68 | lastReceivedEvent = Optional.of(event); 69 | eventPublisher.onNext(event); 70 | } 71 | 72 | public void onError(Throwable error) { 73 | //TODO log error 74 | error.printStackTrace(); 75 | } 76 | 77 | public void onComplete() { 78 | 79 | lastReceivedEvent.ifPresent(event -> eventPublisher.onNext( 80 | Event.type(USER_LEFT) 81 | .withPayload() 82 | .user(event.getUser()) 83 | .build())); 84 | } 85 | 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/Event.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonIgnore; 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | 7 | import java.util.concurrent.atomic.AtomicInteger; 8 | 9 | public class Event { 10 | public enum Type { 11 | CHAT_MESSAGE, USER_JOINED, USER_STATS, USER_LEFT; 12 | } 13 | 14 | 15 | private static AtomicInteger ID_GENERATOR = new AtomicInteger(0); 16 | 17 | 18 | private Type type; 19 | 20 | private final int id; 21 | 22 | private Payload payload; 23 | 24 | private final long timestamp; 25 | 26 | 27 | 28 | @JsonCreator 29 | public Event(@JsonProperty("type") Type type, 30 | @JsonProperty("payload") Payload payload) { 31 | this.type = type; 32 | this.payload = payload; 33 | this.id = ID_GENERATOR.addAndGet(1); 34 | this.timestamp = System.currentTimeMillis(); 35 | } 36 | 37 | 38 | public Type getType() { 39 | return type; 40 | } 41 | 42 | public Payload getPayload() { 43 | return payload; 44 | } 45 | 46 | @JsonIgnore 47 | public User getUser(){ 48 | return getPayload().getUser(); 49 | } 50 | 51 | public int getId() { 52 | return id; 53 | } 54 | 55 | 56 | public long getTimestamp() { 57 | return timestamp; 58 | } 59 | 60 | public static EventBuilder type(Type type) { 61 | return new EventBuilder().type(type); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/EventBuilder.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | public class EventBuilder { 7 | private Event.Type type; 8 | private PayloadBuilder payloadBuilder = new PayloadBuilder(); 9 | 10 | public EventBuilder type(Event.Type type) { 11 | this.type = type; 12 | return this; 13 | } 14 | 15 | public PayloadBuilder withPayload() { 16 | return payloadBuilder; 17 | } 18 | 19 | private Event buildEvent(Payload payload) { 20 | return new Event(type, payload); 21 | } 22 | 23 | protected class PayloadBuilder { 24 | 25 | private String alias; 26 | private String avatar; 27 | private Map properties = new HashMap<>(); 28 | 29 | public PayloadBuilder userAlias(String alias) { 30 | this.alias = alias; 31 | return this; 32 | } 33 | 34 | public PayloadBuilder userAvatar(String avatar) { 35 | this.avatar = avatar; 36 | return this; 37 | } 38 | 39 | public PayloadBuilder user(User user) { 40 | this.alias = user.getAlias(); 41 | this.avatar = user.getAvatar(); 42 | return this; 43 | } 44 | 45 | public PayloadBuilder systemUser() { 46 | user(User.systemUser()); 47 | return this; 48 | } 49 | 50 | public PayloadBuilder property(String property, Object value) { 51 | properties.put(property, value); 52 | return this; 53 | } 54 | 55 | 56 | public Event build() { 57 | return buildEvent(new Payload(new User(payloadBuilder.alias, payloadBuilder.avatar), properties)); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/Payload.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import com.fasterxml.jackson.annotation.JsonAnyGetter; 4 | import com.fasterxml.jackson.annotation.JsonAnySetter; 5 | import com.fasterxml.jackson.annotation.JsonCreator; 6 | import com.fasterxml.jackson.annotation.JsonProperty; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | 11 | public class Payload { 12 | 13 | private User user; 14 | 15 | private Map properties = new HashMap<>(); 16 | 17 | public Payload(User user, Map properties){ 18 | this(user); 19 | this.properties = properties; 20 | } 21 | @JsonCreator 22 | private Payload(@JsonProperty("user") User user) { 23 | this.user = user; 24 | } 25 | 26 | public User getUser() { 27 | return user; 28 | } 29 | 30 | @JsonAnySetter 31 | private void setProperties(String name, Object value){ 32 | properties.put(name, value); 33 | } 34 | 35 | @JsonAnyGetter 36 | private Map getProperties(){ 37 | return properties; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/ReactiveChatApplication.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.core.io.ClassPathResource; 7 | import org.springframework.web.reactive.HandlerMapping; 8 | import org.springframework.web.reactive.function.BodyInserters; 9 | import org.springframework.web.reactive.function.server.RouterFunction; 10 | import org.springframework.web.reactive.function.server.RouterFunctions; 11 | import org.springframework.web.reactive.function.server.ServerResponse; 12 | import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; 13 | import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter; 14 | import reactor.core.publisher.Flux; 15 | import reactor.core.publisher.UnicastProcessor; 16 | 17 | import java.util.HashMap; 18 | import java.util.Map; 19 | 20 | import static org.springframework.web.reactive.function.server.RequestPredicates.GET; 21 | 22 | @SpringBootApplication 23 | public class ReactiveChatApplication { 24 | 25 | public static void main(String[] args) { 26 | SpringApplication.run(ReactiveChatApplication.class, args); 27 | } 28 | 29 | @Bean 30 | public UnicastProcessor eventPublisher(){ 31 | return UnicastProcessor.create(); 32 | } 33 | 34 | @Bean 35 | public Flux events(UnicastProcessor eventPublisher) { 36 | return eventPublisher 37 | .replay(25) 38 | .autoConnect(); 39 | } 40 | 41 | @Bean 42 | public RouterFunction routes(){ 43 | return RouterFunctions.route( 44 | GET("/"), 45 | request -> ServerResponse.ok().body(BodyInserters.fromResource(new ClassPathResource("public/index.html"))) 46 | ); 47 | } 48 | 49 | @Bean 50 | public HandlerMapping webSocketMapping(UnicastProcessor eventPublisher, Flux events) { 51 | Map map = new HashMap<>(); 52 | map.put("/websocket/chat", new ChatSocketHandler(eventPublisher, events)); 53 | SimpleUrlHandlerMapping simpleUrlHandlerMapping = new SimpleUrlHandlerMapping(); 54 | simpleUrlHandlerMapping.setUrlMap(map); 55 | 56 | //Without the order things break :-/ 57 | simpleUrlHandlerMapping.setOrder(10); 58 | return simpleUrlHandlerMapping; 59 | } 60 | 61 | @Bean 62 | public WebSocketHandlerAdapter handlerAdapter() { 63 | return new WebSocketHandlerAdapter(); 64 | } 65 | 66 | @Bean 67 | public UserStats userStats(Flux events, UnicastProcessor eventPublisher){ 68 | return new UserStats(events, eventPublisher); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/User.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | public class User { 7 | 8 | private String alias; 9 | private String avatar; 10 | 11 | public static User systemUser(){ 12 | return new User("System", "https://robohash.org/system.png"); 13 | } 14 | 15 | @JsonCreator 16 | public User(@JsonProperty("alias") String alias, @JsonProperty("avatar") String avatar) { 17 | this.alias = alias; 18 | this.avatar = avatar; 19 | } 20 | 21 | public String getAlias() { 22 | return alias; 23 | } 24 | 25 | public String getAvatar() { 26 | return avatar; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/codes/monkey/reactivechat/UserStats.java: -------------------------------------------------------------------------------- 1 | package codes.monkey.reactivechat; 2 | 3 | import codes.monkey.reactivechat.Event.Type; 4 | import reactor.core.publisher.Flux; 5 | import reactor.core.publisher.UnicastProcessor; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | import java.util.concurrent.ConcurrentHashMap; 10 | import java.util.concurrent.atomic.AtomicInteger; 11 | import java.util.function.Predicate; 12 | 13 | import static codes.monkey.reactivechat.Event.Type.*; 14 | import static java.util.Arrays.asList; 15 | 16 | public class UserStats { 17 | 18 | 19 | UnicastProcessor eventPublisher; 20 | Map userStatsMap = new ConcurrentHashMap(); 21 | 22 | public UserStats(Flux events, UnicastProcessor eventPublisher) { 23 | this.eventPublisher = eventPublisher; 24 | events 25 | .filter(type(CHAT_MESSAGE, USER_JOINED)) 26 | .subscribe(this::onChatMessage); 27 | events 28 | .filter(type(USER_LEFT)) 29 | .map(Event::getUser) 30 | .map(User::getAlias) 31 | .subscribe(userStatsMap::remove); 32 | 33 | events 34 | .filter(type(USER_JOINED)) 35 | .map(event -> Event.type(USER_STATS) 36 | .withPayload() 37 | .systemUser() 38 | .property("stats", new HashMap<>(userStatsMap)) 39 | .build() 40 | ) 41 | .subscribe(eventPublisher::onNext); 42 | } 43 | 44 | private static Predicate type(Type... types){ 45 | return event -> asList(types).contains(event.getType()); 46 | } 47 | 48 | private void onChatMessage(Event event) { 49 | String alias = event.getUser().getAlias(); 50 | Stats stats = userStatsMap.computeIfAbsent(alias, s -> new Stats(event.getUser())); 51 | stats.onChatMessage(event); 52 | } 53 | 54 | private static class Stats { 55 | private User user; 56 | private long lastMessage; 57 | private AtomicInteger messageCount = new AtomicInteger(); 58 | 59 | public Stats(User user) { 60 | this.user = user; 61 | } 62 | 63 | public void onChatMessage(Event event) { 64 | lastMessage = event.getTimestamp(); 65 | if(CHAT_MESSAGE == event.getType()) messageCount.incrementAndGet(); 66 | } 67 | 68 | public User getUser() { 69 | return user; 70 | } 71 | 72 | public long getLastMessage() { 73 | return lastMessage; 74 | } 75 | 76 | public int getMessageCount() { 77 | return messageCount.get(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monkey-codes/java-reactive-chat/9b246d37242556669e146ba246001a989ae17291/src/main/resources/application.properties -------------------------------------------------------------------------------- /src/main/resources/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monkey-codes/java-reactive-chat/9b246d37242556669e146ba246001a989ae17291/src/main/resources/public/.gitkeep -------------------------------------------------------------------------------- /src/main/ui/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-1"], 3 | "plugins": ['transform-runtime', 'transform-decorators-legacy', 'transform-class-properties', 'react-hot-loader/babel'] 4 | } 5 | -------------------------------------------------------------------------------- /src/main/ui/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | 9 | # Matches multiple files with brace expansion notation 10 | # Set default charset 11 | [*.{js,jsx,html,sass}] 12 | charset = utf-8 13 | indent_style = tab 14 | indent_size = 2 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /src/main/ui/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - plugin:react/recommended 4 | 5 | env: 6 | browser: true 7 | node: true 8 | es6: true 9 | 10 | parserOptions: 11 | ecmaVersion: 6 12 | sourceType: "module" 13 | ecmaFeatures: 14 | jsx: true 15 | 16 | globals: 17 | __DEV__: true 18 | __SERVER__: true 19 | 20 | plugins: 21 | - react 22 | 23 | rules: 24 | react/jsx-uses-vars: 1 25 | react/prop-types: [1, { ignore: [children] }] 26 | 27 | semi: 0 28 | key-spacing: 1 29 | curly: 0 30 | consistent-return: 0 31 | space-infix-ops: 1 32 | camelcase: 0 33 | no-spaced-func: 1 34 | no-alert: 1 35 | eol-last: 1 36 | comma-spacing: 1 37 | eqeqeq: 1 38 | 39 | # possible errors 40 | comma-dangle: 0 41 | no-cond-assign: 2 42 | no-console: 0 43 | no-constant-condition: 2 44 | no-control-regex: 2 45 | no-debugger: 2 46 | no-dupe-args: 2 47 | no-dupe-keys: 2 48 | no-duplicate-case: 2 49 | no-empty-character-class: 2 50 | no-empty: 2 51 | no-ex-assign: 2 52 | no-extra-boolean-cast: 2 53 | no-extra-parens: 0 54 | no-extra-semi: 2 55 | no-func-assign: 2 56 | no-inner-declarations: 2 57 | no-invalid-regexp: 2 58 | no-irregular-whitespace: 2 59 | no-negated-in-lhs: 2 60 | no-obj-calls: 2 61 | no-regex-spaces: 2 62 | no-sparse-arrays: 2 63 | no-unexpected-multiline: 2 64 | no-unreachable: 2 65 | use-isnan: 2 66 | valid-jsdoc: 2 67 | valid-typeof: 2 68 | 69 | no-redeclare: 2 70 | 71 | init-declarations: 2 72 | no-catch-shadow: 2 73 | no-delete-var: 2 74 | no-label-var: 2 75 | no-shadow-restricted-names: 2 76 | no-shadow: 2 77 | no-undef-init: 2 78 | no-undef: 2 79 | no-undefined: 2 80 | no-unused-vars: 2 81 | no-use-before-define: 2 82 | -------------------------------------------------------------------------------- /src/main/ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Ignore build files 30 | public 31 | -------------------------------------------------------------------------------- /src/main/ui/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6 3 | } 4 | -------------------------------------------------------------------------------- /src/main/ui/app/actions/chat.js: -------------------------------------------------------------------------------- 1 | import {WEBSOCKET_CONNECT, WEBSOCKET_SEND} from '../middleware/websocket'; 2 | export const MESSAGE_RECEIVED = 'MESSAGE_RECEIVED'; 3 | export const CHAT_MESSAGE = 'CHAT_MESSAGE'; 4 | export const USER_JOINED = 'USER_JOINED'; 5 | export const USER_STATS = 'USER_STATS'; 6 | export const USER_LEFT = 'USER_LEFT'; 7 | 8 | const eventToActionAdapters = { 9 | CHAT_MESSAGE: ({id, timestamp, payload:{user, message}}) => 10 | ({ type:MESSAGE_RECEIVED, payload:{ id, timestamp, user, message }}), 11 | USER_STATS: ({payload}) => ({type: USER_STATS, payload}), 12 | USER_LEFT: ({payload}) => ({type: USER_LEFT, payload}) 13 | }; 14 | 15 | export function messageToActionAdapter(msg){ 16 | const event = JSON.parse(msg.data); 17 | if(eventToActionAdapters[event.type]){ 18 | return eventToActionAdapters[event.type](event); 19 | } 20 | } 21 | 22 | export function connectToChatServer(url) { 23 | return dispatch => { 24 | dispatch({type: WEBSOCKET_CONNECT, payload: {url}}); 25 | } 26 | } 27 | 28 | function overTheSocket(type, payload) { 29 | return { 30 | type: WEBSOCKET_SEND, 31 | payload: { type, payload } 32 | }; 33 | } 34 | 35 | 36 | function doubleDispatch(type, payload){ 37 | return dispatch => { 38 | dispatch(overTheSocket(type, payload)); 39 | dispatch({type, payload}); 40 | } 41 | } 42 | 43 | export function sendMessage(user, message) { 44 | return dispatch => { 45 | dispatch(overTheSocket(CHAT_MESSAGE,{user, message})); 46 | } 47 | } 48 | 49 | export function joinChat(user) { 50 | return doubleDispatch(USER_JOINED, {user}); 51 | } 52 | -------------------------------------------------------------------------------- /src/main/ui/app/actions/time.js: -------------------------------------------------------------------------------- 1 | export const MINUTE_PASSED = 'MINUTE_PASSED'; 2 | export const TICKER_INTERVAL_CREATED = 'TICKER_INTERVAL_CREATED'; 3 | export const TICKER_INTERVAL_REMOVED = 'TICKER_INTERVAL_REMOVED'; 4 | 5 | export function createTimer() { 6 | const now = new Date(); 7 | return dispatch => { 8 | const interval = setInterval(() => { 9 | console.log("minute interval fired"); 10 | dispatch({type: MINUTE_PASSED, payload: new Date()}); 11 | }, 60000); 12 | dispatch({type: TICKER_INTERVAL_CREATED, payload: interval}); 13 | dispatch({type: MINUTE_PASSED, payload: new Date()}); 14 | } 15 | } 16 | 17 | export function removeTimer(interval) { 18 | return dispatch => { 19 | clearInterval(interval); 20 | console.log("interval cleared"); 21 | dispatch({type: TICKER_INTERVAL_REMOVED, payload: interval}); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/ui/app/app.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 4 | import TimeTicker from './components/time_ticker'; 5 | import Chat from './components/chat'; 6 | import Login from './components/login'; 7 | import requireUser from './components/require_user'; 8 | import {connectToChatServer} from './actions/chat'; 9 | 10 | class App extends Component { 11 | 12 | componentDidMount(){ 13 | this.props.connectToChatServer(`ws://${location.host}/websocket/chat`); 14 | } 15 | 16 | render(){ 17 | return( 18 | 19 |
20 | 21 | 22 | 23 |
24 |
25 | ); 26 | } 27 | } 28 | 29 | export default connect(null, {connectToChatServer})(App); 30 | -------------------------------------------------------------------------------- /src/main/ui/app/components/chat.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../styles/index.scss'; 3 | import Nav from './nav'; 4 | import OnlineUsers from './online_users'; 5 | import Messages from './messages'; 6 | import MessageInput from './message_input'; 7 | import UserProfile from './user_profile'; 8 | 9 | export default class Chat extends React.Component { 10 | render() { 11 | return ( 12 |
13 |
14 |
16 |
17 |
18 | 19 | 20 |
21 |
22 |
23 | 24 | 25 |
26 |
27 |
28 |
29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/ui/app/components/humanized_time.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { connect } from 'react-redux'; 3 | import moment from 'moment'; 4 | 5 | class HumanizedTime extends Component { 6 | 7 | render() { 8 | if(!this.props.time.now){ 9 | return (
); 10 | } 11 | const prefix = this.props.prefix || ""; 12 | const suffix = this.props.suffix || "ago"; 13 | const timeAgo = moment.duration(this.props.time.now.getTime() - this.props.date); 14 | return ( 15 | {prefix} {timeAgo.humanize()} {suffix} 16 | ); 17 | } 18 | 19 | } 20 | 21 | export default connect(({time}) => ({time}))(HumanizedTime); 22 | -------------------------------------------------------------------------------- /src/main/ui/app/components/login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Rx from 'rxjs/Rx'; 3 | import '../styles/login.scss'; 4 | import { joinChat } from '../actions/chat'; 5 | import { connect } from 'react-redux'; 6 | 7 | const DEFAULT_AVATAR = '//ssl.gstatic.com/accounts/ui/avatar_2x.png'; 8 | 9 | class Login extends React.Component { 10 | 11 | static contextTypes = { 12 | router: React.PropTypes.object 13 | } 14 | 15 | constructor(props){ 16 | super(props); 17 | this.state = {alias: '', avatar: DEFAULT_AVATAR} 18 | } 19 | 20 | updateAvatar(alias){ 21 | const avatar = alias ? encodeURI(`https://robohash.org/${alias.toLowerCase()}.png`) : DEFAULT_AVATAR; 22 | this.setState({ avatar }); 23 | 24 | } 25 | 26 | onAliasChange(alias){ 27 | this.setState({alias}); 28 | } 29 | 30 | onSubmit(e) { 31 | e.preventDefault(); 32 | const {alias, avatar} = this.state; 33 | this.props.joinChat({alias, avatar}); 34 | this.context.router.history.push('/chat'); 35 | return false; 36 | } 37 | 38 | componentDidMount(){ 39 | this.aliasInput && 40 | Rx.Observable. 41 | fromEvent(this.aliasInput,'keyup'). 42 | map(e => e.target.value). 43 | distinctUntilChanged(). 44 | debounceTime(500). 45 | subscribe(this.updateAvatar.bind(this)) 46 | } 47 | 48 | render() { 49 | return ( 50 |
51 |
52 | 53 |

54 |
55 |
56 | this.aliasInput = input} 62 | onChange={event => this.onAliasChange(event.target.value)} 63 | required autoFocus/> 64 |
65 |
66 | 67 |
68 |
69 |
70 |
71 | ); 72 | } 73 | } 74 | export default connect(null,{ joinChat })(Login); 75 | -------------------------------------------------------------------------------- /src/main/ui/app/components/message_input.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { sendMessage } from '../actions/chat'; 3 | import { connect } from 'react-redux'; 4 | 5 | class MessageInput extends React.Component { 6 | 7 | constructor(props) { 8 | super(props); 9 | this.state = {message: ''}; 10 | } 11 | 12 | onInputChange(message) { 13 | this.setState({message}); 14 | } 15 | 16 | onSubmit(e) { 17 | e.preventDefault(); 18 | this.props.sendMessage(this.props.user, this.state.message); 19 | this.setState({message:''}); 20 | return false; 21 | } 22 | 23 | render() { 24 | return ( 25 | 26 |
27 |
28 |
29 |
30 |
Type Message
31 | this.onInputChange(event.target.value)} 34 | className="form-control input-lg"> 35 |
36 |
37 |
38 |
39 | ); 40 | } 41 | } 42 | 43 | export default connect(({user}) => ({user}), { sendMessage })(MessageInput); 44 | -------------------------------------------------------------------------------- /src/main/ui/app/components/messages.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { connect } from 'react-redux'; 3 | import HumanizedTime from './humanized_time'; 4 | import '../styles/messages.scss'; 5 | 6 | class Messages extends Component { 7 | renderMessages() { 8 | return this.props.messages.map(message => { 9 | return ( 10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 | {message.user.alias} 19 |
20 |
{message.message}
21 |
22 | 23 |
24 |
25 |
26 |
27 |
28 | ); 29 | }); 30 | } 31 | render() { 32 | return ( 33 |
34 | {this.renderMessages()} 35 |
{ 36 | if(div) div.scrollIntoView({block: 'end', behavior: 'smooth'}); 37 | }}>
38 |
39 | ); 40 | } 41 | 42 | } 43 | 44 | function mapStateToProps(state){ 45 | return {messages: state.messages}; 46 | } 47 | 48 | export default connect(mapStateToProps)(Messages); 49 | -------------------------------------------------------------------------------- /src/main/ui/app/components/nav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Nav = () => { 4 | return( 5 | 24 | ); 25 | } 26 | 27 | export default Nav; 28 | -------------------------------------------------------------------------------- /src/main/ui/app/components/online_users.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import '../styles/users.scss'; 4 | import HumanizedTime from './humanized_time'; 5 | 6 | class OnlineUsers extends React.Component { 7 | 8 | renderUsers() { 9 | return Object.values(this.props.stats).map(userStats =>{ 10 | return ( 11 |
  • 12 |
    13 |
    14 | 15 |
    16 |
    {userStats.user.alias}
    17 |
    {userStats.messageCount}
    18 |
    19 |
    20 |
    21 | 22 |
    23 |
    24 |
    25 |
  • 26 | ); 27 | }); 28 | } 29 | 30 | render() { 31 | return ( 32 |
    33 |
      34 | {this.renderUsers()} 35 |
    36 |
    37 | ); 38 | } 39 | } 40 | 41 | export default connect(({stats}) => ({stats}))(OnlineUsers); 42 | -------------------------------------------------------------------------------- /src/main/ui/app/components/require_user.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | export default function(ComposedComponent) { 5 | class RequireUser extends Component { 6 | 7 | static contextTypes = { 8 | router: React.PropTypes.object 9 | } 10 | 11 | componentWillMount(){ 12 | if(!this.props.user.alias){ 13 | this.context.router.history.push('/'); 14 | } 15 | } 16 | 17 | componentWillUpdate(nextProps){ 18 | if(!nextProps.user.alias){ 19 | this.context.router.history.push('/'); 20 | } 21 | } 22 | 23 | render(){ 24 | return 25 | } 26 | } 27 | 28 | function mapStateToProps({user}) { 29 | return {user}; 30 | } 31 | 32 | return connect(mapStateToProps)(RequireUser); 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/main/ui/app/components/time_ticker.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { connect } from 'react-redux'; 3 | import {createTimer, removeTimer} from '../actions/time'; 4 | 5 | class TimeTicker extends Component { 6 | 7 | componentDidMount(){ 8 | console.log('TimeTicker did mount'); 9 | this.props.createTimer(); 10 | } 11 | 12 | componentWillUnmount(){ 13 | console.log('TimeTicker will unmount'); 14 | this.props.removeTimer(this.props.interval); 15 | } 16 | 17 | render(){ 18 | return false; 19 | } 20 | } 21 | 22 | export default connect(({time:{interval}}) => ({interval}),{ createTimer, removeTimer })(TimeTicker); 23 | -------------------------------------------------------------------------------- /src/main/ui/app/components/user_profile.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import '../styles/profile.scss'; 4 | 5 | class UserProfile extends Component { 6 | render() { 7 | return ( 8 |
    9 |
    10 | 11 |
    12 |
    13 |

    {this.props.user.alias}

    14 |
    15 |
    16 | ); 17 | } 18 | } 19 | 20 | export default connect(({user}) => ({user}))(UserProfile); 21 | -------------------------------------------------------------------------------- /src/main/ui/app/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { AppContainer } from 'react-hot-loader'; 4 | import { Provider } from 'react-redux'; 5 | import { createStore, applyMiddleware } from 'redux'; 6 | import reduxThunk from 'redux-thunk'; 7 | import websocket from './middleware/websocket'; 8 | import reducers from './reducers'; 9 | import { messageToActionAdapter } from './actions/chat'; 10 | import App from './app'; 11 | 12 | const store = applyMiddleware( 13 | reduxThunk, 14 | websocket({messageToActionAdapter}) 15 | )(createStore)(reducers) 16 | 17 | const renderApp = (Component) => { 18 | render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | , document.querySelector("#app") 26 | ); 27 | } 28 | renderApp(App); 29 | if (module && module.hot) { 30 | module.hot.accept('./app', () => { 31 | const NextRootContainer = require('./app'); 32 | renderApp(NextRootContainer); 33 | }); 34 | module.hot.accept('./reducers', () => { 35 | const nextReducer = require('./reducers/index').default; 36 | store.replaceReducer(nextReducer); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/main/ui/app/middleware/websocket.js: -------------------------------------------------------------------------------- 1 | export const WEBSOCKET_CONNECT = 'WEBSOCKET_CONNECT'; 2 | export const WEBSOCKET_MESSAGE = 'WEBSOCKET_MESSAGE'; 3 | export const WEBSOCKET_SEND = 'WEBSOCKET_SEND'; 4 | 5 | 6 | class NullSocket { 7 | send(message){ 8 | console.log(`Warning: send called on NullSocket, dispatch a ${WEBSOCKET_CONNECT} first`); 9 | } 10 | } 11 | 12 | function factory({messageToActionAdapter}) { 13 | 14 | let socket = new NullSocket(); 15 | 16 | return ({dispatch}) => { 17 | return next => action => { 18 | 19 | switch (action.type) { 20 | case WEBSOCKET_CONNECT: 21 | socket = new WebSocket(action.payload.url); 22 | socket.onmessage = (msg) => { 23 | dispatch(messageToActionAdapter(msg) || { type:WEBSOCKET_MESSAGE, payload: msg.data}); 24 | } 25 | break; 26 | case WEBSOCKET_SEND: 27 | socket.send(JSON.stringify(action.payload)); 28 | break; 29 | } 30 | return next(action); 31 | } 32 | } 33 | } 34 | export default factory; 35 | 36 | -------------------------------------------------------------------------------- /src/main/ui/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import MessagesReducer from './messages'; 3 | import UserReducer from './user'; 4 | import TimeReducer from './time'; 5 | import UserStatsReducer from './stats'; 6 | 7 | const rootReducer = combineReducers({ 8 | messages: MessagesReducer, 9 | user: UserReducer, 10 | time: TimeReducer, 11 | stats: UserStatsReducer 12 | }); 13 | 14 | export default rootReducer; 15 | -------------------------------------------------------------------------------- /src/main/ui/app/reducers/messages.js: -------------------------------------------------------------------------------- 1 | import {MESSAGE_RECEIVED} from '../actions/chat'; 2 | const initialState = []; 3 | export default function(state = initialState, action){ 4 | switch(action.type){ 5 | case MESSAGE_RECEIVED: return [...state, action.payload]; 6 | default: return state; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/ui/app/reducers/stats.js: -------------------------------------------------------------------------------- 1 | import {USER_STATS, USER_LEFT, MESSAGE_RECEIVED} from '../actions/chat'; 2 | 3 | 4 | export default function(state = {}, action){ 5 | switch(action.type){ 6 | case USER_STATS: return {...action.payload.stats}; 7 | case USER_LEFT: return Object.values(state). 8 | filter(stat => stat.user.alias != action.payload.user.alias). 9 | reduce((acc, val) => ({...acc, [val.user.alias]: val}), {}); 10 | case MESSAGE_RECEIVED: 11 | const {payload:{user:{alias}, timestamp}, payload:{user}} = action; 12 | const messageCount = state[alias] ? state[alias].messageCount +1 : 1; 13 | return {...state, [alias]: {user, lastMessage: timestamp, messageCount }} 14 | default: return state; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/ui/app/reducers/time.js: -------------------------------------------------------------------------------- 1 | import { MINUTE_PASSED, TICKER_INTERVAL_CREATED, TICKER_INTERVAL_REMOVED } from '../actions/time'; 2 | 3 | export default function(state = {}, action){ 4 | switch(action.type){ 5 | case MINUTE_PASSED: return {...state, now: action.payload }; 6 | case TICKER_INTERVAL_CREATED: return {...state, interval: action.payload }; 7 | case TICKER_INTERVAL_REMOVED: return {...state, interval: null }; 8 | default: return state; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/ui/app/reducers/user.js: -------------------------------------------------------------------------------- 1 | import {USER_JOINED} from '../actions/chat'; 2 | 3 | //export default function(state ={alias: "Jack Sparrow", avatar:"https://robohash.org/jack.png"}, action){ 4 | export default function(state ={}, action){ 5 | switch(action.type){ 6 | case USER_JOINED: return { ...state, ...action.payload.user}; 7 | default: return state; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/ui/app/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import 'main'; 2 | -------------------------------------------------------------------------------- /src/main/ui/app/styles/login.scss: -------------------------------------------------------------------------------- 1 | @mixin border-radius($radius) { 2 | -webkit-border-radius: $radius; 3 | -moz-border-radius: $radius; 4 | -ms-border-radius: $radius; 5 | border-radius: $radius; 6 | } 7 | 8 | @mixin box-shadow($shadow) { 9 | -moz-box-shadow: $shadow; 10 | -webkit-box-shadow: $shadow; 11 | box-shadow: $shadow; 12 | } 13 | 14 | 15 | .card-container.card { 16 | max-width: 350px; 17 | padding: 40px 40px; 18 | } 19 | 20 | .card { 21 | padding: 20px 25px 30px; 22 | margin: 0 auto 25px; 23 | margin-top: 50px; 24 | @include border-radius(2px); 25 | @include box-shadow(0px 2px 2px rgba(0, 0, 0, 0.3)); 26 | } 27 | 28 | .profile-img-card { 29 | width: 96px; 30 | height: 96px; 31 | margin: 0 auto 10px; 32 | display: block; 33 | @include border-radius(50%); 34 | } 35 | 36 | .profile-name-card { 37 | margin: 10px 0 0; 38 | min-height: 1em; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/main/ui/app/styles/main.scss: -------------------------------------------------------------------------------- 1 | html, body, .full-height { 2 | min-height:90vh; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /src/main/ui/app/styles/messages.scss: -------------------------------------------------------------------------------- 1 | .chat-messages { 2 | min-height: calc(100vh - 150px); 3 | max-height: 78vh; 4 | overflow-y:scroll; 5 | 6 | img { 7 | position: relative; 8 | top: -7px; 9 | left: -5px; 10 | height: auto; 11 | width: 32px; 12 | } 13 | .media-left { 14 | padding-right: 0px; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/ui/app/styles/profile.scss: -------------------------------------------------------------------------------- 1 | .user-profile { 2 | img { 3 | width: 60%; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/main/ui/app/styles/users.scss: -------------------------------------------------------------------------------- 1 | .online-users { 2 | min-height: calc(100vh - 329px); 3 | max-height: 51.5vh; 4 | overflow-y:scroll; 5 | img { 6 | position: relative; 7 | top: -10px; 8 | top: -10px; 9 | height: auto; 10 | width: 64px; 11 | } 12 | div.media-body { 13 | vertical-align: middle; 14 | span { 15 | margin-right:10px; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/ui/app/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React App 6 | 7 | 8 | 9 | 10 | 11 |
    12 |
    Loading...
    13 |
    14 | 15 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/main/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-webpack-babel", 3 | "version": "0.0.3", 4 | "description": "React Webpack Babel Starter Kit", 5 | "main": "''", 6 | "scripts": { 7 | "build": "webpack --config webpack.production.config.js --progress --profile --colors", 8 | "start": "webpack-dev-server --progress --profile --colors", 9 | "lint": "eslint --ext js --ext jsx src || exit 0", 10 | "dev": " webpack-dashboard -- webpack-dev-server --progress --profile --colors" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/alicoding/react-webpack-babel" 15 | }, 16 | "author": "Ali Al Dallal", 17 | "license": "MIT", 18 | "homepage": "https://github.com/alicoding/react-webpack-babel#readme", 19 | "dependencies": { 20 | "moment": "^2.18.1", 21 | "node-sass": "^4.3.0", 22 | "react": "15.4.2", 23 | "react-dom": "15.4.2", 24 | "react-redux": "^5.0.5", 25 | "react-router": "^4.1.1", 26 | "react-router-dom": "^4.1.1", 27 | "redux": "^3.6.0", 28 | "redux-thunk": "^2.2.0", 29 | "rxjs": "^5.4.0", 30 | "sass-loader": "^6.0.2" 31 | }, 32 | "devDependencies": { 33 | "babel-core": "^6.23.1", 34 | "babel-loader": "^6.3.2", 35 | "babel-plugin-transform-class-properties": "^6.22.0", 36 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 37 | "babel-plugin-transform-runtime": "^6.22.0", 38 | "babel-preset-es2015": "6.22.0", 39 | "babel-preset-react": "^6.23.0", 40 | "babel-preset-stage-1": "^6.24.1", 41 | "babel-runtime": "^6.22.0", 42 | "css-loader": "0.26.1", 43 | "extract-text-webpack-plugin": "^v2.0.0-rc.1", 44 | "file-loader": "^0.10.0", 45 | "html-webpack-plugin": "^2.26.0", 46 | "postcss-loader": "^1.2.2", 47 | "react-hot-loader": "^3.0.0-beta.6", 48 | "style-loader": "0.13.1", 49 | "url-loader": "0.5.7", 50 | "webpack": "^2.2.1", 51 | "webpack-cleanup-plugin": "^0.4.2", 52 | "webpack-dashboard": "^0.3.0", 53 | "webpack-dev-server": "^2.4.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | const AUTOPREFIXER_BROWSERS = [ 2 | 'Android 2.3', 3 | 'Android >= 4', 4 | 'Chrome >= 35', 5 | 'Firefox >= 31', 6 | 'Explorer >= 9', 7 | 'iOS >= 7', 8 | 'Opera >= 12', 9 | 'Safari >= 7.1', 10 | ]; 11 | 12 | module.exports = { 13 | plugins: [ 14 | require('autoprefixer')({ browsers: AUTOPREFIXER_BROWSERS }) 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/main/ui/webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var webpack = require('webpack'); 3 | var path = require('path'); 4 | var loaders = require('./webpack.loaders'); 5 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | var DashboardPlugin = require('webpack-dashboard/plugin'); 7 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 8 | 9 | const HOST = process.env.HOST || "127.0.0.1"; 10 | const PORT = process.env.PORT || "8888"; 11 | 12 | loaders.push({ 13 | test: /\.scss$/, 14 | loaders: ['style-loader', 'css-loader?importLoaders=1', 'sass-loader'], 15 | exclude: ['node_modules'] 16 | }); 17 | 18 | module.exports = { 19 | entry: [ 20 | 'react-hot-loader/patch', 21 | './app/index.jsx', // your app's entry point 22 | ], 23 | devtool: process.env.WEBPACK_DEVTOOL || 'eval-source-map', 24 | output: { 25 | publicPath: '/', 26 | path: path.join(__dirname, 'public'), 27 | filename: 'bundle.js' 28 | }, 29 | resolve: { 30 | extensions: ['.js', '.jsx'] 31 | }, 32 | module: { 33 | loaders 34 | }, 35 | devServer: { 36 | contentBase: "./public", 37 | // do not print bundle build stats 38 | noInfo: true, 39 | // enable HMR 40 | hot: true, 41 | // embed the webpack-dev-server runtime into the bundle 42 | inline: true, 43 | // serve index.html in place of 404 responses to allow HTML5 history 44 | historyApiFallback: true, 45 | port: PORT, 46 | host: HOST, 47 | proxy: { 48 | '/websocket/chat': { 49 | target: 'ws://127.0.0.1:8080', 50 | ws: true 51 | } 52 | } 53 | }, 54 | plugins: [ 55 | new webpack.NoEmitOnErrorsPlugin(), 56 | new webpack.HotModuleReplacementPlugin(), 57 | new ExtractTextPlugin({ 58 | filename: 'style.css', 59 | allChunks: true 60 | }), 61 | new DashboardPlugin(), 62 | new HtmlWebpackPlugin({ 63 | template: './app/template.html', 64 | files: { 65 | css: ['style.css'], 66 | js: [ "bundle.js"], 67 | } 68 | }), 69 | ] 70 | }; 71 | -------------------------------------------------------------------------------- /src/main/ui/webpack.loaders.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | test: /\.jsx?$/, 4 | exclude: /(node_modules|bower_components|public\/)/, 5 | loader: "babel-loader" 6 | }, 7 | { 8 | test: /\.css$/, 9 | loaders: ['style-loader', 'css-loader?importLoaders=1'], 10 | exclude: ['node_modules'] 11 | }, 12 | { 13 | test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, 14 | exclude: /(node_modules|bower_components)/, 15 | loader: "file-loader" 16 | }, 17 | { 18 | test: /\.(woff|woff2)$/, 19 | exclude: /(node_modules|bower_components)/, 20 | loader: "url-loader?prefix=font/&limit=5000" 21 | }, 22 | { 23 | test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, 24 | exclude: /(node_modules|bower_components)/, 25 | loader: "url-loader?limit=10000&mimetype=application/octet-stream" 26 | }, 27 | { 28 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, 29 | exclude: /(node_modules|bower_components)/, 30 | loader: "url-loader?limit=10000&mimetype=image/svg+xml" 31 | }, 32 | { 33 | test: /\.gif/, 34 | exclude: /(node_modules|bower_components)/, 35 | loader: "url-loader?limit=10000&mimetype=image/gif" 36 | }, 37 | { 38 | test: /\.jpg/, 39 | exclude: /(node_modules|bower_components)/, 40 | loader: "url-loader?limit=10000&mimetype=image/jpg" 41 | }, 42 | { 43 | test: /\.png/, 44 | exclude: /(node_modules|bower_components)/, 45 | loader: "url-loader?limit=10000&mimetype=image/png" 46 | } 47 | ]; 48 | -------------------------------------------------------------------------------- /src/main/ui/webpack.production.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | var loaders = require('./webpack.loaders'); 4 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | var WebpackCleanupPlugin = require('webpack-cleanup-plugin'); 6 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 7 | 8 | loaders.push({ 9 | test: /\.scss$/, 10 | loader: ExtractTextPlugin.extract({fallback: 'style-loader', use : 'css-loader?sourceMap&localIdentName=[local]___[hash:base64:5]!sass-loader?outputStyle=expanded'}), 11 | exclude: ['node_modules'] 12 | }); 13 | 14 | module.exports = { 15 | entry: [ 16 | './app/index.jsx', 17 | './app/styles/index.scss' 18 | ], 19 | output: { 20 | publicPath: './', 21 | path: path.join(__dirname, '../../../build/resources/main/public'), 22 | filename: '[chunkhash].js' 23 | }, 24 | resolve: { 25 | extensions: ['.js', '.jsx'] 26 | }, 27 | module: { 28 | loaders 29 | }, 30 | plugins: [ 31 | new WebpackCleanupPlugin(), 32 | new webpack.DefinePlugin({ 33 | 'process.env': { 34 | NODE_ENV: '"production"' 35 | } 36 | }), 37 | new webpack.optimize.UglifyJsPlugin({ 38 | compress: { 39 | warnings: false, 40 | screw_ie8: true, 41 | drop_console: true, 42 | drop_debugger: true 43 | } 44 | }), 45 | new webpack.optimize.OccurrenceOrderPlugin(), 46 | new ExtractTextPlugin({ 47 | filename: '[contenthash].style.css', 48 | allChunks: true 49 | }), 50 | new HtmlWebpackPlugin({ 51 | template: './app/template.html', 52 | files: { 53 | css: ['style.css'], 54 | js: ['bundle.js'], 55 | } 56 | }) 57 | ] 58 | }; 59 | --------------------------------------------------------------------------------