diff --git a/.gitignore b/.gitignore
index 56a409a7..5e5788ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,516 @@
-# Created by .ignore support plugin (hsz.mobi)
-web-widget/node_modules
\ No newline at end of file
+
+# Created by https://www.toptal.com/developers/gitignore/api/node,jetbrains+all,visualstudiocode,react,reactnative
+# Edit at https://www.toptal.com/developers/gitignore?templates=node,jetbrains+all,visualstudiocode,react,reactnative
+
+### JetBrains+all ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### JetBrains+all Patch ###
+# Ignores the whole .idea folder and all .iml files
+# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
+
+.idea/
+
+# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
+
+*.iml
+modules.xml
+.idea/misc.xml
+*.ipr
+
+# Sonarlint plugin
+.idea/sonarlint
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+.env.production
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+### Node Patch ###
+# Serverless Webpack directories
+.webpack/
+
+# Optional stylelint cache
+.stylelintcache
+
+# SvelteKit build / generate output
+.svelte-kit
+
+### react ###
+.DS_*
+**/*.backup.*
+**/*.back.*
+
+node_modules
+
+*.sublime*
+
+psd
+thumb
+sketch
+
+### ReactNative ###
+# React Native Stack Base
+
+.expo
+__generated__
+
+### ReactNative.Android Stack ###
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+.idea/jarRepositories.xml
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/modules.xml
+# Comment next line if keeping position of elements in Navigation Editor is relevant for you
+.idea/navEditor.xml
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+### ReactNative.Buck Stack ###
+buck-out/
+.buckconfig.local
+.buckd/
+.buckversion
+.fakebuckversion
+
+### ReactNative.Gradle Stack ###
+.gradle
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+# Cache of project
+.gradletasknamecache
+
+# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
+# gradle/wrapper/gradle-wrapper.properties
+
+### ReactNative.Linux Stack ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### ReactNative.Node Stack ###
+# Logs
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+# Runtime data
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+# Coverage directory used by tools like istanbul
+
+# nyc test coverage
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+# Bower dependency directory (https://bower.io/)
+
+# node-waf configuration
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+# Dependency directories
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+# TypeScript cache
+
+# Optional npm cache directory
+
+# Optional eslint cache
+
+# Microbundle cache
+
+# Optional REPL history
+
+# Output of 'npm pack'
+
+# Yarn Integrity file
+
+# dotenv environment variables file
+
+# parcel-bundler cache (https://parceljs.org/)
+
+# Next.js build output
+
+# Nuxt.js build / generate output
+
+# Gatsby files
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+
+# Serverless directories
+
+# FuseBox cache
+
+# DynamoDB Local files
+
+# TernJS port file
+
+# Stores VSCode versions used for testing VSCode extensions
+
+# yarn v2
+
+### ReactNative.Xcode Stack ###
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
+*.xcscmblueprint
+*.xccheckout
+
+## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
+DerivedData/
+*.moved-aside
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+
+## Gcc Patch
+/*.gcno
+
+### ReactNative.macOS Stack ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### VisualStudioCode ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+# Support for Project snippet scope
+!.vscode/*.code-snippets
+
+# End of https://www.toptal.com/developers/gitignore/api/node,jetbrains+all,visualstudiocode,react,reactnative
+
+
+package-lock.json
+yarn.lock
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 00000000..a8363dda
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 Sendbird
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 51a9ec6d..623dc870 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,75 @@
-# SendBird JavaScript Sample
+# Sendbird JavaScript SDK v3 samples
+
+
+[](https://www.npmjs.com/package/sendbird)
-The samples in this repository are fully functional messaging applications built using the [SendBird](https://sendbird.com) JS SDK.
- 1. **Web widget sample:** Facebook-chat-like chat widget to regular websites.
- 1. **React Native sample:** Mobile chat sample for iOS and Android.
- 1. **Web sample:** Slack-like full screen chat sample for desktop browsers.
+## Deprecation Note (v3)
+:warning: Please note that Sendbird’s SDK v3 will be deprecated by **July 2023**. You may still use the older SDKs at your choice, but no new updates or bug fixes will be made to SDK v3.
+**We recommend clients to plan their migration to SDK v4 as early as possible as there are breaking changes.** We also provide prioritized support for migration and any issues related to v4. SDK v4 provides far richer and robust features in Websocket, Local caching, Polls, Scheduled Messages, Pinned Message, and many more. So try it out now! ([Chat SDK v4 react samples](https://github.com/sendbird/sendbird-chat-sample-react/))
-## Table of Contents
+
- 1. [Installing the SendBird JS SDK](#installing-the-sendbird-js-sdk)
- 1. [Previous versions](#previous-versions)
- 1. [Contributing](#contributing)
-
-## Installing the SendBird JS SDK
-
-Using [Bower](http://bower.io):
+## 🔒 Security tip
+When a new Sendbird application is created in the dashboard the default security settings are set permissive to simplify running samples and implementing your first code.
- bower install sendbird
+Before launching make sure to review the security tab under ⚙️ Settings -> Security, and set Access token permission to Read Only or Disabled so that unauthenticated users can not login as someone else. And review the Access Control lists. Most apps will want to disable "Allow retrieving user list" as that could expose usage numbers and other information.
+## Introduction
-Using [npm](https://www.npmjs.com/package/sendbird):
+This repository contains samples for how to use Sendbird to add chat using Javascript, React and React Native. You can find more information in the [Javascript SDK documentation](https://sendbird.com/docs/chat/v3/javascript/getting-started/about-chat-sdk) and [React Quickstart Documentation](https://sendbird.com/docs/uikit/v1/react/quickstart/send-first-message)
- npm install sendbird
+
-> We now support both React Native and NodeJS.
+### React
+Sendbird UIKit for React is a set of prebuilt UI components that allows you to easily craft an in-app chat with all the essential messaging features. Our development kit includes light and dark themes, text fonts, colors and more. All the included components can be styled and customized to create an unique experience that fits your app.
+- [**Basic React App**](https://github.com/sendbird/SendBird-JavaScript/tree/master/react/react-app-simple) is a quickest way to get started using UIKit
-### Manual download
-
-Or, you can manually download the JS SDK files [here](https://github.com/smilefam/SendBird-SDK-JavaScript).
+- [**Composed React App**](https://github.com/sendbird/SendBird-JavaScript/tree/master/react/react-app-custom) demonstrates how to use the various smart components.
+- [**Custom React App**](https://github.com/sendbird/SendBird-JavaScript/tree/master/react/react-app-custom) shows how to customize the **Message**, **ChannelPreview**, and **UserList** UI elements.
-## Previous versions
-To view the version 2 sample, checkout the `v2` branch instead of `master.`
+### React Native
+The Sendbird React Native framework allows you to simplify development for iOS and Android apps, and reuse the same code on both web and mobile apps.
+- [**React native Redux**](https://github.com/sendbird/SendBird-JavaScript/tree/master/react-native/react-native-redux) shows how to use Sendbird with React Native on iOS and Android.
-## Contributing
+- [**React native Redux Syncmanager**](https://github.com/sendbird/SendBird-JavaScript/tree/master/react-native/react-native-redux-syncmanager) Expands on the above sample and implements the [Sendbird SyncManager](https://github.com/sendbird/sendbird-syncmanager-javascript)
-The SendBird JavaScript samples are fully open-source. All contributions and suggestions are welcome!
+- [**React Native Hooks**](https://github.com/sendbird/SendBird-JavaScript/tree/master/react-native/react-native-hook) Implements Sendbird on iOS and Android using the hooks pattern.
+
+
+### JavaScript
+
+- [**JavaScript chat sample**](https://github.com/sendbird/SendBird-JavaScript/tree/master/javascript/javascript-basic) is a Slack-like full screen chat sample for desktop browsers using both Group channels and open channels.
+
+- [**JavaScript widget sample**](https://github.com/sendbird/SendBird-JavaScript/tree/master/javascript/javascript-widget) is a Facebook-chat-like chat widget for websites.
+
+- [**JavaScript live chat sample**](https://github.com/sendbird/SendBird-JavaScript/tree/master/javascript/javascript-live-chat) is a Twitch-chat-like experience.
+
+- [**JavaScript chat sample with SyncManager**](https://github.com/sendbird/sendbird-javascript-samples/tree/master/javascript/javascript-basic-syncmanager) is a web chat sample integrated with [Sendbird SyncManager document](https://sendbird.com/docs/syncmanager/v1/javascript/getting-started/about-syncmanager), adds local caching to the core chat features. For faster data loading and caching, the sample synchronizes with the Sendbird server and saves a list of group channels and the messages within the local cache into your client app.
+
+## Installation
+
+To use the Sendbird Chat SDK directly you can install it through npm or yarn with
+
+```bash
+npm install --save sendbird
+```
+or
+
+```bash
+yarn install --save sendbird
+```
+
+Or download the latest release manually from [GitHub](https://github.com/sendbird/SendBird-SDK-JavaScript)
+
+
+## Getting Help
+
+Check out the [UIKit for React docs](https://sendbird.com/docs/uikit/v1/javascript/getting-started/about-uikit). and Sendbird's [Developer Portal](https://sendbird.com/developer) for tutorials and videos. If you need any help in resolving any issues or have questions, visit our [community forums](https://community.sendbird.com/c/sendbird-chat/12).
+
+## We are Hiring!
+Sendbird is made up of a diverse group of humble, friendly, and hardworking individuals united by a shared purpose to build the next generation of mobile & social technologies. Join our team remotely or at one of our locations in San Mateo, Seoul, New York, London, and Singapore. More information on a [careers page](https://sendbird.com/careers).
diff --git a/asset/reactnative-sample-thumbnail.jpg b/asset/reactnative-sample-thumbnail.jpg
new file mode 100644
index 00000000..5e9ac8ec
Binary files /dev/null and b/asset/reactnative-sample-thumbnail.jpg differ
diff --git a/asset/uikit.png b/asset/uikit.png
new file mode 100644
index 00000000..6f13c4c6
Binary files /dev/null and b/asset/uikit.png differ
diff --git a/catalog-info.yaml b/catalog-info.yaml
new file mode 100644
index 00000000..63286af8
--- /dev/null
+++ b/catalog-info.yaml
@@ -0,0 +1,12 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: sendbird-javascript-samples
+ description: A guide of the installation and functions of Sendbird Chat, UIKit, and SyncManager for JavaScript samples
+ annotations:
+ github.com/project-slug: sendbird/sendbird-javascript-samples
+spec:
+ type: library
+ lifecycle: production
+ owner: dep-client-platform
+ system: sendbird-chat
diff --git a/javascript/javascript-basic-local-caching/.babelrc b/javascript/javascript-basic-local-caching/.babelrc
new file mode 100644
index 00000000..a7352030
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/.babelrc
@@ -0,0 +1,8 @@
+{
+ "presets": ["env"],
+ "env": {
+ "test": {
+ "presets": ["env"]
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/.eslintignore b/javascript/javascript-basic-local-caching/.eslintignore
new file mode 100644
index 00000000..feb309d8
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/.eslintignore
@@ -0,0 +1,2 @@
+
+**/*.min.js
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/.eslintrc.js b/javascript/javascript-basic-local-caching/.eslintrc.js
new file mode 100644
index 00000000..0211a4b2
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/.eslintrc.js
@@ -0,0 +1,21 @@
+module.exports = {
+ env: {
+ browser: true,
+ commonjs: true,
+ es6: true
+ },
+ extends: 'eslint:recommended',
+ parserOptions: {
+ parser: 'babel-eslint',
+ sourceType: 'module'
+ },
+ rules: {
+ 'linebreak-style': ['error', 'unix'],
+ quotes: ['warn', 'single'],
+ semi: ['warn', 'always'],
+ 'no-console': 1,
+ 'no-unused-vars': 1,
+ 'no-inner-declarations': 1,
+ 'no-useless-escape': 1
+ }
+};
diff --git a/javascript/javascript-basic-local-caching/.prettierignore b/javascript/javascript-basic-local-caching/.prettierignore
new file mode 100644
index 00000000..52999c0b
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/.prettierignore
@@ -0,0 +1,2 @@
+README.md
+.eslintrc.js
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/.prettierrc b/javascript/javascript-basic-local-caching/.prettierrc
new file mode 100644
index 00000000..e90d7232
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "printWidth": 120,
+ "trailingComma": "none",
+ "arrowParens": "avoid"
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/README.md b/javascript/javascript-basic-local-caching/README.md
new file mode 100644
index 00000000..e06d3c33
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/README.md
@@ -0,0 +1,81 @@
+# Sendbird Local Caching for JavaScript sample
+
+
+[](https://www.npmjs.com/package/sendbird)
+
+## Introduction
+
+Local Caching enables Sendbird Chat SDK for JavaScript to locally cache and retrieve group channel and message data. This facilitates offline messaging by allowing the SDK to create a channel list view or a chat view in a prompt manner and display them even when a client app is in offline mode. Provided here is a Local Caching for JavaScript sample to experience first-hand the benefits of Sendbird's Local Caching.
+
+### Benefits
+
+Sendbird Local Caching provides the local caching system and data synchronization with the Sendbird server, which are run on an event-driven structure. According to the real-time events of the messages and channels, Local Caching takes care of the background tasks for the cache updates from the Sendbird server to the local device. By leveraging this systemized structure with connection-based synchronization, Local Caching allows you to easily integrate the Chat SDK to utilize all of its features, while also reducing data usage and offering a reliable and effortless storage mechanism.
+
+### More about Sendbird Local Caching for JavaScript
+
+Find out more about Sendbird Local Caching for JavaScript at [Local Caching for JavaScript doc](https://sendbird.com/docs/chat/v3/javascript/guides/local-caching). If you need any help in resolving any issues or have questions, visit [our community](https://community.sendbird.com).
+
+
+
+## Before getting started
+This section provides the prerequisites for testing Sendbird Desk for JavaScript sample app.
+
+### Requirements
+The minimum requirements for Local Caching for JavaScript are:
+- Node.js v10+
+- NPM v6+
+- [Chat SDK for JavaScript](https://github.com/sendbird/SendBird-SDK-JavaScript) v3.1.0 or higher
+
+### Try the sample app using your data
+
+If you would like to try the sample app specifically fit to your usage, you can do so by replacing the default sample app ID with yours, which you can obtain by [creating your Sendbird application from the dashboard](https://sendbird.com/docs/chat/v3/javascript/getting-started/install-chat-sdk#2-step-1-create-a-sendbird-application-from-your-dashboard). Furthermore, you could also add data of your choice on the dashboard to test. This will allow you to experience the sample app with data from your Sendbird application.
+
+
+
+## Getting started
+
+You can install and run Local Caching for JavaScript sample app on your system using `npm`.
+
+### Install packages
+
+`Node` v8.x+ should be installed on your system.
+
+```bash
+npm install
+```
+
+### Run the sample
+
+```bash
+npm start
+```
+
+
+
+## Customizing the sample
+
+To implement customization to the sample, you can use `webpack` for buiding it.
+
+### Install packages
+
+`Node` v8.x+ should be installed on your system.
+
+```bash
+npm install
+```
+
+### Modify files
+
+If you want to change `APP_ID`, change `APP_ID` in `const.js` to the other `APP_ID` you want. You can test the sample with local server by running the following command.
+
+```bash
+npm run start:dev
+```
+
+### Build the sample
+
+When the modification is complete, you'll need to bundle the file using `webpack`. The bundled files are created in the **dist** folder. Please check `webpack.config.js` for settings.
+
+```bash
+npm run build
+```
diff --git a/javascript/javascript-basic-local-caching/chat.html b/javascript/javascript-basic-local-caching/chat.html
new file mode 100644
index 00000000..00977a48
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/chat.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Sample with Local Caching | Sendbird
+
+
+
+
+
+
+
+
+
+
+ Sendbird
+
+
+
+
+
+
Start by inviting user to create a channel.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/index.html b/javascript/javascript-basic-local-caching/index.html
new file mode 100644
index 00000000..b9c984b8
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Sample with Local Caching | Sendbird
+
+
+
+
+
+
+
+
+
+
+ Sendbird
+
+
+ Web Basic Sample with Local Caching
+
+
+
+
+
+
+ Start chatting on Sendbird by choosing your display name.
+
This can be changed anytime and will be shown on 1-on-1 and group messaging.
+
+
+
+
+
+ LOGIN
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/javascript-basic-local-caching/migration.ts b/javascript/javascript-basic-local-caching/migration.ts
new file mode 100644
index 00000000..adf743fc
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/migration.ts
@@ -0,0 +1,382 @@
+const sendbird = new SendBird({ appId: APP_ID });
+
+const options = new SendBirdSyncManager.Options();
+options.messageCollectionCapacity = MESSAGE_COLLECTION_CAPACITY;
+options.messageResendPolicy = MESSAGE_RESEND_POLICY;
+options.automaticMessageResendRetryCount = AUTOMATIC_MESSAGE_RESEND_RETRY_COUNT;
+options.maxFailedMessageCountPerChannel = MAX_FAILED_MESSAGE_COUNT_PER_CHANNEL;
+options.failedMessageRetentionDays = FAILED_MESSAGE_RETENTION_DAYS;
+
+SendBirdSyncManager.sendbird = sendbird;
+SendBirdSyncManager.setup(USER_ID, options, () => {
+ sendbird.setErrorFirstCallback(true);
+ sendbird
+ .connect(USER_ID)
+ .then(user => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+});
+
+const sendbird = new SendBird({ appId: APP_ID, localCacheEnabled: true });
+sendbird.setErrorFirstCallback(true);
+sendbird
+ .connect(USER_ID)
+ .then(user => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+
+const myGroupChannelListQuery = sendbird.GroupChannel.createMyGroupChannelListQuery();
+myGroupChannelListQuery.includeEmpty = INCLUDE_EMPTY;
+myGroupChannelListQuery.order = ORDER;
+myGroupChannelListQuery.limit = LIMIT;
+
+const channelCollection = new SendBirdSyncManager.ChannelCollection(myGroupChannelListQuery);
+
+const groupChannelFilter = new sendbird.GroupChannelFilter();
+groupChannelFilter.includeEmpty = INCLUDE_EMPTY;
+
+const groupChannelCollection = sendbird.GroupChannel.createGroupChannelCollection()
+ .setOrder(ORDER)
+ .setFilter(FILTER)
+ .setLimit(LIMIT)
+ .build();
+
+const collectionHandler = new SendBirdSyncManager.ChannelCollection.CollectionHandler();
+collectionHandler.onChannelEvent = (action, channels) => {
+ switch (action) {
+ case 'insert':
+ // Do something...
+ break;
+ case 'update':
+ // Do something...
+ break;
+ case 'move':
+ // Do something...
+ break;
+ case 'remove':
+ // Do something...
+ break;
+ case 'clear':
+ // Do something...
+ break;
+ }
+};
+channelCollection.setCollectionHandler(collectionHandler);
+
+channelCollection.setGroupChannelCollectionHandler({
+ onChannelsAdded: (context, channels) => {
+ // Do something...
+ },
+ onChannelsUpdated: (context, channels) => {
+ // Do something...
+ },
+ onChannelsDeleted: (context, channelUrls) => {
+ // Do something...
+ }
+});
+
+channelCollection.fetch(() => {
+ // Do something...
+});
+
+if (groupChannelCollection.hasMore) {
+ groupChannelCollection
+ .loadMore()
+ .then(channels => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+}
+
+channelCollection.setCollectionHandler(null);
+channelCollection.remove();
+
+groupChannelCollection.dispose();
+
+const messageFilter = {
+ messageTypeFilter: MESSAGE_TYPE_FILTER
+};
+const messageCollection = new SendBirdSyncManager.MessageCollection(channel, messageFilter, STARTING_POINT);
+messageCollection.limit = LIMIT;
+
+const messageFilter = new sendbird.MessageFilter();
+messageFilter.messageType = MESSAGE_TYPE;
+const messageCollection = channel
+ .createMessageCollection()
+ .setFilter(messageFilter)
+ .setStartingPoint(STARTING_POINT)
+ .setLimit(LIMIT)
+ .build();
+
+const messageCollectionHandler = new SendBirdSyncManager.MessageCollection.CollectionHandler();
+messageCollectionHandler.onPendingMessageEvent = (messages, action) => {
+ // Do something...
+};
+messageCollectionHandler.onSucceededMessageEvent = (messages, action) => {
+ // Do something...
+};
+messageCollectionHandler.onFailedMessageEvent = (messages, action, reason) => {
+ // Do something...
+};
+messageCollectionHandler.onNewMessage = message => {
+ // Do something...
+};
+messageCollectionHandler.onMessageEvent = (action, messages, action) => {
+ // Do something...
+};
+messageCollectionHandler.onChannelUpdated = channel => {
+ // Do something...
+};
+messageCollectionHandler.onChannelDeleted = channel => {
+ // Do something...
+};
+messageCollection.setCollectionHandler(messageCollectionHandler);
+
+messageCollection.setMessageCollectionHandler({
+ onMessagesAdded: (context, channel, messages) => {
+ // Do something...
+ },
+ onMessagesUpdated: (context, channel, messages) => {
+ // Do something...
+ },
+ onMessagesDeleted: (context, channel, messages) => {
+ // Do something...
+ },
+ onChannelUpdated: (context, channel) => {
+ // Do something...
+ },
+ onChannelDeleted: (context, channel) => {
+ // Do something...
+ },
+ onHugeGapDetected: () => {
+ // Do something...
+ }
+});
+
+messageCollection
+ .initialize(MESSAGE_COLLECTION_INIT_POLICY)
+ .onCacheResult((error, messages) => {
+ if (error) {
+ // Handle error.
+ }
+ // Do something...
+ })
+ .onApiResult((error, messages) => {
+ if (error) {
+ // Handle error.
+ }
+ // Do something...
+ });
+
+messageCollection.fetchSucceededMessages('next', error => {
+ if (error) {
+ // Handle error;
+ }
+ // Do something...
+});
+
+if (messageCollection.hasNext) {
+ messageCollection
+ .loadNext()
+ .then(messages => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+}
+
+messageCollection.fetchSucceededMessages('prev', error => {
+ if (error) {
+ // Handle error;
+ }
+ // Do something...
+});
+
+if (messageCollection.hasPrevious) {
+ messageCollection
+ .loadPrevious()
+ .then(messages => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+}
+
+const pendingMessage = channel.sendUserMessage(USER_MESSAGE_PARAMS, (error, message) => {
+ messageCollection.handleSendMessageResponse(error, message);
+});
+messageCollection.appendMessage(pendingMessage);
+
+channel.sendUserMessage(USER_MESSAGE_PARAMS, (error, message) => {
+ /**
+ * Pending message will be delivered to `MessageCollectionHandler.onMessagesAdded()`.
+ * The result of sending, either a succeeded or failed message, will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ * Do NOT add the pending, succeeded or failed message objects from the return value of `sendUserMessage()`, `sendFileMessage()`, and from the callback.
+ */
+});
+
+const pendingMessage = channel.sendFileMessage(FILE_MESSAGE_PARAMS, (error, message) => {
+ messageCollection.handleSendMessageResponse(error, message);
+});
+messageCollection.appendMessage(pendingMessage);
+
+channel.sendFileMessage(FILE_MESSAGE_PARAMS, (error, message) => {
+ /**
+ * Pending message will be delivered to `MessageCollectionHandler.onMessagesAdded()`.
+ * The result of sending, either a succeeded or failed message, will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ * Do NOT add the pending, succeeded or failed message objects from the return value of `sendUserMessage()`, `sendFileMessage()`, and from the callback.
+ */
+});
+
+const pendingMessage = channel.resendUserMessage(failedMessage, (error, message) => {
+ messageCollection.handleSendMessageResponse(error, message);
+});
+messageCollection.appendMessage(pendingMessage);
+
+channel.resendUserMessage(failedMessage, (error, message) => {
+ /**
+ * Pending message will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ * The result of sending, either a succeeded or failed message, will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ * Do NOT add the pending, succeeded or failed message objects from the return value of `resendUserMessage()`, `resendFileMessage()`, and from the callback.
+ */
+});
+
+const pendingMessage = channel.resendFileMessage(failedMessage, (error, message) => {
+ messageCollection.handleSendMessageResponse(error, message);
+});
+messageCollection.appendMessage(pendingMessage);
+
+channel.resendFileMessage(failedMessage, (error, message) => {
+ /**
+ * Pending message will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ * The result of sending, either a succeeded or failed message, will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ * Do NOT add the pending, succeeded or failed message objects from the return value of `resendUserMessage()`, `resendFileMessage()`, and from the callback.
+ */
+});
+
+messageCollection.setCollectionHandler(null);
+messageCollection.remove();
+
+messageCollection.dispose();
+
+messageCollectionHandler.onNewMessage = message => {
+ // Do something...
+};
+
+const channelHandler = new sendbird.ChannelHandler();
+channelHandler.onMessageReceived = (channel, message) => {
+ // Do something...
+};
+sendbird.addChannelHandler(CHANNEL_HANDLER_KEY, channelHandler);
+
+channel.updateUserMessage(message.messageId, USER_MESSAGE_PARAMS, (error, message) => {
+ messageCollection.updateMessage(message);
+});
+
+channel.updateUserMessage(message.messageId, USER_MESSAGE_PARAMS, (error, message) => {
+ /**
+ * Updated message will be handled internally and will be delivered to `MessageCollectionHandler.onMessagesUpdated()`.
+ */
+});
+
+channel.deleteMessage(message, error => {
+ messageCollection.deleteMessage(message);
+});
+
+channel.deleteMessage(message, error => {
+ /**
+ * The result will be delivered to `MessageCollectionHandler.onMessagesDeleted()`.
+ */
+});
+
+messageCollection.deleteMessage(failedMessage);
+
+messageCollection
+ .removeFailedMessages(failedMessages)
+ .then(requestIds => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+
+messageCollection
+ .removeAllFailedMessages()
+ .then(() => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
+
+messageCollection.resetViewpointTimestamp(TIMESTAMP);
+
+messageCollection.dispose();
+messageCollection = channel
+ .createMessageCollection()
+ .setFilter(messageFilter)
+ .setStartingPoint(messageCollection.startingPoint)
+ .setLimit(LIMIT)
+ .build();
+messageCollection
+ .initialize(MESSAGE_COLLECTION_INIT_POLICY)
+ .onCacheResult((error, messages) => {
+ if (error) {
+ // Handle error.
+ }
+ // Do something...
+ })
+ .onApiResult((error, messages) => {
+ if (error) {
+ // Handle error.
+ }
+ // Do something...
+ });
+
+messageCollection.fetchPendingMessages(error => {
+ if (error) {
+ // Handle error;
+ }
+ // Do something...
+});
+
+const pendingMessages = messageCollection.pendingMessages;
+
+messageCollection.fetchFailedMessages(error => {
+ if (error) {
+ // Handle error;
+ }
+ // Do something...
+});
+
+const failedMessages = messageCollection.failedMessages;
+
+const messageCount = messageCollection.messageCount;
+
+const messageCount = messageCollection.succeededMessages.length;
+
+SendBirdSyncManager.getInstance().clearCache(error => {
+ if (error) {
+ // Handle error.
+ }
+ // Do something...
+});
+
+sendbird
+ .clearCachedMessages(CHANNEL_URLS)
+ .then(() => {
+ // Do something...
+ })
+ .catch(error => {
+ // Handle error.
+ });
diff --git a/javascript/javascript-basic-local-caching/package.json b/javascript/javascript-basic-local-caching/package.json
new file mode 100644
index 00000000..ac1db3c9
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "sendbird-sample-javascript-local-caching",
+ "version": "1.0.0",
+ "description": "Sendbird Sample using Local Caching",
+ "main": "index.js",
+ "scripts": {
+ "dev": "./node_modules/.bin/webpack --mode=development",
+ "dev:w": "./node_modules/.bin/webpack --mode=none -w",
+ "build": "./node_modules/.bin/webpack --mode=production",
+ "start:dev": "./node_modules/.bin/webpack-dev-server",
+ "start": "npm run build && node server.js",
+ "deploy": "node ./deploy/deploy.js"
+ },
+ "author": "Sendbird",
+ "license": "ISC",
+ "devDependencies": {
+ "babel-core": "^6.26.0",
+ "babel-eslint": "^8.2.3",
+ "babel-loader": "^7.1.4",
+ "babel-preset-env": "^1.6.1",
+ "css-loader": "^0.28.11",
+ "eslint": "^4.19.1",
+ "eslint-loader": "^2.1.1",
+ "express": "^4.16.3",
+ "extract-text-webpack-plugin": "^4.0.0-beta.0",
+ "prettier": "^1.14.3",
+ "sass": "^1.49.9",
+ "sass-loader": "^10",
+ "ssh2": "^0.8.5",
+ "style-loader": "^0.21.0",
+ "webpack": "^4.19.1",
+ "webpack-cli": "^3.1.0",
+ "webpack-dev-server": "^3.1.11"
+ },
+ "dependencies": {
+ "moment": "^2.22.1",
+ "sendbird": "^3.1.1"
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/server.js b/javascript/javascript-basic-local-caching/server.js
new file mode 100644
index 00000000..487ba47b
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/server.js
@@ -0,0 +1,14 @@
+const express = require('express');
+const app = express();
+
+const PORT = 9000;
+
+app.use(express.static('dist'));
+app.use(express.static('./'));
+
+app.get('/', function(req, res) {
+ res.sendfile('index.html');
+});
+
+app.listen(PORT);
+console.log(`[SERVER RUNNING] 127.0.0.1:${PORT}`);
diff --git a/javascript/javascript-basic-local-caching/src/js/Chat.js b/javascript/javascript-basic-local-caching/src/js/Chat.js
new file mode 100644
index 00000000..ee82c7ca
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/Chat.js
@@ -0,0 +1,120 @@
+import styles from '../scss/chat.scss';
+import { createDivEl } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { SendBirdChatEvent } from './SendBirdChatEvent';
+import { ChatLeftMenu } from './ChatLeftMenu';
+import { ChatTopMenu } from './components/ChatTopMenu';
+import { ChatMain } from './components/ChatMain';
+
+let instance = null;
+
+class Chat {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.body = document.querySelector('.body-center');
+
+ this.channel = null;
+ this.element = null;
+ this.top = null;
+ this.emptyElement = this._createEmptyElement();
+ this.render();
+ instance = this;
+ }
+
+ _createEmptyElement() {
+ const item = createDivEl({ className: styles['chat-empty'] });
+
+ const content = createDivEl({ className: styles['empty-content'] });
+ item.appendChild(content);
+
+ const title = createDivEl({ className: styles['content-title'], content: 'WELCOME TO SAMPLE CHAT' });
+ content.appendChild(title);
+ const image = createDivEl({ className: styles['content-image'] });
+ content.appendChild(image);
+ const desc = createDivEl({
+ className: styles['content-desc'],
+ content:
+ 'Create or select a channel to chat in.\n' +
+ "If you don't have a channel to participate,\n" +
+ 'go ahead and create your first channel now.'
+ });
+ content.appendChild(desc);
+ return item;
+ }
+
+ renderEmptyElement() {
+ this._removeChatElement();
+ this.body.appendChild(this.emptyElement);
+ }
+
+ _removeEmptyElement() {
+ if (this.body.contains(this.emptyElement)) {
+ this.body.removeChild(this.emptyElement);
+ }
+ }
+
+ _createChatElement(channel) {
+ this.element = createDivEl({ className: styles['chat-root'] });
+
+ this.top = new ChatTopMenu(channel);
+ this.element.appendChild(this.top.element);
+
+ /// reset manager when ChatMain is obsolete
+ if (this.main && this.main.body && this.main.body.collection) {
+ this.main.body.collection.dispose();
+ }
+ this.main = new ChatMain(channel);
+ }
+
+ _addEventHandler() {
+ const channelEvent = new SendBirdChatEvent();
+ channelEvent.onTypingStatusUpdated = groupChannel => {
+ if (this.channel.url === groupChannel.url) {
+ this.main.updateTyping(groupChannel.getTypingMembers());
+ }
+ };
+ }
+
+ _renderChatElement(channel) {
+ const sendbirdAction = SendBirdAction.getInstance();
+ this._removeEmptyElement();
+ this._removeChatElement();
+ this.channel = channel;
+
+ ChatLeftMenu.getInstance().activeChannelItem(channel.url);
+ this._addEventHandler();
+
+ sendbirdAction
+ .getChannel(channel.url)
+ .then(channel => {
+ this.channel = channel;
+ this._createChatElement(this.channel);
+ this.body.appendChild(this.element);
+ this.main.loadInitialMessages();
+ })
+ .catch(() => {
+ this._createChatElement(this.channel);
+ this.body.appendChild(this.element);
+ this.main.loadInitialMessages();
+ });
+ }
+
+ _removeChatElement() {
+ const chatElements = this.body.getElementsByClassName(styles['chat-root']);
+ Array.prototype.slice.call(chatElements).forEach(chatEl => {
+ chatEl.parentNode.removeChild(chatEl);
+ });
+ }
+
+ render(channel) {
+ channel ? this._renderChatElement(channel) : this.renderEmptyElement();
+ }
+
+ static getInstance() {
+ return new Chat();
+ }
+}
+
+export { Chat };
diff --git a/javascript/javascript-basic-local-caching/src/js/ChatLeftMenu.js b/javascript/javascript-basic-local-caching/src/js/ChatLeftMenu.js
new file mode 100644
index 00000000..27707918
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/ChatLeftMenu.js
@@ -0,0 +1,144 @@
+import { LeftListItem } from './components/LeftListItem';
+import { ACTIVE_CLASSNAME, DISPLAY_BLOCK, DISPLAY_NONE } from './const';
+import { addClass, isScrollBottom, isUrl, protectFromXSS, removeClass, findChannelIndex } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { UserList } from './components/UserList';
+import { Chat } from './Chat';
+
+let instance = null;
+
+class ChatLeftMenu {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.activeChannelUrl = null;
+
+ const action = new SendBirdAction();
+ const sb = action.sb;
+ const groupChannelFilter = new sb.GroupChannelFilter();
+ groupChannelFilter.includeEmpty = false;
+ this.channelCollection = sb.GroupChannel.createGroupChannelCollection()
+ .setOrder(sb.GroupChannelCollection.GroupChannelOrder.ORDER_LATEST_LAST_MESSAGE)
+ .setFilter(groupChannelFilter)
+ .setLimit(50)
+ .build();
+ this.channelCollection.setGroupChannelCollectionHandler({
+ onChannelsAdded: (context, channels) => {
+ for (let i in channels) {
+ const channel = channels[i];
+ const index = findChannelIndex(channel, this.channelCollection.channels);
+ const handler = () => {
+ Chat.getInstance().render(channel, false);
+ this.activeChannelUrl = channel.url;
+ };
+ const item = new LeftListItem({ channel, handler });
+ if (index < this.groupChannelList.childNodes.length - 1) {
+ this.groupChannelList.insertBefore(item.element, this.groupChannelList.childNodes[index]);
+ } else {
+ this.groupChannelList.appendChild(item.element);
+ }
+ if (this.activeChannelUrl === channel.url) {
+ this.activeChannelItem(channel.url);
+ }
+ }
+ LeftListItem.updateUnreadCount();
+ this.toggleGroupChannelDefaultItem();
+ },
+ onChannelsUpdated: (context, channels) => {
+ for (let i in channels) {
+ const channel = channels[i];
+ const item = this.getItem(channel.url);
+ const handler = () => {
+ Chat.getInstance().render(channel, false);
+ this.activeChannelUrl = channel.url;
+ };
+ const newItem = new LeftListItem({ channel, handler });
+ this.groupChannelList.replaceChild(newItem.element, item);
+ if (this.activeChannelUrl === channel.url) {
+ this.activeChannelItem(channel.url);
+ }
+ }
+ LeftListItem.updateUnreadCount();
+ },
+ onChannelsDeleted: (context, channelUrls) => {
+ for (let i in channelUrls) {
+ const channelUrl = channelUrls[i];
+ if (this.activeChannelUrl === channelUrl) {
+ this.activeChannelUrl = null;
+ Chat.getInstance().render();
+ }
+ const element = this.getItem(channelUrl);
+ this.groupChannelList.removeChild(element);
+ }
+ this.toggleGroupChannelDefaultItem();
+ }
+ });
+
+ this.groupChannelList = document.getElementById('group_list');
+ this.groupChannelList.addEventListener('scroll', () => {
+ if (isScrollBottom(this.groupChannelList)) {
+ this.loadGroupChannelList();
+ }
+ });
+ this.groupChannelDefaultItem = document.getElementById('default_item_group');
+
+ const groupChannelCreateBtn = document.getElementById('group_chat_add');
+ groupChannelCreateBtn.addEventListener('click', () => {
+ UserList.getInstance().render();
+ });
+ instance = this;
+ }
+
+ updateUserInfo(user) {
+ const userInfoEl = document.getElementById('user_info');
+ const profileEl = userInfoEl.getElementsByClassName('image-profile')[0];
+ if (isUrl(user.profileUrl)) {
+ profileEl.setAttribute('src', protectFromXSS(user.profileUrl));
+ }
+ const nicknameEl = userInfoEl.getElementsByClassName('nickname-content')[0];
+ nicknameEl.innerHTML = protectFromXSS(user.nickname);
+ }
+
+ loadGroupChannelList() {
+ this.channelCollection.loadMore().then(channels => {
+ for (let channel of channels) {
+ const handler = () => {
+ Chat.getInstance().render(channel, false);
+ this.activeChannelUrl = channel.url;
+ };
+ const item = new LeftListItem({ channel, handler });
+ this.groupChannelList.appendChild(item.element);
+ }
+ this.toggleGroupChannelDefaultItem();
+ });
+ }
+ getItem(elementId) {
+ const groupChannelItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i = 0; i < groupChannelItems.length; i++) {
+ if (groupChannelItems[i].id === elementId) {
+ return groupChannelItems[i];
+ }
+ }
+ return null;
+ }
+ activeChannelItem(channelUrl) {
+ const groupItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i = 0; i < groupItems.length; i++) {
+ groupItems[i].id === channelUrl
+ ? addClass(groupItems[i], ACTIVE_CLASSNAME)
+ : removeClass(groupItems[i], ACTIVE_CLASSNAME);
+ }
+ }
+ toggleGroupChannelDefaultItem() {
+ this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName()).length > 0
+ ? (this.groupChannelDefaultItem.style.display = DISPLAY_NONE)
+ : (this.groupChannelDefaultItem.style.display = DISPLAY_BLOCK);
+ }
+
+ static getInstance() {
+ return new ChatLeftMenu();
+ }
+}
+
+export { ChatLeftMenu };
diff --git a/javascript/javascript-basic-local-caching/src/js/SendBirdAction.js b/javascript/javascript-basic-local-caching/src/js/SendBirdAction.js
new file mode 100644
index 00000000..826bbe31
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/SendBirdAction.js
@@ -0,0 +1,237 @@
+import { APP_ID as appId } from './const';
+import { isNull } from './utils';
+
+import SendBird from 'sendbird';
+
+let instance = null;
+
+class SendBirdAction {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.sb = new SendBird({
+ appId,
+ localCacheEnabled: true
+ });
+ this.sb.setErrorFirstCallback(true);
+ this.userQuery = null;
+ this.groupChannelQuery = null;
+ this.previousMessageQuery = null;
+ this.blockedQuery = null;
+ instance = this;
+ }
+
+ /**
+ * Connect
+ */
+ connect(userId, nickname) {
+ return new Promise((resolve, reject) => {
+ const sb = SendBird.getInstance();
+ sb.connect(userId, (error, user) => {
+ if (error) {
+ reject(error);
+ } else {
+ sb.updateCurrentUserInfo(decodeURIComponent(nickname), null, (error, user) => {
+ error ? reject(error) : resolve(user);
+ });
+ }
+ });
+ });
+ }
+
+ /**
+ * User
+ */
+ getCurrentUser() {
+ return this.sb.currentUser;
+ }
+
+ getConnectionState() {
+ return this.sb.getConnectionState();
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow retrieving user list from SDK" under ⚙️ Sendbird Dashboard ->Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow retrieving user list from SDK" as that could possibly expose user information
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ getUserList(isInit = false) {
+ if (isInit || isNull(this.userQuery)) {
+ this.userQuery = this.sb.createApplicationUserListQuery();
+ this.userQuery.limit = 30;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.userQuery.hasNext && !this.userQuery.isLoading) {
+ this.userQuery.next((error, list) => {
+ error ? reject(error) : resolve(list);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ isCurrentUser(user) {
+ return user.userId === this.sb.currentUser.userId;
+ }
+
+ getBlockedList(isInit = false) {
+ if (isInit || isNull(this.blockedQuery)) {
+ this.blockedQuery = this.sb.createBlockedUserListQuery();
+ this.blockedQuery.limit = 30;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.blockedQuery.hasNext && !this.blockedQuery.isLoading) {
+ this.blockedQuery.next((error, blockedList) => {
+ error ? reject(error) : resolve(blockedList);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ blockUser(user, isBlock = true) {
+ return new Promise((resolve, reject) => {
+ if (isBlock) {
+ this.sb.blockUser(user, (error, response) => {
+ error ? reject(error) : resolve();
+ });
+ } else {
+ this.sb.unblockUser(user, (error, response) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ }
+
+ /**
+ * Channel
+ */
+ getChannel(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (error, groupChannel) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ });
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow creating group channels from SDK" under ⚙️ Sendbird Dashboard -> Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow creating group channels from SDK" as that could cause unwanted operations.
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ createGroupChannel(userIds) {
+ return new Promise((resolve, reject) => {
+ let params = new this.sb.GroupChannelParams();
+ params.addUserIds(userIds);
+ this.sb.GroupChannel.createChannel(params, (error, groupChannel) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ });
+ }
+
+ inviteGroupChannel(channelUrl, userIds) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (error, groupChannel) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.inviteWithUserIds(userIds, (error, groupChannel) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ }
+ });
+ });
+ }
+
+ leave(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (error, groupChannel) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.leave((error, response) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ hide(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (error, groupChannel) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.hide((error, response) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ markAsRead(channel) {
+ channel.markAsRead();
+ }
+
+ getReadReceipt(channel, message) {
+ if (this.isCurrentUser(message.sender)) {
+ return this.sb.currentUser ? channel.getReadReceipt(message) : 0;
+ } else {
+ return 0;
+ }
+ }
+
+ sendUserMessage({ channel, message, handler }) {
+ return channel.sendUserMessage(message, (error, message) => {
+ if (handler) handler(error, message);
+ });
+ }
+
+ sendFileMessage({ channel, file, thumbnailSizes, handler }) {
+ const fileMessageParams = new this.sb.FileMessageParams();
+ fileMessageParams.file = file;
+ fileMessageParams.thumbnailSizes = thumbnailSizes;
+
+ return channel.sendFileMessage(fileMessageParams, (error, message) => {
+ if (handler) handler(error, message);
+ });
+ }
+
+ deleteMessage({ channel, message, col }) {
+ return new Promise((resolve, reject) => {
+ if (!this.isCurrentUser(message.sender)) {
+ reject({
+ message: 'You have not ownership in this message.'
+ });
+ return;
+ }
+ if (message.messageId === 0 && (message.sendingStatus === 'pending' || message.sendingStatus === 'failed')) {
+ col.deleteMessage(message);
+ resolve(true);
+ } else {
+ channel.deleteMessage(message, (error, response) => {
+ error ? reject(error) : resolve(response);
+ });
+ }
+ });
+ }
+
+ static getInstance() {
+ return new SendBirdAction();
+ }
+}
+
+export { SendBirdAction };
diff --git a/javascript/javascript-basic-local-caching/src/js/SendBirdChatEvent.js b/javascript/javascript-basic-local-caching/src/js/SendBirdChatEvent.js
new file mode 100644
index 00000000..8f12fd0e
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/SendBirdChatEvent.js
@@ -0,0 +1,68 @@
+import { uuid4 } from './utils';
+import SendBird from 'sendbird';
+
+let instance = null;
+
+class SendBirdChatEvent {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this._createChannelHandler();
+
+ this.onMessageReceived = null;
+ this.onMessageUpdated = null;
+ this.onMessageDeleted = null;
+
+ this.onReadReceiptUpdated = null;
+ this.onTypingStatusUpdated = null;
+ instance = this;
+ }
+
+ /**
+ * Channel Handler
+ */
+ _createChannelHandler() {
+ const handler = new this.sb.ChannelHandler();
+ handler.onMessageReceived = (channel, message) => {
+ if (this.onMessageReceived) {
+ this.onMessageReceived(channel, message);
+ }
+ };
+ handler.onMessageUpdated = (channel, message) => {
+ if (this.onMessageUpdated) {
+ this.onMessageUpdated(channel, message);
+ }
+ };
+ handler.onMessageDeleted = (channel, messageId) => {
+ if (this.onMessageDeleted) {
+ this.onMessageDeleted(channel, messageId);
+ }
+ };
+
+ handler.onReadReceiptUpdated = groupChannel => {
+ if (this.onReadReceiptUpdated) {
+ this.onReadReceiptUpdated(groupChannel);
+ }
+ };
+ handler.onTypingStatusUpdated = groupChannel => {
+ if (this.onTypingStatusUpdated) {
+ this.onTypingStatusUpdated(groupChannel);
+ }
+ };
+ this.sb.addChannelHandler(this.key, handler);
+ }
+
+ remove() {
+ this.sb.removeChannelHandler(this.key);
+ }
+
+ static getInstance() {
+ return instance;
+ }
+}
+
+export { SendBirdChatEvent };
diff --git a/javascript/javascript-basic-local-caching/src/js/SendBirdConnection.js b/javascript/javascript-basic-local-caching/src/js/SendBirdConnection.js
new file mode 100644
index 00000000..5bda9b1a
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/SendBirdConnection.js
@@ -0,0 +1,62 @@
+import { uuid4 } from './utils';
+import SendBird from 'sendbird';
+import { Chat } from './Chat';
+
+let instance = null;
+
+class SendBirdConnection {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this.channel = null;
+ this._createConnectionHandler(this.key);
+ this.chat = Chat.getInstance();
+
+ this.onReconnectStarted = null;
+ this.onReconnectSucceeded = null;
+ this.onReconnectFailed = null;
+
+ instance = this;
+ }
+
+ _createConnectionHandler(key) {
+ const handler = new this.sb.ConnectionHandler();
+ handler.onReconnectStarted = () => {
+ if (this.chat && this.chat.main) {
+ this.chat.main.body.stopSpinner();
+ }
+ if (this.onReconnectStarted) {
+ this.onReconnectStarted();
+ }
+ };
+ handler.onReconnectSucceeded = () => {
+ if (this.onReconnectSucceeded) {
+ this.onReconnectSucceeded();
+ }
+ };
+ handler.onReconnectFailed = () => {
+ if (this.onReconnectFailed) {
+ this.onReconnectFailed();
+ }
+ };
+ this.sb.addConnectionHandler(key, handler);
+ }
+
+ remove() {
+ this.sb.removeConnectionHandler(this.key);
+ }
+
+ reconnect() {
+ this.sb.reconnect();
+ }
+
+ static getInstance() {
+ return new SendBirdConnection();
+ }
+}
+
+export { SendBirdConnection };
diff --git a/javascript/javascript-basic-local-caching/src/js/SendBirdEvent.js b/javascript/javascript-basic-local-caching/src/js/SendBirdEvent.js
new file mode 100644
index 00000000..58c82969
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/SendBirdEvent.js
@@ -0,0 +1,47 @@
+import { uuid4 } from './utils';
+import SendBird from 'sendbird';
+
+class SendBirdEvent {
+ constructor() {
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this._createChannelHandler();
+
+ this.onChannelChanged = null;
+ this.onUserJoined = null;
+ this.onUserLeft = null;
+ this.onChannelHidden = null;
+ this.onUserEntered = null;
+ }
+
+ _createChannelHandler() {
+ const handler = new this.sb.ChannelHandler();
+ handler.onChannelChanged = channel => {
+ if (this.onChannelChanged) {
+ this.onChannelChanged(channel);
+ }
+ };
+ handler.onUserJoined = (groupChannel, user) => {
+ if (this.onUserJoined) {
+ this.onUserJoined(groupChannel, user);
+ }
+ };
+ handler.onUserLeft = (groupChannel, user) => {
+ if (this.onUserLeft) {
+ this.onUserLeft(groupChannel, user);
+ }
+ };
+ handler.onChannelHidden = groupChannel => {
+ if (this.onChannelHidden) {
+ this.onChannelHidden(groupChannel);
+ }
+ };
+ this.sb.addChannelHandler(this.key, handler);
+ }
+
+ remove() {
+ this.sb.removeChannelHandler(this.key);
+ }
+}
+
+export { SendBirdEvent };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/ChatBody.js b/javascript/javascript-basic-local-caching/src/js/components/ChatBody.js
new file mode 100644
index 00000000..e96efcf9
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/ChatBody.js
@@ -0,0 +1,247 @@
+import styles from '../../scss/chat-body.scss';
+import { createDivEl, getDataInElement, removeClass, findMessageIndex, mergeFailedWithSuccessful } from '../utils';
+import { Message } from './Message';
+import { SendBirdAction } from '../SendBirdAction';
+import { MESSAGE_REQ_ID } from '../const';
+import { Spinner } from './Spinner';
+
+class ChatBody {
+ constructor(channel) {
+ this.channel = channel;
+ this.readReceiptManageList = [];
+ this.scrollHeight = 0;
+ this.collection = null;
+ this.limit = 50;
+ this.element = createDivEl({ className: styles['chat-body'] });
+ this._initElement();
+ this.spinnerStarted = false;
+ this.messagesView = [];
+ }
+
+ _initElement() {
+ if (this.collection) {
+ this.collection.dispose();
+ }
+ const action = SendBirdAction.getInstance();
+ const sb = action.sb;
+ const messageFilter = new sb.MessageFilter();
+ this.collection = this.channel
+ .createMessageCollection()
+ .setFilter(messageFilter)
+ .setStartingPoint(new Date().getTime())
+ .setLimit(this.limit)
+ .build();
+
+ this.collection.setMessageCollectionHandler({
+ onMessagesAdded: (context, channel, messages) => {
+ this._mergeMessagesOnInsert(messages);
+ },
+ onMessagesUpdated: (context, channel, messages) => {
+ this._updateMessages(messages);
+ },
+ onMessagesDeleted: (context, channel, messages) => {
+ this._removeMessages(messages);
+ },
+ onChannelUpdated: (context, channel) => {},
+ onChannelDeleted: (context, channel) => {},
+ onHugeGapDetected: () => {}
+ });
+
+ this.element.addEventListener('scroll', () => {
+ if (this.element.scrollTop === 0) {
+ this.updateCurrentScrollHeight();
+ this.collection.loadPrevious().then(messages => {
+ this.element.scrollTop = this.element.scrollHeight - this.scrollHeight;
+ });
+ }
+
+ if (this.element.scrollHeight - this.element.scrollTop - this.element.clientHeight === 0) {
+ const newMessagePop = document.getElementById('new-message-pop');
+ if (newMessagePop) newMessagePop.remove();
+ }
+ });
+ }
+
+ _mergeMessagesOnInsert(messages) {
+ const { pendingMessages, succeededMessages, failedMessages } = this.collection;
+ const wholeCollectionMessages = [...pendingMessages, ...succeededMessages, ...failedMessages];
+ wholeCollectionMessages.sort((message1, message2) => {
+ if (message1.messageId !== 0 && message2.messageId !== 0) {
+ return message1.messageId - message2.messageId;
+ } else if (message1.reqId && message2.reqId) {
+ return parseInt(message1.reqId) - parseInt(message2.reqId);
+ }
+ return message1.createdAt - message2.createdAt;
+ });
+ for (let message of messages) {
+ const index = findMessageIndex(message, wholeCollectionMessages);
+ if (index >= 0) {
+ const messageElements = this.element.querySelectorAll('.chat-message');
+ const messageItem = new Message({ channel: this.channel, message, col: this.collection });
+ this.element.insertBefore(messageItem.element, messageElements[index]);
+ if (
+ (message.isUserMessage() || message.isFileMessage()) &&
+ SendBirdAction.getInstance().isCurrentUser(message.sender)
+ ) {
+ this.readReceiptManage(message);
+ }
+ }
+ }
+ }
+
+ _updateMessages(messages, transformToManual = false) {
+ for (let i in messages) {
+ const message = messages[i];
+ const messageItem = new Message({
+ channel: this.channel,
+ message,
+ isManual: transformToManual,
+ col: this.collection
+ });
+ const currentItem = this._getItem(message.reqId);
+ const requestItem = message.reqId ? this._getItem(message.reqId) : null;
+ if (currentItem || requestItem) {
+ this.element.replaceChild(messageItem.element, requestItem ? requestItem : currentItem);
+ }
+ }
+ }
+
+ _removeMessages(messages) {
+ if (
+ this.collection.pendingMessages.length > 0 &&
+ messages.length > 0 &&
+ messages[0].messageId === 0 &&
+ !this.spinnerStarted
+ ) {
+ const el = this._getItem(messages[0].reqId);
+ const resendButton = el.firstChild.getElementsByClassName('resend-button');
+ if (resendButton && resendButton.length === 0) {
+ Spinner.start(this.element);
+ this.spinnerStarted = true;
+ }
+ }
+ for (let i in messages) {
+ const message = messages[i];
+ this.removeMessage(message.reqId);
+ }
+ if (
+ (this.spinnerStarted && this.collection.pendingMessages.length === 0) ||
+ SendBirdAction.getInstance().getConnectionState() !== 'OPEN'
+ ) {
+ this.stopSpinner();
+ }
+ }
+
+ stopSpinner() {
+ Spinner.remove();
+ this.spinnerStarted = false;
+ }
+
+ _clearMessages() {
+ while (this.element.firstChild) {
+ this.element.removeChild(this.element.firstChild);
+ }
+ }
+
+ loadPreviousMessages(callback) {
+ this.collection.loadPrevious().then(messages => {
+ this._mergeMessagesOnInsert(messages);
+ callback();
+ });
+ }
+
+ loadInitialMessages(callback) {
+ const action = SendBirdAction.getInstance();
+ const sb = action.sb;
+ this.collection
+ .initialize(sb.MessageCollection.MessageCollectionInitPolicy.CACHE_AND_REPLACE_BY_API, new Date().getTime())
+ .onCacheResult((error, messages) => {
+ if (!error) {
+ this._mergeMessagesOnInsert(messages);
+ }
+ })
+ .onApiResult((error, messages) => {
+ if (!error) {
+ this.element.innerHTML = '';
+ this._mergeMessagesOnInsert(messages);
+ }
+ callback();
+ });
+ }
+
+ scrollToBottom() {
+ this.element.scrollTop = this.element.scrollHeight - this.element.offsetHeight;
+ }
+
+ updateCurrentScrollHeight() {
+ this.scrollHeight = this.element.scrollHeight;
+ }
+
+ repositionScroll(imageOffsetHeight) {
+ this.element.scrollTop += imageOffsetHeight;
+ }
+
+ updateReadReceipt() {
+ this.readReceiptManageList.forEach(message => {
+ if (message.messageId.toString() !== '0') {
+ const className = Message.getReadReceiptElementClassName();
+ const messageItem = this._getItem(message.reqId);
+ if (messageItem) {
+ let readItem = null;
+ try {
+ readItem = messageItem.getElementsByClassName(className)[0];
+ } catch (e) {
+ readItem = null;
+ }
+ const latestCount = SendBirdAction.getInstance().getReadReceipt(this.channel, message);
+ if (readItem && latestCount.toString() !== readItem.textContent.toString()) {
+ readItem.innerHTML = latestCount;
+ if (latestCount.toString() === '0') {
+ removeClass(readItem, className);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ readReceiptManage(message) {
+ for (let i = 0; i < this.readReceiptManageList.length; i++) {
+ if (message.reqId) {
+ if (this.readReceiptManageList[i].reqId === message.reqId) {
+ this.readReceiptManageList.splice(i, 1);
+ break;
+ }
+ } else {
+ if (this.readReceiptManageList[i].messageId === message.messageId) {
+ this.readReceiptManageList.splice(i, 1);
+ break;
+ }
+ }
+ }
+ this.readReceiptManageList.push(message);
+ this.updateReadReceipt();
+ }
+
+ _getItem(reqId) {
+ const items = this.element.childNodes;
+ // We go in reverse order to prevent situations that
+ // pending-message remove requests accidentally delete succeeded messages
+ for (let i = items.length - 1; i >= 0; i--) {
+ const elementId = getDataInElement(items[i], MESSAGE_REQ_ID);
+ if (elementId === reqId.toString()) {
+ return items[i];
+ }
+ }
+ return null;
+ }
+
+ removeMessage(reqId) {
+ const removeElement = this._getItem(reqId);
+ if (removeElement) {
+ this.element.removeChild(removeElement);
+ }
+ }
+}
+
+export { ChatBody };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/ChatInput.js b/javascript/javascript-basic-local-caching/src/js/components/ChatInput.js
new file mode 100644
index 00000000..da23c486
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/ChatInput.js
@@ -0,0 +1,116 @@
+import styles from '../../scss/chat-input.scss';
+import { createDivEl, protectFromXSS } from '../utils';
+import { DISPLAY_BLOCK, DISPLAY_NONE, FILE_ID, KEY_ENTER } from '../const';
+import { SendBirdAction } from '../SendBirdAction';
+import { Chat } from '../Chat';
+
+class ChatInput {
+ constructor(channel) {
+ this.channel = channel;
+ this.input = null;
+ this.typing = null;
+ this.element = this._createElement(channel);
+ }
+
+ _createElement(channel) {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const chat = Chat.getInstance();
+ const root = createDivEl({ className: styles['chat-input'] });
+
+ this.typing = createDivEl({ className: styles['typing-field'] });
+ root.appendChild(this.typing);
+
+ const file = document.createElement('label');
+ file.className = styles['input-file'];
+ file.for = FILE_ID;
+ file.addEventListener('click', () => {
+ sendbirdAction.markAsRead(this.channel);
+ });
+
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.id = FILE_ID;
+ fileInput.style.display = DISPLAY_NONE;
+ fileInput.addEventListener('change', () => {
+ const sendFile = fileInput.files[0];
+ if (sendFile) {
+ const previewMessage = SendBirdAction.getInstance().sendFileMessage({
+ channel: this.channel,
+ file: sendFile,
+ thumbnailSizes: [
+ { maxWidth: 240, maxHeight: 240 },
+ { maxWidth: 320, maxHeight: 320 }
+ ],
+ handler: (error, message) => {
+ chat.main.body.scrollToBottom();
+ }
+ });
+ previewMessage.createdAt = new Date().getTime();
+ }
+ });
+
+ file.appendChild(fileInput);
+ root.appendChild(file);
+
+ const inputText = createDivEl({ className: styles['input-text'] });
+
+ this.input = document.createElement('textarea');
+ this.input.className = styles['input-text-area'];
+ this.input.placeholder = 'Write a chat...';
+ this.input.addEventListener('click', () => {
+ sendbirdAction.markAsRead(this.channel);
+ });
+ this.input.addEventListener('keypress', e => {
+ if (e.keyCode === KEY_ENTER) {
+ if (!e.shiftKey) {
+ e.preventDefault();
+ const message = this.input.value;
+ this.input.value = '';
+ if (message) {
+ const previewMessage = SendBirdAction.getInstance().sendUserMessage({
+ channel: this.channel,
+ message,
+ handler: (error, message) => {
+ chat.main.body.scrollToBottom();
+ }
+ });
+ channel.endTyping();
+ }
+ } else {
+ channel.startTyping();
+ }
+ } else {
+ channel.startTyping();
+ }
+ });
+ this.input.addEventListener('focusin', () => {
+ inputText.style.border = '1px solid #2C2D30';
+ });
+ this.input.addEventListener('focusout', () => {
+ inputText.style.border = '';
+ });
+
+ inputText.appendChild(this.input);
+ root.appendChild(inputText);
+ return root;
+ }
+
+ updateTyping(memberList) {
+ let nicknames = '';
+ if (memberList.length === 1) {
+ nicknames = `${protectFromXSS(memberList[0].nickname)} is`;
+ } else if (memberList.length === 2) {
+ nicknames = `${memberList
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ')} are`;
+ } else if (memberList.length !== 0) {
+ nicknames = 'Several are';
+ }
+ this.typing.style.display = nicknames ? DISPLAY_BLOCK : DISPLAY_NONE;
+ this.typing.innerHTML = `${nicknames} typing...`;
+ }
+}
+
+export { ChatInput };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/ChatMain.js b/javascript/javascript-basic-local-caching/src/js/components/ChatMain.js
new file mode 100644
index 00000000..8d323d98
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/ChatMain.js
@@ -0,0 +1,59 @@
+import styles from '../../scss/chat-main.scss';
+import { ChatBody } from './ChatBody';
+import { ChatInput } from './ChatInput';
+import { Chat } from '../Chat';
+import { createDivEl } from '../utils';
+import { ChatMenu } from './ChatMenu';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatMain {
+ constructor(channel) {
+ this.channel = channel;
+ this.body = null;
+ this.input = null;
+ this.menu = null;
+ this._create();
+ }
+
+ _create() {
+ const root = createDivEl({ className: styles['chat-main-root'] });
+
+ const main = createDivEl({ className: styles['chat-main'] });
+ root.appendChild(main);
+
+ this.body = new ChatBody(this.channel);
+ main.appendChild(this.body.element);
+
+ this.input = new ChatInput(this.channel);
+ main.appendChild(this.input.element);
+
+ this.menu = new ChatMenu(this.channel);
+ root.appendChild(this.menu.element);
+
+ Chat.getInstance().element.appendChild(root);
+ }
+
+ updateTyping(memberList) {
+ this.input.updateTyping(memberList);
+ }
+
+ repositionScroll(height) {
+ this.body.repositionScroll(height);
+ }
+
+ updateBlockedList(user, isBlock) {
+ this.menu.updateBlockedList(user, isBlock);
+ }
+
+ loadInitialMessages() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ this.body.loadInitialMessages(() => {
+ this.body.loadPreviousMessages(() => {
+ sendbirdAction.markAsRead(this.channel);
+ this.body.scrollToBottom();
+ });
+ });
+ }
+}
+
+export { ChatMain };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/ChatMenu.js b/javascript/javascript-basic-local-caching/src/js/components/ChatMenu.js
new file mode 100644
index 00000000..0ad4e845
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/ChatMenu.js
@@ -0,0 +1,155 @@
+import styles from '../../scss/chat-menu.scss';
+import { appendToFirst, errorAlert, createDivEl } from '../utils';
+import { DISPLAY_FLEX, DISPLAY_NONE } from '../const';
+import { Spinner } from './Spinner';
+import { ChatUserItem } from './ChatUserItem';
+import { SendBirdAction } from '../SendBirdAction';
+
+const Type = {
+ PARTICIPANTS: 'PARTICIPANTS',
+ MEMBERS: 'MEMBERS',
+ BLOCKED: 'BLOCKED'
+};
+
+class ChatMenu {
+ constructor(channel) {
+ this.channel = channel;
+ this.element = createDivEl({ className: styles['chat-menu-root'] });
+ this.listElement = null;
+ this.type = null;
+ this._createListElement();
+ this._createElement();
+ }
+
+ _createListElement() {
+ this.listElement = createDivEl({ className: styles['menu-list'] });
+
+ const title = createDivEl({ className: styles['list-title'] });
+ title.addEventListener('click', () => {
+ this.type = null;
+ this.list.innerHTML = '';
+ this.listElement.style.display = DISPLAY_NONE;
+ });
+ this.listElement.appendChild(title);
+ const backBtn = createDivEl({ className: styles['list-back'] });
+ title.appendChild(backBtn);
+ this.titleText = createDivEl({ className: styles['list-text'] });
+ title.appendChild(this.titleText);
+
+ this.list = createDivEl({ className: styles['list-body'] });
+ this.list.addEventListener('scroll', () => {
+ if (this.type === Type.BLOCKED) {
+ this._getBlockedList(this.type);
+ }
+ });
+ this.listElement.appendChild(this.list);
+
+ this.element.appendChild(this.listElement);
+ }
+
+ _createElement() {
+ const usersItem = createDivEl({ className: styles['menu-item'] });
+ const users = createDivEl({
+ className: styles['menu-users'],
+ content: Type.MEMBERS
+ });
+ usersItem.appendChild(users);
+ const arrowUser = createDivEl({ className: styles['menu-arrow'] });
+ usersItem.appendChild(arrowUser);
+ usersItem.addEventListener('click', () => {
+ this._renderList(users.textContent);
+ });
+ this.element.appendChild(usersItem);
+
+ const blockedItem = createDivEl({ className: styles['menu-item'] });
+ const blocked = createDivEl({ className: styles['menu-blocked'], content: Type.BLOCKED });
+ blockedItem.appendChild(blocked);
+ const arrowBlocked = createDivEl({ className: styles['menu-arrow'] });
+ blockedItem.appendChild(arrowBlocked);
+ blockedItem.addEventListener('click', () => {
+ this._renderList(blocked.textContent);
+ });
+ this.element.appendChild(blockedItem);
+ }
+
+ _renderList(listTitle) {
+ switch (listTitle) {
+ case Type.MEMBERS:
+ this.type = Type.MEMBERS;
+ this._getMemberList(listTitle);
+ break;
+ case Type.BLOCKED:
+ this.type = Type.BLOCKED;
+ this._getBlockedList(listTitle, true);
+ break;
+ default:
+ this.titleText.innerHTML = '';
+ break;
+ }
+ }
+
+ _getMemberList(listTitle) {
+ if (this.channel.isGroupChannel()) {
+ Spinner.start(this.listElement);
+ this.list.innerHTML = '';
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ this.channel.members.forEach(user => {
+ const memberItem = new ChatUserItem({ user, hasEvent: false });
+ this.list.appendChild(memberItem.element);
+ });
+ Spinner.remove();
+ }
+ }
+
+ _getBlockedList(listTitle, isInit = false) {
+ Spinner.start(this.listElement);
+ if (isInit) {
+ this.list.innerHTML = '';
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ }
+ SendBirdAction.getInstance()
+ .getBlockedList(isInit)
+ .then(blockedList => {
+ blockedList.forEach(user => {
+ const blockedItem = new ChatUserItem({ user, hasEvent: true });
+ this.list.appendChild(blockedItem.element);
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ updateBlockedList(user, isBlock) {
+ if (this.list) {
+ if (isBlock) {
+ const blockedItem = new ChatUserItem({ user, hasEvent: true });
+ appendToFirst(this.list, blockedItem.element);
+ } else {
+ const items = this.list.childNodes;
+ for (let i = 0; i < items.length; i++) {
+ if (items[0].id === user.userId) {
+ this.list.removeChild(items[0]);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ updateMenu(channel) {
+ if (this.type === Type.MEMBERS) {
+ this.channel = channel;
+ this._getMemberList(this.type);
+ }
+ }
+
+ static get Type() {
+ return Type;
+ }
+}
+
+export { ChatMenu };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/ChatTopMenu.js b/javascript/javascript-basic-local-caching/src/js/components/ChatTopMenu.js
new file mode 100644
index 00000000..9c81f892
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/ChatTopMenu.js
@@ -0,0 +1,73 @@
+import styles from '../../scss/chat-top-menu.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { Chat } from '../Chat';
+import { UserList } from './UserList';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatTopMenu {
+ constructor(channel) {
+ this.channel = channel;
+ this.element = this._createElement(channel);
+ }
+
+ get chatTitle() {
+ return this.channel.members
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ');
+ }
+
+ _createElement(channel) {
+ const root = createDivEl({ className: styles['chat-top'] });
+
+ this.title = createDivEl({
+ className: [styles['chat-title'], styles['is-group']],
+ content: this.chatTitle
+ });
+ root.appendChild(this.title);
+
+ const button = createDivEl({ className: styles['chat-button'] });
+ const invite = createDivEl({ className: styles['button-invite'] });
+ invite.addEventListener('click', () => {
+ UserList.getInstance().render(true);
+ });
+ button.appendChild(invite);
+ const hide = createDivEl({ className: styles['button-hide'] });
+ hide.addEventListener('click', () => {
+ SendBirdAction.getInstance()
+ .hide(channel.url)
+ .then(() => {
+ // ChatLeftMenu.getInstance().removeGroupChannelItem(this.channel.url);
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ });
+ button.appendChild(hide);
+
+ const leave = createDivEl({ className: styles['button-leave'] });
+ leave.addEventListener('click', () => {
+ SendBirdAction.getInstance()
+ .leave(channel.url)
+ .then(() => {
+ // ChatLeftMenu.getInstance().removeGroupChannelItem(this.channel.url);
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ });
+ button.appendChild(leave);
+ root.appendChild(button);
+ return root;
+ }
+
+ updateTitle(channel) {
+ this.channel = channel;
+ this.title.innerHTML = this.chatTitle;
+ }
+}
+
+export { ChatTopMenu };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/ChatUserItem.js b/javascript/javascript-basic-local-caching/src/js/components/ChatUserItem.js
new file mode 100644
index 00000000..fcc51d66
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/ChatUserItem.js
@@ -0,0 +1,49 @@
+import styles from '../../scss/chat-user-item.scss';
+import { createDivEl, protectFromXSS } from '../utils';
+import { COLOR_RED } from '../const';
+import { UserBlockModal } from './UserBlockModal';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatUserItem {
+ constructor({ user, hasEvent }) {
+ this.user = user;
+ this.hasEvent = hasEvent;
+ this.element = null;
+ this._create();
+ }
+
+ _create() {
+ this.element = createDivEl({ className: styles['chat-user-item'], id: this.user.userId });
+ if (this.hasEvent) {
+ this.element.addEventListener('mouseover', () => {
+ this._hoverOnUser(this.user.nickname, true);
+ });
+ this.element.addEventListener('mouseleave', () => {
+ this._hoverOnUser(this.user.nickname, false);
+ });
+ this.element.addEventListener('click', () => {
+ const userBlockModal = new UserBlockModal({ user: this.user, isBlock: false });
+ userBlockModal.render();
+ });
+ }
+
+ const image = createDivEl({ className: styles['user-image'], background: protectFromXSS(this.user.profileUrl) });
+ this.element.appendChild(image);
+
+ this.nickname = createDivEl({
+ className: SendBirdAction.getInstance().isCurrentUser(this.user)
+ ? [styles['user-nickname'], styles['is-user']]
+ : styles['user-nickname'],
+ content: protectFromXSS(this.user.nickname)
+ });
+ this.element.appendChild(this.nickname);
+ }
+
+ _hoverOnUser(nickname, hover) {
+ this.nickname.innerHTML = hover ? 'UNBLOCK' : protectFromXSS(nickname);
+ this.nickname.style.color = hover ? COLOR_RED : '';
+ this.nickname.style.opacity = hover ? '1' : '';
+ }
+}
+
+export { ChatUserItem };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/LeftListItem.js b/javascript/javascript-basic-local-caching/src/js/components/LeftListItem.js
new file mode 100644
index 00000000..43d3dbb4
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/LeftListItem.js
@@ -0,0 +1,138 @@
+import styles from '../../scss/list-item.scss';
+import {
+ addClass,
+ createDivEl,
+ getDataInElement,
+ protectFromXSS,
+ removeClass,
+ setDataInElement,
+ timestampFromNow
+} from '../utils';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+
+const KEY_MESSAGE_LAST_TIME = 'origin';
+
+class LeftListItem {
+ constructor({ channel, handler }) {
+ this.channel = channel;
+ this.element = this._createElement(handler);
+ }
+
+ get channelUrl() {
+ return this.channel.url;
+ }
+
+ get title() {
+ return this.channel.members
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ');
+ }
+
+ get lastMessagetime() {
+ if (!this.channel.lastMessage) {
+ return 0;
+ } else {
+ return this.channel.lastMessage.createdAt;
+ }
+ }
+
+ get lastMessageTimeText() {
+ if (!this.channel.lastMessage) {
+ return 0;
+ } else {
+ return LeftListItem.getTimeFromNow(this.channel.lastMessage.createdAt);
+ }
+ }
+
+ get lastMessageText() {
+ if (!this.channel.lastMessage) {
+ return '';
+ } else {
+ return this.channel.lastMessage.isFileMessage()
+ ? protectFromXSS(this.channel.lastMessage.name)
+ : protectFromXSS(this.channel.lastMessage.message);
+ }
+ }
+
+ get memberCount() {
+ return this.channel.memberCount;
+ }
+
+ get unreadMessageCount() {
+ const count = this.channel.unreadMessageCount > 9 ? '+9' : this.channel.unreadMessageCount.toString();
+ return count;
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['list-item'], id: this.channelUrl });
+ const itemTop = createDivEl({ className: styles['item-top'] });
+ const itemTopCount = createDivEl({ className: styles['item-count'], content: this.memberCount });
+ const itemTopTitle = createDivEl({ className: styles['item-title'], content: this.title });
+ itemTop.appendChild(itemTopCount);
+ itemTop.appendChild(itemTopTitle);
+ item.appendChild(itemTop);
+
+ const itemBottom = createDivEl({ className: styles['item-bottom'] });
+
+ const itemBottomMessage = createDivEl({ className: styles['item-message'] });
+ const itemBottomMessageText = createDivEl({
+ className: styles['item-message-text'],
+ content: this.lastMessageText
+ });
+ itemBottomMessage.appendChild(itemBottomMessageText);
+ const itemBottomMessageUnread = createDivEl({
+ className: [styles['item-message-unread'], styles.active],
+ content: this.unreadMessageCount
+ });
+ itemBottomMessage.appendChild(itemBottomMessageUnread);
+
+ const itemBottomTime = createDivEl({ className: styles['item-time'], content: this.lastMessageTimeText });
+ setDataInElement(itemBottomTime, KEY_MESSAGE_LAST_TIME, this.lastMessagetime);
+ itemBottom.appendChild(itemBottomMessage);
+ itemBottom.appendChild(itemBottomTime);
+ item.appendChild(itemBottom);
+
+ item.addEventListener('click', () => {
+ if (handler) handler();
+ });
+ return item;
+ }
+
+ static updateUnreadCount() {
+ const items = ChatLeftMenu.getInstance().groupChannelList.getElementsByClassName(styles['item-message-unread']);
+ if (items && items.length > 0) {
+ Array.prototype.slice.call(items).forEach(targetItemEl => {
+ const originTs = targetItemEl.textContent;
+ if (originTs === '0') {
+ removeClass(targetItemEl, styles.active);
+ } else {
+ addClass(targetItemEl, styles.active);
+ }
+ });
+ }
+ }
+
+ static updateLastMessageTime() {
+ const items = ChatLeftMenu.getInstance().groupChannelList.getElementsByClassName(styles['item-time']);
+ if (items && items.length > 0) {
+ Array.prototype.slice.call(items).forEach(targetItemEl => {
+ const originTs = parseInt(getDataInElement(targetItemEl, KEY_MESSAGE_LAST_TIME));
+ if (originTs) {
+ targetItemEl.innerHTML = LeftListItem.getTimeFromNow(originTs);
+ }
+ });
+ }
+ }
+
+ static getTimeFromNow(timestamp) {
+ return timestampFromNow(timestamp);
+ }
+
+ static getItemRootClassName() {
+ return styles['list-item'];
+ }
+}
+
+export { LeftListItem };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/List.js b/javascript/javascript-basic-local-caching/src/js/components/List.js
new file mode 100644
index 00000000..2350f8ec
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/List.js
@@ -0,0 +1,80 @@
+import styles from '../../scss/list.scss';
+import { createDivEl, isScrollBottom } from '../utils';
+
+let instance = null;
+
+class List {
+ constructor(title, createSearchBox = false) {
+ if (instance) {
+ return instance;
+ }
+ this.createSearchBox = createSearchBox;
+ this.element = this._create(title);
+ this.scrollEventHandler = null;
+ this.closeEventHandler = null;
+ this.searchKeyword = '';
+ }
+
+ _create(title) {
+ const root = createDivEl({ className: styles['list-root'] });
+
+ const listBody = createDivEl({ className: styles['list-body'] });
+ root.appendChild(listBody);
+
+ const listTop = createDivEl({ className: styles['list-top'] });
+ listBody.appendChild(listTop);
+
+ const listTopTitle = createDivEl({ className: styles['list-title'], content: title });
+ listTop.appendChild(listTopTitle);
+ const listTopButton = createDivEl({ className: styles['list-button'] });
+ listTop.appendChild(listTopButton);
+ const listTopButtonExit = createDivEl({ className: styles['button-exit'] });
+ listTopButton.appendChild(listTopButtonExit);
+ listTopButtonExit.addEventListener('click', () => {
+ this.searchKeyword = '';
+ const listContent = document.querySelector(`.${styles['list-content']}`);
+ if (this.closeEventHandler) {
+ this.closeEventHandler();
+ }
+ listContent.innerHTML = '';
+ root.parentElement.removeChild(this.element);
+ });
+ this.buttonRootElement = listTopButton;
+
+ const hr = createDivEl({ className: styles['list-hr'] });
+ listBody.appendChild(hr);
+
+ const listContent = createDivEl({ className: styles['list-content'] });
+ listBody.appendChild(listContent);
+ listContent.addEventListener('scroll', () => {
+ if (isScrollBottom(listContent)) {
+ if (this.scrollEventHandler) {
+ this.scrollEventHandler(false, this.searchKeyword);
+ }
+ }
+ });
+
+ return root;
+ }
+
+ close() {
+ const btnExit = document.querySelector(`.${styles['button-exit']}`);
+ if (btnExit) {
+ document.querySelector(`.${styles['button-exit']}`).click();
+ }
+ }
+
+ getRootElement() {
+ return document.querySelector(`.${styles['list-root']}`);
+ }
+
+ getRootClassName() {
+ return styles['list-root'];
+ }
+
+ getContentElement() {
+ return document.querySelector(`.${styles['list-content']}`);
+ }
+}
+
+export { List };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/Message.js b/javascript/javascript-basic-local-caching/src/js/components/Message.js
new file mode 100644
index 00000000..ec32cd31
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/Message.js
@@ -0,0 +1,249 @@
+import styles from '../../scss/message.scss';
+import { createDivEl, isImage, protectFromXSS, setDataInElement, timestampToTime } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { COLOR_RED, MESSAGE_REQ_ID } from '../const';
+import { MessageDeleteModal } from './MessageDeleteModal';
+import { UserBlockModal } from './UserBlockModal';
+import { Chat } from '../Chat';
+
+class Message {
+ constructor({ channel, message, isManual = false, col = null }) {
+ this.channel = channel;
+ this.message = message;
+ this.isPending = message.messageId === 0 && message.sendingStatus === 'pending';
+ this.isFailed = message.messageId === 0 && message.sendingStatus === 'failed';
+ this.isManual = (this.isPending || this.isFailed) ? isManual : false;
+ this.element = this._createElement();
+ if (col) {
+ this.col = col;
+ }
+ }
+
+ _createElement() {
+ if (this.message.isUserMessage()) {
+ return this._createUserElement();
+ } else if (this.message.isFileMessage()) {
+ return this._createFileElement();
+ } else if (this.message.isAdminMessage()) {
+ return this._createAdminElement();
+ } else {
+ // console.error('Message is invalid data.');
+ return null;
+ }
+ }
+
+ _hoverOnNickname(nickname, hover) {
+ if (!SendBirdAction.getInstance().isCurrentUser(this.message.sender)) {
+ nickname.innerHTML = hover ? 'BLOCK ' : `${protectFromXSS(this.message.sender.nickname)} : `;
+ nickname.style.color = hover ? COLOR_RED : '';
+ nickname.style.opacity = hover ? '1' : '';
+ }
+ }
+
+ _hoverOnTime(time, hover) {
+ if (SendBirdAction.getInstance().isCurrentUser(this.message.sender)) {
+ time.innerHTML = hover ? 'DELETE' : timestampToTime(this.message.createdAt);
+ time.style.color = hover ? COLOR_RED : '';
+ time.style.opacity = hover ? '1' : '';
+ time.style.fontWeight = hover ? '600' : '';
+ }
+ }
+
+ _createUserElement() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const isCurrentUser = sendbirdAction.isCurrentUser(this.message.sender);
+ let root;
+ if (this.isFailed && !this.isManual) {
+ root = createDivEl({
+ className: [styles['chat-message'], styles['is-failed']],
+ id: this.message.reqId
+ });
+ } else if (this.isPending && !this.isManual) {
+ root = createDivEl({
+ className: [styles['chat-message'], styles['is-pending']],
+ id: this.message.reqId
+ });
+ } else {
+ root = createDivEl({
+ className: styles['chat-message'],
+ id: this.message.reqId
+ });
+ }
+ setDataInElement(root, MESSAGE_REQ_ID, this.message.reqId);
+
+ const messageContent = createDivEl({ className: styles['message-content'] });
+ const nickname = createDivEl({
+ className: isCurrentUser ? [styles['message-nickname'], styles['is-user']] : styles['message-nickname'],
+ content: `${protectFromXSS(this.message.sender.nickname)} : `
+ });
+ nickname.addEventListener('mouseover', () => {
+ this._hoverOnNickname(nickname, true);
+ });
+ nickname.addEventListener('mouseleave', () => {
+ this._hoverOnNickname(nickname, false);
+ });
+ nickname.addEventListener('click', () => {
+ if (!isCurrentUser) {
+ const userBlockModal = new UserBlockModal({ user: this.message.sender, isBlock: true });
+ userBlockModal.render();
+ }
+ });
+ messageContent.appendChild(nickname);
+
+ const msg = createDivEl({ className: styles['message-content'], content: protectFromXSS(this.message.message) });
+ messageContent.appendChild(msg);
+
+ if (this.isFailed && this.isManual) {
+ const resendButton = createDivEl({
+ className: styles['resend-button'],
+ content: 'RESEND'
+ });
+ resendButton.addEventListener('click', () => {
+ this._resendUserMessage();
+ });
+ messageContent.appendChild(resendButton);
+ }
+ if (this.isFailed) {
+ const deleteButton = createDivEl({
+ className: styles['delete-button'],
+ content: 'DELETE'
+ });
+ deleteButton.addEventListener('click', () => {
+ if (isCurrentUser) {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message,
+ col: this.col
+ });
+ messageDeleteModal.render();
+ }
+ });
+ messageContent.appendChild(deleteButton);
+ }
+ if (!this.isFailed) {
+ const time = createDivEl({
+ className: isCurrentUser ? [styles.time, styles['is-user']] : styles.time,
+ content: timestampToTime(this.message.createdAt)
+ });
+ time.addEventListener('mouseover', () => {
+ this._hoverOnTime(time, true);
+ });
+ time.addEventListener('mouseleave', () => {
+ this._hoverOnTime(time, false);
+ });
+ time.addEventListener('click', () => {
+ if (isCurrentUser) {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message
+ });
+ messageDeleteModal.render();
+ }
+ });
+ messageContent.appendChild(time);
+
+ const count = sendbirdAction.getReadReceipt(this.channel, this.message);
+ const read = createDivEl({
+ className: count ? [styles.read, styles.active] : styles.read,
+ content: count
+ });
+ messageContent.appendChild(read);
+ }
+
+ root.appendChild(messageContent);
+ return root;
+ }
+
+ _resendUserMessage() {
+ this.channel.resendUserMessage(this.message, (error, message) => { });
+ }
+
+ _createFileElement() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ setDataInElement(root, MESSAGE_REQ_ID, this.message.reqId);
+
+ const messageContent = createDivEl({ className: styles['message-content'] });
+ const nickname = createDivEl({
+ className: sendbirdAction.isCurrentUser(this.message.sender)
+ ? [styles['message-nickname'], styles['is-user']]
+ : styles['message-nickname'],
+ content: `${protectFromXSS(this.message.sender.nickname)} : `
+ });
+ messageContent.appendChild(nickname);
+
+ const msg = createDivEl({
+ className: [styles['message-content'], styles['is-file']],
+ content: protectFromXSS(this.message.name)
+ });
+ msg.addEventListener('click', () => {
+ window.open(this.message.url);
+ });
+ messageContent.appendChild(msg);
+
+ const time = createDivEl({ className: styles.time, content: timestampToTime(this.message.createdAt) });
+ time.addEventListener('mouseover', () => {
+ this._hoverOnTime(time, true);
+ });
+ time.addEventListener('mouseleave', () => {
+ this._hoverOnTime(time, false);
+ });
+ time.addEventListener('click', () => {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message
+ });
+ messageDeleteModal.render();
+ });
+ messageContent.appendChild(time);
+
+ if (this.channel.isGroupChannel()) {
+ const count = sendbirdAction.getReadReceipt(this.channel, this.message);
+ const read = createDivEl({
+ className: count ? [styles.read, styles.active] : styles.read,
+ content: count
+ });
+ messageContent.appendChild(read);
+ }
+
+ root.appendChild(messageContent);
+
+ if (this.message.isFileMessage() && this.message.messageId) {
+ const fileContent = createDivEl({ className: styles['file-content'] });
+ fileContent.addEventListener('click', () => {
+ window.open(this.message.url);
+ });
+ if (this.message.thumbnails.length > 0 || isImage(this.message.type)) {
+ const fileRender = document.createElement('img');
+ fileRender.className = styles['file-render'];
+
+ if (isImage(this.message.type)) {
+ fileRender.src = protectFromXSS(this.message.url);
+ } else if (this.message.thumbnails.length > 0) {
+ fileRender.src = protectFromXSS(this.message.thumbnails[0].url);
+ }
+
+ fileRender.onload = () => {
+ Chat.getInstance().main.repositionScroll(fileRender.offsetHeight);
+ };
+ fileContent.appendChild(fileRender);
+ }
+ root.appendChild(fileContent);
+ }
+
+ return root;
+ }
+
+ _createAdminElement() {
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ const msg = createDivEl({ className: styles['message-admin'], content: protectFromXSS(this.message.message) });
+ root.appendChild(msg);
+ return root;
+ }
+
+ static getReadReceiptElementClassName() {
+ return styles.active;
+ }
+}
+
+export { Message };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/MessageDeleteModal.js b/javascript/javascript-basic-local-caching/src/js/components/MessageDeleteModal.js
new file mode 100644
index 00000000..ffa1e6ab
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/MessageDeleteModal.js
@@ -0,0 +1,42 @@
+import styles from '../../scss/message-delete-modal.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+
+const title = 'Delete Message';
+const description = 'Are you Sure? Do you want to delete message?';
+const submitText = 'DELETE';
+
+class MessageDeleteModal extends Modal {
+ constructor({ channel, message, col }) {
+ super({ title, description, submitText });
+ this.channel = channel;
+ this.message = message;
+ this.col = col;
+ this._createElement();
+
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .deleteMessage({ channel: this.channel, message: this.message, col: this.col })
+ .then(() => {
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ };
+ }
+
+ _createElement() {
+ const content = createDivEl({
+ className: styles['modal-message'],
+ content: this.message.isFileMessage() ? protectFromXSS(this.message.name) : protectFromXSS(this.message.message)
+ });
+ this.contentElement.appendChild(content);
+ }
+}
+
+export { MessageDeleteModal };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/Modal.js b/javascript/javascript-basic-local-caching/src/js/components/Modal.js
new file mode 100644
index 00000000..d90ea634
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/Modal.js
@@ -0,0 +1,62 @@
+import styles from '../../scss/modal.scss';
+import { createDivEl } from '../utils';
+import { Spinner } from './Spinner';
+
+class Modal {
+ constructor({ title, description, submitText }) {
+ this.contentElement = null;
+ this.cancelHandler = null;
+ this.submitHandler = null;
+ this.element = this._create({ title, description, submitText });
+ }
+
+ _create({ title, description, submitText }) {
+ const root = createDivEl({ className: styles['modal-root'] });
+ const modal = createDivEl({ className: styles['modal-body'] });
+ root.appendChild(modal);
+
+ const titleText = createDivEl({ className: styles['modal-title'], content: title });
+ modal.appendChild(titleText);
+
+ const desc = createDivEl({ className: styles['modal-desc'], content: description });
+ modal.appendChild(desc);
+
+ this.contentElement = createDivEl({ className: styles['modal-content'] });
+ modal.appendChild(this.contentElement);
+
+ const bottom = createDivEl({ className: styles['modal-bottom'] });
+ modal.appendChild(bottom);
+ const cancel = createDivEl({ className: styles['modal-cancel'], content: 'CANCEL' });
+ cancel.addEventListener('click', () => {
+ if (this.cancelHandler) {
+ this.cancelHandler();
+ }
+ this.close();
+ });
+ bottom.appendChild(cancel);
+ const submit = createDivEl({ className: styles['modal-submit'], content: submitText });
+ submit.addEventListener('click', () => {
+ Spinner.start(modal);
+ if (this.submitHandler) {
+ this.submitHandler();
+ }
+ });
+ bottom.appendChild(submit);
+
+ return root;
+ }
+
+ close() {
+ if (document.body.contains(this.element)) {
+ document.body.removeChild(this.element);
+ }
+ }
+
+ render() {
+ if (!document.body.querySelector(`.${styles['modal-root']}`)) {
+ document.body.appendChild(this.element);
+ }
+ }
+}
+
+export { Modal };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/Spinner.js b/javascript/javascript-basic-local-caching/src/js/components/Spinner.js
new file mode 100644
index 00000000..b7d83297
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/Spinner.js
@@ -0,0 +1,42 @@
+import styles from '../../scss/spinner.scss';
+import { createDivEl } from '../utils';
+
+let instance = null;
+
+class Spinner {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.element = this._createSpinner();
+ instance = this;
+ }
+
+ _createSpinner() {
+ const item = createDivEl({ className: styles['sb-spinner'] });
+ const bubble = createDivEl({ className: styles['sb-spinner-bubble'] });
+ item.appendChild(bubble);
+ return item;
+ }
+
+ static start(target) {
+ const spinnerEl = Spinner.getInstance().element;
+ if (!target.contains(spinnerEl)) {
+ target.appendChild(spinnerEl);
+ }
+ }
+
+ static remove() {
+ const spinnerEl = Spinner.getInstance().element;
+ const targetEl = spinnerEl.parentElement;
+ if (targetEl && targetEl.contains(spinnerEl)) {
+ spinnerEl.parentElement.removeChild(spinnerEl);
+ }
+ }
+
+ static getInstance() {
+ return new Spinner();
+ }
+}
+
+export { Spinner };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/Toast.js b/javascript/javascript-basic-local-caching/src/js/components/Toast.js
new file mode 100644
index 00000000..c215c251
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/Toast.js
@@ -0,0 +1,49 @@
+import styles from '../../scss/toast.scss';
+import { createDivEl } from '../utils';
+
+let instance = null;
+
+class Toast {
+ constructor(message) {
+ if (instance) {
+ const messageEl = instance.element.getElementsByClassName('sb-toast-message')[0];
+ if (messageEl) {
+ if (!message) {
+ message = messageEl.innerHTML;
+ }
+ messageEl.innerHTML = message;
+ }
+ return instance;
+ }
+ this.element = this._createToast(message);
+ instance = this;
+ }
+
+ _createToast(text) {
+ const item = createDivEl({ className: styles['sb-toast'] });
+ const message = createDivEl({ className: styles['sb-toast-message'] });
+ message.innerHTML = text;
+ item.appendChild(message);
+ return item;
+ }
+
+ static start(target, message) {
+ const toast = new Toast(message);
+ const toastEl = toast.element;
+ if (!target.contains(toastEl)) {
+ target.appendChild(toastEl);
+ }
+ }
+
+ static remove() {
+ const toastEl = instance ? instance.element : null;
+ if (toastEl) {
+ const targetEl = toastEl.parentElement;
+ if (targetEl && targetEl.contains(toastEl)) {
+ toastEl.parentElement.removeChild(toastEl);
+ }
+ }
+ }
+}
+
+export { Toast };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/UserBlockModal.js b/javascript/javascript-basic-local-caching/src/js/components/UserBlockModal.js
new file mode 100644
index 00000000..85f9d1c7
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/UserBlockModal.js
@@ -0,0 +1,53 @@
+import styles from '../../scss/user-block-modal.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+import { Chat } from '../Chat';
+
+const blockTitle = 'Block User';
+const blockDescription = 'Are you Sure? Do you want to block this user?';
+const blockSubmitText = 'BLOCK';
+
+const unblockTitle = 'Unblock User';
+const unblockDescription = 'Are you Sure? Do you want to unblock this user?';
+const unblockSubmitText = 'UNBLOCK';
+
+class UserBlockModal extends Modal {
+ constructor({ user, isBlock = true }) {
+ isBlock
+ ? super({ title: blockTitle, description: blockDescription, submitText: blockSubmitText })
+ : super({ title: unblockTitle, description: unblockDescription, submitText: unblockSubmitText });
+ this.isBlock = isBlock;
+ this.user = user;
+ this._createElement();
+
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .blockUser(this.user, this.isBlock)
+ .then(() => {
+ Chat.getInstance().main.updateBlockedList(this.user, this.isBlock);
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ };
+ }
+
+ _createElement() {
+ const content = createDivEl({ className: styles['modal-user'] });
+
+ const image = createDivEl({ className: styles['user-profile'], background: protectFromXSS(this.user.profileUrl) });
+ content.appendChild(image);
+
+ const nickname = createDivEl({ className: styles['user-nickname'], content: protectFromXSS(this.user.nickname) });
+ content.appendChild(nickname);
+
+ this.contentElement.appendChild(content);
+ }
+}
+
+export { UserBlockModal };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/UserItem.js b/javascript/javascript-basic-local-caching/src/js/components/UserItem.js
new file mode 100644
index 00000000..799ec0cb
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/UserItem.js
@@ -0,0 +1,63 @@
+import styles from '../../scss/user-item.scss';
+import { createDivEl, protectFromXSS, timestampFromNow, toggleClass } from '../utils';
+
+class UserItem {
+ constructor({ user, handler }) {
+ this.user = user;
+ this.element = this._createElement(handler);
+ }
+
+ get userId() {
+ return this.user.userId;
+ }
+
+ get nickname() {
+ return protectFromXSS(this.user.nickname);
+ }
+
+ get profileUrl() {
+ return protectFromXSS(this.user.profileUrl);
+ }
+
+ get lastSeenTimeString() {
+ return this.user.lastSeenAt ? timestampFromNow(this.user.lastSeenAt) : '';
+ }
+
+ get isOnline() {
+ return this.user.connectionStatus === 'online';
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['user-item'], id: this.userId });
+
+ const userInfo = createDivEl({ className: styles['user-info'] });
+ item.appendChild(userInfo);
+ const profile = createDivEl({ className: styles['user-profile'], background: this.profileUrl });
+ userInfo.appendChild(profile);
+ const nickname = createDivEl({ className: styles['user-nickname'], content: this.nickname });
+ userInfo.appendChild(nickname);
+ const isOnline = createDivEl({
+ className: this.isOnline ? [styles['user-online'], styles.active] : styles['user-online']
+ });
+ userInfo.appendChild(isOnline);
+
+ const userState = createDivEl({ className: styles['user-state'] });
+ item.appendChild(userState);
+ const lastSeenTime = createDivEl({ className: styles['user-time'], content: this.lastSeenTimeString });
+ userState.appendChild(lastSeenTime);
+ const selectIcon = createDivEl({ className: styles['user-select'] });
+ userState.appendChild(selectIcon);
+ item.addEventListener('click', () => {
+ toggleClass(item.querySelector(`.${UserItem.selectIconClassName}`), styles.active);
+ if (handler) handler();
+ });
+
+ return item;
+ }
+
+ static get selectIconClassName() {
+ return styles['user-select'];
+ }
+}
+
+export { UserItem };
diff --git a/javascript/javascript-basic-local-caching/src/js/components/UserList.js b/javascript/javascript-basic-local-caching/src/js/components/UserList.js
new file mode 100644
index 00000000..a93c9837
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/components/UserList.js
@@ -0,0 +1,128 @@
+import styles from '../../scss/user-list.scss';
+import { createDivEl, errorAlert, appendToFirst } from '../utils';
+import { List } from './List';
+import { Spinner } from './Spinner';
+import { SendBirdAction } from '../SendBirdAction';
+import { UserItem } from './UserItem';
+import { Chat } from '../Chat';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+
+let instance = null;
+
+class UserList extends List {
+ constructor() {
+ super('User List');
+ if (instance) {
+ return instance;
+ }
+
+ this.scrollEventHandler = this._getUserList;
+ this.closeEventHandler = this._close;
+ this.createBtn = this._addCreateBtn();
+ this.selectedUserIds = [];
+ instance = this;
+ }
+
+ _addCreateBtn() {
+ const createBtn = createDivEl({ className: styles['button-create'], content: 'CREATE' });
+ const oldCreateBtn = this.buttonRootElement.getElementsByClassName(styles['button-create'])[0];
+ if (oldCreateBtn) {
+ this.buttonRootElement.removeChild(oldCreateBtn);
+ }
+ appendToFirst(this.buttonRootElement, createBtn);
+ return createBtn;
+ }
+
+ _createChannel() {
+ SendBirdAction.getInstance()
+ .createGroupChannel(this.selectedUserIds)
+ .then(channel => {
+ ChatLeftMenu.getInstance().activeChannelUrl = channel.url;
+ Chat.getInstance().render(channel, false);
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ }
+
+ _inviteChannel() {
+ const channelUrl = Chat.getInstance().channel.url;
+ SendBirdAction.getInstance()
+ .inviteGroupChannel(channelUrl, this.selectedUserIds)
+ .then(() => {
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ }
+
+ _updateCreateType(isInvite) {
+ this.createBtn = this._addCreateBtn();
+ this.createBtn.innerHTML = isInvite ? 'INVITE' : 'CREATE';
+ this.createBtn.addEventListener('click', () => {
+ Spinner.start(this.element);
+ if (isInvite) {
+ this._inviteChannel();
+ } else {
+ this._createChannel();
+ }
+ });
+ }
+
+ _getUserList(isInit = false) {
+ Spinner.start(this.element);
+ const sendbirdAction = SendBirdAction.getInstance();
+ const listContent = this.getContentElement();
+ sendbirdAction
+ .getUserList(isInit)
+ .then(userList => {
+ userList.forEach(user => {
+ if (!sendbirdAction.isCurrentUser(user)) {
+ const handler = () => {
+ this._toggleUserId(item.userId);
+ };
+ const item = new UserItem({ user, handler });
+ listContent.appendChild(item.element);
+ }
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ }
+
+ _toggleUserId(userId) {
+ const index = this.selectedUserIds.indexOf(userId);
+ if (index > -1) {
+ this.selectedUserIds.splice(index, 1);
+ } else {
+ this.selectedUserIds.push(userId);
+ }
+ }
+
+ _close() {
+ this.selectedUserIds = [];
+ }
+
+ render(isInvite = false) {
+ if (!document.body.querySelector(`.${this.getRootClassName()}`)) {
+ this._updateCreateType(isInvite);
+ document.body.appendChild(this.element);
+ this._getUserList(true);
+ }
+ }
+
+ static getInstance() {
+ return new UserList();
+ }
+}
+
+export { UserList };
diff --git a/javascript/javascript-basic-local-caching/src/js/const.js b/javascript/javascript-basic-local-caching/src/js/const.js
new file mode 100644
index 00000000..f4d5f183
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/const.js
@@ -0,0 +1,11 @@
+export const APP_ID = '9DA1B1F4-0BE6-4DA8-82C5-2E81DAB56F23';
+export const USER_ID = 'user_id';
+export const DISPLAY_NONE = 'none';
+export const DISPLAY_BLOCK = 'block';
+export const DISPLAY_FLEX = 'flex';
+export const ACTIVE_CLASSNAME = 'active';
+export const KEY_ENTER = 13;
+export const FILE_ID = 'attach_file_id';
+export const UPDATE_INTERVAL_TIME = 5 * 1000;
+export const COLOR_RED = '#DC5960';
+export const MESSAGE_REQ_ID = 'reqId';
diff --git a/javascript/javascript-basic-local-caching/src/js/index.js b/javascript/javascript-basic-local-caching/src/js/index.js
new file mode 100644
index 00000000..5cbeefcf
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/index.js
@@ -0,0 +1,36 @@
+import { isEmpty, setCookie, getCookie } from './utils';
+import { USER_ID, KEY_ENTER } from './const';
+
+const userIdEl = document.querySelector('#user_id');
+const nicknameEl = document.querySelector('#user_nickname');
+const buttonEl = document.querySelector('#login-button');
+
+document.addEventListener('DOMContentLoaded', () => {
+ const cookieUserId = getCookie(USER_ID);
+ if (cookieUserId) {
+ userIdEl.value = cookieUserId;
+ }
+});
+
+nicknameEl.addEventListener('keydown', e => {
+ if (e.which === KEY_ENTER) {
+ login();
+ }
+});
+
+buttonEl.addEventListener('click', () => {
+ login();
+});
+
+const login = () => {
+ const userId = userIdEl.value.trim();
+ const nickname = nicknameEl.value.trim();
+ if (isEmpty(nickname)) {
+ alert('Please enter user nickname');
+ return;
+ }
+ userIdEl.value = '';
+ nicknameEl.value = '';
+ setCookie(USER_ID, userId);
+ window.location.href = `chat.html?userid=${encodeURIComponent(userId)}&nickname=${encodeURIComponent(nickname)}`;
+};
diff --git a/javascript/javascript-basic-local-caching/src/js/main.js b/javascript/javascript-basic-local-caching/src/js/main.js
new file mode 100644
index 00000000..d75ea5ad
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/main.js
@@ -0,0 +1,55 @@
+import { getVariableFromUrl, isEmpty, redirectToIndex } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { SendBirdConnection } from './SendBirdConnection';
+import { ChatLeftMenu } from './ChatLeftMenu';
+import { Chat } from './Chat';
+import { UPDATE_INTERVAL_TIME } from './const';
+import { LeftListItem } from './components/LeftListItem';
+
+import { Toast } from './components/Toast';
+
+const sb = new SendBirdAction();
+
+let chat = null;
+let chatLeft = null;
+
+const createConnectionHandler = () => {
+ const connectionManager = new SendBirdConnection();
+ connectionManager.onReconnectStarted = () => {
+ Toast.start(document.body, 'Connection is lost. Trying to reconnect...');
+ connectionManager.channel = chat.channel;
+ };
+ connectionManager.onReconnectSucceeded = () => {
+ chatLeft.updateUserInfo(SendBirdAction.getInstance().getCurrentUser());
+ Toast.remove();
+ };
+ connectionManager.onReconnectFailed = () => {
+ connectionManager.reconnect();
+ };
+};
+
+const updateGroupChannelTime = () => {
+ setInterval(() => {
+ LeftListItem.updateLastMessageTime();
+ }, UPDATE_INTERVAL_TIME);
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const { userid, nickname } = getVariableFromUrl();
+ if (isEmpty(userid) || isEmpty(nickname)) {
+ redirectToIndex('UserID and Nickname must be required.');
+ }
+
+ sb.connect(userid, nickname)
+ .then(user => {
+ chat = new Chat();
+ chatLeft = new ChatLeftMenu();
+ chatLeft.updateUserInfo(user);
+ updateGroupChannelTime();
+ chatLeft.loadGroupChannelList(true);
+ createConnectionHandler();
+ })
+ .catch(() => {
+ Toast.start(document.body, 'Connection is not established.');
+ });
+});
diff --git a/javascript/javascript-basic-local-caching/src/js/utils.js b/javascript/javascript-basic-local-caching/src/js/utils.js
new file mode 100644
index 00000000..3e94efc2
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/js/utils.js
@@ -0,0 +1,231 @@
+import moment from 'moment';
+
+export function findChannelIndex(newChannel, channels) {
+ const newChannelLastMessageUpdated = newChannel.lastMessage ? newChannel.lastMessage.createdAt : newChannel.createdAt;
+
+ let index = channels.length;
+ for (let i = 0; i < channels.length; i++) {
+ const comparedChannel = channels[i];
+ const comparedChannelLastMessageUpdated = comparedChannel.lastMessage
+ ? comparedChannel.lastMessage.createdAt
+ : comparedChannel.createdAt;
+ if (newChannel.url === comparedChannel.url) {
+ index = i;
+ break;
+ } else if (newChannelLastMessageUpdated > comparedChannelLastMessageUpdated) {
+ index = i;
+ break;
+ }
+ }
+ return index;
+}
+export function findMessageIndex(newMessage, messages, isRequestId = false) {
+ let index = messages.length;
+ for (let i = 0; i < messages.length; i++) {
+ if (
+ !isRequestId &&
+ newMessage.messageId !== 0 &&
+ messages[i].messageId !== 0 &&
+ messages[i].messageId === newMessage.messageId
+ ) {
+ index = i;
+ break;
+ } else if (isRequestId && messages[i].reqId === newMessage.reqId) {
+ index = i;
+ break;
+ } else if (messages[i].createdAt >= newMessage.createdAt) {
+ index = i;
+ break;
+ }
+ }
+ return index;
+}
+
+export function mergeFailedWithSuccessful(failedMessages, successfulMessages) {
+ const wholeMessages = [...successfulMessages];
+ for (let i = 0; i < failedMessages.length; i++) {
+ const index = findMessageIndex(failedMessages[i], wholeMessages);
+ wholeMessages.splice(index, 0, failedMessages[i]);
+ }
+ return wholeMessages;
+}
+
+export const timestampToTime = timestamp => {
+ const now = new Date().getTime();
+ const nowDate = moment.unix(now.toString().length === 13 ? now / 1000 : now).format('MM/DD');
+
+ let date = moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('MM/DD');
+ if (date === 'Invalid date') {
+ date = '';
+ }
+
+ return nowDate === date
+ ? moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('HH:mm')
+ : date;
+};
+
+export const timestampToDateString = timestamp => {
+ return moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('LL');
+};
+
+export const timestampFromNow = timestamp => {
+ return moment(timestamp).fromNow();
+};
+
+export const isUrl = urlString => {
+ const regex = /^(http|https):\/\/[^ "]+$/;
+ return regex.test(urlString);
+};
+
+export const isImage = fileType => {
+ const regex = /^image\/.+$/;
+ return regex.test(fileType);
+};
+
+export const isEmpty = value => {
+ return value === null || value === undefined || value.length === 0;
+};
+
+export const isNull = value => {
+ try {
+ return value === null;
+ } catch (e) {
+ return false;
+ }
+};
+
+export const setCookie = (key, value) => {
+ document.cookie = `${key}=${value}; expires=Fri, 31 Dec 9999 23:59:59 GMT`;
+};
+
+export const getCookie = key => {
+ let name = `${key}=`;
+ let ca = document.cookie.split(';');
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i];
+ if (!c) continue;
+ while (c.charAt(0) === ' ') {
+ c = c.substring(1);
+ }
+ if (c.indexOf(name) === 0) {
+ return c.substring(name.length, c.length);
+ }
+ }
+ return '';
+};
+
+export const getVariableFromUrl = () => {
+ let vars = {};
+ let hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
+ for (let i = 0; i < hashes.length; i++) {
+ let hash = hashes[i].split('=');
+ vars[hash[0]] = hash[1];
+ }
+ return vars;
+};
+
+export const errorAlert = (message, reload = false) => {
+ // alert(message);
+ // eslint-disable-next-line no-console
+ console.error(message);
+ if (reload) {
+ location.reload(true);
+ }
+};
+
+export const redirectToIndex = message => {
+ if (message) {
+ errorAlert(message, false);
+ }
+ window.location.href = 'index.html';
+};
+
+export const setDataInElement = (target, key, data) => {
+ target.dataset[`${key}`] = data;
+};
+
+export const getDataInElement = (target, key) => {
+ return target.dataset[`${key}`];
+};
+
+export const createDivEl = ({ id, className, content, background }) => {
+ const el = document.createElement('div');
+ if (id) {
+ el.id = id;
+ }
+ if (className) {
+ el.className = Array.isArray(className) ? className.join(' ') : className;
+ }
+ if (content) {
+ el.innerHTML = content;
+ }
+ if (background) {
+ el.style.backgroundImage = `url(${background})`;
+ }
+ return el;
+};
+
+export const isScrollBottom = target => {
+ return target.scrollTop + target.offsetHeight >= target.scrollHeight;
+};
+
+export const appendToFirst = (target, newElement) => {
+ if (target.childNodes.length > 0) {
+ target.insertBefore(newElement, target.childNodes[0]);
+ } else {
+ target.appendChild(newElement);
+ }
+};
+
+const hasClass = (target, className) => {
+ return target.classList
+ ? target.classList.contains(className)
+ : new RegExp('(^| )' + className + '( |$)', 'gi').test(target.className);
+};
+
+export const addClass = (target, className) => {
+ if (target.classList) {
+ if (!(className in target.classList)) {
+ target.classList.add(className);
+ }
+ } else {
+ if (target.className.indexOf(className) < 0) {
+ target.className += ` ${className}`;
+ }
+ }
+};
+
+export const removeClass = (target, className) => {
+ if (target.classList) {
+ target.classList.remove(className);
+ } else {
+ target.className = target.className.replace(
+ new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'),
+ ''
+ );
+ }
+};
+
+export const toggleClass = (target, className) => {
+ hasClass(target, className) ? removeClass(target, className) : addClass(target, className);
+};
+
+export const uuid4 = () => {
+ let d = new Date().getTime();
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+ const r = (d + Math.random() * 16) % 16 | 0;
+ d = Math.floor(d / 16);
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+ });
+};
+
+export const protectFromXSS = text => {
+ return typeof text === 'string'
+ ? text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ : text;
+};
diff --git a/javascript/javascript-basic-local-caching/src/scss/_animation.scss b/javascript/javascript-basic-local-caching/src/scss/_animation.scss
new file mode 100644
index 00000000..75dad441
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/_animation.scss
@@ -0,0 +1,40 @@
+// Mixin
+@mixin keyframes($name) {
+ @-webkit-keyframes #{$name} { @content; }
+ @-moz-keyframes #{$name} { @content; }
+ @-o-keyframes #{$name} { @content; }
+ @-ms-keyframes #{$name} { @content; }
+ @keyframes #{$name} { @content; }
+}
+
+@mixin transition($options...) {
+ -webkit-transition: $options;
+ -moz-transition: $options;
+ -ms-transition: $options;
+ -o-transition: $options;
+ transition: $options;
+}
+
+@mixin transform-scale($size) {
+ -webkit-transform: scale($size);
+ -moz-transform: scale($size);
+ -ms-transform: scale($size);
+ -o-transform: scale($size);
+ transform: scale($size);
+}
+
+@mixin animation($animation...) {
+ -webkit-animation: $animation;
+ -moz-animation: $animation;
+ -o-animation: $animation;
+ -ms-animation: $animation;
+ animation: $animation;
+}
+
+@mixin animation-delay($delay) {
+ -webkit-animation-delay: $delay;
+ -moz-animation-delay: $delay;
+ -o-animation-delay: $delay;
+ -ms-animation-delay: $delay;
+ animation-delay: $delay;
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/_common.scss b/javascript/javascript-basic-local-caching/src/scss/_common.scss
new file mode 100644
index 00000000..9727b042
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/_common.scss
@@ -0,0 +1,10 @@
+@import 'normalize';
+@import 'variables';
+@import 'mixins';
+@import 'icons';
+
+body {
+ display: flex;
+ font-family: $font-family-exo2;
+ -webkit-font-smoothing: antialiased;
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/_icons.scss b/javascript/javascript-basic-local-caching/src/scss/_icons.scss
new file mode 100644
index 00000000..01c41b28
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/_icons.scss
@@ -0,0 +1,40 @@
+// Icons
+$ic-prefix: 'https://dxstmhyqfqr1o.cloudfront.net/web-basic/';
+$ic-input-user: 'icon-username-landing.svg';
+$ic-profile-default: 'image-profile.svg';
+$ic-add-normal: 'icon-add-normal.png';
+$ic-add-over: 'icon-add-over.png';
+$ic-close: 'icon-close.png';
+$ic-enter: 'icon-enter.png';
+$ic-check-unselect: 'icon-check-unselect.png';
+$ic-check-select: 'icon-check-select.png';
+$ic-empty-chat: 'img-empty.svg';
+
+$ic-group: 'icon-group.png';
+$ic-hide-normal: 'icon-hide-normal.png';
+$ic-hide: 'icon-hide.png';
+$ic-group-add-normal: 'icon-group-add-normal.png';
+$ic-group-add: 'icon-group-add.png';
+$ic-leave-normal: 'icon-leave-normal.png';
+$ic-leave: 'icon-leave.png';
+$ic-attach-file-normal: 'icon-attach-file-normal.png';
+$ic-attach-file: 'icon-attach-file.png';
+$ic-arrow-normal: 'icon-arrow-nomal.png';
+$ic-arrow: 'icon-arrow.png';
+$ic-back: 'icon-back.png';
+
+$ic-search: 'icon-search-nomal.png';
+$ic-search-over: 'icon-search-over.png';
+
+@mixin icon($url, $size: cover, $position: center) {
+ background-image: url($ic-prefix + $url);
+ background-position: $position;
+ background-size: $size;
+ background-repeat: no-repeat;
+}
+
+@mixin imageMessage() {
+ background-position: center;
+ background-size: 160px 160px;
+ background-repeat: no-repeat;
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/src/scss/_mixins.scss b/javascript/javascript-basic-local-caching/src/scss/_mixins.scss
new file mode 100644
index 00000000..a954ed4b
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/_mixins.scss
@@ -0,0 +1,4 @@
+@import 'mixins/border-radius';
+@import 'mixins/state';
+@import 'mixins/transform';
+@import 'mixins/reset';
diff --git a/javascript/javascript-basic-local-caching/src/scss/_normalize.scss b/javascript/javascript-basic-local-caching/src/scss/_normalize.scss
new file mode 100644
index 00000000..08e68694
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/_normalize.scss
@@ -0,0 +1,450 @@
+/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
+
+/* Document
+ ========================================================================== */
+
+/**
+ * 1. Correct the line height in all browsers.
+ * 2. Prevent adjustments of font size after orientation changes in
+ * IE on Windows Phone and in iOS.
+ */
+
+html {
+ line-height: 1.15; /* 1 */
+ -ms-text-size-adjust: 100%; /* 2 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/* Sections
+ ========================================================================== */
+
+/**
+ * Remove the margin in all browsers (opinionated).
+ */
+
+body {
+ margin: 0;
+}
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+article,
+aside,
+footer,
+header,
+nav,
+section {
+ display: block;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in IE.
+ */
+
+figcaption,
+figure,
+main {
+ /* 1 */
+ display: block;
+}
+
+/**
+ * Add the correct margin in IE 8.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box; /* 1 */
+ height: 0; /* 1 */
+ overflow: visible; /* 2 */
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+pre {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * 1. Remove the gray background on active links in IE 10.
+ * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
+ */
+
+a {
+ background-color: transparent; /* 1 */
+ -webkit-text-decoration-skip: objects; /* 2 */
+}
+
+/**
+ * 1. Remove the bottom border in Chrome 57- and Firefox 39-.
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none; /* 1 */
+ text-decoration: underline; /* 2 */
+ text-decoration: underline dotted; /* 2 */
+}
+
+/**
+ * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
+ */
+
+b,
+strong {
+ font-weight: inherit;
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+samp {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/**
+ * Add the correct font style in Android 4.3-.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Add the correct background and color in IE 9-.
+ */
+
+mark {
+ background-color: #ff0;
+ color: #000;
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+audio,
+video {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in iOS 4-7.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Remove the border on images inside links in IE 10-.
+ */
+
+img {
+ border-style: none;
+}
+
+/**
+ * Hide the overflow in IE.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change the font styles in all browsers (opinionated).
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: sans-serif; /* 1 */
+ font-size: 100%; /* 1 */
+ line-height: 1.15; /* 1 */
+ margin: 0; /* 2 */
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input {
+ /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select {
+ /* 1 */
+ text-transform: none;
+}
+
+/**
+ * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
+ * controls in Android 4.
+ * 2. Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+html [type='button'], /* 1 */
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button; /* 2 */
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type='button']::-moz-focus-inner,
+[type='reset']::-moz-focus-inner,
+[type='submit']::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type='button']:-moz-focusring,
+[type='reset']:-moz-focusring,
+[type='submit']:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Correct the padding in Firefox.
+ */
+
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box; /* 1 */
+ color: inherit; /* 2 */
+ display: table; /* 1 */
+ max-width: 100%; /* 1 */
+ padding: 0; /* 3 */
+ white-space: normal; /* 1 */
+}
+
+/**
+ * 1. Add the correct display in IE 9-.
+ * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ display: inline-block; /* 1 */
+ vertical-align: baseline; /* 2 */
+}
+
+/**
+ * Remove the default vertical scrollbar in IE.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10-.
+ * 2. Remove the padding in IE 10-.
+ */
+
+[type='checkbox'],
+[type='radio'] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type='number']::-webkit-inner-spin-button,
+[type='number']::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type='search'] {
+ -webkit-appearance: textfield; /* 1 */
+ outline-offset: -2px; /* 2 */
+}
+
+/**
+ * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
+ */
+
+[type='search']::-webkit-search-cancel-button,
+[type='search']::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button; /* 1 */
+ font: inherit; /* 2 */
+}
+
+/* Interactive
+ ========================================================================== */
+
+/*
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in Edge, IE, and Firefox.
+ */
+
+details, /* 1 */
+menu {
+ display: block;
+}
+
+/*
+ * Add the correct display in all browsers.
+ */
+
+summary {
+ display: list-item;
+}
+
+/* Scripting
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+canvas {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in IE.
+ */
+
+template {
+ display: none;
+}
+
+/* Hidden
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 10-.
+ */
+
+[hidden] {
+ display: none;
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/_variables.scss b/javascript/javascript-basic-local-caching/src/scss/_variables.scss
new file mode 100644
index 00000000..346a8a67
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/_variables.scss
@@ -0,0 +1,41 @@
+// Color
+$color-transparent: transparent !default;
+
+$color-black-border: #2C2D30 !default;
+$color-black: #000000 !default;
+$color-black-text: #555555 !default;
+$color-black-text-light: #abb8c4 !default;
+
+$color-gray-admin: #e8ecef !default;
+$color-gray-dark: #dedede !default;
+$color-gray: #e3e3e3 !default;
+$color-gray-light: #F8F8F8 !default;
+
+$color-white: #ffffff !default;
+
+$color-blue-dark: #328fe6 !default;
+$color-blue: #32c5e6 !default;
+
+
+$color-purple-darker: #463c66 !default;
+$color-purple-dark: #4E4273 !default;
+$color-purple: #6e5baa !default;
+$color-purple-light: #6742d6 !default;
+
+$color-purple-deep: #673AB7 !default;
+
+$color-purple-text-dark: #7F6DA0 !default;
+$color-purple-text: #c7b0ff !default;
+$color-purple-text-light: #A08DCE !default;
+
+$color-green-online: #00C853 !default;
+
+$color-red: #DC5960 !default;
+
+$color-chat-border: #e0e2e5 !default;
+$color-chat-select: #f8f9fa !default;
+
+$color-message-not-sent: #e5e5e5;
+
+// Font
+$font-family-exo2: 'Exo 2';
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat-body.scss b/javascript/javascript-basic-local-caching/src/scss/chat-body.scss
new file mode 100644
index 00000000..edfabbe2
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat-body.scss
@@ -0,0 +1,35 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-body {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 10px 0;
+
+ & > .new-message-pop {
+ margin: 0px 10px;
+ display: flex;
+ width: inherit;
+
+ position: sticky;
+ bottom: -10px;
+ background-color: $color-white;
+ border: 5px solid $color-blue;
+ border-radius: 10px;
+
+ & > .new-message-pop-text {
+ margin-left: 30px;
+ height: 30px;
+ font-size: 24px;
+ color: $color-blue;
+ @include hover-focus {
+ cursor: pointer;
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat-input.scss b/javascript/javascript-basic-local-caching/src/scss/chat-input.scss
new file mode 100644
index 00000000..089761eb
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat-input.scss
@@ -0,0 +1,74 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-input {
+ display: flex;
+ padding: 20px;
+ border-top: 1px solid $color-chat-border;
+ background-color: $color-white;
+
+ & > .typing-field {
+ display: none;
+ position: absolute;
+ bottom: 79px;
+ left: 220px;
+ width: calc(100vw - 220px - 240px);
+ padding: 6px 20px;
+ box-sizing: border-box;
+ background-color: rgba(0, 0, 0, 0.1);
+ color: $color-black-text;
+ opacity: 0.4;
+ vertical-align: middle;
+ font-size: 13px;
+ font-style: italic;
+ }
+
+ & > .input-file {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ border-right: 0;
+ background-color: $color-white;
+ cursor: pointer;
+ @include border-left-radius(4px);
+ @include icon($ic-attach-file-normal, 20px 20px, center center);
+ @include hover-focus {
+ border: 1px solid $color-black-border;
+ @include icon($ic-attach-file, 20px 20px, center center);
+ }
+ }
+
+ & > .input-text {
+ display: flex;
+ font-size: 15px;
+ width: 100%;
+ height: 38px;
+ padding: 7px 8px 6px 8px;
+ box-sizing: border-box;
+ border: 1px solid $color-chat-border;
+ background-color: $color-white;
+ @include border-right-radius(4px);
+ @include hover-focus-active {
+ border: 1px solid $color-black-border;
+ }
+
+ & > .input-text-area {
+ width: 100%;
+ outline: none;
+ border: 0;
+ resize: none;
+ line-height: 1.4;
+ background-color: $color-white;
+ overflow: hidden;
+ @include hover-focus {
+ outline: none;
+ border: 0;
+ resize: none;
+ padding-top: 2px;
+ line-height: 1.4;
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat-main.scss b/javascript/javascript-basic-local-caching/src/scss/chat-main.scss
new file mode 100644
index 00000000..34279652
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat-main.scss
@@ -0,0 +1,18 @@
+@import 'mixins';
+@import 'variables';
+
+.chat-main-root {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 0;
+
+ & > .chat-main {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 100%;
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat-menu.scss b/javascript/javascript-basic-local-caching/src/scss/chat-menu.scss
new file mode 100644
index 00000000..b54403ae
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat-menu.scss
@@ -0,0 +1,97 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-menu-root {
+ display: flex;
+ flex-direction: column;
+ width: 240px;
+ min-width: 240px;
+ max-width: 240px;
+ background-color: $color-white;
+ box-sizing: border-box;
+ border-left: 1px solid $color-chat-border;
+ color: $color-black-border;
+ padding: 0;
+
+ & > .menu-item {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ align-content: center;
+ padding: 10px 20px;
+ border-bottom: 1px solid $color-chat-border;
+ cursor: pointer;
+
+ & > .menu-users,
+ & > .menu-blocked {
+ display: flex;
+ opacity: 0.6;
+ }
+
+ & > .menu-arrow {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ @include icon($ic-arrow-normal, 26px 26px, center center);
+ }
+
+ @include hover-focus {
+ background-color: $color-chat-select;
+
+ & > .menu-users,
+ & > .menu-blocked {
+ opacity: 1;
+ }
+
+ & > .menu-arrow {
+ @include icon($ic-arrow, 26px 26px, center center);
+ }
+ }
+ }
+
+ & > .menu-list {
+ display: none;
+ flex-direction: column;
+ position: absolute;
+ width: 239px;
+ height: calc(100% - 77px);
+ background: $color-white;
+ z-index: 999;
+
+ & > .list-title {
+ display: flex;
+ align-items: center;
+ align-content: center;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ color: $color-black-border;
+ border-bottom: 1px solid $color-chat-border;
+ cursor: pointer;
+ @include hover-focus {
+ background-color: $color-chat-select;
+ }
+
+ & > .list-back {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ @include icon($ic-back, 24px 24px, 0 center);
+ }
+
+ & > .list-text {
+ display: flex;
+ }
+ }
+
+ & > .list-body {
+ display: block;
+ flex-direction: column;
+ height: 100%;
+ max-height: calc(100% - 56px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat-top-menu.scss b/javascript/javascript-basic-local-caching/src/scss/chat-top-menu.scss
new file mode 100644
index 00000000..239bb56c
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat-top-menu.scss
@@ -0,0 +1,81 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-top {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ height: 80px;
+ box-sizing: border-box;
+ padding: 15px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-chat-border;
+ color: $color-black-border;
+
+ & > .chat-title {
+ max-width: 800px;
+ font-size: 20px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+ & > .chat-title.is-group {
+ padding-left: 34px;
+ @include icon($ic-group, 27px 27px, 0 center);
+ }
+
+ & > .chat-button {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ width: 150px;
+ margin-left: 20px;
+
+ & > .button-invite {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ margin-right: 10px;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-group-add-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-group-add, 20px 20px, center center);
+ }
+ }
+
+ & > .button-hide {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ margin-right: 10px;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-hide-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-hide, 20px 20px, center center);
+ }
+ }
+
+ & > .button-leave {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-leave-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-leave, 20px 20px, center center);
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat-user-item.scss b/javascript/javascript-basic-local-caching/src/scss/chat-user-item.scss
new file mode 100644
index 00000000..649deefb
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat-user-item.scss
@@ -0,0 +1,34 @@
+@import 'mixins';
+@import 'variables';
+
+.chat-user-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 10px 20px;
+ cursor: pointer;
+
+ & > .user-image {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ margin-right: 10px;
+ background-size: 36px 36px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ @include border-radius(50%);
+ }
+
+ & > .user-nickname {
+ width: 154px;
+ max-width: 154px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+ & > .user-nickname.is-user {
+ font-weight: 600;
+ color: $color-purple-deep;
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/chat.scss b/javascript/javascript-basic-local-caching/src/scss/chat.scss
new file mode 100644
index 00000000..9c9242ae
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/chat.scss
@@ -0,0 +1,51 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-empty {
+ display: flex;
+ width: 100%;
+ height: 100%;
+
+ & > .empty-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: auto;
+ text-align: center;
+ color: $color-black-text-light;
+ @include transform-translate(0, -50%);
+
+ & > .content-title {
+ display: flex;
+ font-size: 28px;
+ }
+
+ & > .content-image {
+ display: flex;
+ width: 80px;
+ height: 80px;
+ padding: 8px;
+ @include icon($ic-empty-chat, 80px 80px, center center);
+ }
+
+ & > .content-desc {
+ display: flex;
+ font-size: 14px;
+ white-space: pre;
+ }
+ }
+}
+
+.logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+}
+
+.chat-root {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 100%;
+ height: 100%;
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/index.scss b/javascript/javascript-basic-local-caching/src/scss/index.scss
new file mode 100644
index 00000000..c504b226
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/index.scss
@@ -0,0 +1,146 @@
+@import 'common';
+
+body {
+ background-color: $color-purple;
+}
+
+.logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-width: 900px;
+ min-height: 650px;
+ font-family: $font-family-exo2;
+
+ &>.top {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 80px;
+ color: $color-white;
+
+ &>.logo {
+ display: flex;
+ align-items: center;
+ background-color: $color-white;
+ width: 87px;
+ height: 87px;
+ flex-direction: column;
+ justify-content: center;
+ @include border-radius(50%);
+
+ &>.logo-image {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ &>.title {
+ display: flex;
+ align-items: center;
+
+ &>.title-company {
+ display: flex;
+ align-items: center;
+ font-size: 30px;
+ font-weight: 600;
+ margin: 0 10px;
+ }
+
+ &>.title-desc {
+ display: flex;
+ align-items: center;
+ font-size: 26px;
+ font-weight: 200;
+ }
+ }
+ }
+
+ &>.login {
+ display: flex;
+ margin-top: 40px;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ &>.desc {
+ display: flex;
+ color: $color-purple-text;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+
+ &>.download {
+ display: flex;
+ text-align: center;
+ margin: 20px 0;
+
+ &>.download-sample {
+ color: $color-purple-text;
+ cursor: pointer;
+
+ @include hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ &>.form {
+ display: flex;
+ flex-direction: column;
+ margin-top: 10px;
+
+ &>.form-input {
+ display: flex;
+ margin-top: 10px;
+ border: 2px solid $color-white;
+ padding: 0 10px 0 40px;
+ width: 300px;
+ height: 50px;
+ font-size: 16px;
+ color: $color-black-text;
+ @include border-radius(2px);
+ @include icon($ic-input-user, 20px 20px, 10px center);
+
+ @include hover-focus {
+ border: 2px solid $color-blue;
+ }
+ }
+
+ &>.button {
+ display: flex;
+ justify-content: center;
+ margin-top: 10px;
+ width: 100%;
+ height: 48px;
+ background-color: $color-blue;
+ color: $color-white;
+ font-size: 16px;
+ font-weight: 700;
+ border: 0;
+ @include border-radius(2px);
+ cursor: pointer;
+
+ @include hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ &>.image {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ margin-top: 50px;
+ color: $color-white;
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/src/scss/list-item.scss b/javascript/javascript-basic-local-caching/src/scss/list-item.scss
new file mode 100644
index 00000000..a476e87f
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/list-item.scss
@@ -0,0 +1,84 @@
+@import 'mixins';
+@import 'variables';
+
+.list-item {
+ display: flex;
+ flex-direction: column;
+ padding: 6px 20px;
+ cursor: pointer;
+ @include hover {
+ background-color: $color-purple-darker;
+ }
+ & > .item-top {
+ display: flex;
+ color: $color-purple-text-light;
+ & > .item-count {
+ width: 18px;
+ height: 18px;
+ box-sizing: border-box;
+ border: 1px solid $color-purple-text-light;
+ align-items: center;
+ align-content: center;
+ text-align: center;
+ margin-right: 8px;
+ font-size: 13px;
+ line-height: 17px;
+ }
+ & > .item-title {
+ width: 100%;
+ max-width: 150px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ & > .item-bottom {
+ display: flex;
+ color: $color-purple-text-dark;
+ justify-content: space-between;
+ flex-direction: column;
+ font-size: 14px;
+ margin-top: 4px;
+ padding-left: 26px;
+ & > .item-message {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ & > .item-message-text {
+ width: 130px;
+ max-width: 130px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ color: $color-purple-text-light;
+ opacity: 0.7;
+ }
+ & > .item-message-unread {
+ display: none;
+ width: 16px;
+ height: 16px;
+ background-color: $color-red;
+ text-align: center;
+ color: $color-white;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+ @include border-radius(50%);
+ }
+ & > .item-message-unread.active {
+ display: block;
+ }
+ }
+ & > .item-time {
+ display: flex;
+ font-size: 11px;
+ }
+ }
+}
+
+.list-item.active {
+ & > .item-top {
+ color: $color-purple-text;
+ font-weight: 600;
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/list.scss b/javascript/javascript-basic-local-caching/src/scss/list.scss
new file mode 100644
index 00000000..7365ce94
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/list.scss
@@ -0,0 +1,106 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.list-root {
+ min-width: 980px;
+ width: 100vw;
+ height: 100vh;
+ max-height: 100vh;
+ overflow: hidden;
+ position: absolute;
+ z-index: 9999;
+ background-color: $color-white;
+ font-family: $font-family-exo2;
+ & > .list-body {
+ max-width: 700px;
+ min-width: 500px;
+ width: 100%;
+ height: 100%;
+ margin: 70px auto 50px auto;
+ display: flex;
+ box-sizing: border-box;
+ flex-direction: column;
+ & > .list-top {
+ width: 100%;
+ height: 70px;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ & > .list-title {
+ display: flex;
+ font-size: 30px;
+ font-weight: 700;
+ margin-left: 20px;
+ }
+ & > .list-button {
+ display: flex;
+ flex-direction: row;
+ margin-right: 20px;
+
+ & > .button-exit {
+ width: 36px;
+ height: 36px;
+ text-align: center;
+ justify-content: center;
+ display: flex;
+ line-height: 36px;
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include border-radius(4px);
+ @include icon($ic-close, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-gray;
+ background-color: $color-gray;
+ }
+ }
+ }
+ }
+ & > .list-hr {
+ height: 0;
+ margin: 8px 20px;
+ border-top: 1px solid $color-gray;
+ }
+
+ & > .list-search {
+ box-sizing: border-box;
+ padding: 10px 20px;
+ overflow: hidden;
+ & > .search-input {
+ font-size: 18px;
+ font-family: $font-family-exo2;
+ box-sizing: border-box;
+ width: calc(100% - 40px);
+ height: 42px;
+ margin: 0 20px;
+ padding-left: 44px;
+ outline: none;
+ border: 1px solid $color-gray;
+ @include border-radius(4px);
+ @include icon($ic-search, 26px 26px, 8px center);
+ @include hover-focus {
+ @include icon($ic-search-over, 26px 26px, 8px center);
+ border: 1px solid $color-purple-light;
+ font-weight: 300;
+ }
+ &::placeholder {
+ color: $color-gray;
+ font-size: 18px;
+ font-weight: 300;
+ }
+ }
+ }
+
+ & > .list-content {
+ box-sizing: border-box;
+ padding: 10px 20px;
+ max-height: calc(100vh - 205px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/main.scss b/javascript/javascript-basic-local-caching/src/scss/main.scss
new file mode 100644
index 00000000..bc07b2e2
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/main.scss
@@ -0,0 +1,163 @@
+@import 'common';
+
+.body {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ min-width: 980px;
+ font-family: $font-family-exo2;
+
+ &>.body-left {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 220px;
+ height: 100vh;
+ background-color: $color-purple-dark;
+
+ &>.body-left-top {
+ display: flex;
+ padding: 20px;
+ justify-content: center;
+
+ &>.top-logo {
+ display: flex;
+ align-items: center;
+ background-color: $color-white;
+ width: 50px;
+ height: 50px;
+ flex-direction: column;
+ justify-content: center;
+ @include border-radius(50%);
+
+ &>.logo-image {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ &>.top-text {
+ color: $color-white;
+ display: flex;
+ align-items: center;
+ font-size: 30px;
+ font-weight: 600;
+ margin-left: 5px;
+ }
+ }
+
+ &>.body-left-list {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 170px);
+ color: $color-purple-text-dark;
+
+ .icon-create-chat {
+ width: 20px;
+ height: 20px;
+ @include border-radius(4px);
+ @include icon($ic-add-normal, 17px 17px, center center);
+
+ @include hover {
+ cursor: pointer;
+ background-color: $color-purple-text-light;
+ }
+ }
+
+ &>.chat-type {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ font-family: $font-family-exo2;
+ font-weight: 400;
+ font-size: 14px;
+ color: #9F8DC0;
+ line-height: 20px;
+ padding: 8px 20px;
+
+ &>.chat-type-title {
+ @include hover {
+ cursor: pointer;
+ font-weight: 600;
+ color: $color-purple-text-light;
+ }
+ }
+ }
+
+ &>.chat-list {
+ flex-direction: column;
+ width: 100%;
+ max-height: 450px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ box-sizing: border-box;
+
+ &>.default-item {
+ display: block;
+ padding: 10px;
+ margin: 0 20px;
+ color: $color-purple-text-light;
+ font-size: 16px;
+ border: 1px dashed $color-purple-text-light;
+ @include border-radius(4px);
+ }
+ }
+
+ &>.chat-list.chat-list-group {
+ max-height: calc(100% - 130px);
+ }
+ }
+
+ &>.body-left-bottom {
+ display: flex;
+ padding: 20px;
+ background-color: $color-purple-darker;
+
+ &>.bottom-profile {
+ display: flex;
+ height: 40px;
+ align-items: center;
+
+ &>.image-profile {
+ display: flex;
+ align-items: center;
+ @include border-radius(50%);
+ }
+ }
+
+ &>.bottom-nickname {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 10px;
+
+ &>.nickname-title {
+ display: flex;
+ color: $color-purple-text-dark;
+ font-size: 14px;
+ }
+
+ &>.nickname-content {
+ display: inline-block;
+ max-width: 150px;
+ height: 18px;
+ color: $color-purple-text-light;
+ font-size: 16px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -ms-text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+ }
+
+ &>.body-center {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 500px;
+ height: 100%;
+ background-color: $color-white;
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/src/scss/message-delete-modal.scss b/javascript/javascript-basic-local-caching/src/scss/message-delete-modal.scss
new file mode 100644
index 00000000..57c4db82
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/message-delete-modal.scss
@@ -0,0 +1,14 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-message {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px;
+ width: 100%;
+ border: 1px solid $color-red;
+ background-color: $color-white;
+ font-size: 18px;
+ margin: 10px 0;
+ @include border-radius(4px);
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/message.scss b/javascript/javascript-basic-local-caching/src/scss/message.scss
new file mode 100644
index 00000000..e3f6d3ab
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/message.scss
@@ -0,0 +1,113 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-message {
+ display: block;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0 20px;
+ margin-bottom: 8px;
+ border: 1px solid transparent;
+
+ & > .message-content {
+ display: inline;
+ & > .message-nickname {
+ align-items: center;
+ display: inline;
+ justify-content: flex-start;
+ flex-direction: column;
+ cursor: pointer;
+ }
+ & > .message-nickname.is-user {
+ font-weight: 600;
+ color: $color-purple-deep;
+ cursor: initial;
+ }
+
+ & > .message-content {
+ display: inline;
+ white-space: pre-line;
+ }
+ & > .message-content.is-file {
+ cursor: pointer;
+ @include hover-focus {
+ color: $color-blue-dark;
+ }
+ }
+
+ & > .time {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ opacity: 0.5;
+ }
+ & > .time.is-user {
+ cursor: pointer;
+ }
+
+ & > .read {
+ display: none;
+ vertical-align: middle;
+ text-align: center;
+ width: 18px;
+ height: 18px;
+ line-height: 17px;
+ margin-left: 8px;
+ font-size: 12px;
+ color: $color-white;
+ font-weight: 500;
+ @include border-radius(50%);
+ background: $color-red;
+ }
+ & > .read.active {
+ display: inline-block;
+ }
+ & > .resend-button {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: $color-red;
+ }
+
+ & > .delete-button {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: $color-red;
+ }
+ }
+
+ & > .file-content {
+ display: block;
+ border-left: 2px solid $color-black-text;
+ padding-left: 10px;
+ margin-top: 8px;
+ cursor: pointer;
+ & > .file-render {
+ display: inline;
+ max-width: 300px;
+ max-height: 300px;
+ }
+ }
+
+ & > .message-admin {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-style: italic;
+ color: $color-black-text;
+ }
+}
+
+.chat-message.is-failed {
+ background: $color-message-not-sent;
+}
+.chat-message.is-pending {
+ color: $color-message-not-sent;
+}
diff --git a/web-live-chat/src/scss/mixins/_border-radius.scss b/javascript/javascript-basic-local-caching/src/scss/mixins/_border-radius.scss
similarity index 100%
rename from web-live-chat/src/scss/mixins/_border-radius.scss
rename to javascript/javascript-basic-local-caching/src/scss/mixins/_border-radius.scss
diff --git a/web-live-chat/src/scss/mixins/_reset.scss b/javascript/javascript-basic-local-caching/src/scss/mixins/_reset.scss
similarity index 100%
rename from web-live-chat/src/scss/mixins/_reset.scss
rename to javascript/javascript-basic-local-caching/src/scss/mixins/_reset.scss
diff --git a/javascript/javascript-basic-local-caching/src/scss/mixins/_state.scss b/javascript/javascript-basic-local-caching/src/scss/mixins/_state.scss
new file mode 100644
index 00000000..94e4cea3
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/mixins/_state.scss
@@ -0,0 +1,58 @@
+@mixin hover {
+ &:hover { @content; }
+}
+
+@mixin plain-hover {
+ &,
+ &:hover { @content; }
+}
+
+@mixin focus {
+ &:focus { @content; }
+}
+
+@mixin plain-focus {
+ &,
+ &:focus { @content; }
+}
+
+@mixin hover-focus {
+ &:hover,
+ &:focus { @content; }
+}
+
+@mixin plain-hover-focus {
+ &,
+ &:hover,
+ &:focus { @content; }
+}
+
+@mixin hover-focus-active {
+ &:hover,
+ &:focus,
+ &:active { @content; }
+}
+
+@mixin after {
+ &::after {
+ @content
+ }
+}
+
+@mixin before {
+ &::before {
+ @content
+ }
+}
+
+@mixin before-after {
+ &::after, &::before {
+ @content
+ }
+}
+
+@mixin plain-before-after {
+ &, &::after, &::before {
+ @content
+ }
+}
diff --git a/web-live-chat/src/scss/mixins/_transform.scss b/javascript/javascript-basic-local-caching/src/scss/mixins/_transform.scss
similarity index 100%
rename from web-live-chat/src/scss/mixins/_transform.scss
rename to javascript/javascript-basic-local-caching/src/scss/mixins/_transform.scss
diff --git a/javascript/javascript-basic-local-caching/src/scss/modal.scss b/javascript/javascript-basic-local-caching/src/scss/modal.scss
new file mode 100644
index 00000000..3590e65d
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/modal.scss
@@ -0,0 +1,74 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-root {
+ display: flex;
+ position: absolute;
+ width: 100vw;
+ height: 100vh;
+ z-index: 9999;
+ background-color: rgba(0, 0, 0, 0.5);
+
+ & > .modal-body {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ width: 450px;
+ background-color: $color-white;
+ margin: auto;
+ padding: 24px;
+ @include border-radius(4px);
+ @include transform-translate(0, -50%);
+
+ & > .modal-title {
+ display: flex;
+ font-size: 26px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+
+ & > .modal-desc {
+ display: flex;
+ color: $color-black-text;
+ font-size: 14px;
+ font-weight: 300;
+ }
+
+ & > .modal-content {
+ display: flex;
+ margin: 10px 0;
+ }
+
+ & > .modal-bottom {
+ display: flex;
+ justify-content: flex-end;
+ & > .modal-cancel {
+ display: flex;
+ margin-right: 12px;
+ color: $color-black-text-light;
+ border: 1px solid $color-black-text-light;
+ cursor: pointer;
+ padding: 6px;
+ @include border-radius(4px);
+ @include hover-focus {
+ color: $color-purple-light;
+ border: 1px solid $color-purple-light;
+ }
+ }
+ & > .modal-submit {
+ display: flex;
+ color: $color-white;
+ background-color: $color-blue;
+ border: 1px solid $color-blue;
+ cursor: pointer;
+ padding: 6px;
+ font-weight: 600;
+ @include border-radius(4px);
+ @include hover-focus {
+ background-color: $color-blue-dark;
+ border: 1px solid $color-blue-dark;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-local-caching/src/scss/spinner.scss b/javascript/javascript-basic-local-caching/src/scss/spinner.scss
new file mode 100644
index 00000000..7bf66632
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/spinner.scss
@@ -0,0 +1,71 @@
+@import 'mixins';
+@import 'variables';
+
+.sb-spinner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0.6;
+ background-color: $color-white;
+ flex-direction: column;
+ justify-content: center;
+ display: flex;
+
+ .sb-spinner-bubble {
+ color: $color-black;
+ font-size: 10px;
+ margin: 80px auto;
+ position: relative;
+ text-indent: -9999em;
+ -webkit-animation-delay: -0.16s;
+ animation-delay: -0.16s;
+ @include transform-translate(0, -2em);
+ @include plain-before-after {
+ @include border-radius(50%);
+ width: 1.5em;
+ height: 1.5em;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+ -webkit-animation: load7 1.8s infinite ease-in-out;
+ animation: load7 1.8s infinite ease-in-out;
+ }
+ @include before-after {
+ content: '';
+ position: absolute;
+ top: 0;
+ }
+ @include before {
+ left: -3.5em;
+ -webkit-animation-delay: -0.32s;
+ animation-delay: -0.32s;
+ }
+ @include after {
+ left: 3.5em;
+ }
+ }
+
+}
+
+@-webkit-keyframes load7 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 2.5em 0 -1.3em;
+ }
+ 40% {
+ box-shadow: 0 2.5em 0 0;
+ }
+}
+@keyframes load7 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 2.5em 0 -1.3em;
+ }
+ 40% {
+ box-shadow: 0 2.5em 0 0;
+ }
+}
+
diff --git a/javascript/javascript-basic-local-caching/src/scss/toast.scss b/javascript/javascript-basic-local-caching/src/scss/toast.scss
new file mode 100644
index 00000000..112eaa3b
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/toast.scss
@@ -0,0 +1,21 @@
+@import 'variables';
+@import 'animation';
+
+.sb-toast {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ text-align: center;
+
+ .sb-toast-message {
+ color: $color-white;
+ display:inline-block;
+ font-size: 14px;
+ padding:10px 15px;
+ margin-top:20px;
+ border-radius:5px;
+ background-color: $color-purple-dark;
+ }
+}
+
diff --git a/javascript/javascript-basic-local-caching/src/scss/user-block-modal.scss b/javascript/javascript-basic-local-caching/src/scss/user-block-modal.scss
new file mode 100644
index 00000000..9df0631c
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/user-block-modal.scss
@@ -0,0 +1,34 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-user {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px;
+ width: 100%;
+ border: 1px solid $color-red;
+ background-color: $color-white;
+ font-size: 18px;
+ margin: 10px 0;
+ @include border-radius(4px);
+
+ & > .user-profile {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ margin-right: 10px;
+ background-size: 36px 36px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ @include border-radius(50%);
+ }
+
+ & > .user-nickname {
+ width: 330px;
+ max-width: 330px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/user-item.scss b/javascript/javascript-basic-local-caching/src/scss/user-item.scss
new file mode 100644
index 00000000..de417bfd
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/user-item.scss
@@ -0,0 +1,77 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.user-item {
+ display: flex;
+ padding: 8px 20px 8px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-gray-dark;
+ justify-content: space-between;
+ cursor: pointer;
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-purple-light;
+ @include border-radius(2px);
+ }
+
+ & > .user-info {
+ display: flex;
+ align-items: center;
+
+ & > .user-profile {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ @include icon($ic-profile-default, 40px 40px, center center);
+ }
+
+ & > .user-nickname {
+ margin: 0 10px;
+ font-size: 18px;
+ max-width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -ms-text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ & > .user-online {
+ display: flex;
+ width: 8px;
+ height: 8px;
+ border: 1px solid $color-black-text-light;
+ background-color: $color-black-text-light;
+ opacity: 0.4;
+ @include border-radius(50%);
+ }
+ & > .user-online.active {
+ border: 1px solid $color-green-online;
+ background-color: $color-green-online;
+ opacity: 1;
+ }
+ }
+
+ & > .user-state {
+ display: flex;
+ align-items: center;
+
+ & > .user-time {
+ display: flex;
+ color: $color-black-text-light;
+ margin-right: 10px;
+ }
+
+ & > .user-select {
+ display: flex;
+ width: 30px;
+ height: 30px;
+ opacity: 0.4;
+ @include icon($ic-check-unselect, 30px 30px, center center);
+ }
+ & > .user-select.active {
+ opacity: 1;
+ @include icon($ic-check-select, 30px 30px, center center);
+ }
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/src/scss/user-list.scss b/javascript/javascript-basic-local-caching/src/scss/user-list.scss
new file mode 100644
index 00000000..bc99f7b4
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/src/scss/user-list.scss
@@ -0,0 +1,23 @@
+@import 'mixins';
+@import 'variables';
+
+.button-create {
+ width: 80px;
+ height: 36px;
+ text-align: center;
+ justify-content: center;
+ display: flex;
+ line-height: 36px;
+ font-weight: 600;
+ color: $color-white;
+ cursor: pointer;
+ background-color: $color-blue;
+ border: 1px solid $color-blue;
+ margin-right: 12px;
+ @include border-radius(4px);
+ @include hover-focus {
+ cursor: pointer;
+ background-color: $color-blue-dark;
+ border: 1px solid $color-blue-dark;
+ }
+}
diff --git a/javascript/javascript-basic-local-caching/webpack.config.js b/javascript/javascript-basic-local-caching/webpack.config.js
new file mode 100644
index 00000000..a33ab0a6
--- /dev/null
+++ b/javascript/javascript-basic-local-caching/webpack.config.js
@@ -0,0 +1,79 @@
+'use strict';
+const path = require('path');
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
+
+const PRODUCTION = 'production';
+
+module.exports = () => {
+ const config = {
+ mode: 'production',
+ entry: {
+ index: ['./src/js/index.js', './src/scss/index.scss'],
+ main: ['./src/js/main.js', './src/scss/main.scss']
+ },
+ output: {
+ path: path.resolve(__dirname, './dist'),
+ filename: 'sample.[name].js',
+ library: '[name]',
+ libraryExport: 'default',
+ libraryTarget: 'umd',
+ publicPath: 'dist'
+ },
+ devtool: 'cheap-eval-source-map',
+ devServer: {
+ publicPath: '/dist/',
+ compress: true,
+ port: 9000
+ },
+ performance: { hints: false },
+ module: {
+ rules: [
+ {
+ // SCSS/SASS
+ test: /\.s[ac]ss$/i,
+ use: ExtractTextPlugin.extract({
+ fallback: 'style-loader',
+ use: [
+ {
+ loader: 'css-loader',
+ options: {
+ module: true,
+ minimize: process.env.WEBPACK_MODE === PRODUCTION,
+ // sourceMap: true,
+ localIdentName: '[local]'
+ }
+ },
+ {
+ loader: 'sass-loader',
+ options: {
+ implementation: require("sass"),
+ }
+ }
+ ]
+ })
+ },
+ {
+ // ESLint
+ enforce: 'pre',
+ test: /\.js$/,
+ exclude: /node_modules/,
+ loader: 'eslint-loader',
+ options: { failOnError: true }
+ },
+ {
+ // ES6
+ test: /\.js$/,
+ loader: 'babel-loader',
+ exclude: '/node_modules/'
+ }
+ ]
+ },
+ plugins: [
+ new ExtractTextPlugin({
+ filename: 'sample.[name].css'
+ })
+ ]
+ };
+
+ return config;
+};
diff --git a/javascript/javascript-basic-syncmanager/.babelrc b/javascript/javascript-basic-syncmanager/.babelrc
new file mode 100644
index 00000000..a7352030
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/.babelrc
@@ -0,0 +1,8 @@
+{
+ "presets": ["env"],
+ "env": {
+ "test": {
+ "presets": ["env"]
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/.eslintignore b/javascript/javascript-basic-syncmanager/.eslintignore
new file mode 100644
index 00000000..feb309d8
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/.eslintignore
@@ -0,0 +1,2 @@
+
+**/*.min.js
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/.eslintrc.js b/javascript/javascript-basic-syncmanager/.eslintrc.js
new file mode 100644
index 00000000..0211a4b2
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/.eslintrc.js
@@ -0,0 +1,21 @@
+module.exports = {
+ env: {
+ browser: true,
+ commonjs: true,
+ es6: true
+ },
+ extends: 'eslint:recommended',
+ parserOptions: {
+ parser: 'babel-eslint',
+ sourceType: 'module'
+ },
+ rules: {
+ 'linebreak-style': ['error', 'unix'],
+ quotes: ['warn', 'single'],
+ semi: ['warn', 'always'],
+ 'no-console': 1,
+ 'no-unused-vars': 1,
+ 'no-inner-declarations': 1,
+ 'no-useless-escape': 1
+ }
+};
diff --git a/javascript/javascript-basic-syncmanager/.prettierignore b/javascript/javascript-basic-syncmanager/.prettierignore
new file mode 100644
index 00000000..52999c0b
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/.prettierignore
@@ -0,0 +1,2 @@
+README.md
+.eslintrc.js
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/.prettierrc b/javascript/javascript-basic-syncmanager/.prettierrc
new file mode 100644
index 00000000..f65aabcb
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "printWidth": 120
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/README.md b/javascript/javascript-basic-syncmanager/README.md
new file mode 100644
index 00000000..845d7e55
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/README.md
@@ -0,0 +1,87 @@
+# Sendbird SyncManager for JavaScript sample
+
+
+[](https://www.npmjs.com/package/sendbird-syncmanager)
+
+## Introduction
+
+SyncManager for Javascript is a Chat SDK add-on that optimizes the user caching experience by interlinking the synchronization of the local data storage with the chat data in Sendbird server through an event-driven structure. Provided here is a SyncManager sample for Javascript to experience first-hand the benefits of Sendbird’s SyncManager.
+
+### Benefits
+
+Sendbird SyncManager provides the local caching system and data synchronization with the Sendbird server, which are run on an event-driven structure. According to the real-time events of the messages and channels, SyncManager takes care of the background tasks for the cache updates from the Sendbird server to the local device. By leveraging this systemized structure with connection-based synchronization, SyncManager allows you to easily integrate the Chat SDK to utilize all of its features, while also reducing data usage and offering a reliable and effortless storage mechanism.
+
+### More about Sendbird SyncManager for JavaScript
+
+Find out more about Sendbird SyncManager for JavaScript at [SyncManager for JavaScript doc](https://sendbird.com/docs/syncmanager/v1/javascript/getting-started/about-syncmanager). If you need any help in resolving any issues or have questions, visit [our community](https://community.sendbird.com).
+
+
+
+## Before getting started
+This section provides the prerequisites for testing Sendbird Desk for Javascript sample app.
+
+### Requirements
+The minimum requirements for SyncManager for Javascript are:
+- Node. js v8+
+- NPM v6+
+- [Chat SDK for JavaScript](https://github.com/sendbird/SendBird-SDK-JavaScript) v3.0 115+
+
+### Try the sample app using your data
+
+If you would like to try the sample app specifically fit to your usage, you can do so by replacing the default sample app ID with yours, which you can obtain by [creating your Sendbird application from the dashboard](https://sendbird.com/docs/chat/v3/javascript/getting-started/install-chat-sdk#2-step-1-create-a-sendbird-application-from-your-dashboard). Furthermore, you could also add data of your choice on the dashboard to test. This will allow you to experience the sample app with data from your Sendbird application.
+
+### Try the SyncManager on our demo website
+
+By using this [link](https://sample.sendbird.com/basic/sync-manager), you can test the SyncManager through our demo website.
+
+
+
+## Getting started
+
+You can install and run SyncManager for JavaScript sample app on your system using `npm`.
+
+### Install packages
+
+`Node` v8.x+ should be installed on your system.
+
+> `node-sass` package requires XCode developer tools (MacOS only) and Node.js version matching. If you have any trouble in the installation, see https://www.npmjs.com/package/node-sass.
+
+```bash
+npm install
+```
+
+### Run the sample
+
+```bash
+npm start
+```
+
+
+
+## Customizing the sample
+
+To implement customization to the sample, you can use `webpack` for buiding it.
+
+### Install packages
+
+`Node` v8.x+ should be installed on your system.
+
+```bash
+npm install
+```
+
+### Modify files
+
+If you want to change `APP_ID`, change `APP_ID` in `const.js` to the other `APP_ID` you want. You can test the sample with local server by running the following command.
+
+```bash
+npm run start:dev
+```
+
+### Build the sample
+
+When the modification is complete, you'll need to bundle the file using `webpack`. The bundled files are created in the **dist** folder. Please check `webpack.config.js` for settings.
+
+```bash
+npm run build
+```
diff --git a/javascript/javascript-basic-syncmanager/chat.html b/javascript/javascript-basic-syncmanager/chat.html
new file mode 100644
index 00000000..01169d0b
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/chat.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Sample with SyncManager | Sendbird
+
+
+
+
+
+
+
+
+
+
+ Sendbird
+
+
+
+
+
+
Start by inviting user to create a channel.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/index.html b/javascript/javascript-basic-syncmanager/index.html
new file mode 100644
index 00000000..e5745b1e
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Sample with SyncManager | Sendbird
+
+
+
+
+
+
+
+
+
+
+ Sendbird
+
+
+ Web Basic Sample with SyncManager
+
+
+
+
+
+
+ Start chatting on Sendbird by choosing your display name.
+
This can be changed anytime and will be shown on 1-on-1 and group messaging.
+
+
+
+
+
+ LOGIN
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/javascript-basic-syncmanager/package.json b/javascript/javascript-basic-syncmanager/package.json
new file mode 100644
index 00000000..d16575b0
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "Sample-JS-Web-Basic-SyncManager",
+ "version": "1.0.4",
+ "description": "Sendbird Web Basic Sample using SyncManager",
+ "main": "index.js",
+ "scripts": {
+ "dev": "./node_modules/.bin/webpack --mode=development",
+ "dev:w": "./node_modules/.bin/webpack --mode=none -w",
+ "build": "./node_modules/.bin/webpack --mode=production",
+ "start:dev": "./node_modules/.bin/webpack-dev-server",
+ "start": "npm run build && node server.js",
+ "deploy": "node ./deploy/deploy.js"
+ },
+ "author": "SendBird",
+ "license": "ISC",
+ "devDependencies": {
+ "babel-core": "^6.26.0",
+ "babel-eslint": "^8.2.3",
+ "babel-loader": "^7.1.4",
+ "babel-preset-env": "^1.6.1",
+ "css-loader": "^0.28.11",
+ "eslint": "^4.19.1",
+ "eslint-loader": "^2.1.1",
+ "express": "^4.16.3",
+ "extract-text-webpack-plugin": "^4.0.0-beta.0",
+ "node-sass": "^4.9.3",
+ "prettier": "^1.14.3",
+ "sass-loader": "^7.0.1",
+ "ssh2": "^0.8.5",
+ "style-loader": "^0.21.0",
+ "webpack": "^4.19.1",
+ "webpack-cli": "^3.1.0",
+ "webpack-dev-server": "^3.1.11"
+ },
+ "dependencies": {
+ "moment": "^2.22.1",
+ "sendbird": "^3.0.111",
+ "sendbird-syncmanager": "^1.1.14"
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/server.js b/javascript/javascript-basic-syncmanager/server.js
new file mode 100644
index 00000000..487ba47b
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/server.js
@@ -0,0 +1,14 @@
+const express = require('express');
+const app = express();
+
+const PORT = 9000;
+
+app.use(express.static('dist'));
+app.use(express.static('./'));
+
+app.get('/', function(req, res) {
+ res.sendfile('index.html');
+});
+
+app.listen(PORT);
+console.log(`[SERVER RUNNING] 127.0.0.1:${PORT}`);
diff --git a/javascript/javascript-basic-syncmanager/src/js/Chat.js b/javascript/javascript-basic-syncmanager/src/js/Chat.js
new file mode 100644
index 00000000..0cdf9fa9
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/Chat.js
@@ -0,0 +1,120 @@
+import styles from '../scss/chat.scss';
+import { createDivEl } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { SendBirdChatEvent } from './SendBirdChatEvent';
+import { ChatLeftMenu } from './ChatLeftMenu';
+import { ChatTopMenu } from './components/ChatTopMenu';
+import { ChatMain } from './components/ChatMain';
+
+let instance = null;
+
+class Chat {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.body = document.querySelector('.body-center');
+
+ this.channel = null;
+ this.element = null;
+ this.top = null;
+ this.emptyElement = this._createEmptyElement();
+ this.render();
+ instance = this;
+ }
+
+ _createEmptyElement() {
+ const item = createDivEl({ className: styles['chat-empty'] });
+
+ const content = createDivEl({ className: styles['empty-content'] });
+ item.appendChild(content);
+
+ const title = createDivEl({ className: styles['content-title'], content: 'WELCOME TO SAMPLE CHAT' });
+ content.appendChild(title);
+ const image = createDivEl({ className: styles['content-image'] });
+ content.appendChild(image);
+ const desc = createDivEl({
+ className: styles['content-desc'],
+ content:
+ 'Create or select a channel to chat in.\n' +
+ "If you don't have a channel to participate,\n" +
+ 'go ahead and create your first channel now.'
+ });
+ content.appendChild(desc);
+ return item;
+ }
+
+ renderEmptyElement() {
+ this._removeChatElement();
+ this.body.appendChild(this.emptyElement);
+ }
+
+ _removeEmptyElement() {
+ if (this.body.contains(this.emptyElement)) {
+ this.body.removeChild(this.emptyElement);
+ }
+ }
+
+ _createChatElement(channel) {
+ this.element = createDivEl({ className: styles['chat-root'] });
+
+ this.top = new ChatTopMenu(channel);
+ this.element.appendChild(this.top.element);
+
+ /// reset manager when ChatMain is obsolete
+ if (this.main && this.main.body && this.main.body.collection) {
+ this.main.body.collection.remove();
+ }
+ this.main = new ChatMain(channel);
+ }
+
+ _addEventHandler() {
+ const channelEvent = new SendBirdChatEvent();
+ channelEvent.onTypingStatusUpdated = groupChannel => {
+ if (this.channel.url === groupChannel.url) {
+ this.main.updateTyping(groupChannel.getTypingMembers());
+ }
+ };
+ }
+
+ _renderChatElement(channel) {
+ const sendbirdAction = SendBirdAction.getInstance();
+ this._removeEmptyElement();
+ this._removeChatElement();
+ this.channel = channel;
+
+ ChatLeftMenu.getInstance().activeChannelItem(channel.url);
+ this._addEventHandler();
+
+ sendbirdAction
+ .getChannel(channel.url)
+ .then(channel => {
+ this.channel = channel;
+ this._createChatElement(this.channel);
+ this.body.appendChild(this.element);
+ this.main.loadInitialMessages();
+ })
+ .catch(() => {
+ this._createChatElement(this.channel);
+ this.body.appendChild(this.element);
+ this.main.loadInitialMessages();
+ });
+ }
+
+ _removeChatElement() {
+ const chatElements = this.body.getElementsByClassName(styles['chat-root']);
+ Array.prototype.slice.call(chatElements).forEach(chatEl => {
+ chatEl.parentNode.removeChild(chatEl);
+ });
+ }
+
+ render(channel) {
+ channel ? this._renderChatElement(channel) : this.renderEmptyElement();
+ }
+
+ static getInstance() {
+ return new Chat();
+ }
+}
+
+export { Chat };
diff --git a/javascript/javascript-basic-syncmanager/src/js/ChatLeftMenu.js b/javascript/javascript-basic-syncmanager/src/js/ChatLeftMenu.js
new file mode 100644
index 00000000..b9a4ffbc
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/ChatLeftMenu.js
@@ -0,0 +1,180 @@
+import { LeftListItem } from './components/LeftListItem';
+import { ACTIVE_CLASSNAME, DISPLAY_BLOCK, DISPLAY_NONE } from './const';
+import { addClass, isScrollBottom, isUrl, protectFromXSS, removeClass, findChannelIndex } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { UserList } from './components/UserList';
+import { Chat } from './Chat';
+
+import SendBirdSyncManager from 'sendbird-syncmanager';
+
+let instance = null;
+
+class ChatLeftMenu {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.activeChannelUrl = null;
+
+ const action = new SendBirdAction();
+ const query = action.sb.GroupChannel.createMyGroupChannelListQuery();
+ query.limit = 50;
+ query.includeEmpty = false;
+ query.order = 'latest_last_message';
+
+ this.channelCollection = new SendBirdSyncManager.ChannelCollection(query);
+ const collectionHandler = new SendBirdSyncManager.ChannelCollection.CollectionHandler();
+ collectionHandler.onChannelEvent = (action, channels) => {
+ switch (action) {
+ case 'insert': {
+ for (let i in channels) {
+ const channel = channels[i];
+ const index = findChannelIndex(channel, this.channelCollection.channels);
+ const handler = () => {
+ Chat.getInstance().render(channel, false);
+ this.activeChannelUrl = channel.url;
+ };
+ const item = new LeftListItem({ channel, handler });
+ if (index < this.groupChannelList.childNodes.length - 1) {
+ this.groupChannelList.insertBefore(item.element, this.groupChannelList.childNodes[index]);
+ } else {
+ this.groupChannelList.appendChild(item.element);
+ }
+ if (this.activeChannelUrl === channel.url) {
+ this.activeChannelItem(channel.url);
+ }
+ }
+ LeftListItem.updateUnreadCount();
+ this.toggleGroupChannelDefaultItem();
+ break;
+ }
+ case 'update': {
+ for (let i in channels) {
+ const channel = channels[i];
+ const item = this.getItem(channel.url);
+ const handler = () => {
+ Chat.getInstance().render(channel, false);
+ this.activeChannelUrl = channel.url;
+ };
+ const newItem = new LeftListItem({ channel, handler });
+ this.groupChannelList.replaceChild(newItem.element, item);
+ if (this.activeChannelUrl === channel.url) {
+ this.activeChannelItem(channel.url);
+ }
+ }
+ LeftListItem.updateUnreadCount();
+ break;
+ }
+ case 'move': {
+ for (let i in channels) {
+ const channel = channels[i];
+ const previousElement = this.getItem(channel.url);
+ this.groupChannelList.removeChild(previousElement);
+
+ const handler = () => {
+ channel.markAsRead();
+ Chat.getInstance().render(channel, false);
+ this.activeChannelUrl = channel.url;
+ };
+ const newItem = new LeftListItem({ channel, handler });
+ const index = findChannelIndex(channel, this.channelCollection.channels);
+ if (index < this.groupChannelList.childNodes.length - 1) {
+ this.groupChannelList.insertBefore(newItem.element, this.groupChannelList.childNodes[index]);
+ } else {
+ this.groupChannelList.appendChild(newItem.element);
+ }
+ if (this.activeChannelUrl === channel.url) {
+ this.activeChannelItem(channel.url);
+ }
+ }
+ LeftListItem.updateUnreadCount();
+ break;
+ }
+ case 'remove': {
+ for (let i in channels) {
+ const channel = channels[i];
+ if (this.activeChannelUrl === channel.url) {
+ this.activeChannelUrl = null;
+ Chat.getInstance().render();
+ }
+ const element = this.getItem(channel.url);
+ this.groupChannelList.removeChild(element);
+ }
+ this.toggleGroupChannelDefaultItem();
+ break;
+ }
+ case 'clear': {
+ if (this.activeChannelUrl) {
+ Chat.getInstance().render();
+ }
+ this.activeChannelUrl = null;
+ const elements = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i in elements) {
+ this.groupChannelList.removeChild(elements[i]);
+ }
+ this.toggleGroupChannelDefaultItem();
+ break;
+ }
+ }
+ };
+ this.channelCollection.setCollectionHandler(collectionHandler);
+
+ this.groupChannelList = document.getElementById('group_list');
+ this.groupChannelList.addEventListener('scroll', () => {
+ if (isScrollBottom(this.groupChannelList)) {
+ this.loadGroupChannelList();
+ }
+ });
+ this.groupChannelDefaultItem = document.getElementById('default_item_group');
+
+ const groupChannelCreateBtn = document.getElementById('group_chat_add');
+ groupChannelCreateBtn.addEventListener('click', () => {
+ UserList.getInstance().render();
+ });
+ instance = this;
+ }
+
+ updateUserInfo(user) {
+ const userInfoEl = document.getElementById('user_info');
+ const profileEl = userInfoEl.getElementsByClassName('image-profile')[0];
+ if (isUrl(user.profileUrl)) {
+ profileEl.setAttribute('src', protectFromXSS(user.profileUrl));
+ }
+ const nicknameEl = userInfoEl.getElementsByClassName('nickname-content')[0];
+ nicknameEl.innerHTML = protectFromXSS(user.nickname);
+ }
+
+ loadGroupChannelList() {
+ this.channelCollection.fetch(() => {
+ this.toggleGroupChannelDefaultItem();
+ });
+ }
+ getItem(elementId) {
+ const groupChannelItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i = 0; i < groupChannelItems.length; i++) {
+ if (groupChannelItems[i].id === elementId) {
+ return groupChannelItems[i];
+ }
+ }
+ return null;
+ }
+ activeChannelItem(channelUrl) {
+ const groupItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i = 0; i < groupItems.length; i++) {
+ groupItems[i].id === channelUrl
+ ? addClass(groupItems[i], ACTIVE_CLASSNAME)
+ : removeClass(groupItems[i], ACTIVE_CLASSNAME);
+ }
+ }
+ toggleGroupChannelDefaultItem() {
+ this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName()).length > 0
+ ? (this.groupChannelDefaultItem.style.display = DISPLAY_NONE)
+ : (this.groupChannelDefaultItem.style.display = DISPLAY_BLOCK);
+ }
+
+ static getInstance() {
+ return new ChatLeftMenu();
+ }
+}
+
+export { ChatLeftMenu };
diff --git a/javascript/javascript-basic-syncmanager/src/js/SendBirdAction.js b/javascript/javascript-basic-syncmanager/src/js/SendBirdAction.js
new file mode 100644
index 00000000..30392af5
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/SendBirdAction.js
@@ -0,0 +1,237 @@
+import { APP_ID as appId } from './const';
+import { isNull } from './utils';
+
+import SendBird from 'sendbird';
+import SendBirdSyncManager from 'sendbird-syncmanager';
+
+let instance = null;
+
+class SendBirdAction {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.sb = new SendBird({
+ appId
+ });
+ this.userQuery = null;
+ this.groupChannelQuery = null;
+ this.previousMessageQuery = null;
+ this.blockedQuery = null;
+ instance = this;
+ }
+
+ /**
+ * Connect
+ */
+ connect(userId, nickname) {
+ return new Promise((resolve, reject) => {
+ const sb = SendBird.getInstance();
+ sb.connect(userId, (user, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ sb.updateCurrentUserInfo(decodeURIComponent(nickname), null, (user, error) => {
+ error ? reject(error) : resolve(user);
+ });
+ }
+ });
+ });
+ }
+
+ /**
+ * User
+ */
+ getCurrentUser() {
+ return this.sb.currentUser;
+ }
+
+ getConnectionState() {
+ return this.sb.getConnectionState();
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow retrieving user list from SDK" under ⚙️ Sendbird Dashboard ->Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow retrieving user list from SDK" as that could possibly expose user information
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ getUserList(isInit = false) {
+ if (isInit || isNull(this.userQuery)) {
+ this.userQuery = this.sb.createApplicationUserListQuery();
+ this.userQuery.limit = 30;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.userQuery.hasNext && !this.userQuery.isLoading) {
+ this.userQuery.next((list, error) => {
+ error ? reject(error) : resolve(list);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ isCurrentUser(user) {
+ const manager = SendBirdSyncManager.getInstance();
+ return user.userId === manager.currentUserId;
+ }
+
+ getBlockedList(isInit = false) {
+ if (isInit || isNull(this.blockedQuery)) {
+ this.blockedQuery = this.sb.createBlockedUserListQuery();
+ this.blockedQuery.limit = 30;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.blockedQuery.hasNext && !this.blockedQuery.isLoading) {
+ this.blockedQuery.next((blockedList, error) => {
+ error ? reject(error) : resolve(blockedList);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ blockUser(user, isBlock = true) {
+ return new Promise((resolve, reject) => {
+ if (isBlock) {
+ this.sb.blockUser(user, (response, error) => {
+ error ? reject(error) : resolve();
+ });
+ } else {
+ this.sb.unblockUser(user, (response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ }
+
+ /**
+ * Channel
+ */
+ getChannel(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ });
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow creating group channels from SDK" under ⚙️ Sendbird Dashboard -> Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow creating group channels from SDK" as that could cause unwanted operations.
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ createGroupChannel(userIds) {
+ return new Promise((resolve, reject) => {
+ let params = new this.sb.GroupChannelParams();
+ params.addUserIds(userIds);
+ this.sb.GroupChannel.createChannel(params, (groupChannel, error) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ });
+ }
+
+ inviteGroupChannel(channelUrl, userIds) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.inviteWithUserIds(userIds, (groupChannel, error) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ }
+ });
+ });
+ }
+
+ leave(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.leave((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ hide(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.hide((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ markAsRead(channel) {
+ channel.markAsRead();
+ }
+
+ getReadReceipt(channel, message) {
+ if (this.isCurrentUser(message.sender)) {
+ return this.sb.currentUser ? channel.getReadReceipt(message) : 0;
+ } else {
+ return 0;
+ }
+ }
+
+ sendUserMessage({ channel, message, handler }) {
+ return channel.sendUserMessage(message, (message, error) => {
+ if (handler) handler(message, error);
+ });
+ }
+
+ sendFileMessage({ channel, file, thumbnailSizes, handler }) {
+ const fileMessageParams = new this.sb.FileMessageParams();
+ fileMessageParams.file = file;
+ fileMessageParams.thumbnailSizes = thumbnailSizes;
+
+ return channel.sendFileMessage(fileMessageParams, (message, error) => {
+ if (handler) handler(message, error);
+ });
+ }
+
+ deleteMessage({ channel, message, col }) {
+ return new Promise((resolve, reject) => {
+ if (!this.isCurrentUser(message.sender)) {
+ reject({
+ message: 'You have not ownership in this message.'
+ });
+ return;
+ }
+ if (message.messageId === 0 && message.requestState === 'failed') {
+ col.deleteMessage(message);
+ resolve(true);
+ } else {
+ channel.deleteMessage(message, (response, error) => {
+ error ? reject(error) : resolve(response);
+ });
+ }
+ });
+ }
+
+ static getInstance() {
+ return new SendBirdAction();
+ }
+}
+
+export { SendBirdAction };
diff --git a/javascript/javascript-basic-syncmanager/src/js/SendBirdChatEvent.js b/javascript/javascript-basic-syncmanager/src/js/SendBirdChatEvent.js
new file mode 100644
index 00000000..8f12fd0e
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/SendBirdChatEvent.js
@@ -0,0 +1,68 @@
+import { uuid4 } from './utils';
+import SendBird from 'sendbird';
+
+let instance = null;
+
+class SendBirdChatEvent {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this._createChannelHandler();
+
+ this.onMessageReceived = null;
+ this.onMessageUpdated = null;
+ this.onMessageDeleted = null;
+
+ this.onReadReceiptUpdated = null;
+ this.onTypingStatusUpdated = null;
+ instance = this;
+ }
+
+ /**
+ * Channel Handler
+ */
+ _createChannelHandler() {
+ const handler = new this.sb.ChannelHandler();
+ handler.onMessageReceived = (channel, message) => {
+ if (this.onMessageReceived) {
+ this.onMessageReceived(channel, message);
+ }
+ };
+ handler.onMessageUpdated = (channel, message) => {
+ if (this.onMessageUpdated) {
+ this.onMessageUpdated(channel, message);
+ }
+ };
+ handler.onMessageDeleted = (channel, messageId) => {
+ if (this.onMessageDeleted) {
+ this.onMessageDeleted(channel, messageId);
+ }
+ };
+
+ handler.onReadReceiptUpdated = groupChannel => {
+ if (this.onReadReceiptUpdated) {
+ this.onReadReceiptUpdated(groupChannel);
+ }
+ };
+ handler.onTypingStatusUpdated = groupChannel => {
+ if (this.onTypingStatusUpdated) {
+ this.onTypingStatusUpdated(groupChannel);
+ }
+ };
+ this.sb.addChannelHandler(this.key, handler);
+ }
+
+ remove() {
+ this.sb.removeChannelHandler(this.key);
+ }
+
+ static getInstance() {
+ return instance;
+ }
+}
+
+export { SendBirdChatEvent };
diff --git a/javascript/javascript-basic-syncmanager/src/js/SendBirdConnection.js b/javascript/javascript-basic-syncmanager/src/js/SendBirdConnection.js
new file mode 100644
index 00000000..5bda9b1a
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/SendBirdConnection.js
@@ -0,0 +1,62 @@
+import { uuid4 } from './utils';
+import SendBird from 'sendbird';
+import { Chat } from './Chat';
+
+let instance = null;
+
+class SendBirdConnection {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this.channel = null;
+ this._createConnectionHandler(this.key);
+ this.chat = Chat.getInstance();
+
+ this.onReconnectStarted = null;
+ this.onReconnectSucceeded = null;
+ this.onReconnectFailed = null;
+
+ instance = this;
+ }
+
+ _createConnectionHandler(key) {
+ const handler = new this.sb.ConnectionHandler();
+ handler.onReconnectStarted = () => {
+ if (this.chat && this.chat.main) {
+ this.chat.main.body.stopSpinner();
+ }
+ if (this.onReconnectStarted) {
+ this.onReconnectStarted();
+ }
+ };
+ handler.onReconnectSucceeded = () => {
+ if (this.onReconnectSucceeded) {
+ this.onReconnectSucceeded();
+ }
+ };
+ handler.onReconnectFailed = () => {
+ if (this.onReconnectFailed) {
+ this.onReconnectFailed();
+ }
+ };
+ this.sb.addConnectionHandler(key, handler);
+ }
+
+ remove() {
+ this.sb.removeConnectionHandler(this.key);
+ }
+
+ reconnect() {
+ this.sb.reconnect();
+ }
+
+ static getInstance() {
+ return new SendBirdConnection();
+ }
+}
+
+export { SendBirdConnection };
diff --git a/javascript/javascript-basic-syncmanager/src/js/SendBirdEvent.js b/javascript/javascript-basic-syncmanager/src/js/SendBirdEvent.js
new file mode 100644
index 00000000..58c82969
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/SendBirdEvent.js
@@ -0,0 +1,47 @@
+import { uuid4 } from './utils';
+import SendBird from 'sendbird';
+
+class SendBirdEvent {
+ constructor() {
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this._createChannelHandler();
+
+ this.onChannelChanged = null;
+ this.onUserJoined = null;
+ this.onUserLeft = null;
+ this.onChannelHidden = null;
+ this.onUserEntered = null;
+ }
+
+ _createChannelHandler() {
+ const handler = new this.sb.ChannelHandler();
+ handler.onChannelChanged = channel => {
+ if (this.onChannelChanged) {
+ this.onChannelChanged(channel);
+ }
+ };
+ handler.onUserJoined = (groupChannel, user) => {
+ if (this.onUserJoined) {
+ this.onUserJoined(groupChannel, user);
+ }
+ };
+ handler.onUserLeft = (groupChannel, user) => {
+ if (this.onUserLeft) {
+ this.onUserLeft(groupChannel, user);
+ }
+ };
+ handler.onChannelHidden = groupChannel => {
+ if (this.onChannelHidden) {
+ this.onChannelHidden(groupChannel);
+ }
+ };
+ this.sb.addChannelHandler(this.key, handler);
+ }
+
+ remove() {
+ this.sb.removeChannelHandler(this.key);
+ }
+}
+
+export { SendBirdEvent };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/ChatBody.js b/javascript/javascript-basic-syncmanager/src/js/components/ChatBody.js
new file mode 100644
index 00000000..04e9d2fc
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/ChatBody.js
@@ -0,0 +1,266 @@
+import styles from '../../scss/chat-body.scss';
+import { createDivEl, getDataInElement, removeClass, findMessageIndex, mergeFailedWithSuccessful } from '../utils';
+import { Message } from './Message';
+import { SendBirdAction } from '../SendBirdAction';
+import { MESSAGE_REQ_ID } from '../const';
+import SendBirdSyncManager from 'sendbird-syncmanager';
+import { Spinner } from './Spinner';
+
+class ChatBody {
+ constructor(channel) {
+ this.channel = channel;
+ this.readReceiptManageList = [];
+ this.scrollHeight = 0;
+ this.collection = null;
+ this.limit = 50;
+ this.element = createDivEl({ className: styles['chat-body'] });
+ this._initElement();
+ this.spinnerStarted = false;
+ }
+
+ _initElement() {
+ if (this.collection) {
+ this.collection.remove();
+ }
+ this.collection = new SendBirdSyncManager.MessageCollection(this.channel);
+ this.collection.limit = this.limit;
+
+ const collectionHandler = new SendBirdSyncManager.MessageCollection.CollectionHandler();
+ collectionHandler.onSucceededMessageEvent = this._messageEventHandler.bind(this);
+ collectionHandler.onFailedMessageEvent = this._messageEventHandler.bind(this);
+ collectionHandler.onPendingMessageEvent = this._messageEventHandler.bind(this);
+ collectionHandler.onNewMessage = (event) => { this._onNewMessageEventHandler(event, this.collection) };
+ this.collection.setCollectionHandler(collectionHandler);
+
+ this.element.addEventListener('scroll', () => {
+ if (this.element.scrollTop === 0) {
+ this.updateCurrentScrollHeight();
+ this.collection.fetchSucceededMessages('prev', () => {
+ this.element.scrollTop = this.element.scrollHeight - this.scrollHeight;
+ });
+ }
+
+ if (this.element.scrollHeight - this.element.scrollTop - this.element.clientHeight === 0) {
+ const newMessagePop = document.getElementById('new-message-pop');
+ if (newMessagePop) newMessagePop.remove();
+ }
+ });
+ }
+
+ _onNewMessageEventHandler(event, col) {
+ const messages = col.messages;
+ let isOnNewMessage = !(messages.every(message => (message.messageId !== event.messageId)));
+
+ if (isOnNewMessage) {
+ if (this.element.scrollTop < this.element.scrollHeight - this.element.offsetHeight && !(document.getElementById('new-message-pop'))) {
+ const newMessagePop = document.createElement('div');
+ newMessagePop.setAttribute('id', 'new-message-pop');
+ newMessagePop.setAttribute('class', 'new-message-pop');
+
+ const popText = document.createElement('div');
+ popText.setAttribute('class', 'new-message-pop-text');
+ popText.innerText = 'check new message';
+ newMessagePop.appendChild(popText);
+ popText.addEventListener('click', () => {
+ newMessagePop.remove();
+ this.scrollToBottom();
+ });
+
+ this.element.appendChild(newMessagePop);
+ }
+ } else {
+ console.log('There is no onNewMessage in collection');
+ }
+ }
+
+ _messageEventHandler(messages, action, reason) {
+ const keepScrollToBottom = this.element.scrollTop >= this.element.scrollHeight - this.element.offsetHeight;
+ messages.sort((a, b) => a.createdAt - b.createdAt);
+ switch (action) {
+ case 'insert': {
+ this._mergeMessagesOnInsert(messages);
+ break;
+ }
+ case 'update': {
+ if (reason === SendBirdSyncManager.MessageCollection.FailedMessageEventActionReason.UPDATE_RESEND_FAILED) {
+ this._updateMessages(messages, true);
+ } else {
+ this._updateMessages(messages);
+ }
+ break;
+ }
+ case 'remove': {
+ this._removeMessages(messages);
+ break;
+ }
+ case 'clear': {
+ this._clearMessages();
+ break;
+ }
+ default: break;
+ }
+ if (keepScrollToBottom) {
+ this.scrollToBottom();
+ }
+ }
+
+ _mergeMessagesOnInsert(messages) {
+ const wholeCollectionMessages = mergeFailedWithSuccessful(
+ this.collection.unsentMessages,
+ this.collection.succeededMessages
+ );
+ for (let i in messages) {
+ const message = messages[i];
+ const index = findMessageIndex(message, wholeCollectionMessages);
+ if (index >= 0) {
+ const messageElements = this.element.querySelectorAll('.chat-message');
+ const messageItem = new Message({ channel: this.channel, message, col: this.collection });
+ this.element.insertBefore(messageItem.element, messageElements[index]);
+ if (
+ (message.isUserMessage() || message.isFileMessage()) &&
+ SendBirdAction.getInstance().isCurrentUser(message.sender)
+ ) {
+ this.readReceiptManage(message);
+ }
+ }
+ }
+ }
+
+ _updateMessages(messages, transformToManual = false) {
+ for (let i in messages) {
+ const message = messages[i];
+ const messageItem = new Message({
+ channel: this.channel,
+ message,
+ isManual: transformToManual,
+ col: this.collection
+ });
+ const currentItem = this._getItem(message.reqId);
+ const requestItem = message.reqId ? this._getItem(message.reqId) : null;
+ if (currentItem || requestItem) {
+ this.element.replaceChild(messageItem.element, requestItem ? requestItem : currentItem);
+ }
+ }
+ }
+
+ _removeMessages(messages) {
+ if (
+ this.collection.unsentMessages.length > 0 &&
+ messages.length > 0 &&
+ messages[0].messageId === 0 &&
+ !this.spinnerStarted
+ ) {
+ const el = this._getItem(messages[0].reqId);
+ const resendButton = el.firstChild.getElementsByClassName('resend-button');
+ if (resendButton && resendButton.length === 0) {
+ Spinner.start(this.element);
+ this.spinnerStarted = true;
+ }
+ }
+ for (let i in messages) {
+ const message = messages[i];
+ this.removeMessage(message.reqId);
+ }
+ if (
+ (this.spinnerStarted && this.collection.unsentMessages.length === 0) ||
+ SendBirdAction.getInstance().getConnectionState() !== 'OPEN'
+ ) {
+ this.stopSpinner();
+ }
+ }
+
+ stopSpinner() {
+ Spinner.remove();
+ this.spinnerStarted = false;
+ }
+
+ _clearMessages() {
+ while (this.element.firstChild) {
+ this.element.removeChild(this.element.firstChild);
+ }
+ }
+
+ loadPreviousMessages(callback) {
+ this.collection.fetchSucceededMessages('prev', () => {
+ this.collection.fetchFailedMessages(() => {
+ if (callback) callback();
+ });
+ });
+ }
+
+ scrollToBottom() {
+ this.element.scrollTop = this.element.scrollHeight - this.element.offsetHeight;
+ }
+
+ updateCurrentScrollHeight() {
+ this.scrollHeight = this.element.scrollHeight;
+ }
+
+ repositionScroll(imageOffsetHeight) {
+ this.element.scrollTop += imageOffsetHeight;
+ }
+
+ updateReadReceipt() {
+ this.readReceiptManageList.forEach(message => {
+ if (message.messageId.toString() !== '0') {
+ const className = Message.getReadReceiptElementClassName();
+ const messageItem = this._getItem(message.reqId);
+ if (messageItem) {
+ let readItem = null;
+ try {
+ readItem = messageItem.getElementsByClassName(className)[0];
+ } catch (e) {
+ readItem = null;
+ }
+ const latestCount = SendBirdAction.getInstance().getReadReceipt(this.channel, message);
+ if (readItem && latestCount.toString() !== readItem.textContent.toString()) {
+ readItem.innerHTML = latestCount;
+ if (latestCount.toString() === '0') {
+ removeClass(readItem, className);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ readReceiptManage(message) {
+ for (let i = 0; i < this.readReceiptManageList.length; i++) {
+ if (message.reqId) {
+ if (this.readReceiptManageList[i].reqId === message.reqId) {
+ this.readReceiptManageList.splice(i, 1);
+ break;
+ }
+ } else {
+ if (this.readReceiptManageList[i].messageId === message.messageId) {
+ this.readReceiptManageList.splice(i, 1);
+ break;
+ }
+ }
+ }
+ this.readReceiptManageList.push(message);
+ this.updateReadReceipt();
+ }
+
+ _getItem(reqId) {
+ const items = this.element.childNodes;
+ // We go in reverse order to prevent situations that
+ // pending-message remove requests accidentally delete succeeded messages
+ for (let i = items.length - 1; i >= 0; i--) {
+ const elementId = getDataInElement(items[i], MESSAGE_REQ_ID);
+ if (elementId === reqId.toString()) {
+ return items[i];
+ }
+ }
+ return null;
+ }
+
+ removeMessage(reqId) {
+ const removeElement = this._getItem(reqId);
+ if (removeElement) {
+ this.element.removeChild(removeElement);
+ }
+ }
+}
+
+export { ChatBody };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/ChatInput.js b/javascript/javascript-basic-syncmanager/src/js/components/ChatInput.js
new file mode 100644
index 00000000..a7ab8e4e
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/ChatInput.js
@@ -0,0 +1,119 @@
+import styles from '../../scss/chat-input.scss';
+import { createDivEl, protectFromXSS } from '../utils';
+import { DISPLAY_BLOCK, DISPLAY_NONE, FILE_ID, KEY_ENTER } from '../const';
+import { SendBirdAction } from '../SendBirdAction';
+import { Chat } from '../Chat';
+
+class ChatInput {
+ constructor(channel) {
+ this.channel = channel;
+ this.input = null;
+ this.typing = null;
+ this.element = this._createElement(channel);
+ }
+
+ _createElement(channel) {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const chat = Chat.getInstance();
+ const root = createDivEl({ className: styles['chat-input'] });
+
+ this.typing = createDivEl({ className: styles['typing-field'] });
+ root.appendChild(this.typing);
+
+ const file = document.createElement('label');
+ file.className = styles['input-file'];
+ file.for = FILE_ID;
+ file.addEventListener('click', () => {
+ sendbirdAction.markAsRead(this.channel);
+ });
+
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.id = FILE_ID;
+ fileInput.style.display = DISPLAY_NONE;
+ fileInput.addEventListener('change', () => {
+ const sendFile = fileInput.files[0];
+ if (sendFile) {
+ const previewMessage = SendBirdAction.getInstance().sendFileMessage({
+ channel: this.channel,
+ file: sendFile,
+ thumbnailSizes: [{ maxWidth: 240, maxHeight: 240 }, { maxWidth: 320, maxHeight: 320 }],
+ handler: (message, error) => {
+ if (!error) {
+ chat.main.body.collection.appendMessage(message);
+ }
+ chat.main.body.scrollToBottom();
+ }
+ });
+ previewMessage.createdAt = new Date().getTime();
+ chat.main.body.collection.appendMessage(previewMessage);
+ }
+ });
+
+ file.appendChild(fileInput);
+ root.appendChild(file);
+
+ const inputText = createDivEl({ className: styles['input-text'] });
+
+ this.input = document.createElement('textarea');
+ this.input.className = styles['input-text-area'];
+ this.input.placeholder = 'Write a chat...';
+ this.input.addEventListener('click', () => {
+ sendbirdAction.markAsRead(this.channel);
+ });
+ this.input.addEventListener('keypress', e => {
+ if (e.keyCode === KEY_ENTER) {
+ if (!e.shiftKey) {
+ e.preventDefault();
+ const message = this.input.value;
+ this.input.value = '';
+ if (message) {
+ const previewMessage = SendBirdAction.getInstance().sendUserMessage({
+ channel: this.channel,
+ message,
+ handler: (message, error) => {
+ chat.main.body.collection.handleSendMessageResponse(error, message);
+ chat.main.body.scrollToBottom();
+ }
+ });
+ chat.main.body.collection.appendMessage(previewMessage);
+ channel.endTyping();
+ }
+ } else {
+ channel.startTyping();
+ }
+ } else {
+ channel.startTyping();
+ }
+ });
+ this.input.addEventListener('focusin', () => {
+ inputText.style.border = '1px solid #2C2D30';
+ });
+ this.input.addEventListener('focusout', () => {
+ inputText.style.border = '';
+ });
+
+ inputText.appendChild(this.input);
+ root.appendChild(inputText);
+ return root;
+ }
+
+ updateTyping(memberList) {
+ let nicknames = '';
+ if (memberList.length === 1) {
+ nicknames = `${protectFromXSS(memberList[0].nickname)} is`;
+ } else if (memberList.length === 2) {
+ nicknames = `${memberList
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ')} are`;
+ } else if (memberList.length !== 0) {
+ nicknames = 'Several are';
+ }
+ this.typing.style.display = nicknames ? DISPLAY_BLOCK : DISPLAY_NONE;
+ this.typing.innerHTML = `${nicknames} typing...`;
+ }
+}
+
+export { ChatInput };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/ChatMain.js b/javascript/javascript-basic-syncmanager/src/js/components/ChatMain.js
new file mode 100644
index 00000000..dd8826bb
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/ChatMain.js
@@ -0,0 +1,57 @@
+import styles from '../../scss/chat-main.scss';
+import { ChatBody } from './ChatBody';
+import { ChatInput } from './ChatInput';
+import { Chat } from '../Chat';
+import { createDivEl } from '../utils';
+import { ChatMenu } from './ChatMenu';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatMain {
+ constructor(channel) {
+ this.channel = channel;
+ this.body = null;
+ this.input = null;
+ this.menu = null;
+ this._create();
+ }
+
+ _create() {
+ const root = createDivEl({ className: styles['chat-main-root'] });
+
+ const main = createDivEl({ className: styles['chat-main'] });
+ root.appendChild(main);
+
+ this.body = new ChatBody(this.channel);
+ main.appendChild(this.body.element);
+
+ this.input = new ChatInput(this.channel);
+ main.appendChild(this.input.element);
+
+ this.menu = new ChatMenu(this.channel);
+ root.appendChild(this.menu.element);
+
+ Chat.getInstance().element.appendChild(root);
+ }
+
+ updateTyping(memberList) {
+ this.input.updateTyping(memberList);
+ }
+
+ repositionScroll(height) {
+ this.body.repositionScroll(height);
+ }
+
+ updateBlockedList(user, isBlock) {
+ this.menu.updateBlockedList(user, isBlock);
+ }
+
+ loadInitialMessages() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ this.body.loadPreviousMessages(() => {
+ sendbirdAction.markAsRead(this.channel);
+ this.body.scrollToBottom();
+ });
+ }
+}
+
+export { ChatMain };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/ChatMenu.js b/javascript/javascript-basic-syncmanager/src/js/components/ChatMenu.js
new file mode 100644
index 00000000..0ad4e845
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/ChatMenu.js
@@ -0,0 +1,155 @@
+import styles from '../../scss/chat-menu.scss';
+import { appendToFirst, errorAlert, createDivEl } from '../utils';
+import { DISPLAY_FLEX, DISPLAY_NONE } from '../const';
+import { Spinner } from './Spinner';
+import { ChatUserItem } from './ChatUserItem';
+import { SendBirdAction } from '../SendBirdAction';
+
+const Type = {
+ PARTICIPANTS: 'PARTICIPANTS',
+ MEMBERS: 'MEMBERS',
+ BLOCKED: 'BLOCKED'
+};
+
+class ChatMenu {
+ constructor(channel) {
+ this.channel = channel;
+ this.element = createDivEl({ className: styles['chat-menu-root'] });
+ this.listElement = null;
+ this.type = null;
+ this._createListElement();
+ this._createElement();
+ }
+
+ _createListElement() {
+ this.listElement = createDivEl({ className: styles['menu-list'] });
+
+ const title = createDivEl({ className: styles['list-title'] });
+ title.addEventListener('click', () => {
+ this.type = null;
+ this.list.innerHTML = '';
+ this.listElement.style.display = DISPLAY_NONE;
+ });
+ this.listElement.appendChild(title);
+ const backBtn = createDivEl({ className: styles['list-back'] });
+ title.appendChild(backBtn);
+ this.titleText = createDivEl({ className: styles['list-text'] });
+ title.appendChild(this.titleText);
+
+ this.list = createDivEl({ className: styles['list-body'] });
+ this.list.addEventListener('scroll', () => {
+ if (this.type === Type.BLOCKED) {
+ this._getBlockedList(this.type);
+ }
+ });
+ this.listElement.appendChild(this.list);
+
+ this.element.appendChild(this.listElement);
+ }
+
+ _createElement() {
+ const usersItem = createDivEl({ className: styles['menu-item'] });
+ const users = createDivEl({
+ className: styles['menu-users'],
+ content: Type.MEMBERS
+ });
+ usersItem.appendChild(users);
+ const arrowUser = createDivEl({ className: styles['menu-arrow'] });
+ usersItem.appendChild(arrowUser);
+ usersItem.addEventListener('click', () => {
+ this._renderList(users.textContent);
+ });
+ this.element.appendChild(usersItem);
+
+ const blockedItem = createDivEl({ className: styles['menu-item'] });
+ const blocked = createDivEl({ className: styles['menu-blocked'], content: Type.BLOCKED });
+ blockedItem.appendChild(blocked);
+ const arrowBlocked = createDivEl({ className: styles['menu-arrow'] });
+ blockedItem.appendChild(arrowBlocked);
+ blockedItem.addEventListener('click', () => {
+ this._renderList(blocked.textContent);
+ });
+ this.element.appendChild(blockedItem);
+ }
+
+ _renderList(listTitle) {
+ switch (listTitle) {
+ case Type.MEMBERS:
+ this.type = Type.MEMBERS;
+ this._getMemberList(listTitle);
+ break;
+ case Type.BLOCKED:
+ this.type = Type.BLOCKED;
+ this._getBlockedList(listTitle, true);
+ break;
+ default:
+ this.titleText.innerHTML = '';
+ break;
+ }
+ }
+
+ _getMemberList(listTitle) {
+ if (this.channel.isGroupChannel()) {
+ Spinner.start(this.listElement);
+ this.list.innerHTML = '';
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ this.channel.members.forEach(user => {
+ const memberItem = new ChatUserItem({ user, hasEvent: false });
+ this.list.appendChild(memberItem.element);
+ });
+ Spinner.remove();
+ }
+ }
+
+ _getBlockedList(listTitle, isInit = false) {
+ Spinner.start(this.listElement);
+ if (isInit) {
+ this.list.innerHTML = '';
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ }
+ SendBirdAction.getInstance()
+ .getBlockedList(isInit)
+ .then(blockedList => {
+ blockedList.forEach(user => {
+ const blockedItem = new ChatUserItem({ user, hasEvent: true });
+ this.list.appendChild(blockedItem.element);
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ updateBlockedList(user, isBlock) {
+ if (this.list) {
+ if (isBlock) {
+ const blockedItem = new ChatUserItem({ user, hasEvent: true });
+ appendToFirst(this.list, blockedItem.element);
+ } else {
+ const items = this.list.childNodes;
+ for (let i = 0; i < items.length; i++) {
+ if (items[0].id === user.userId) {
+ this.list.removeChild(items[0]);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ updateMenu(channel) {
+ if (this.type === Type.MEMBERS) {
+ this.channel = channel;
+ this._getMemberList(this.type);
+ }
+ }
+
+ static get Type() {
+ return Type;
+ }
+}
+
+export { ChatMenu };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/ChatTopMenu.js b/javascript/javascript-basic-syncmanager/src/js/components/ChatTopMenu.js
new file mode 100644
index 00000000..9c81f892
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/ChatTopMenu.js
@@ -0,0 +1,73 @@
+import styles from '../../scss/chat-top-menu.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { Chat } from '../Chat';
+import { UserList } from './UserList';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatTopMenu {
+ constructor(channel) {
+ this.channel = channel;
+ this.element = this._createElement(channel);
+ }
+
+ get chatTitle() {
+ return this.channel.members
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ');
+ }
+
+ _createElement(channel) {
+ const root = createDivEl({ className: styles['chat-top'] });
+
+ this.title = createDivEl({
+ className: [styles['chat-title'], styles['is-group']],
+ content: this.chatTitle
+ });
+ root.appendChild(this.title);
+
+ const button = createDivEl({ className: styles['chat-button'] });
+ const invite = createDivEl({ className: styles['button-invite'] });
+ invite.addEventListener('click', () => {
+ UserList.getInstance().render(true);
+ });
+ button.appendChild(invite);
+ const hide = createDivEl({ className: styles['button-hide'] });
+ hide.addEventListener('click', () => {
+ SendBirdAction.getInstance()
+ .hide(channel.url)
+ .then(() => {
+ // ChatLeftMenu.getInstance().removeGroupChannelItem(this.channel.url);
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ });
+ button.appendChild(hide);
+
+ const leave = createDivEl({ className: styles['button-leave'] });
+ leave.addEventListener('click', () => {
+ SendBirdAction.getInstance()
+ .leave(channel.url)
+ .then(() => {
+ // ChatLeftMenu.getInstance().removeGroupChannelItem(this.channel.url);
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ });
+ button.appendChild(leave);
+ root.appendChild(button);
+ return root;
+ }
+
+ updateTitle(channel) {
+ this.channel = channel;
+ this.title.innerHTML = this.chatTitle;
+ }
+}
+
+export { ChatTopMenu };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/ChatUserItem.js b/javascript/javascript-basic-syncmanager/src/js/components/ChatUserItem.js
new file mode 100644
index 00000000..fcc51d66
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/ChatUserItem.js
@@ -0,0 +1,49 @@
+import styles from '../../scss/chat-user-item.scss';
+import { createDivEl, protectFromXSS } from '../utils';
+import { COLOR_RED } from '../const';
+import { UserBlockModal } from './UserBlockModal';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatUserItem {
+ constructor({ user, hasEvent }) {
+ this.user = user;
+ this.hasEvent = hasEvent;
+ this.element = null;
+ this._create();
+ }
+
+ _create() {
+ this.element = createDivEl({ className: styles['chat-user-item'], id: this.user.userId });
+ if (this.hasEvent) {
+ this.element.addEventListener('mouseover', () => {
+ this._hoverOnUser(this.user.nickname, true);
+ });
+ this.element.addEventListener('mouseleave', () => {
+ this._hoverOnUser(this.user.nickname, false);
+ });
+ this.element.addEventListener('click', () => {
+ const userBlockModal = new UserBlockModal({ user: this.user, isBlock: false });
+ userBlockModal.render();
+ });
+ }
+
+ const image = createDivEl({ className: styles['user-image'], background: protectFromXSS(this.user.profileUrl) });
+ this.element.appendChild(image);
+
+ this.nickname = createDivEl({
+ className: SendBirdAction.getInstance().isCurrentUser(this.user)
+ ? [styles['user-nickname'], styles['is-user']]
+ : styles['user-nickname'],
+ content: protectFromXSS(this.user.nickname)
+ });
+ this.element.appendChild(this.nickname);
+ }
+
+ _hoverOnUser(nickname, hover) {
+ this.nickname.innerHTML = hover ? 'UNBLOCK' : protectFromXSS(nickname);
+ this.nickname.style.color = hover ? COLOR_RED : '';
+ this.nickname.style.opacity = hover ? '1' : '';
+ }
+}
+
+export { ChatUserItem };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/LeftListItem.js b/javascript/javascript-basic-syncmanager/src/js/components/LeftListItem.js
new file mode 100644
index 00000000..43d3dbb4
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/LeftListItem.js
@@ -0,0 +1,138 @@
+import styles from '../../scss/list-item.scss';
+import {
+ addClass,
+ createDivEl,
+ getDataInElement,
+ protectFromXSS,
+ removeClass,
+ setDataInElement,
+ timestampFromNow
+} from '../utils';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+
+const KEY_MESSAGE_LAST_TIME = 'origin';
+
+class LeftListItem {
+ constructor({ channel, handler }) {
+ this.channel = channel;
+ this.element = this._createElement(handler);
+ }
+
+ get channelUrl() {
+ return this.channel.url;
+ }
+
+ get title() {
+ return this.channel.members
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ');
+ }
+
+ get lastMessagetime() {
+ if (!this.channel.lastMessage) {
+ return 0;
+ } else {
+ return this.channel.lastMessage.createdAt;
+ }
+ }
+
+ get lastMessageTimeText() {
+ if (!this.channel.lastMessage) {
+ return 0;
+ } else {
+ return LeftListItem.getTimeFromNow(this.channel.lastMessage.createdAt);
+ }
+ }
+
+ get lastMessageText() {
+ if (!this.channel.lastMessage) {
+ return '';
+ } else {
+ return this.channel.lastMessage.isFileMessage()
+ ? protectFromXSS(this.channel.lastMessage.name)
+ : protectFromXSS(this.channel.lastMessage.message);
+ }
+ }
+
+ get memberCount() {
+ return this.channel.memberCount;
+ }
+
+ get unreadMessageCount() {
+ const count = this.channel.unreadMessageCount > 9 ? '+9' : this.channel.unreadMessageCount.toString();
+ return count;
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['list-item'], id: this.channelUrl });
+ const itemTop = createDivEl({ className: styles['item-top'] });
+ const itemTopCount = createDivEl({ className: styles['item-count'], content: this.memberCount });
+ const itemTopTitle = createDivEl({ className: styles['item-title'], content: this.title });
+ itemTop.appendChild(itemTopCount);
+ itemTop.appendChild(itemTopTitle);
+ item.appendChild(itemTop);
+
+ const itemBottom = createDivEl({ className: styles['item-bottom'] });
+
+ const itemBottomMessage = createDivEl({ className: styles['item-message'] });
+ const itemBottomMessageText = createDivEl({
+ className: styles['item-message-text'],
+ content: this.lastMessageText
+ });
+ itemBottomMessage.appendChild(itemBottomMessageText);
+ const itemBottomMessageUnread = createDivEl({
+ className: [styles['item-message-unread'], styles.active],
+ content: this.unreadMessageCount
+ });
+ itemBottomMessage.appendChild(itemBottomMessageUnread);
+
+ const itemBottomTime = createDivEl({ className: styles['item-time'], content: this.lastMessageTimeText });
+ setDataInElement(itemBottomTime, KEY_MESSAGE_LAST_TIME, this.lastMessagetime);
+ itemBottom.appendChild(itemBottomMessage);
+ itemBottom.appendChild(itemBottomTime);
+ item.appendChild(itemBottom);
+
+ item.addEventListener('click', () => {
+ if (handler) handler();
+ });
+ return item;
+ }
+
+ static updateUnreadCount() {
+ const items = ChatLeftMenu.getInstance().groupChannelList.getElementsByClassName(styles['item-message-unread']);
+ if (items && items.length > 0) {
+ Array.prototype.slice.call(items).forEach(targetItemEl => {
+ const originTs = targetItemEl.textContent;
+ if (originTs === '0') {
+ removeClass(targetItemEl, styles.active);
+ } else {
+ addClass(targetItemEl, styles.active);
+ }
+ });
+ }
+ }
+
+ static updateLastMessageTime() {
+ const items = ChatLeftMenu.getInstance().groupChannelList.getElementsByClassName(styles['item-time']);
+ if (items && items.length > 0) {
+ Array.prototype.slice.call(items).forEach(targetItemEl => {
+ const originTs = parseInt(getDataInElement(targetItemEl, KEY_MESSAGE_LAST_TIME));
+ if (originTs) {
+ targetItemEl.innerHTML = LeftListItem.getTimeFromNow(originTs);
+ }
+ });
+ }
+ }
+
+ static getTimeFromNow(timestamp) {
+ return timestampFromNow(timestamp);
+ }
+
+ static getItemRootClassName() {
+ return styles['list-item'];
+ }
+}
+
+export { LeftListItem };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/List.js b/javascript/javascript-basic-syncmanager/src/js/components/List.js
new file mode 100644
index 00000000..2350f8ec
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/List.js
@@ -0,0 +1,80 @@
+import styles from '../../scss/list.scss';
+import { createDivEl, isScrollBottom } from '../utils';
+
+let instance = null;
+
+class List {
+ constructor(title, createSearchBox = false) {
+ if (instance) {
+ return instance;
+ }
+ this.createSearchBox = createSearchBox;
+ this.element = this._create(title);
+ this.scrollEventHandler = null;
+ this.closeEventHandler = null;
+ this.searchKeyword = '';
+ }
+
+ _create(title) {
+ const root = createDivEl({ className: styles['list-root'] });
+
+ const listBody = createDivEl({ className: styles['list-body'] });
+ root.appendChild(listBody);
+
+ const listTop = createDivEl({ className: styles['list-top'] });
+ listBody.appendChild(listTop);
+
+ const listTopTitle = createDivEl({ className: styles['list-title'], content: title });
+ listTop.appendChild(listTopTitle);
+ const listTopButton = createDivEl({ className: styles['list-button'] });
+ listTop.appendChild(listTopButton);
+ const listTopButtonExit = createDivEl({ className: styles['button-exit'] });
+ listTopButton.appendChild(listTopButtonExit);
+ listTopButtonExit.addEventListener('click', () => {
+ this.searchKeyword = '';
+ const listContent = document.querySelector(`.${styles['list-content']}`);
+ if (this.closeEventHandler) {
+ this.closeEventHandler();
+ }
+ listContent.innerHTML = '';
+ root.parentElement.removeChild(this.element);
+ });
+ this.buttonRootElement = listTopButton;
+
+ const hr = createDivEl({ className: styles['list-hr'] });
+ listBody.appendChild(hr);
+
+ const listContent = createDivEl({ className: styles['list-content'] });
+ listBody.appendChild(listContent);
+ listContent.addEventListener('scroll', () => {
+ if (isScrollBottom(listContent)) {
+ if (this.scrollEventHandler) {
+ this.scrollEventHandler(false, this.searchKeyword);
+ }
+ }
+ });
+
+ return root;
+ }
+
+ close() {
+ const btnExit = document.querySelector(`.${styles['button-exit']}`);
+ if (btnExit) {
+ document.querySelector(`.${styles['button-exit']}`).click();
+ }
+ }
+
+ getRootElement() {
+ return document.querySelector(`.${styles['list-root']}`);
+ }
+
+ getRootClassName() {
+ return styles['list-root'];
+ }
+
+ getContentElement() {
+ return document.querySelector(`.${styles['list-content']}`);
+ }
+}
+
+export { List };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/Message.js b/javascript/javascript-basic-syncmanager/src/js/components/Message.js
new file mode 100644
index 00000000..7f9ea5ce
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/Message.js
@@ -0,0 +1,245 @@
+import styles from '../../scss/message.scss';
+import { createDivEl, isImage, protectFromXSS, setDataInElement, timestampToTime } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { COLOR_RED, MESSAGE_REQ_ID } from '../const';
+import { MessageDeleteModal } from './MessageDeleteModal';
+import { UserBlockModal } from './UserBlockModal';
+import { Chat } from '../Chat';
+
+class Message {
+ constructor({ channel, message, isManual = false, col = null }) {
+ this.channel = channel;
+ this.message = message;
+ this.isFailed = message.messageId === 0 && message.requestState === 'failed';
+ this.isManual = this.isFailed ? isManual : false;
+ this.element = this._createElement();
+ if (col) {
+ this.col = col;
+ }
+ }
+
+ _createElement() {
+ if (this.message.isUserMessage()) {
+ return this._createUserElement();
+ } else if (this.message.isFileMessage()) {
+ return this._createFileElement();
+ } else if (this.message.isAdminMessage()) {
+ return this._createAdminElement();
+ } else {
+ // console.error('Message is invalid data.');
+ return null;
+ }
+ }
+
+ _hoverOnNickname(nickname, hover) {
+ if (!SendBirdAction.getInstance().isCurrentUser(this.message.sender)) {
+ nickname.innerHTML = hover ? 'BLOCK ' : `${protectFromXSS(this.message.sender.nickname)} : `;
+ nickname.style.color = hover ? COLOR_RED : '';
+ nickname.style.opacity = hover ? '1' : '';
+ }
+ }
+
+ _hoverOnTime(time, hover) {
+ if (SendBirdAction.getInstance().isCurrentUser(this.message.sender)) {
+ time.innerHTML = hover ? 'DELETE' : timestampToTime(this.message.createdAt);
+ time.style.color = hover ? COLOR_RED : '';
+ time.style.opacity = hover ? '1' : '';
+ time.style.fontWeight = hover ? '600' : '';
+ }
+ }
+
+ _createUserElement() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const isCurrentUser = sendbirdAction.isCurrentUser(this.message.sender);
+ let root;
+ if (this.isFailed && !this.isManual) {
+ root = createDivEl({
+ className: [styles['chat-message'], styles['is-failed']],
+ id: this.message.reqId
+ });
+ } else {
+ root = createDivEl({
+ className: styles['chat-message'],
+ id: this.message.reqId
+ });
+ }
+ setDataInElement(root, MESSAGE_REQ_ID, this.message.reqId);
+
+ const messageContent = createDivEl({ className: styles['message-content'] });
+ const nickname = createDivEl({
+ className: isCurrentUser ? [styles['message-nickname'], styles['is-user']] : styles['message-nickname'],
+ content: `${protectFromXSS(this.message.sender.nickname)} : `
+ });
+ nickname.addEventListener('mouseover', () => {
+ this._hoverOnNickname(nickname, true);
+ });
+ nickname.addEventListener('mouseleave', () => {
+ this._hoverOnNickname(nickname, false);
+ });
+ nickname.addEventListener('click', () => {
+ if (!isCurrentUser) {
+ const userBlockModal = new UserBlockModal({ user: this.message.sender, isBlock: true });
+ userBlockModal.render();
+ }
+ });
+ messageContent.appendChild(nickname);
+
+ const msg = createDivEl({ className: styles['message-content'], content: protectFromXSS(this.message.message) });
+ messageContent.appendChild(msg);
+
+ if (this.isFailed && this.isManual) {
+ const resendButton = createDivEl({
+ className: styles['resend-button'],
+ content: 'RESEND'
+ });
+ resendButton.addEventListener('click', () => {
+ this._resendUserMessage();
+ });
+ messageContent.appendChild(resendButton);
+ }
+ if (this.isFailed) {
+ const deleteButton = createDivEl({
+ className: styles['delete-button'],
+ content: 'DELETE'
+ });
+ deleteButton.addEventListener('click', () => {
+ if (isCurrentUser) {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message,
+ col: this.col
+ });
+ messageDeleteModal.render();
+ }
+ });
+ messageContent.appendChild(deleteButton);
+ }
+ if (!this.isFailed) {
+ const time = createDivEl({
+ className: isCurrentUser ? [styles.time, styles['is-user']] : styles.time,
+ content: timestampToTime(this.message.createdAt)
+ });
+ time.addEventListener('mouseover', () => {
+ this._hoverOnTime(time, true);
+ });
+ time.addEventListener('mouseleave', () => {
+ this._hoverOnTime(time, false);
+ });
+ time.addEventListener('click', () => {
+ if (isCurrentUser) {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message
+ });
+ messageDeleteModal.render();
+ }
+ });
+ messageContent.appendChild(time);
+
+ const count = sendbirdAction.getReadReceipt(this.channel, this.message);
+ const read = createDivEl({
+ className: count ? [styles.read, styles.active] : styles.read,
+ content: count
+ });
+ messageContent.appendChild(read);
+ }
+
+ root.appendChild(messageContent);
+ return root;
+ }
+
+ _resendUserMessage() {
+ this.channel.resendUserMessage(this.message, (message, err) => {
+ this.col.handleSendMessageResponse(err, message);
+ });
+ }
+
+ _createFileElement() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ setDataInElement(root, MESSAGE_REQ_ID, this.message.reqId);
+
+ const messageContent = createDivEl({ className: styles['message-content'] });
+ const nickname = createDivEl({
+ className: sendbirdAction.isCurrentUser(this.message.sender)
+ ? [styles['message-nickname'], styles['is-user']]
+ : styles['message-nickname'],
+ content: `${protectFromXSS(this.message.sender.nickname)} : `
+ });
+ messageContent.appendChild(nickname);
+
+ const msg = createDivEl({
+ className: [styles['message-content'], styles['is-file']],
+ content: protectFromXSS(this.message.name)
+ });
+ msg.addEventListener('click', () => {
+ window.open(this.message.url);
+ });
+ messageContent.appendChild(msg);
+
+ const time = createDivEl({ className: styles.time, content: timestampToTime(this.message.createdAt) });
+ time.addEventListener('mouseover', () => {
+ this._hoverOnTime(time, true);
+ });
+ time.addEventListener('mouseleave', () => {
+ this._hoverOnTime(time, false);
+ });
+ time.addEventListener('click', () => {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message
+ });
+ messageDeleteModal.render();
+ });
+ messageContent.appendChild(time);
+
+ if (this.channel.isGroupChannel()) {
+ const count = sendbirdAction.getReadReceipt(this.channel, this.message);
+ const read = createDivEl({
+ className: count ? [styles.read, styles.active] : styles.read,
+ content: count
+ });
+ messageContent.appendChild(read);
+ }
+
+ root.appendChild(messageContent);
+
+ if (this.message.isFileMessage() && this.message.messageId) {
+ const fileContent = createDivEl({ className: styles['file-content'] });
+ fileContent.addEventListener('click', () => {
+ window.open(this.message.url);
+ });
+ if (this.message.thumbnails.length > 0 || isImage(this.message.type)) {
+ const fileRender = document.createElement('img');
+ fileRender.className = styles['file-render'];
+
+ if (isImage(this.message.type)) {
+ fileRender.src = protectFromXSS(this.message.url);
+ } else if (this.message.thumbnails.length > 0) {
+ fileRender.src = protectFromXSS(this.message.thumbnails[0].url);
+ }
+
+ fileRender.onload = () => {
+ Chat.getInstance().main.repositionScroll(fileRender.offsetHeight);
+ };
+ fileContent.appendChild(fileRender);
+ }
+ root.appendChild(fileContent);
+ }
+
+ return root;
+ }
+
+ _createAdminElement() {
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ const msg = createDivEl({ className: styles['message-admin'], content: protectFromXSS(this.message.message) });
+ root.appendChild(msg);
+ return root;
+ }
+
+ static getReadReceiptElementClassName() {
+ return styles.active;
+ }
+}
+
+export { Message };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/MessageDeleteModal.js b/javascript/javascript-basic-syncmanager/src/js/components/MessageDeleteModal.js
new file mode 100644
index 00000000..ffa1e6ab
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/MessageDeleteModal.js
@@ -0,0 +1,42 @@
+import styles from '../../scss/message-delete-modal.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+
+const title = 'Delete Message';
+const description = 'Are you Sure? Do you want to delete message?';
+const submitText = 'DELETE';
+
+class MessageDeleteModal extends Modal {
+ constructor({ channel, message, col }) {
+ super({ title, description, submitText });
+ this.channel = channel;
+ this.message = message;
+ this.col = col;
+ this._createElement();
+
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .deleteMessage({ channel: this.channel, message: this.message, col: this.col })
+ .then(() => {
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ };
+ }
+
+ _createElement() {
+ const content = createDivEl({
+ className: styles['modal-message'],
+ content: this.message.isFileMessage() ? protectFromXSS(this.message.name) : protectFromXSS(this.message.message)
+ });
+ this.contentElement.appendChild(content);
+ }
+}
+
+export { MessageDeleteModal };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/Modal.js b/javascript/javascript-basic-syncmanager/src/js/components/Modal.js
new file mode 100644
index 00000000..d90ea634
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/Modal.js
@@ -0,0 +1,62 @@
+import styles from '../../scss/modal.scss';
+import { createDivEl } from '../utils';
+import { Spinner } from './Spinner';
+
+class Modal {
+ constructor({ title, description, submitText }) {
+ this.contentElement = null;
+ this.cancelHandler = null;
+ this.submitHandler = null;
+ this.element = this._create({ title, description, submitText });
+ }
+
+ _create({ title, description, submitText }) {
+ const root = createDivEl({ className: styles['modal-root'] });
+ const modal = createDivEl({ className: styles['modal-body'] });
+ root.appendChild(modal);
+
+ const titleText = createDivEl({ className: styles['modal-title'], content: title });
+ modal.appendChild(titleText);
+
+ const desc = createDivEl({ className: styles['modal-desc'], content: description });
+ modal.appendChild(desc);
+
+ this.contentElement = createDivEl({ className: styles['modal-content'] });
+ modal.appendChild(this.contentElement);
+
+ const bottom = createDivEl({ className: styles['modal-bottom'] });
+ modal.appendChild(bottom);
+ const cancel = createDivEl({ className: styles['modal-cancel'], content: 'CANCEL' });
+ cancel.addEventListener('click', () => {
+ if (this.cancelHandler) {
+ this.cancelHandler();
+ }
+ this.close();
+ });
+ bottom.appendChild(cancel);
+ const submit = createDivEl({ className: styles['modal-submit'], content: submitText });
+ submit.addEventListener('click', () => {
+ Spinner.start(modal);
+ if (this.submitHandler) {
+ this.submitHandler();
+ }
+ });
+ bottom.appendChild(submit);
+
+ return root;
+ }
+
+ close() {
+ if (document.body.contains(this.element)) {
+ document.body.removeChild(this.element);
+ }
+ }
+
+ render() {
+ if (!document.body.querySelector(`.${styles['modal-root']}`)) {
+ document.body.appendChild(this.element);
+ }
+ }
+}
+
+export { Modal };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/Spinner.js b/javascript/javascript-basic-syncmanager/src/js/components/Spinner.js
new file mode 100644
index 00000000..b7d83297
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/Spinner.js
@@ -0,0 +1,42 @@
+import styles from '../../scss/spinner.scss';
+import { createDivEl } from '../utils';
+
+let instance = null;
+
+class Spinner {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.element = this._createSpinner();
+ instance = this;
+ }
+
+ _createSpinner() {
+ const item = createDivEl({ className: styles['sb-spinner'] });
+ const bubble = createDivEl({ className: styles['sb-spinner-bubble'] });
+ item.appendChild(bubble);
+ return item;
+ }
+
+ static start(target) {
+ const spinnerEl = Spinner.getInstance().element;
+ if (!target.contains(spinnerEl)) {
+ target.appendChild(spinnerEl);
+ }
+ }
+
+ static remove() {
+ const spinnerEl = Spinner.getInstance().element;
+ const targetEl = spinnerEl.parentElement;
+ if (targetEl && targetEl.contains(spinnerEl)) {
+ spinnerEl.parentElement.removeChild(spinnerEl);
+ }
+ }
+
+ static getInstance() {
+ return new Spinner();
+ }
+}
+
+export { Spinner };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/Toast.js b/javascript/javascript-basic-syncmanager/src/js/components/Toast.js
new file mode 100644
index 00000000..c215c251
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/Toast.js
@@ -0,0 +1,49 @@
+import styles from '../../scss/toast.scss';
+import { createDivEl } from '../utils';
+
+let instance = null;
+
+class Toast {
+ constructor(message) {
+ if (instance) {
+ const messageEl = instance.element.getElementsByClassName('sb-toast-message')[0];
+ if (messageEl) {
+ if (!message) {
+ message = messageEl.innerHTML;
+ }
+ messageEl.innerHTML = message;
+ }
+ return instance;
+ }
+ this.element = this._createToast(message);
+ instance = this;
+ }
+
+ _createToast(text) {
+ const item = createDivEl({ className: styles['sb-toast'] });
+ const message = createDivEl({ className: styles['sb-toast-message'] });
+ message.innerHTML = text;
+ item.appendChild(message);
+ return item;
+ }
+
+ static start(target, message) {
+ const toast = new Toast(message);
+ const toastEl = toast.element;
+ if (!target.contains(toastEl)) {
+ target.appendChild(toastEl);
+ }
+ }
+
+ static remove() {
+ const toastEl = instance ? instance.element : null;
+ if (toastEl) {
+ const targetEl = toastEl.parentElement;
+ if (targetEl && targetEl.contains(toastEl)) {
+ toastEl.parentElement.removeChild(toastEl);
+ }
+ }
+ }
+}
+
+export { Toast };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/UserBlockModal.js b/javascript/javascript-basic-syncmanager/src/js/components/UserBlockModal.js
new file mode 100644
index 00000000..85f9d1c7
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/UserBlockModal.js
@@ -0,0 +1,53 @@
+import styles from '../../scss/user-block-modal.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+import { Chat } from '../Chat';
+
+const blockTitle = 'Block User';
+const blockDescription = 'Are you Sure? Do you want to block this user?';
+const blockSubmitText = 'BLOCK';
+
+const unblockTitle = 'Unblock User';
+const unblockDescription = 'Are you Sure? Do you want to unblock this user?';
+const unblockSubmitText = 'UNBLOCK';
+
+class UserBlockModal extends Modal {
+ constructor({ user, isBlock = true }) {
+ isBlock
+ ? super({ title: blockTitle, description: blockDescription, submitText: blockSubmitText })
+ : super({ title: unblockTitle, description: unblockDescription, submitText: unblockSubmitText });
+ this.isBlock = isBlock;
+ this.user = user;
+ this._createElement();
+
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .blockUser(this.user, this.isBlock)
+ .then(() => {
+ Chat.getInstance().main.updateBlockedList(this.user, this.isBlock);
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ };
+ }
+
+ _createElement() {
+ const content = createDivEl({ className: styles['modal-user'] });
+
+ const image = createDivEl({ className: styles['user-profile'], background: protectFromXSS(this.user.profileUrl) });
+ content.appendChild(image);
+
+ const nickname = createDivEl({ className: styles['user-nickname'], content: protectFromXSS(this.user.nickname) });
+ content.appendChild(nickname);
+
+ this.contentElement.appendChild(content);
+ }
+}
+
+export { UserBlockModal };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/UserItem.js b/javascript/javascript-basic-syncmanager/src/js/components/UserItem.js
new file mode 100644
index 00000000..799ec0cb
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/UserItem.js
@@ -0,0 +1,63 @@
+import styles from '../../scss/user-item.scss';
+import { createDivEl, protectFromXSS, timestampFromNow, toggleClass } from '../utils';
+
+class UserItem {
+ constructor({ user, handler }) {
+ this.user = user;
+ this.element = this._createElement(handler);
+ }
+
+ get userId() {
+ return this.user.userId;
+ }
+
+ get nickname() {
+ return protectFromXSS(this.user.nickname);
+ }
+
+ get profileUrl() {
+ return protectFromXSS(this.user.profileUrl);
+ }
+
+ get lastSeenTimeString() {
+ return this.user.lastSeenAt ? timestampFromNow(this.user.lastSeenAt) : '';
+ }
+
+ get isOnline() {
+ return this.user.connectionStatus === 'online';
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['user-item'], id: this.userId });
+
+ const userInfo = createDivEl({ className: styles['user-info'] });
+ item.appendChild(userInfo);
+ const profile = createDivEl({ className: styles['user-profile'], background: this.profileUrl });
+ userInfo.appendChild(profile);
+ const nickname = createDivEl({ className: styles['user-nickname'], content: this.nickname });
+ userInfo.appendChild(nickname);
+ const isOnline = createDivEl({
+ className: this.isOnline ? [styles['user-online'], styles.active] : styles['user-online']
+ });
+ userInfo.appendChild(isOnline);
+
+ const userState = createDivEl({ className: styles['user-state'] });
+ item.appendChild(userState);
+ const lastSeenTime = createDivEl({ className: styles['user-time'], content: this.lastSeenTimeString });
+ userState.appendChild(lastSeenTime);
+ const selectIcon = createDivEl({ className: styles['user-select'] });
+ userState.appendChild(selectIcon);
+ item.addEventListener('click', () => {
+ toggleClass(item.querySelector(`.${UserItem.selectIconClassName}`), styles.active);
+ if (handler) handler();
+ });
+
+ return item;
+ }
+
+ static get selectIconClassName() {
+ return styles['user-select'];
+ }
+}
+
+export { UserItem };
diff --git a/javascript/javascript-basic-syncmanager/src/js/components/UserList.js b/javascript/javascript-basic-syncmanager/src/js/components/UserList.js
new file mode 100644
index 00000000..a93c9837
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/components/UserList.js
@@ -0,0 +1,128 @@
+import styles from '../../scss/user-list.scss';
+import { createDivEl, errorAlert, appendToFirst } from '../utils';
+import { List } from './List';
+import { Spinner } from './Spinner';
+import { SendBirdAction } from '../SendBirdAction';
+import { UserItem } from './UserItem';
+import { Chat } from '../Chat';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+
+let instance = null;
+
+class UserList extends List {
+ constructor() {
+ super('User List');
+ if (instance) {
+ return instance;
+ }
+
+ this.scrollEventHandler = this._getUserList;
+ this.closeEventHandler = this._close;
+ this.createBtn = this._addCreateBtn();
+ this.selectedUserIds = [];
+ instance = this;
+ }
+
+ _addCreateBtn() {
+ const createBtn = createDivEl({ className: styles['button-create'], content: 'CREATE' });
+ const oldCreateBtn = this.buttonRootElement.getElementsByClassName(styles['button-create'])[0];
+ if (oldCreateBtn) {
+ this.buttonRootElement.removeChild(oldCreateBtn);
+ }
+ appendToFirst(this.buttonRootElement, createBtn);
+ return createBtn;
+ }
+
+ _createChannel() {
+ SendBirdAction.getInstance()
+ .createGroupChannel(this.selectedUserIds)
+ .then(channel => {
+ ChatLeftMenu.getInstance().activeChannelUrl = channel.url;
+ Chat.getInstance().render(channel, false);
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ }
+
+ _inviteChannel() {
+ const channelUrl = Chat.getInstance().channel.url;
+ SendBirdAction.getInstance()
+ .inviteGroupChannel(channelUrl, this.selectedUserIds)
+ .then(() => {
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ }
+
+ _updateCreateType(isInvite) {
+ this.createBtn = this._addCreateBtn();
+ this.createBtn.innerHTML = isInvite ? 'INVITE' : 'CREATE';
+ this.createBtn.addEventListener('click', () => {
+ Spinner.start(this.element);
+ if (isInvite) {
+ this._inviteChannel();
+ } else {
+ this._createChannel();
+ }
+ });
+ }
+
+ _getUserList(isInit = false) {
+ Spinner.start(this.element);
+ const sendbirdAction = SendBirdAction.getInstance();
+ const listContent = this.getContentElement();
+ sendbirdAction
+ .getUserList(isInit)
+ .then(userList => {
+ userList.forEach(user => {
+ if (!sendbirdAction.isCurrentUser(user)) {
+ const handler = () => {
+ this._toggleUserId(item.userId);
+ };
+ const item = new UserItem({ user, handler });
+ listContent.appendChild(item.element);
+ }
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ Spinner.remove();
+ errorAlert(error.message);
+ });
+ }
+
+ _toggleUserId(userId) {
+ const index = this.selectedUserIds.indexOf(userId);
+ if (index > -1) {
+ this.selectedUserIds.splice(index, 1);
+ } else {
+ this.selectedUserIds.push(userId);
+ }
+ }
+
+ _close() {
+ this.selectedUserIds = [];
+ }
+
+ render(isInvite = false) {
+ if (!document.body.querySelector(`.${this.getRootClassName()}`)) {
+ this._updateCreateType(isInvite);
+ document.body.appendChild(this.element);
+ this._getUserList(true);
+ }
+ }
+
+ static getInstance() {
+ return new UserList();
+ }
+}
+
+export { UserList };
diff --git a/javascript/javascript-basic-syncmanager/src/js/const.js b/javascript/javascript-basic-syncmanager/src/js/const.js
new file mode 100644
index 00000000..f4d5f183
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/const.js
@@ -0,0 +1,11 @@
+export const APP_ID = '9DA1B1F4-0BE6-4DA8-82C5-2E81DAB56F23';
+export const USER_ID = 'user_id';
+export const DISPLAY_NONE = 'none';
+export const DISPLAY_BLOCK = 'block';
+export const DISPLAY_FLEX = 'flex';
+export const ACTIVE_CLASSNAME = 'active';
+export const KEY_ENTER = 13;
+export const FILE_ID = 'attach_file_id';
+export const UPDATE_INTERVAL_TIME = 5 * 1000;
+export const COLOR_RED = '#DC5960';
+export const MESSAGE_REQ_ID = 'reqId';
diff --git a/javascript/javascript-basic-syncmanager/src/js/index.js b/javascript/javascript-basic-syncmanager/src/js/index.js
new file mode 100644
index 00000000..5cbeefcf
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/index.js
@@ -0,0 +1,36 @@
+import { isEmpty, setCookie, getCookie } from './utils';
+import { USER_ID, KEY_ENTER } from './const';
+
+const userIdEl = document.querySelector('#user_id');
+const nicknameEl = document.querySelector('#user_nickname');
+const buttonEl = document.querySelector('#login-button');
+
+document.addEventListener('DOMContentLoaded', () => {
+ const cookieUserId = getCookie(USER_ID);
+ if (cookieUserId) {
+ userIdEl.value = cookieUserId;
+ }
+});
+
+nicknameEl.addEventListener('keydown', e => {
+ if (e.which === KEY_ENTER) {
+ login();
+ }
+});
+
+buttonEl.addEventListener('click', () => {
+ login();
+});
+
+const login = () => {
+ const userId = userIdEl.value.trim();
+ const nickname = nicknameEl.value.trim();
+ if (isEmpty(nickname)) {
+ alert('Please enter user nickname');
+ return;
+ }
+ userIdEl.value = '';
+ nicknameEl.value = '';
+ setCookie(USER_ID, userId);
+ window.location.href = `chat.html?userid=${encodeURIComponent(userId)}&nickname=${encodeURIComponent(nickname)}`;
+};
diff --git a/javascript/javascript-basic-syncmanager/src/js/main.js b/javascript/javascript-basic-syncmanager/src/js/main.js
new file mode 100644
index 00000000..55ff7f94
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/main.js
@@ -0,0 +1,71 @@
+import { getVariableFromUrl, isEmpty, redirectToIndex } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { SendBirdConnection } from './SendBirdConnection';
+import { ChatLeftMenu } from './ChatLeftMenu';
+import { Chat } from './Chat';
+import { UPDATE_INTERVAL_TIME } from './const';
+import { LeftListItem } from './components/LeftListItem';
+
+import SendBirdSyncManager from 'sendbird-syncmanager';
+import { Toast } from './components/Toast';
+
+const sb = new SendBirdAction();
+
+let chat = null;
+let chatLeft = null;
+
+const createConnectionHandler = () => {
+ const manager = SendBirdSyncManager.getInstance();
+ const connectionManager = new SendBirdConnection();
+ connectionManager.onReconnectStarted = () => {
+ Toast.start(document.body, 'Connection is lost. Trying to reconnect...');
+ connectionManager.channel = chat.channel;
+ };
+ connectionManager.onReconnectSucceeded = () => {
+ chatLeft.updateUserInfo(SendBirdAction.getInstance().getCurrentUser());
+ Toast.remove();
+ manager.resumeSync();
+ };
+ connectionManager.onReconnectFailed = () => {
+ connectionManager.reconnect();
+ };
+};
+
+const updateGroupChannelTime = () => {
+ setInterval(() => {
+ LeftListItem.updateLastMessageTime();
+ }, UPDATE_INTERVAL_TIME);
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const { userid, nickname } = getVariableFromUrl();
+ if (isEmpty(userid) || isEmpty(nickname)) {
+ redirectToIndex('UserID and Nickname must be required.');
+ }
+
+ SendBirdSyncManager.sendBird = sb.sb;
+ const options = new SendBirdSyncManager.Options();
+ options.messageCollectionCapacity = 2000;
+ options.messageResendPolicy = 'automatic';
+ options.automaticMessageResendRetryCount = 5;
+ options.maxFailedMessageCountPerChannel = 50;
+ options.failedMessageRetentionDays = 7;
+ SendBirdSyncManager.setup(userid, options, () => {
+ chat = new Chat();
+ chatLeft = new ChatLeftMenu();
+ updateGroupChannelTime();
+ chatLeft.loadGroupChannelList(true);
+
+ sb.connect(
+ userid,
+ nickname
+ )
+ .then(user => {
+ chatLeft.updateUserInfo(user);
+ createConnectionHandler();
+ })
+ .catch(() => {
+ Toast.start(document.body, 'Connection is not established.');
+ });
+ });
+});
diff --git a/javascript/javascript-basic-syncmanager/src/js/utils.js b/javascript/javascript-basic-syncmanager/src/js/utils.js
new file mode 100644
index 00000000..73310dc3
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/js/utils.js
@@ -0,0 +1,231 @@
+import moment from 'moment';
+
+export function findChannelIndex(newChannel, channels) {
+ const newChannelLastMessageUpdated = newChannel.lastMessage ? newChannel.lastMessage.createdAt : newChannel.createdAt;
+
+ let index = channels.length;
+ for (let i = 0; i < channels.length; i++) {
+ const comparedChannel = channels[i];
+ const comparedChannelLastMessageUpdated = comparedChannel.lastMessage
+ ? comparedChannel.lastMessage.createdAt
+ : comparedChannel.createdAt;
+ if (newChannel.url === comparedChannel.url) {
+ index = i;
+ break;
+ } else if (newChannelLastMessageUpdated > comparedChannelLastMessageUpdated) {
+ index = i;
+ break;
+ }
+ }
+ return index;
+}
+export function findMessageIndex(newMessage, messages, isRequestId = false) {
+ let index = messages.length;
+ for (let i = 0; i < messages.length; i++) {
+ if (
+ !isRequestId &&
+ newMessage.messageId !== 0 &&
+ messages[i].messageId !== 0 &&
+ messages[i].messageId === newMessage.messageId
+ ) {
+ index = i;
+ break;
+ } else if (isRequestId && messages[i].reqId === newMessage.reqId) {
+ index = i;
+ break;
+ } else if (messages[i].createdAt >= newMessage.createdAt) {
+ index = i;
+ break;
+ }
+ }
+ return index;
+}
+
+export function mergeFailedWithSuccessful(failedMessages, successfulMessages) {
+ const wholeMessages = [...successfulMessages];
+ for (let i = 0; i < failedMessages.length; i++) {
+ const index = findMessageIndex(failedMessages[i], wholeMessages);
+ wholeMessages.splice(index, 0, failedMessages[i]);
+ }
+ return wholeMessages;
+}
+
+export const timestampToTime = timestamp => {
+ const now = new Date().getTime();
+ const nowDate = moment.unix(now.toString().length === 13 ? now / 1000 : now).format('MM/DD');
+
+ let date = moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('MM/DD');
+ if (date === 'Invalid date') {
+ date = '';
+ }
+
+ return nowDate === date
+ ? moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('HH:mm')
+ : date;
+};
+
+export const timestampToDateString = timestamp => {
+ return moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('LL');
+};
+
+export const timestampFromNow = timestamp => {
+ return moment(timestamp).fromNow();
+};
+
+export const isUrl = urlString => {
+ const regex = /^(http|https):\/\/[^ "]+$/;
+ return regex.test(urlString);
+};
+
+export const isImage = fileType => {
+ const regex = /^image\/.+$/;
+ return regex.test(fileType);
+};
+
+export const isEmpty = value => {
+ return value === null || value === undefined || value.length === 0;
+};
+
+export const isNull = value => {
+ try {
+ return value === null;
+ } catch (e) {
+ return false;
+ }
+};
+
+export const setCookie = (key, value) => {
+ document.cookie = `${key}=${value}; expires=Fri, 31 Dec 9999 23:59:59 GMT`;
+};
+
+export const getCookie = key => {
+ let name = `${key}=`;
+ let ca = document.cookie.split(';');
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i];
+ if (!c) continue;
+ while (c.charAt(0) === ' ') {
+ c = c.substring(1);
+ }
+ if (c.indexOf(name) === 0) {
+ return c.substring(name.length, c.length);
+ }
+ }
+ return '';
+};
+
+export const getVariableFromUrl = () => {
+ let vars = {};
+ let hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
+ for (let i = 0; i < hashes.length; i++) {
+ let hash = hashes[i].split('=');
+ vars[hash[0]] = hash[1];
+ }
+ return vars;
+};
+
+export const errorAlert = (message, reload = false) => {
+ // alert(message);
+ // eslint-disable-next-line no-console
+ console.error(message);
+ if (reload) {
+ location.reload(true);
+ }
+};
+
+export const redirectToIndex = message => {
+ if (message) {
+ errorAlert(message, false);
+ }
+ window.location.href = 'index.html';
+};
+
+export const setDataInElement = (target, key, data) => {
+ target.dataset[`${key}`] = data;
+};
+
+export const getDataInElement = (target, key) => {
+ return target.dataset[`${key}`];
+};
+
+export const createDivEl = ({ id, className, content, background }) => {
+ const el = document.createElement('div');
+ if (id) {
+ el.id = id;
+ }
+ if (className) {
+ el.className = Array.isArray(className) ? className.join(' ') : className;
+ }
+ if (content) {
+ el.innerHTML = content;
+ }
+ if (background) {
+ el.style.backgroundImage = `url(${background})`;
+ }
+ return el;
+};
+
+export const isScrollBottom = target => {
+ return target.scrollTop + target.offsetHeight >= target.scrollHeight;
+};
+
+export const appendToFirst = (target, newElement) => {
+ if (target.childNodes.length > 0) {
+ target.insertBefore(newElement, target.childNodes[0]);
+ } else {
+ target.appendChild(newElement);
+ }
+};
+
+const hasClass = (target, className) => {
+ return target.classList
+ ? target.classList.contains(className)
+ : new RegExp('(^| )' + className + '( |$)', 'gi').test(target.className);
+};
+
+export const addClass = (target, className) => {
+ if (target.classList) {
+ if (!(className in target.classList)) {
+ target.classList.add(className);
+ }
+ } else {
+ if (target.className.indexOf(className) < 0) {
+ target.className += ` ${className}`;
+ }
+ }
+};
+
+export const removeClass = (target, className) => {
+ if (target.classList) {
+ target.classList.remove(className);
+ } else {
+ target.className = target.className.replace(
+ new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'),
+ ''
+ );
+ }
+};
+
+export const toggleClass = (target, className) => {
+ hasClass(target, className) ? removeClass(target, className) : addClass(target, className);
+};
+
+export const uuid4 = () => {
+ let d = new Date().getTime();
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = (d + Math.random() * 16) % 16 | 0;
+ d = Math.floor(d / 16);
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+ });
+};
+
+export const protectFromXSS = text => {
+ return typeof text === 'string'
+ ? text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ : text;
+};
diff --git a/javascript/javascript-basic-syncmanager/src/scss/_animation.scss b/javascript/javascript-basic-syncmanager/src/scss/_animation.scss
new file mode 100644
index 00000000..75dad441
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/_animation.scss
@@ -0,0 +1,40 @@
+// Mixin
+@mixin keyframes($name) {
+ @-webkit-keyframes #{$name} { @content; }
+ @-moz-keyframes #{$name} { @content; }
+ @-o-keyframes #{$name} { @content; }
+ @-ms-keyframes #{$name} { @content; }
+ @keyframes #{$name} { @content; }
+}
+
+@mixin transition($options...) {
+ -webkit-transition: $options;
+ -moz-transition: $options;
+ -ms-transition: $options;
+ -o-transition: $options;
+ transition: $options;
+}
+
+@mixin transform-scale($size) {
+ -webkit-transform: scale($size);
+ -moz-transform: scale($size);
+ -ms-transform: scale($size);
+ -o-transform: scale($size);
+ transform: scale($size);
+}
+
+@mixin animation($animation...) {
+ -webkit-animation: $animation;
+ -moz-animation: $animation;
+ -o-animation: $animation;
+ -ms-animation: $animation;
+ animation: $animation;
+}
+
+@mixin animation-delay($delay) {
+ -webkit-animation-delay: $delay;
+ -moz-animation-delay: $delay;
+ -o-animation-delay: $delay;
+ -ms-animation-delay: $delay;
+ animation-delay: $delay;
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/_common.scss b/javascript/javascript-basic-syncmanager/src/scss/_common.scss
new file mode 100644
index 00000000..9727b042
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/_common.scss
@@ -0,0 +1,10 @@
+@import 'normalize';
+@import 'variables';
+@import 'mixins';
+@import 'icons';
+
+body {
+ display: flex;
+ font-family: $font-family-exo2;
+ -webkit-font-smoothing: antialiased;
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/_icons.scss b/javascript/javascript-basic-syncmanager/src/scss/_icons.scss
new file mode 100644
index 00000000..01c41b28
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/_icons.scss
@@ -0,0 +1,40 @@
+// Icons
+$ic-prefix: 'https://dxstmhyqfqr1o.cloudfront.net/web-basic/';
+$ic-input-user: 'icon-username-landing.svg';
+$ic-profile-default: 'image-profile.svg';
+$ic-add-normal: 'icon-add-normal.png';
+$ic-add-over: 'icon-add-over.png';
+$ic-close: 'icon-close.png';
+$ic-enter: 'icon-enter.png';
+$ic-check-unselect: 'icon-check-unselect.png';
+$ic-check-select: 'icon-check-select.png';
+$ic-empty-chat: 'img-empty.svg';
+
+$ic-group: 'icon-group.png';
+$ic-hide-normal: 'icon-hide-normal.png';
+$ic-hide: 'icon-hide.png';
+$ic-group-add-normal: 'icon-group-add-normal.png';
+$ic-group-add: 'icon-group-add.png';
+$ic-leave-normal: 'icon-leave-normal.png';
+$ic-leave: 'icon-leave.png';
+$ic-attach-file-normal: 'icon-attach-file-normal.png';
+$ic-attach-file: 'icon-attach-file.png';
+$ic-arrow-normal: 'icon-arrow-nomal.png';
+$ic-arrow: 'icon-arrow.png';
+$ic-back: 'icon-back.png';
+
+$ic-search: 'icon-search-nomal.png';
+$ic-search-over: 'icon-search-over.png';
+
+@mixin icon($url, $size: cover, $position: center) {
+ background-image: url($ic-prefix + $url);
+ background-position: $position;
+ background-size: $size;
+ background-repeat: no-repeat;
+}
+
+@mixin imageMessage() {
+ background-position: center;
+ background-size: 160px 160px;
+ background-repeat: no-repeat;
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/src/scss/_mixins.scss b/javascript/javascript-basic-syncmanager/src/scss/_mixins.scss
new file mode 100644
index 00000000..a954ed4b
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/_mixins.scss
@@ -0,0 +1,4 @@
+@import 'mixins/border-radius';
+@import 'mixins/state';
+@import 'mixins/transform';
+@import 'mixins/reset';
diff --git a/javascript/javascript-basic-syncmanager/src/scss/_normalize.scss b/javascript/javascript-basic-syncmanager/src/scss/_normalize.scss
new file mode 100644
index 00000000..08e68694
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/_normalize.scss
@@ -0,0 +1,450 @@
+/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
+
+/* Document
+ ========================================================================== */
+
+/**
+ * 1. Correct the line height in all browsers.
+ * 2. Prevent adjustments of font size after orientation changes in
+ * IE on Windows Phone and in iOS.
+ */
+
+html {
+ line-height: 1.15; /* 1 */
+ -ms-text-size-adjust: 100%; /* 2 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/* Sections
+ ========================================================================== */
+
+/**
+ * Remove the margin in all browsers (opinionated).
+ */
+
+body {
+ margin: 0;
+}
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+article,
+aside,
+footer,
+header,
+nav,
+section {
+ display: block;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in IE.
+ */
+
+figcaption,
+figure,
+main {
+ /* 1 */
+ display: block;
+}
+
+/**
+ * Add the correct margin in IE 8.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box; /* 1 */
+ height: 0; /* 1 */
+ overflow: visible; /* 2 */
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+pre {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * 1. Remove the gray background on active links in IE 10.
+ * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
+ */
+
+a {
+ background-color: transparent; /* 1 */
+ -webkit-text-decoration-skip: objects; /* 2 */
+}
+
+/**
+ * 1. Remove the bottom border in Chrome 57- and Firefox 39-.
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none; /* 1 */
+ text-decoration: underline; /* 2 */
+ text-decoration: underline dotted; /* 2 */
+}
+
+/**
+ * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
+ */
+
+b,
+strong {
+ font-weight: inherit;
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+samp {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/**
+ * Add the correct font style in Android 4.3-.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Add the correct background and color in IE 9-.
+ */
+
+mark {
+ background-color: #ff0;
+ color: #000;
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+audio,
+video {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in iOS 4-7.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Remove the border on images inside links in IE 10-.
+ */
+
+img {
+ border-style: none;
+}
+
+/**
+ * Hide the overflow in IE.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change the font styles in all browsers (opinionated).
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: sans-serif; /* 1 */
+ font-size: 100%; /* 1 */
+ line-height: 1.15; /* 1 */
+ margin: 0; /* 2 */
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input {
+ /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select {
+ /* 1 */
+ text-transform: none;
+}
+
+/**
+ * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
+ * controls in Android 4.
+ * 2. Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+html [type='button'], /* 1 */
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button; /* 2 */
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type='button']::-moz-focus-inner,
+[type='reset']::-moz-focus-inner,
+[type='submit']::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type='button']:-moz-focusring,
+[type='reset']:-moz-focusring,
+[type='submit']:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Correct the padding in Firefox.
+ */
+
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box; /* 1 */
+ color: inherit; /* 2 */
+ display: table; /* 1 */
+ max-width: 100%; /* 1 */
+ padding: 0; /* 3 */
+ white-space: normal; /* 1 */
+}
+
+/**
+ * 1. Add the correct display in IE 9-.
+ * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ display: inline-block; /* 1 */
+ vertical-align: baseline; /* 2 */
+}
+
+/**
+ * Remove the default vertical scrollbar in IE.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10-.
+ * 2. Remove the padding in IE 10-.
+ */
+
+[type='checkbox'],
+[type='radio'] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type='number']::-webkit-inner-spin-button,
+[type='number']::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type='search'] {
+ -webkit-appearance: textfield; /* 1 */
+ outline-offset: -2px; /* 2 */
+}
+
+/**
+ * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
+ */
+
+[type='search']::-webkit-search-cancel-button,
+[type='search']::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button; /* 1 */
+ font: inherit; /* 2 */
+}
+
+/* Interactive
+ ========================================================================== */
+
+/*
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in Edge, IE, and Firefox.
+ */
+
+details, /* 1 */
+menu {
+ display: block;
+}
+
+/*
+ * Add the correct display in all browsers.
+ */
+
+summary {
+ display: list-item;
+}
+
+/* Scripting
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+canvas {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in IE.
+ */
+
+template {
+ display: none;
+}
+
+/* Hidden
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 10-.
+ */
+
+[hidden] {
+ display: none;
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/_variables.scss b/javascript/javascript-basic-syncmanager/src/scss/_variables.scss
new file mode 100644
index 00000000..346a8a67
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/_variables.scss
@@ -0,0 +1,41 @@
+// Color
+$color-transparent: transparent !default;
+
+$color-black-border: #2C2D30 !default;
+$color-black: #000000 !default;
+$color-black-text: #555555 !default;
+$color-black-text-light: #abb8c4 !default;
+
+$color-gray-admin: #e8ecef !default;
+$color-gray-dark: #dedede !default;
+$color-gray: #e3e3e3 !default;
+$color-gray-light: #F8F8F8 !default;
+
+$color-white: #ffffff !default;
+
+$color-blue-dark: #328fe6 !default;
+$color-blue: #32c5e6 !default;
+
+
+$color-purple-darker: #463c66 !default;
+$color-purple-dark: #4E4273 !default;
+$color-purple: #6e5baa !default;
+$color-purple-light: #6742d6 !default;
+
+$color-purple-deep: #673AB7 !default;
+
+$color-purple-text-dark: #7F6DA0 !default;
+$color-purple-text: #c7b0ff !default;
+$color-purple-text-light: #A08DCE !default;
+
+$color-green-online: #00C853 !default;
+
+$color-red: #DC5960 !default;
+
+$color-chat-border: #e0e2e5 !default;
+$color-chat-select: #f8f9fa !default;
+
+$color-message-not-sent: #e5e5e5;
+
+// Font
+$font-family-exo2: 'Exo 2';
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat-body.scss b/javascript/javascript-basic-syncmanager/src/scss/chat-body.scss
new file mode 100644
index 00000000..edfabbe2
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat-body.scss
@@ -0,0 +1,35 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-body {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 10px 0;
+
+ & > .new-message-pop {
+ margin: 0px 10px;
+ display: flex;
+ width: inherit;
+
+ position: sticky;
+ bottom: -10px;
+ background-color: $color-white;
+ border: 5px solid $color-blue;
+ border-radius: 10px;
+
+ & > .new-message-pop-text {
+ margin-left: 30px;
+ height: 30px;
+ font-size: 24px;
+ color: $color-blue;
+ @include hover-focus {
+ cursor: pointer;
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat-input.scss b/javascript/javascript-basic-syncmanager/src/scss/chat-input.scss
new file mode 100644
index 00000000..089761eb
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat-input.scss
@@ -0,0 +1,74 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-input {
+ display: flex;
+ padding: 20px;
+ border-top: 1px solid $color-chat-border;
+ background-color: $color-white;
+
+ & > .typing-field {
+ display: none;
+ position: absolute;
+ bottom: 79px;
+ left: 220px;
+ width: calc(100vw - 220px - 240px);
+ padding: 6px 20px;
+ box-sizing: border-box;
+ background-color: rgba(0, 0, 0, 0.1);
+ color: $color-black-text;
+ opacity: 0.4;
+ vertical-align: middle;
+ font-size: 13px;
+ font-style: italic;
+ }
+
+ & > .input-file {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ border-right: 0;
+ background-color: $color-white;
+ cursor: pointer;
+ @include border-left-radius(4px);
+ @include icon($ic-attach-file-normal, 20px 20px, center center);
+ @include hover-focus {
+ border: 1px solid $color-black-border;
+ @include icon($ic-attach-file, 20px 20px, center center);
+ }
+ }
+
+ & > .input-text {
+ display: flex;
+ font-size: 15px;
+ width: 100%;
+ height: 38px;
+ padding: 7px 8px 6px 8px;
+ box-sizing: border-box;
+ border: 1px solid $color-chat-border;
+ background-color: $color-white;
+ @include border-right-radius(4px);
+ @include hover-focus-active {
+ border: 1px solid $color-black-border;
+ }
+
+ & > .input-text-area {
+ width: 100%;
+ outline: none;
+ border: 0;
+ resize: none;
+ line-height: 1.4;
+ background-color: $color-white;
+ overflow: hidden;
+ @include hover-focus {
+ outline: none;
+ border: 0;
+ resize: none;
+ padding-top: 2px;
+ line-height: 1.4;
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat-main.scss b/javascript/javascript-basic-syncmanager/src/scss/chat-main.scss
new file mode 100644
index 00000000..34279652
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat-main.scss
@@ -0,0 +1,18 @@
+@import 'mixins';
+@import 'variables';
+
+.chat-main-root {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 0;
+
+ & > .chat-main {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 100%;
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat-menu.scss b/javascript/javascript-basic-syncmanager/src/scss/chat-menu.scss
new file mode 100644
index 00000000..b54403ae
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat-menu.scss
@@ -0,0 +1,97 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-menu-root {
+ display: flex;
+ flex-direction: column;
+ width: 240px;
+ min-width: 240px;
+ max-width: 240px;
+ background-color: $color-white;
+ box-sizing: border-box;
+ border-left: 1px solid $color-chat-border;
+ color: $color-black-border;
+ padding: 0;
+
+ & > .menu-item {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ align-content: center;
+ padding: 10px 20px;
+ border-bottom: 1px solid $color-chat-border;
+ cursor: pointer;
+
+ & > .menu-users,
+ & > .menu-blocked {
+ display: flex;
+ opacity: 0.6;
+ }
+
+ & > .menu-arrow {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ @include icon($ic-arrow-normal, 26px 26px, center center);
+ }
+
+ @include hover-focus {
+ background-color: $color-chat-select;
+
+ & > .menu-users,
+ & > .menu-blocked {
+ opacity: 1;
+ }
+
+ & > .menu-arrow {
+ @include icon($ic-arrow, 26px 26px, center center);
+ }
+ }
+ }
+
+ & > .menu-list {
+ display: none;
+ flex-direction: column;
+ position: absolute;
+ width: 239px;
+ height: calc(100% - 77px);
+ background: $color-white;
+ z-index: 999;
+
+ & > .list-title {
+ display: flex;
+ align-items: center;
+ align-content: center;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ color: $color-black-border;
+ border-bottom: 1px solid $color-chat-border;
+ cursor: pointer;
+ @include hover-focus {
+ background-color: $color-chat-select;
+ }
+
+ & > .list-back {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ @include icon($ic-back, 24px 24px, 0 center);
+ }
+
+ & > .list-text {
+ display: flex;
+ }
+ }
+
+ & > .list-body {
+ display: block;
+ flex-direction: column;
+ height: 100%;
+ max-height: calc(100% - 56px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat-top-menu.scss b/javascript/javascript-basic-syncmanager/src/scss/chat-top-menu.scss
new file mode 100644
index 00000000..239bb56c
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat-top-menu.scss
@@ -0,0 +1,81 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-top {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ height: 80px;
+ box-sizing: border-box;
+ padding: 15px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-chat-border;
+ color: $color-black-border;
+
+ & > .chat-title {
+ max-width: 800px;
+ font-size: 20px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+ & > .chat-title.is-group {
+ padding-left: 34px;
+ @include icon($ic-group, 27px 27px, 0 center);
+ }
+
+ & > .chat-button {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ width: 150px;
+ margin-left: 20px;
+
+ & > .button-invite {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ margin-right: 10px;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-group-add-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-group-add, 20px 20px, center center);
+ }
+ }
+
+ & > .button-hide {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ margin-right: 10px;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-hide-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-hide, 20px 20px, center center);
+ }
+ }
+
+ & > .button-leave {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-leave-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-leave, 20px 20px, center center);
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat-user-item.scss b/javascript/javascript-basic-syncmanager/src/scss/chat-user-item.scss
new file mode 100644
index 00000000..649deefb
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat-user-item.scss
@@ -0,0 +1,34 @@
+@import 'mixins';
+@import 'variables';
+
+.chat-user-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 10px 20px;
+ cursor: pointer;
+
+ & > .user-image {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ margin-right: 10px;
+ background-size: 36px 36px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ @include border-radius(50%);
+ }
+
+ & > .user-nickname {
+ width: 154px;
+ max-width: 154px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+ & > .user-nickname.is-user {
+ font-weight: 600;
+ color: $color-purple-deep;
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/chat.scss b/javascript/javascript-basic-syncmanager/src/scss/chat.scss
new file mode 100644
index 00000000..9c9242ae
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/chat.scss
@@ -0,0 +1,51 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-empty {
+ display: flex;
+ width: 100%;
+ height: 100%;
+
+ & > .empty-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: auto;
+ text-align: center;
+ color: $color-black-text-light;
+ @include transform-translate(0, -50%);
+
+ & > .content-title {
+ display: flex;
+ font-size: 28px;
+ }
+
+ & > .content-image {
+ display: flex;
+ width: 80px;
+ height: 80px;
+ padding: 8px;
+ @include icon($ic-empty-chat, 80px 80px, center center);
+ }
+
+ & > .content-desc {
+ display: flex;
+ font-size: 14px;
+ white-space: pre;
+ }
+ }
+}
+
+.logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+}
+
+.chat-root {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 100%;
+ height: 100%;
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/index.scss b/javascript/javascript-basic-syncmanager/src/scss/index.scss
new file mode 100644
index 00000000..c504b226
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/index.scss
@@ -0,0 +1,146 @@
+@import 'common';
+
+body {
+ background-color: $color-purple;
+}
+
+.logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-width: 900px;
+ min-height: 650px;
+ font-family: $font-family-exo2;
+
+ &>.top {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 80px;
+ color: $color-white;
+
+ &>.logo {
+ display: flex;
+ align-items: center;
+ background-color: $color-white;
+ width: 87px;
+ height: 87px;
+ flex-direction: column;
+ justify-content: center;
+ @include border-radius(50%);
+
+ &>.logo-image {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ &>.title {
+ display: flex;
+ align-items: center;
+
+ &>.title-company {
+ display: flex;
+ align-items: center;
+ font-size: 30px;
+ font-weight: 600;
+ margin: 0 10px;
+ }
+
+ &>.title-desc {
+ display: flex;
+ align-items: center;
+ font-size: 26px;
+ font-weight: 200;
+ }
+ }
+ }
+
+ &>.login {
+ display: flex;
+ margin-top: 40px;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ &>.desc {
+ display: flex;
+ color: $color-purple-text;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+
+ &>.download {
+ display: flex;
+ text-align: center;
+ margin: 20px 0;
+
+ &>.download-sample {
+ color: $color-purple-text;
+ cursor: pointer;
+
+ @include hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ &>.form {
+ display: flex;
+ flex-direction: column;
+ margin-top: 10px;
+
+ &>.form-input {
+ display: flex;
+ margin-top: 10px;
+ border: 2px solid $color-white;
+ padding: 0 10px 0 40px;
+ width: 300px;
+ height: 50px;
+ font-size: 16px;
+ color: $color-black-text;
+ @include border-radius(2px);
+ @include icon($ic-input-user, 20px 20px, 10px center);
+
+ @include hover-focus {
+ border: 2px solid $color-blue;
+ }
+ }
+
+ &>.button {
+ display: flex;
+ justify-content: center;
+ margin-top: 10px;
+ width: 100%;
+ height: 48px;
+ background-color: $color-blue;
+ color: $color-white;
+ font-size: 16px;
+ font-weight: 700;
+ border: 0;
+ @include border-radius(2px);
+ cursor: pointer;
+
+ @include hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ &>.image {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ margin-top: 50px;
+ color: $color-white;
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/src/scss/list-item.scss b/javascript/javascript-basic-syncmanager/src/scss/list-item.scss
new file mode 100644
index 00000000..a476e87f
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/list-item.scss
@@ -0,0 +1,84 @@
+@import 'mixins';
+@import 'variables';
+
+.list-item {
+ display: flex;
+ flex-direction: column;
+ padding: 6px 20px;
+ cursor: pointer;
+ @include hover {
+ background-color: $color-purple-darker;
+ }
+ & > .item-top {
+ display: flex;
+ color: $color-purple-text-light;
+ & > .item-count {
+ width: 18px;
+ height: 18px;
+ box-sizing: border-box;
+ border: 1px solid $color-purple-text-light;
+ align-items: center;
+ align-content: center;
+ text-align: center;
+ margin-right: 8px;
+ font-size: 13px;
+ line-height: 17px;
+ }
+ & > .item-title {
+ width: 100%;
+ max-width: 150px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ & > .item-bottom {
+ display: flex;
+ color: $color-purple-text-dark;
+ justify-content: space-between;
+ flex-direction: column;
+ font-size: 14px;
+ margin-top: 4px;
+ padding-left: 26px;
+ & > .item-message {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ & > .item-message-text {
+ width: 130px;
+ max-width: 130px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ color: $color-purple-text-light;
+ opacity: 0.7;
+ }
+ & > .item-message-unread {
+ display: none;
+ width: 16px;
+ height: 16px;
+ background-color: $color-red;
+ text-align: center;
+ color: $color-white;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+ @include border-radius(50%);
+ }
+ & > .item-message-unread.active {
+ display: block;
+ }
+ }
+ & > .item-time {
+ display: flex;
+ font-size: 11px;
+ }
+ }
+}
+
+.list-item.active {
+ & > .item-top {
+ color: $color-purple-text;
+ font-weight: 600;
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/list.scss b/javascript/javascript-basic-syncmanager/src/scss/list.scss
new file mode 100644
index 00000000..7365ce94
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/list.scss
@@ -0,0 +1,106 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.list-root {
+ min-width: 980px;
+ width: 100vw;
+ height: 100vh;
+ max-height: 100vh;
+ overflow: hidden;
+ position: absolute;
+ z-index: 9999;
+ background-color: $color-white;
+ font-family: $font-family-exo2;
+ & > .list-body {
+ max-width: 700px;
+ min-width: 500px;
+ width: 100%;
+ height: 100%;
+ margin: 70px auto 50px auto;
+ display: flex;
+ box-sizing: border-box;
+ flex-direction: column;
+ & > .list-top {
+ width: 100%;
+ height: 70px;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ & > .list-title {
+ display: flex;
+ font-size: 30px;
+ font-weight: 700;
+ margin-left: 20px;
+ }
+ & > .list-button {
+ display: flex;
+ flex-direction: row;
+ margin-right: 20px;
+
+ & > .button-exit {
+ width: 36px;
+ height: 36px;
+ text-align: center;
+ justify-content: center;
+ display: flex;
+ line-height: 36px;
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include border-radius(4px);
+ @include icon($ic-close, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-gray;
+ background-color: $color-gray;
+ }
+ }
+ }
+ }
+ & > .list-hr {
+ height: 0;
+ margin: 8px 20px;
+ border-top: 1px solid $color-gray;
+ }
+
+ & > .list-search {
+ box-sizing: border-box;
+ padding: 10px 20px;
+ overflow: hidden;
+ & > .search-input {
+ font-size: 18px;
+ font-family: $font-family-exo2;
+ box-sizing: border-box;
+ width: calc(100% - 40px);
+ height: 42px;
+ margin: 0 20px;
+ padding-left: 44px;
+ outline: none;
+ border: 1px solid $color-gray;
+ @include border-radius(4px);
+ @include icon($ic-search, 26px 26px, 8px center);
+ @include hover-focus {
+ @include icon($ic-search-over, 26px 26px, 8px center);
+ border: 1px solid $color-purple-light;
+ font-weight: 300;
+ }
+ &::placeholder {
+ color: $color-gray;
+ font-size: 18px;
+ font-weight: 300;
+ }
+ }
+ }
+
+ & > .list-content {
+ box-sizing: border-box;
+ padding: 10px 20px;
+ max-height: calc(100vh - 205px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/main.scss b/javascript/javascript-basic-syncmanager/src/scss/main.scss
new file mode 100644
index 00000000..bc07b2e2
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/main.scss
@@ -0,0 +1,163 @@
+@import 'common';
+
+.body {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ min-width: 980px;
+ font-family: $font-family-exo2;
+
+ &>.body-left {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 220px;
+ height: 100vh;
+ background-color: $color-purple-dark;
+
+ &>.body-left-top {
+ display: flex;
+ padding: 20px;
+ justify-content: center;
+
+ &>.top-logo {
+ display: flex;
+ align-items: center;
+ background-color: $color-white;
+ width: 50px;
+ height: 50px;
+ flex-direction: column;
+ justify-content: center;
+ @include border-radius(50%);
+
+ &>.logo-image {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ &>.top-text {
+ color: $color-white;
+ display: flex;
+ align-items: center;
+ font-size: 30px;
+ font-weight: 600;
+ margin-left: 5px;
+ }
+ }
+
+ &>.body-left-list {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 170px);
+ color: $color-purple-text-dark;
+
+ .icon-create-chat {
+ width: 20px;
+ height: 20px;
+ @include border-radius(4px);
+ @include icon($ic-add-normal, 17px 17px, center center);
+
+ @include hover {
+ cursor: pointer;
+ background-color: $color-purple-text-light;
+ }
+ }
+
+ &>.chat-type {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ font-family: $font-family-exo2;
+ font-weight: 400;
+ font-size: 14px;
+ color: #9F8DC0;
+ line-height: 20px;
+ padding: 8px 20px;
+
+ &>.chat-type-title {
+ @include hover {
+ cursor: pointer;
+ font-weight: 600;
+ color: $color-purple-text-light;
+ }
+ }
+ }
+
+ &>.chat-list {
+ flex-direction: column;
+ width: 100%;
+ max-height: 450px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ box-sizing: border-box;
+
+ &>.default-item {
+ display: block;
+ padding: 10px;
+ margin: 0 20px;
+ color: $color-purple-text-light;
+ font-size: 16px;
+ border: 1px dashed $color-purple-text-light;
+ @include border-radius(4px);
+ }
+ }
+
+ &>.chat-list.chat-list-group {
+ max-height: calc(100% - 130px);
+ }
+ }
+
+ &>.body-left-bottom {
+ display: flex;
+ padding: 20px;
+ background-color: $color-purple-darker;
+
+ &>.bottom-profile {
+ display: flex;
+ height: 40px;
+ align-items: center;
+
+ &>.image-profile {
+ display: flex;
+ align-items: center;
+ @include border-radius(50%);
+ }
+ }
+
+ &>.bottom-nickname {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 10px;
+
+ &>.nickname-title {
+ display: flex;
+ color: $color-purple-text-dark;
+ font-size: 14px;
+ }
+
+ &>.nickname-content {
+ display: inline-block;
+ max-width: 150px;
+ height: 18px;
+ color: $color-purple-text-light;
+ font-size: 16px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -ms-text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+ }
+
+ &>.body-center {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 500px;
+ height: 100%;
+ background-color: $color-white;
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/src/scss/message-delete-modal.scss b/javascript/javascript-basic-syncmanager/src/scss/message-delete-modal.scss
new file mode 100644
index 00000000..57c4db82
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/message-delete-modal.scss
@@ -0,0 +1,14 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-message {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px;
+ width: 100%;
+ border: 1px solid $color-red;
+ background-color: $color-white;
+ font-size: 18px;
+ margin: 10px 0;
+ @include border-radius(4px);
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/message.scss b/javascript/javascript-basic-syncmanager/src/scss/message.scss
new file mode 100644
index 00000000..504ab0d0
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/message.scss
@@ -0,0 +1,110 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-message {
+ display: block;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0 20px;
+ margin-bottom: 8px;
+ border: 1px solid transparent;
+
+ & > .message-content {
+ display: inline;
+ & > .message-nickname {
+ align-items: center;
+ display: inline;
+ justify-content: flex-start;
+ flex-direction: column;
+ cursor: pointer;
+ }
+ & > .message-nickname.is-user {
+ font-weight: 600;
+ color: $color-purple-deep;
+ cursor: initial;
+ }
+
+ & > .message-content {
+ display: inline;
+ white-space: pre-line;
+ }
+ & > .message-content.is-file {
+ cursor: pointer;
+ @include hover-focus {
+ color: $color-blue-dark;
+ }
+ }
+
+ & > .time {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ opacity: 0.5;
+ }
+ & > .time.is-user {
+ cursor: pointer;
+ }
+
+ & > .read {
+ display: none;
+ vertical-align: middle;
+ text-align: center;
+ width: 18px;
+ height: 18px;
+ line-height: 17px;
+ margin-left: 8px;
+ font-size: 12px;
+ color: $color-white;
+ font-weight: 500;
+ @include border-radius(50%);
+ background: $color-red;
+ }
+ & > .read.active {
+ display: inline-block;
+ }
+ & > .resend-button {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: $color-red;
+ }
+
+ & > .delete-button {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: $color-red;
+ }
+ }
+
+ & > .file-content {
+ display: block;
+ border-left: 2px solid $color-black-text;
+ padding-left: 10px;
+ margin-top: 8px;
+ cursor: pointer;
+ & > .file-render {
+ display: inline;
+ max-width: 300px;
+ max-height: 300px;
+ }
+ }
+
+ & > .message-admin {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-style: italic;
+ color: $color-black-text;
+ }
+}
+
+.chat-message.is-failed {
+ background: $color-message-not-sent;
+}
diff --git a/web-widget/src/scss/mixins/_border-radius.scss b/javascript/javascript-basic-syncmanager/src/scss/mixins/_border-radius.scss
similarity index 100%
rename from web-widget/src/scss/mixins/_border-radius.scss
rename to javascript/javascript-basic-syncmanager/src/scss/mixins/_border-radius.scss
diff --git a/web-widget/src/scss/mixins/_reset.scss b/javascript/javascript-basic-syncmanager/src/scss/mixins/_reset.scss
similarity index 100%
rename from web-widget/src/scss/mixins/_reset.scss
rename to javascript/javascript-basic-syncmanager/src/scss/mixins/_reset.scss
diff --git a/javascript/javascript-basic-syncmanager/src/scss/mixins/_state.scss b/javascript/javascript-basic-syncmanager/src/scss/mixins/_state.scss
new file mode 100644
index 00000000..94e4cea3
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/mixins/_state.scss
@@ -0,0 +1,58 @@
+@mixin hover {
+ &:hover { @content; }
+}
+
+@mixin plain-hover {
+ &,
+ &:hover { @content; }
+}
+
+@mixin focus {
+ &:focus { @content; }
+}
+
+@mixin plain-focus {
+ &,
+ &:focus { @content; }
+}
+
+@mixin hover-focus {
+ &:hover,
+ &:focus { @content; }
+}
+
+@mixin plain-hover-focus {
+ &,
+ &:hover,
+ &:focus { @content; }
+}
+
+@mixin hover-focus-active {
+ &:hover,
+ &:focus,
+ &:active { @content; }
+}
+
+@mixin after {
+ &::after {
+ @content
+ }
+}
+
+@mixin before {
+ &::before {
+ @content
+ }
+}
+
+@mixin before-after {
+ &::after, &::before {
+ @content
+ }
+}
+
+@mixin plain-before-after {
+ &, &::after, &::before {
+ @content
+ }
+}
diff --git a/web-widget/src/scss/mixins/_transform.scss b/javascript/javascript-basic-syncmanager/src/scss/mixins/_transform.scss
similarity index 100%
rename from web-widget/src/scss/mixins/_transform.scss
rename to javascript/javascript-basic-syncmanager/src/scss/mixins/_transform.scss
diff --git a/javascript/javascript-basic-syncmanager/src/scss/modal.scss b/javascript/javascript-basic-syncmanager/src/scss/modal.scss
new file mode 100644
index 00000000..3590e65d
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/modal.scss
@@ -0,0 +1,74 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-root {
+ display: flex;
+ position: absolute;
+ width: 100vw;
+ height: 100vh;
+ z-index: 9999;
+ background-color: rgba(0, 0, 0, 0.5);
+
+ & > .modal-body {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ width: 450px;
+ background-color: $color-white;
+ margin: auto;
+ padding: 24px;
+ @include border-radius(4px);
+ @include transform-translate(0, -50%);
+
+ & > .modal-title {
+ display: flex;
+ font-size: 26px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+
+ & > .modal-desc {
+ display: flex;
+ color: $color-black-text;
+ font-size: 14px;
+ font-weight: 300;
+ }
+
+ & > .modal-content {
+ display: flex;
+ margin: 10px 0;
+ }
+
+ & > .modal-bottom {
+ display: flex;
+ justify-content: flex-end;
+ & > .modal-cancel {
+ display: flex;
+ margin-right: 12px;
+ color: $color-black-text-light;
+ border: 1px solid $color-black-text-light;
+ cursor: pointer;
+ padding: 6px;
+ @include border-radius(4px);
+ @include hover-focus {
+ color: $color-purple-light;
+ border: 1px solid $color-purple-light;
+ }
+ }
+ & > .modal-submit {
+ display: flex;
+ color: $color-white;
+ background-color: $color-blue;
+ border: 1px solid $color-blue;
+ cursor: pointer;
+ padding: 6px;
+ font-weight: 600;
+ @include border-radius(4px);
+ @include hover-focus {
+ background-color: $color-blue-dark;
+ border: 1px solid $color-blue-dark;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic-syncmanager/src/scss/spinner.scss b/javascript/javascript-basic-syncmanager/src/scss/spinner.scss
new file mode 100644
index 00000000..7bf66632
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/spinner.scss
@@ -0,0 +1,71 @@
+@import 'mixins';
+@import 'variables';
+
+.sb-spinner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0.6;
+ background-color: $color-white;
+ flex-direction: column;
+ justify-content: center;
+ display: flex;
+
+ .sb-spinner-bubble {
+ color: $color-black;
+ font-size: 10px;
+ margin: 80px auto;
+ position: relative;
+ text-indent: -9999em;
+ -webkit-animation-delay: -0.16s;
+ animation-delay: -0.16s;
+ @include transform-translate(0, -2em);
+ @include plain-before-after {
+ @include border-radius(50%);
+ width: 1.5em;
+ height: 1.5em;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+ -webkit-animation: load7 1.8s infinite ease-in-out;
+ animation: load7 1.8s infinite ease-in-out;
+ }
+ @include before-after {
+ content: '';
+ position: absolute;
+ top: 0;
+ }
+ @include before {
+ left: -3.5em;
+ -webkit-animation-delay: -0.32s;
+ animation-delay: -0.32s;
+ }
+ @include after {
+ left: 3.5em;
+ }
+ }
+
+}
+
+@-webkit-keyframes load7 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 2.5em 0 -1.3em;
+ }
+ 40% {
+ box-shadow: 0 2.5em 0 0;
+ }
+}
+@keyframes load7 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 2.5em 0 -1.3em;
+ }
+ 40% {
+ box-shadow: 0 2.5em 0 0;
+ }
+}
+
diff --git a/javascript/javascript-basic-syncmanager/src/scss/toast.scss b/javascript/javascript-basic-syncmanager/src/scss/toast.scss
new file mode 100644
index 00000000..112eaa3b
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/toast.scss
@@ -0,0 +1,21 @@
+@import 'variables';
+@import 'animation';
+
+.sb-toast {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ text-align: center;
+
+ .sb-toast-message {
+ color: $color-white;
+ display:inline-block;
+ font-size: 14px;
+ padding:10px 15px;
+ margin-top:20px;
+ border-radius:5px;
+ background-color: $color-purple-dark;
+ }
+}
+
diff --git a/javascript/javascript-basic-syncmanager/src/scss/user-block-modal.scss b/javascript/javascript-basic-syncmanager/src/scss/user-block-modal.scss
new file mode 100644
index 00000000..9df0631c
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/user-block-modal.scss
@@ -0,0 +1,34 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-user {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px;
+ width: 100%;
+ border: 1px solid $color-red;
+ background-color: $color-white;
+ font-size: 18px;
+ margin: 10px 0;
+ @include border-radius(4px);
+
+ & > .user-profile {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ margin-right: 10px;
+ background-size: 36px 36px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ @include border-radius(50%);
+ }
+
+ & > .user-nickname {
+ width: 330px;
+ max-width: 330px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/user-item.scss b/javascript/javascript-basic-syncmanager/src/scss/user-item.scss
new file mode 100644
index 00000000..de417bfd
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/user-item.scss
@@ -0,0 +1,77 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.user-item {
+ display: flex;
+ padding: 8px 20px 8px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-gray-dark;
+ justify-content: space-between;
+ cursor: pointer;
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-purple-light;
+ @include border-radius(2px);
+ }
+
+ & > .user-info {
+ display: flex;
+ align-items: center;
+
+ & > .user-profile {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ @include icon($ic-profile-default, 40px 40px, center center);
+ }
+
+ & > .user-nickname {
+ margin: 0 10px;
+ font-size: 18px;
+ max-width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -ms-text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ & > .user-online {
+ display: flex;
+ width: 8px;
+ height: 8px;
+ border: 1px solid $color-black-text-light;
+ background-color: $color-black-text-light;
+ opacity: 0.4;
+ @include border-radius(50%);
+ }
+ & > .user-online.active {
+ border: 1px solid $color-green-online;
+ background-color: $color-green-online;
+ opacity: 1;
+ }
+ }
+
+ & > .user-state {
+ display: flex;
+ align-items: center;
+
+ & > .user-time {
+ display: flex;
+ color: $color-black-text-light;
+ margin-right: 10px;
+ }
+
+ & > .user-select {
+ display: flex;
+ width: 30px;
+ height: 30px;
+ opacity: 0.4;
+ @include icon($ic-check-unselect, 30px 30px, center center);
+ }
+ & > .user-select.active {
+ opacity: 1;
+ @include icon($ic-check-select, 30px 30px, center center);
+ }
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/src/scss/user-list.scss b/javascript/javascript-basic-syncmanager/src/scss/user-list.scss
new file mode 100644
index 00000000..bc99f7b4
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/src/scss/user-list.scss
@@ -0,0 +1,23 @@
+@import 'mixins';
+@import 'variables';
+
+.button-create {
+ width: 80px;
+ height: 36px;
+ text-align: center;
+ justify-content: center;
+ display: flex;
+ line-height: 36px;
+ font-weight: 600;
+ color: $color-white;
+ cursor: pointer;
+ background-color: $color-blue;
+ border: 1px solid $color-blue;
+ margin-right: 12px;
+ @include border-radius(4px);
+ @include hover-focus {
+ cursor: pointer;
+ background-color: $color-blue-dark;
+ border: 1px solid $color-blue-dark;
+ }
+}
diff --git a/javascript/javascript-basic-syncmanager/webpack.config.js b/javascript/javascript-basic-syncmanager/webpack.config.js
new file mode 100644
index 00000000..55375e1a
--- /dev/null
+++ b/javascript/javascript-basic-syncmanager/webpack.config.js
@@ -0,0 +1,76 @@
+'use strict';
+const path = require('path');
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
+
+const PRODUCTION = 'production';
+
+module.exports = () => {
+ const config = {
+ mode: 'production',
+ entry: {
+ index: ['./src/js/index.js', './src/scss/index.scss'],
+ main: ['./src/js/main.js', './src/scss/main.scss']
+ },
+ output: {
+ path: path.resolve(__dirname, './dist'),
+ filename: 'sample.[name].js',
+ library: '[name]',
+ libraryExport: 'default',
+ libraryTarget: 'umd',
+ publicPath: 'dist'
+ },
+ devtool: 'cheap-eval-source-map',
+ devServer: {
+ publicPath: '/dist/',
+ compress: true,
+ port: 9000
+ },
+ performance: { hints: false },
+ module: {
+ rules: [
+ {
+ // SCSS
+ test: /\.scss$/,
+ use: ExtractTextPlugin.extract({
+ fallback: 'style-loader',
+ use: [
+ {
+ loader: 'css-loader',
+ options: {
+ module: true,
+ minimize: process.env.WEBPACK_MODE === PRODUCTION,
+ // sourceMap: true,
+ localIdentName: '[local]'
+ }
+ },
+ {
+ loader: 'sass-loader'
+ }
+ ]
+ })
+ },
+ {
+ // ESLint
+ enforce: 'pre',
+ test: /\.js$/,
+ exclude: /node_modules/,
+ loader: 'eslint-loader',
+ options: { failOnError: true }
+ },
+ {
+ // ES6
+ test: /\.js$/,
+ loader: 'babel-loader',
+ exclude: '/node_modules/'
+ }
+ ]
+ },
+ plugins: [
+ new ExtractTextPlugin({
+ filename: 'sample.[name].css'
+ })
+ ]
+ };
+
+ return config;
+};
diff --git a/javascript/javascript-basic/.babelrc b/javascript/javascript-basic/.babelrc
new file mode 100644
index 00000000..a7352030
--- /dev/null
+++ b/javascript/javascript-basic/.babelrc
@@ -0,0 +1,8 @@
+{
+ "presets": ["env"],
+ "env": {
+ "test": {
+ "presets": ["env"]
+ }
+ }
+}
diff --git a/javascript/javascript-basic/.eslintrc.js b/javascript/javascript-basic/.eslintrc.js
new file mode 100644
index 00000000..0211a4b2
--- /dev/null
+++ b/javascript/javascript-basic/.eslintrc.js
@@ -0,0 +1,21 @@
+module.exports = {
+ env: {
+ browser: true,
+ commonjs: true,
+ es6: true
+ },
+ extends: 'eslint:recommended',
+ parserOptions: {
+ parser: 'babel-eslint',
+ sourceType: 'module'
+ },
+ rules: {
+ 'linebreak-style': ['error', 'unix'],
+ quotes: ['warn', 'single'],
+ semi: ['warn', 'always'],
+ 'no-console': 1,
+ 'no-unused-vars': 1,
+ 'no-inner-declarations': 1,
+ 'no-useless-escape': 1
+ }
+};
diff --git a/javascript/javascript-basic/.prettierignore b/javascript/javascript-basic/.prettierignore
new file mode 100644
index 00000000..52999c0b
--- /dev/null
+++ b/javascript/javascript-basic/.prettierignore
@@ -0,0 +1,2 @@
+README.md
+.eslintrc.js
\ No newline at end of file
diff --git a/javascript/javascript-basic/.prettierrc b/javascript/javascript-basic/.prettierrc
new file mode 100644
index 00000000..f65aabcb
--- /dev/null
+++ b/javascript/javascript-basic/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "printWidth": 120
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic/README.md b/javascript/javascript-basic/README.md
new file mode 100644
index 00000000..8b3a256f
--- /dev/null
+++ b/javascript/javascript-basic/README.md
@@ -0,0 +1,60 @@
+# SendBird JavaScript Web Basic Sample
+This is full screen chat sample like Slack using the [Sendbird SDK](https://github.com/sendbird/SendBird-SDK-JavaScript) for desktop browsers.
+
+1. [Demo](#demo)
+1. [Run the sample](#run-the-sample)
+1. [Customizing the sample](#customizing-the-sample)
+
+## [Demo](https://sample.sendbird.com/basic)
+You can try out a live demo from the link [here](https://sample.sendbird.com/basic).
+
+> If you want to legacy basic sample used jQuery, you can find the [Legacy tag](https://github.com/sendbird/SendBird-JavaScript/tree/Legacy(WebBasic)).
+
+
+## Run the sample
+1. Install packages
+
+> Require that you have Node v8.x+ installed.
+
+> `node-sass` package requires XCode developer tools (MacOS only) and Node.js version matching. If you have any trouble in the installation, see https://www.npmjs.com/package/node-sass.
+
+```bash
+npm install
+```
+
+2. Run
+
+```bash
+npm start
+```
+
+## Customizing the sample
+If you want to put some changes into the sample, you should build it using `webpack`.
+
+1. Install packages
+
+> Require that you have Node v8.x+ installed.
+
+> `node-sass` package requires XCode developer tools (MacOS only) and Node.js version matching. If you have any trouble in the installation, see https://www.npmjs.com/package/node-sass.
+
+```bash
+npm install
+```
+
+2. Modify files
+If you want to change `APP_ID`, change `APP_ID` in `./src/js/const.js` to the other `APP_ID` you want.
+You can test the sample with local server by running the following command.
+
+```bash
+npm run start:dev
+```
+
+3. Build the sample
+When the modification is complete, you'll need to bundle the file using `webpack`. The bundled files are created in the `dist` folder.
+Please check `webpack.config.js` for settings.
+
+```bash
+npm run build
+```
+
+> The `npm start` command contains `npm run build`. Check the scripts part of the package.json file.
diff --git a/javascript/javascript-basic/chat.html b/javascript/javascript-basic/chat.html
new file mode 100644
index 00000000..6afa2110
--- /dev/null
+++ b/javascript/javascript-basic/chat.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Sample | Sendbird
+
+
+
+
+
+
+
+
+
+
+ Sendbird
+
+
+
+
+
+
Start by selecting or creating a channel.
+
+
+
+
Start by inviting user to create a channel.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/javascript-basic/index.html b/javascript/javascript-basic/index.html
new file mode 100644
index 00000000..88dbc52c
--- /dev/null
+++ b/javascript/javascript-basic/index.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Sample | Sendbird
+
+
+
+
+
+
+
+
+
+
+ Sendbird
+
+
+ Web Basic Sample
+
+
+
+
+
+
+ Start chatting on Sendbird by choosing your display name.
+
This can be changed anytime and will be shown on 1-on-1 and group messaging.
+
+
+
+
+
+ LOGIN
+
+
+
+
+
+
+
+
+
+
+
diff --git a/javascript/javascript-basic/package.json b/javascript/javascript-basic/package.json
new file mode 100644
index 00000000..8b06a0e2
--- /dev/null
+++ b/javascript/javascript-basic/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "Sample-JS-Web-Basic",
+ "version": "1.0.0",
+ "description": "Sendbird Web Basic Sample",
+ "main": "index.js",
+ "scripts": {
+ "dev": "./node_modules/.bin/webpack --mode=development",
+ "dev:w": "./node_modules/.bin/webpack --mode=none -w",
+ "build": "./node_modules/.bin/webpack --mode=production",
+ "start:dev": "./node_modules/.bin/webpack-dev-server",
+ "start": "npm run build && node server.js",
+ "deploy": "node ./deploy/deploy.js"
+ },
+ "author": "SendBird",
+ "license": "ISC",
+ "devDependencies": {
+ "babel-core": "^6.26.0",
+ "babel-eslint": "^8.2.3",
+ "babel-loader": "^7.1.4",
+ "babel-polyfill": "^6.26.0",
+ "babel-preset-env": "^1.6.1",
+ "css-loader": "^0.28.11",
+ "eslint": "^4.19.1",
+ "eslint-loader": "^2.0.0",
+ "express": "^4.16.3",
+ "extract-text-webpack-plugin": "^4.0.0-beta.0",
+ "node-sass": "^4.11.0",
+ "prettier": "^1.15.3",
+ "sass-loader": "^7.1.0",
+ "ssh2": "^0.8.5",
+ "style-loader": "^0.21.0",
+ "webpack": "^4.6.0",
+ "webpack-cli": "^3.1.0",
+ "webpack-dev-server": "^3.1.11"
+ },
+ "dependencies": {
+ "moment": "^2.22.1",
+ "sendbird": "^3.0.111"
+ }
+}
diff --git a/javascript/javascript-basic/server.js b/javascript/javascript-basic/server.js
new file mode 100644
index 00000000..487ba47b
--- /dev/null
+++ b/javascript/javascript-basic/server.js
@@ -0,0 +1,14 @@
+const express = require('express');
+const app = express();
+
+const PORT = 9000;
+
+app.use(express.static('dist'));
+app.use(express.static('./'));
+
+app.get('/', function(req, res) {
+ res.sendfile('index.html');
+});
+
+app.listen(PORT);
+console.log(`[SERVER RUNNING] 127.0.0.1:${PORT}`);
diff --git a/javascript/javascript-basic/src/js/Chat.js b/javascript/javascript-basic/src/js/Chat.js
new file mode 100644
index 00000000..9748e009
--- /dev/null
+++ b/javascript/javascript-basic/src/js/Chat.js
@@ -0,0 +1,169 @@
+import styles from '../scss/chat.scss';
+import { createDivEl, errorAlert } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { Spinner } from './components/Spinner';
+import { ChatLeftMenu } from './ChatLeftMenu';
+import { ChatTopMenu } from './components/ChatTopMenu';
+import { ChatMain } from './components/ChatMain';
+import { SendBirdChatEvent } from './SendBirdChatEvent';
+
+const targetEl = document.querySelector('.body-center');
+
+let instance = null;
+
+class Chat {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.channel = null;
+ this.element = null;
+ this.top = null;
+ this.emptyElement = this._createEmptyElement();
+ this.render();
+ instance = this;
+ }
+
+ _createEmptyElement() {
+ const item = createDivEl({ className: styles['chat-empty'] });
+
+ const content = createDivEl({ className: styles['empty-content'] });
+ item.appendChild(content);
+
+ const title = createDivEl({ className: styles['content-title'], content: 'WELCOME TO SAMPLE CHAT' });
+ content.appendChild(title);
+ const image = createDivEl({ className: styles['content-image'] });
+ content.appendChild(image);
+ const desc = createDivEl({
+ className: styles['content-desc'],
+ content:
+ 'Create or select a channel to chat in.\n' +
+ "If you don't have a channel to participate,\n" +
+ 'go ahead and create your first channel now.'
+ });
+ content.appendChild(desc);
+ return item;
+ }
+
+ renderEmptyElement() {
+ this._removeChatElement();
+ targetEl.appendChild(this.emptyElement);
+ }
+
+ _removeEmptyElement() {
+ if (targetEl.contains(this.emptyElement)) {
+ targetEl.removeChild(this.emptyElement);
+ }
+ }
+
+ _createChatElement(channel) {
+ this.element = createDivEl({ className: styles['chat-root'] });
+
+ this.top = new ChatTopMenu(channel);
+ this.element.appendChild(this.top.element);
+
+ this.main = new ChatMain(channel);
+ }
+
+ _addEventHandler() {
+ const channelEvent = new SendBirdChatEvent();
+ channelEvent.onMessageReceived = (channel, message) => {
+ if (this.channel.url === channel.url) {
+ this.main.renderMessages([message], false);
+ }
+ };
+ channelEvent.onMessageUpdated = (channel, message) => {
+ if (this.channel.url === channel.url) {
+ this.main.renderMessages([message], false);
+ }
+ };
+ channelEvent.onMessageDeleted = (channel, messageId) => {
+ if (this.channel.url === channel.url) {
+ this.main.removeMessage(messageId, false);
+ }
+ };
+
+ if (this.channel.isGroupChannel()) {
+ channelEvent.onReadReceiptUpdated = groupChannel => {
+ if (this.channel.url === groupChannel.url) {
+ this.main.updateReadReceipt();
+ }
+ };
+ channelEvent.onTypingStatusUpdated = groupChannel => {
+ if (this.channel.url === groupChannel.url) {
+ this.main.updateTyping(groupChannel.getTypingMembers());
+ }
+ };
+ }
+ }
+
+ _renderChatElement(channelUrl, isOpenChannel = true) {
+ Spinner.start(targetEl);
+ const sendbirdAction = SendBirdAction.getInstance();
+ this._removeEmptyElement();
+ this._removeChatElement();
+ ChatLeftMenu.getInstance().activeChannelItem(channelUrl);
+ sendbirdAction
+ .getChannel(channelUrl, isOpenChannel)
+ .then(channel => {
+ this.channel = channel;
+ this._addEventHandler();
+ this._createChatElement(this.channel);
+ targetEl.appendChild(this.element);
+ sendbirdAction
+ .getMessageList(this.channel, true)
+ .then(messageList => {
+ this.main.renderMessages(messageList);
+ if (this.channel.isGroupChannel()) {
+ sendbirdAction.markAsRead(this.channel);
+ }
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ _removeChatElement() {
+ const chatElements = targetEl.getElementsByClassName(styles['chat-root']);
+ Array.prototype.slice.call(chatElements).forEach(chatEl => {
+ chatEl.parentNode.removeChild(chatEl);
+ });
+ }
+
+ updateChatInfo(channel) {
+ if (this.channel && this.channel.url === channel.url) {
+ if (this.top) {
+ this.top.updateTitle(channel);
+ }
+ if (this.main) {
+ this.main.updateMenu(channel);
+ }
+ }
+ }
+
+ render(channelUrl, isOpenChannel = true) {
+ channelUrl ? this._renderChatElement(channelUrl, isOpenChannel) : this.renderEmptyElement();
+ }
+
+ refresh(channel) {
+ this._removeEmptyElement();
+ this._removeChatElement();
+ this.renderEmptyElement();
+ const reconnectChannel = channel ? channel : this.channel;
+ if (reconnectChannel) {
+ this.render(reconnectChannel.url, reconnectChannel.isOpenChannel());
+ }
+ }
+
+ static getInstance() {
+ return new Chat();
+ }
+}
+
+export { Chat };
diff --git a/javascript/javascript-basic/src/js/ChatLeftMenu.js b/javascript/javascript-basic/src/js/ChatLeftMenu.js
new file mode 100644
index 00000000..20fb7e1f
--- /dev/null
+++ b/javascript/javascript-basic/src/js/ChatLeftMenu.js
@@ -0,0 +1,239 @@
+import { LeftListItem } from './components/LeftListItem';
+import { ACTIVE_CLASSNAME, body, DISPLAY_BLOCK, DISPLAY_NONE } from './const';
+import { addClass, appendToFirst, errorAlert, isScrollBottom, isUrl, protectFromXSS, removeClass } from './utils';
+import { Spinner } from './components/Spinner';
+import { OpenChannelList } from './components/OpenChannelList';
+import { SendBirdAction } from './SendBirdAction';
+import { UserList } from './components/UserList';
+import { Chat } from './Chat';
+import { OpenChannelCreateModal } from './components/OpenChannelCreateModal';
+
+const openChannelBtn = document.querySelector('#open_chat');
+const openChannelCreateBtn = document.querySelector('#open_chat_add');
+const groupChannelCreateBtn = document.querySelector('#group_chat_add');
+
+let instance = null;
+
+class ChatLeftMenu {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.openChannelList = document.getElementById('open_list');
+ this.openChannelDefaultItem = document.getElementById('default_item_open');
+ this.groupChannelList = document.getElementById('group_list');
+ this.groupChannelList.addEventListener('scroll', () => {
+ if (isScrollBottom(this.groupChannelList)) {
+ this.getGroupChannelList();
+ }
+ });
+ this.groupChannelDefaultItem = document.getElementById('default_item_group');
+ this._addEvent();
+ instance = this;
+ }
+
+ _addEvent() {
+ openChannelBtn.addEventListener('click', () => {
+ OpenChannelList.getInstance().render();
+ });
+
+ openChannelCreateBtn.addEventListener('click', () => {
+ const title = 'Create Open Channel';
+ const description =
+ 'Open Channel is a public chat. In this channel type, anyone can enter and participate in the chat without permission.';
+ const submitText = 'CREATE';
+ const openChannelCreateModal = new OpenChannelCreateModal({ title, description, submitText });
+ openChannelCreateModal.render();
+ });
+
+ groupChannelCreateBtn.addEventListener('click', () => {
+ UserList.getInstance().render();
+ });
+ }
+
+ updateUserInfo(user) {
+ const userInfoEl = document.getElementById('user_info');
+ const profileEl = userInfoEl.getElementsByClassName('image-profile')[0];
+ if (isUrl(user.profileUrl)) {
+ profileEl.setAttribute('src', protectFromXSS(user.profileUrl));
+ }
+ const nicknameEl = userInfoEl.getElementsByClassName('nickname-content')[0];
+ nicknameEl.innerHTML = protectFromXSS(user.nickname);
+ }
+
+ /**
+ * Item
+ */
+ getChannelItem(channelUrl) {
+ const openItems = this.openChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ const groupItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ const checkList = [...openItems, ...groupItems];
+ for (let i = 0; i < checkList.length; i++) {
+ if (checkList[i].id === channelUrl) {
+ return checkList[i];
+ }
+ }
+ return null;
+ }
+
+ activeChannelItem(channelUrl) {
+ const openItems = this.openChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ const groupItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ const checkList = [...openItems, ...groupItems];
+ for (let i = 0; i < checkList.length; i++) {
+ checkList[i].id === channelUrl
+ ? addClass(checkList[i], ACTIVE_CLASSNAME)
+ : removeClass(checkList[i], ACTIVE_CLASSNAME);
+ }
+ }
+
+ getItem(elementId) {
+ const openChannelItems = this.openChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i = 0; i < openChannelItems.length; i++) {
+ if (openChannelItems[i].id === elementId) {
+ return openChannelItems[i];
+ }
+ }
+
+ const groupChannelItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ for (let i = 0; i < groupChannelItems.length; i++) {
+ if (groupChannelItems[i].id === elementId) {
+ return groupChannelItems[i];
+ }
+ }
+
+ return null;
+ }
+
+ updateItem(channel, isFirst = false) {
+ const item = this.getItem(channel.url);
+ const handler = () => {
+ Chat.getInstance().render(channel.url, false);
+ ChatLeftMenu.getInstance().activeChannelItem(channel.url);
+ };
+ const newItem = new LeftListItem({ channel, handler });
+ const parentNode = this.groupChannelList;
+ if (isFirst) {
+ if (item) {
+ parentNode.removeChild(item);
+ }
+ appendToFirst(parentNode, newItem.element);
+ } else {
+ parentNode.replaceChild(newItem.element, item);
+ }
+ LeftListItem.updateUnreadCount();
+ }
+
+ /**
+ * Open Channel
+ */
+ toggleOpenChannelDefaultItem() {
+ this.openChannelList.getElementsByClassName(LeftListItem.getItemRootClassName()).length > 0
+ ? (this.openChannelDefaultItem.style.display = DISPLAY_NONE)
+ : (this.openChannelDefaultItem.style.display = DISPLAY_BLOCK);
+ }
+
+ addOpenChannelItem(element) {
+ if (!this.openChannelList.contains(element)) {
+ this.openChannelList.appendChild(element);
+ }
+ this.toggleOpenChannelDefaultItem();
+ }
+
+ removeOpenChannelItem(elementId) {
+ const removeEl = this.getItem(elementId);
+ if (removeEl) {
+ this.openChannelList.removeChild(removeEl);
+ }
+ this.toggleOpenChannelDefaultItem();
+ }
+
+ /**
+ * Group Channel
+ */
+ getGroupChannelList(isInit = false) {
+ Spinner.start(body);
+ SendBirdAction.getInstance()
+ .getGroupChannelList(isInit)
+ .then(groupChannelList => {
+ this.toggleGroupChannelDefaultItem(groupChannelList);
+ groupChannelList.forEach(channel => {
+ const handler = () => {
+ Chat.getInstance().render(channel.url, false);
+ ChatLeftMenu.getInstance().activeChannelItem(channel.url);
+ };
+ const item = new LeftListItem({ channel, handler });
+ this.groupChannelList.appendChild(item.element);
+ LeftListItem.updateUnreadCount();
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ toggleGroupChannelDefaultItem(items) {
+ if (items) {
+ this.groupChannelDefaultItem.style.display = items && items.length > 0 ? DISPLAY_NONE : DISPLAY_BLOCK;
+ } else {
+ this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName()).length > 0
+ ? (this.groupChannelDefaultItem.style.display = DISPLAY_NONE)
+ : (this.groupChannelDefaultItem.style.display = DISPLAY_BLOCK);
+ }
+ }
+
+ addGroupChannelItem(element, isFirst = false) {
+ const listItems = this.groupChannelList.childNodes;
+ let isExist = false;
+ for (let i = 0; i < listItems.length; i++) {
+ if (listItems[i].id === element.id) {
+ isExist = true;
+ }
+ }
+ if (isExist) {
+ SendBirdAction.getInstance()
+ .getChannel(element.id, false)
+ .then(channel => {
+ this.updateItem(channel);
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ } else {
+ if (isFirst) {
+ appendToFirst(this.groupChannelList, element);
+ } else {
+ this.groupChannelList.appendChild(element);
+ }
+ }
+ LeftListItem.updateUnreadCount();
+ this.toggleGroupChannelDefaultItem();
+ }
+
+ removeGroupChannelItem(elementId) {
+ const removeEl = this.getItem(elementId);
+ if (removeEl) {
+ this.groupChannelList.removeChild(removeEl);
+ }
+ this.toggleGroupChannelDefaultItem();
+ }
+
+ clear() {
+ const openItems = this.openChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ const groupItems = this.groupChannelList.getElementsByClassName(LeftListItem.getItemRootClassName());
+ const removeItems = [...openItems, ...groupItems];
+ for (let i = 0; i < removeItems.length; i++) {
+ removeItems[i].parentNode.removeChild(removeItems[i]);
+ }
+ this.toggleOpenChannelDefaultItem();
+ this.toggleGroupChannelDefaultItem();
+ }
+
+ static getInstance() {
+ return new ChatLeftMenu();
+ }
+}
+
+export { ChatLeftMenu };
diff --git a/javascript/javascript-basic/src/js/SendBirdAction.js b/javascript/javascript-basic/src/js/SendBirdAction.js
new file mode 100644
index 00000000..d57278f7
--- /dev/null
+++ b/javascript/javascript-basic/src/js/SendBirdAction.js
@@ -0,0 +1,392 @@
+import SendBird from 'sendbird';
+import {
+ APP_ID as appId
+} from './const';
+import {
+ isNull
+} from './utils';
+
+let instance = null;
+
+class SendBirdAction {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.sb = new SendBird({
+ appId
+ });
+ this.userQuery = null;
+ this.openChannelQuery = null;
+ this.groupChannelQuery = null;
+ this.previousMessageQuery = null;
+ this.participantQuery = null;
+ this.blockedQuery = null;
+ instance = this;
+ }
+
+ /**
+ * Connect
+ */
+ connect(userId, nickname) {
+ return new Promise((resolve, reject) => {
+ const sb = SendBird.getInstance();
+ sb.connect(
+ userId,
+ (user, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ sb.updateCurrentUserInfo(decodeURIComponent(nickname), null, (user, error) => {
+ error ? reject(error) : resolve(user);
+ });
+ }
+ }
+ );
+ });
+ }
+
+ disconnect() {
+ return new Promise((resolve, reject) => {
+ this.sb.disconnect((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ });
+ }
+
+ /**
+ * User
+ */
+ getCurrentUser() {
+ return this.sb.currentUser;
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow retrieving user list from SDK" under ⚙️ Sendbird Dashboard ->Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow retrieving user list from SDK" as that could possibly expose user information
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ getUserList(isInit = false) {
+ if (isInit || isNull(this.userQuery)) {
+ this.userQuery = this.sb.createApplicationUserListQuery();
+ this.userQuery.limit = 30;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.userQuery.hasNext && !this.userQuery.isLoading) {
+ this.userQuery.next((list, error) => {
+ error ? reject(error) : resolve(list);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ isCurrentUser(user) {
+ return user.userId === this.sb.currentUser.userId;
+ }
+
+ getBlockedList(isInit = false) {
+ if (isInit || isNull(this.blockedQuery)) {
+ this.blockedQuery = this.sb.createBlockedUserListQuery();
+ this.blockedQuery.limit = 30;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.blockedQuery.hasNext && !this.blockedQuery.isLoading) {
+ this.blockedQuery.next((blockedList, error) => {
+ error ? reject(error) : resolve(blockedList);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ blockUser(user, isBlock = true) {
+ return new Promise((resolve, reject) => {
+ if (isBlock) {
+ this.sb.blockUser(user, (response, error) => {
+ error ? reject(error) : resolve();
+ });
+ } else {
+ this.sb.unblockUser(user, (response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ }
+
+ /**
+ * Channel
+ */
+ getChannel(channelUrl, isOpenChannel = true) {
+ return new Promise((resolve, reject) => {
+ if (isOpenChannel) {
+ this.sb.OpenChannel.getChannel(channelUrl, (openChannel, error) => {
+ error ? reject(error) : resolve(openChannel);
+ });
+ } else {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ }
+ });
+ }
+
+ /**
+ * Open Channel
+ */
+ getOpenChannelList(isInit = false, urlKeyword = '') {
+ if (isInit || isNull(this.openChannelQuery)) {
+ this.openChannelQuery = this.sb.OpenChannel.createOpenChannelListQuery();
+ this.openChannelQuery.limit = 20;
+ this.openChannelQuery.urlKeyword = urlKeyword;
+ }
+ return new Promise((resolve, reject) => {
+ if (this.openChannelQuery.hasNext && !this.openChannelQuery.isLoading) {
+ this.openChannelQuery.next((list, error) => {
+ error ? reject(error) : resolve(list);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow creating open channels from SDK" under ⚙️ Sendbird Dashboard -> Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow creating open channels from SDK" as that could cause unwanted operations
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ createOpenChannel(channelName) {
+ return new Promise((resolve, reject) => {
+ channelName
+ ?
+ this.sb.OpenChannel.createChannel(channelName, null, null, (openChannel, error) => {
+ error ? reject(error) : resolve(openChannel);
+ }) :
+ this.sb.OpenChannel.createChannel((openChannel, error) => {
+ error ? reject(error) : resolve(openChannel);
+ });
+ });
+ }
+
+ enter(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.OpenChannel.getChannel(channelUrl, (openChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ openChannel.enter((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ exit(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.OpenChannel.getChannel(channelUrl, (openChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ openChannel.exit((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ getParticipantList(channelUrl, isInit = false) {
+ return new Promise((resolve, reject) => {
+ this.sb.OpenChannel.getChannel(channelUrl, (openChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ if (isInit || isNull(this.participantQuery)) {
+ this.participantQuery = openChannel.createParticipantListQuery();
+ this.participantQuery.limit = 30;
+ }
+ if (this.participantQuery.hasNext && !this.participantQuery.isLoading) {
+ this.participantQuery.next((participantList, error) => {
+ error ? reject(error) : resolve(participantList);
+ });
+ } else {
+ resolve([]);
+ }
+ }
+ });
+ });
+ }
+
+ /**
+ * Group Channel
+ */
+ getGroupChannelList(isInit = false) {
+ if (isInit || isNull(this.groupChannelQuery)) {
+ this.groupChannelQuery = this.sb.GroupChannel.createMyGroupChannelListQuery();
+ this.groupChannelQuery.limit = 50;
+ this.groupChannelQuery.includeEmpty = false;
+ this.groupChannelQuery.order = 'latest_last_message';
+ }
+ return new Promise((resolve, reject) => {
+ if (this.groupChannelQuery.hasNext && !this.groupChannelQuery.isLoading) {
+ this.groupChannelQuery.next((list, error) => {
+ error ? reject(error) : resolve(list);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ /**
+ *
+ * #################### SECURITY TIPS ####################
+ * Before launching, you should review "Allow creating group channels from SDK" under ⚙️ Sendbird Dashboard -> Settings -> Security.
+ * It's turned on at first to simplify running samples and implementing your first code.
+ * Most apps will want to disable "Allow creating group channels from SDK" as that could cause unwanted operations.
+ * #################### SECURITY TIPS ####################
+ *
+ */
+ createGroupChannel(userIds) {
+ return new Promise((resolve, reject) => {
+ let params = new this.sb.GroupChannelParams();
+ params.addUserIds(userIds);
+ this.sb.GroupChannel.createChannel(params, (groupChannel, error) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ });
+ }
+
+ inviteGroupChannel(channelUrl, userIds) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.inviteWithUserIds(userIds, (groupChannel, error) => {
+ error ? reject(error) : resolve(groupChannel);
+ });
+ }
+ });
+ });
+ }
+
+ leave(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.leave((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ hide(channelUrl) {
+ return new Promise((resolve, reject) => {
+ this.sb.GroupChannel.getChannel(channelUrl, (groupChannel, error) => {
+ if (error) {
+ reject(error);
+ } else {
+ groupChannel.hide((response, error) => {
+ error ? reject(error) : resolve();
+ });
+ }
+ });
+ });
+ }
+
+ markAsRead(channel) {
+ channel.markAsRead();
+ }
+
+ /**
+ * Message
+ */
+ getMessageList(channel, isInit = false) {
+ if (isInit || isNull(this.previousMessageQuery)) {
+ this.previousMessageQuery = channel.createPreviousMessageListQuery();
+ }
+ return new Promise((resolve, reject) => {
+ if (this.previousMessageQuery.hasMore && !this.previousMessageQuery.isLoading) {
+ this.previousMessageQuery.load(50, false, (messageList, error) => {
+ error ? reject(error) : resolve(messageList);
+ });
+ } else {
+ resolve([]);
+ }
+ });
+ }
+
+ getReadReceipt(channel, message) {
+ if (this.isCurrentUser(message.sender)) {
+ return channel.getReadReceipt(message);
+ } else {
+ return 0;
+ }
+ }
+
+ sendUserMessage({
+ channel,
+ message,
+ handler
+ }) {
+ return channel.sendUserMessage(message, (message, error) => {
+ if (handler) handler(message, error);
+ });
+ }
+
+ sendFileMessage({
+ channel,
+ file,
+ thumbnailSizes,
+ handler
+ }) {
+ const fileMessageParams = new this.sb.FileMessageParams();
+ fileMessageParams.file = file;
+ fileMessageParams.thumbnailSizes = thumbnailSizes;
+
+ return channel.sendFileMessage(fileMessageParams, (message, error) => {
+ if (handler) handler(message, error);
+ });
+ }
+
+ deleteMessage({
+ channel,
+ message
+ }) {
+ return new Promise((resolve, reject) => {
+ if (!this.isCurrentUser(message.sender)) {
+ reject({
+ message: 'You have not ownership in this message.'
+ });
+ }
+ channel.deleteMessage(message, (response, error) => {
+ error ? reject(error) : resolve(response);
+ });
+ });
+ }
+
+ static getInstance() {
+ return new SendBirdAction();
+ }
+}
+
+export {
+ SendBirdAction
+};
diff --git a/javascript/javascript-basic/src/js/SendBirdChatEvent.js b/javascript/javascript-basic/src/js/SendBirdChatEvent.js
new file mode 100644
index 00000000..16e2fb95
--- /dev/null
+++ b/javascript/javascript-basic/src/js/SendBirdChatEvent.js
@@ -0,0 +1,68 @@
+import SendBird from 'sendbird';
+import { uuid4 } from './utils';
+
+let instance = null;
+
+class SendBirdChatEvent {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this._createChannelHandler();
+
+ this.onMessageReceived = null;
+ this.onMessageUpdated = null;
+ this.onMessageDeleted = null;
+
+ this.onReadReceiptUpdated = null;
+ this.onTypingStatusUpdated = null;
+ instance = this;
+ }
+
+ /**
+ * Channel Handler
+ */
+ _createChannelHandler() {
+ const handler = new this.sb.ChannelHandler();
+ handler.onMessageReceived = (channel, message) => {
+ if (this.onMessageReceived) {
+ this.onMessageReceived(channel, message);
+ }
+ };
+ handler.onMessageUpdated = (channel, message) => {
+ if (this.onMessageUpdated) {
+ this.onMessageUpdated(channel, message);
+ }
+ };
+ handler.onMessageDeleted = (channel, messageId) => {
+ if (this.onMessageDeleted) {
+ this.onMessageDeleted(channel, messageId);
+ }
+ };
+
+ handler.onReadReceiptUpdated = groupChannel => {
+ if (this.onReadReceiptUpdated) {
+ this.onReadReceiptUpdated(groupChannel);
+ }
+ };
+ handler.onTypingStatusUpdated = groupChannel => {
+ if (this.onTypingStatusUpdated) {
+ this.onTypingStatusUpdated(groupChannel);
+ }
+ };
+ this.sb.addChannelHandler(this.key, handler);
+ }
+
+ remove() {
+ this.sb.removeChannelHandler(this.key);
+ }
+
+ static getInstance() {
+ return instance;
+ }
+}
+
+export { SendBirdChatEvent };
diff --git a/javascript/javascript-basic/src/js/SendBirdConnection.js b/javascript/javascript-basic/src/js/SendBirdConnection.js
new file mode 100644
index 00000000..c335f638
--- /dev/null
+++ b/javascript/javascript-basic/src/js/SendBirdConnection.js
@@ -0,0 +1,53 @@
+import SendBird from 'sendbird';
+import { uuid4 } from './utils';
+
+let instance = null;
+
+class SendBirdConnection {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this.channel = null;
+ this._createConnectionHandler(this.key);
+
+ this.onReconnectStarted = null;
+ this.onReconnectSucceeded = null;
+ this.onReconnectFailed = null;
+
+ instance = this;
+ }
+
+ _createConnectionHandler(key) {
+ const handler = new this.sb.ConnectionHandler();
+ handler.onReconnectStarted = () => {
+ if (this.onReconnectStarted) {
+ this.onReconnectStarted();
+ }
+ };
+ handler.onReconnectSucceeded = () => {
+ if (this.onReconnectSucceeded) {
+ this.onReconnectSucceeded();
+ }
+ };
+ handler.onReconnectFailed = () => {
+ if (this.onReconnectFailed) {
+ this.onReconnectFailed();
+ }
+ };
+ this.sb.addConnectionHandler(key, handler);
+ }
+
+ remove() {
+ this.sb.removeConnectionHandler(this.key);
+ }
+
+ static getInstance() {
+ return new SendBirdConnection();
+ }
+}
+
+export { SendBirdConnection };
diff --git a/javascript/javascript-basic/src/js/SendBirdEvent.js b/javascript/javascript-basic/src/js/SendBirdEvent.js
new file mode 100644
index 00000000..19996ea7
--- /dev/null
+++ b/javascript/javascript-basic/src/js/SendBirdEvent.js
@@ -0,0 +1,52 @@
+import SendBird from 'sendbird';
+import { uuid4 } from './utils';
+
+class SendBirdEvent {
+ constructor() {
+ this.sb = SendBird.getInstance();
+ this.key = uuid4();
+ this._createChannelHandler();
+
+ this.onChannelChanged = null;
+ this.onUserJoined = null;
+ this.onUserLeft = null;
+ this.onChannelHidden = null;
+ this.onUserEntered = null;
+ }
+
+ _createChannelHandler() {
+ const handler = new this.sb.ChannelHandler();
+ handler.onChannelChanged = channel => {
+ if (this.onChannelChanged) {
+ this.onChannelChanged(channel);
+ }
+ };
+ handler.onUserJoined = (groupChannel, user) => {
+ if (this.onUserJoined) {
+ this.onUserJoined(groupChannel, user);
+ }
+ };
+ handler.onUserLeft = (groupChannel, user) => {
+ if (this.onUserLeft) {
+ this.onUserLeft(groupChannel, user);
+ }
+ };
+ handler.onChannelHidden = groupChannel => {
+ if (this.onChannelHidden) {
+ this.onChannelHidden(groupChannel);
+ }
+ };
+ handler.onUserEntered = (openChannel, user) => {
+ if (this.onUserEntered) {
+ this.onUserEntered(openChannel, user);
+ }
+ };
+ this.sb.addChannelHandler(this.key, handler);
+ }
+
+ remove() {
+ this.sb.removeChannelHandler(this.key);
+ }
+}
+
+export { SendBirdEvent };
diff --git a/javascript/javascript-basic/src/js/components/ChatBody.js b/javascript/javascript-basic/src/js/components/ChatBody.js
new file mode 100644
index 00000000..1089d043
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/ChatBody.js
@@ -0,0 +1,143 @@
+import styles from '../../scss/chat-body.scss';
+import { appendToFirst, createDivEl, errorAlert, getDataInElement, isScrollBottom, removeClass } from '../utils';
+import { Message } from './Message';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { MESSAGE_REQ_ID } from '../const';
+
+class ChatBody {
+ constructor(channel) {
+ this.channel = channel;
+ this.readReceiptManageList = [];
+ this.scrollHeight = 0;
+ this.element = this._createElement();
+ }
+
+ _createElement() {
+ const root = createDivEl({ className: styles['chat-body'] });
+ root.addEventListener('scroll', () => {
+ if (root.scrollTop === 0) {
+ Spinner.start(root);
+ this.updateCurrentScrollHeight();
+ SendBirdAction.getInstance()
+ .getMessageList(this.channel)
+ .then(messageList => {
+ messageList.reverse();
+ this.renderMessages(messageList, false, true);
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+ });
+ return root;
+ }
+
+ scrollToBottom() {
+ this.element.scrollTop = this.element.scrollHeight - this.element.offsetHeight;
+ }
+
+ updateCurrentScrollHeight() {
+ this.scrollHeight = this.element.scrollHeight;
+ }
+
+ repositionScroll(imageOffsetHeight) {
+ this.element.scrollTop += imageOffsetHeight;
+ }
+
+ updateReadReceipt() {
+ this.readReceiptManageList.forEach(message => {
+ if (message.messageId.toString() !== '0') {
+ const className = Message.getReadReceiptElementClassName();
+ const messageItem = this._getItem(message.messageId, false);
+ if (messageItem) {
+ let readItem = null;
+ try {
+ readItem = messageItem.getElementsByClassName(className)[0];
+ } catch (e) {
+ readItem = null;
+ }
+ const latestCount = SendBirdAction.getInstance().getReadReceipt(this.channel, message);
+ if (readItem && latestCount.toString() !== readItem.textContent.toString()) {
+ readItem.innerHTML = latestCount;
+ if (latestCount.toString() === '0') {
+ removeClass(readItem, className);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ readReceiptManage(message) {
+ for (let i = 0; i < this.readReceiptManageList.length; i++) {
+ if (message.reqId) {
+ if (this.readReceiptManageList[i].reqId === message.reqId) {
+ this.readReceiptManageList.splice(i, 1);
+ break;
+ }
+ } else {
+ if (this.readReceiptManageList[i].messageId === message.messageId) {
+ this.readReceiptManageList.splice(i, 1);
+ break;
+ }
+ }
+ }
+ this.readReceiptManageList.push(message);
+ this.updateReadReceipt();
+ }
+
+ _getItem(messageId, isRequestId = false) {
+ const items = this.element.childNodes;
+ for (let i = 0; i < items.length; i++) {
+ const elementId = isRequestId ? getDataInElement(items[i], MESSAGE_REQ_ID) : items[i].id;
+ if (elementId === messageId.toString()) {
+ return items[i];
+ }
+ }
+ return null;
+ }
+
+ renderMessages(messageList, goToBottom = true, isPastMessage = false) {
+ messageList.forEach(message => {
+ const messageItem = new Message({ channel: this.channel, message });
+ const requestId = getDataInElement(messageItem.element, MESSAGE_REQ_ID)
+ ? getDataInElement(messageItem.element, MESSAGE_REQ_ID)
+ : '-1';
+ const requestItem = this._getItem(requestId, true);
+ const existItem = this._getItem(messageItem.element.id, false);
+ if (requestItem || existItem) {
+ this.element.replaceChild(messageItem.element, requestItem ? requestItem : existItem);
+ } else {
+ if (isPastMessage) {
+ appendToFirst(this.element, messageItem.element);
+ this.element.scrollTop = this.element.scrollHeight - this.scrollHeight;
+ } else {
+ const isBottom = isScrollBottom(this.element);
+ this.element.appendChild(messageItem.element);
+ if (isBottom) {
+ this.scrollToBottom();
+ }
+ }
+ }
+ if (
+ (message.isUserMessage() || message.isFileMessage()) &&
+ SendBirdAction.getInstance().isCurrentUser(message.sender) &&
+ this.channel.isGroupChannel()
+ ) {
+ this.readReceiptManage(message);
+ }
+ });
+ if (goToBottom) this.scrollToBottom();
+ }
+
+ removeMessage(messageId, isRequestId = false) {
+ const removeElement = this._getItem(messageId, isRequestId);
+ if (removeElement) {
+ this.element.removeChild(removeElement);
+ }
+ }
+}
+
+export { ChatBody };
diff --git a/javascript/javascript-basic/src/js/components/ChatInput.js b/javascript/javascript-basic/src/js/components/ChatInput.js
new file mode 100644
index 00000000..caa2d065
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/ChatInput.js
@@ -0,0 +1,127 @@
+import styles from '../../scss/chat-input.scss';
+import { createDivEl, protectFromXSS } from '../utils';
+import { DISPLAY_BLOCK, DISPLAY_NONE, FILE_ID, KEY_ENTER } from '../const';
+import { SendBirdAction } from '../SendBirdAction';
+import { Chat } from '../Chat';
+
+class ChatInput {
+ constructor(channel) {
+ this.channel = channel;
+ this.input = null;
+ this.typing = null;
+ this.element = this._createElement(channel);
+ }
+
+ _createElement(channel) {
+ const root = createDivEl({ className: styles['chat-input'] });
+
+ this.typing = createDivEl({ className: styles['typing-field'] });
+ root.appendChild(this.typing);
+
+ const file = document.createElement('label');
+ file.className = styles['input-file'];
+ file.for = FILE_ID;
+ file.addEventListener('click', () => {
+ if (this.channel.isGroupChannel()) {
+ SendBirdAction.getInstance().markAsRead(this.channel);
+ }
+ });
+
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.id = FILE_ID;
+ fileInput.style.display = DISPLAY_NONE;
+ fileInput.addEventListener('change', () => {
+ const sendFile = fileInput.files[0];
+ if (sendFile) {
+ const tempMessage = SendBirdAction.getInstance().sendFileMessage({
+ channel: this.channel,
+ file: sendFile,
+ handler: (message, error) => {
+ error
+ ? Chat.getInstance().main.removeMessage(tempMessage.reqId, true)
+ : Chat.getInstance().main.renderMessages([message]);
+ }
+ });
+ Chat.getInstance().main.renderMessages([tempMessage]);
+ }
+ });
+
+ file.appendChild(fileInput);
+ root.appendChild(file);
+
+ const inputText = createDivEl({ className: styles['input-text'] });
+
+ this.input = document.createElement('textarea');
+ this.input.className = styles['input-text-area'];
+ this.input.placeholder = 'Write a chat...';
+ this.input.addEventListener('click', () => {
+ if (this.channel.isGroupChannel()) {
+ SendBirdAction.getInstance().markAsRead(this.channel);
+ }
+ });
+ this.input.addEventListener('keypress', e => {
+ if (e.keyCode === KEY_ENTER) {
+ if (!e.shiftKey) {
+ e.preventDefault();
+ const message = this.input.value;
+ this.input.value = '';
+ if (message) {
+ const tempMessage = SendBirdAction.getInstance().sendUserMessage({
+ channel: this.channel,
+ message,
+ handler: (message, error) => {
+ error
+ ? Chat.getInstance().main.removeMessage(tempMessage.reqId, true)
+ : Chat.getInstance().main.renderMessages([message]);
+ }
+ });
+ Chat.getInstance().main.renderMessages([tempMessage]);
+ if (channel.isGroupChannel()) {
+ channel.endTyping();
+ }
+ }
+ } else {
+ if (channel.isGroupChannel()) {
+ channel.startTyping();
+ }
+ }
+ } else {
+ if (channel.isGroupChannel()) {
+ channel.startTyping();
+ }
+ }
+ });
+ this.input.addEventListener('focusin', () => {
+ this.channel._autoMarkAsRead = true;
+ inputText.style.border = '1px solid #2C2D30';
+ });
+ this.input.addEventListener('focusout', () => {
+ this.channel._autoMarkAsRead = false;
+ inputText.style.border = '';
+ });
+
+ inputText.appendChild(this.input);
+ root.appendChild(inputText);
+ return root;
+ }
+
+ updateTyping(memberList) {
+ let nicknames = '';
+ if (memberList.length === 1) {
+ nicknames = `${protectFromXSS(memberList[0].nickname)} is`;
+ } else if (memberList.length === 2) {
+ nicknames = `${memberList
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ')} are`;
+ } else if (memberList.length !== 0) {
+ nicknames = 'Several are';
+ }
+ this.typing.style.display = nicknames ? DISPLAY_BLOCK : DISPLAY_NONE;
+ this.typing.innerHTML = `${nicknames} typing...`;
+ }
+}
+
+export { ChatInput };
diff --git a/javascript/javascript-basic/src/js/components/ChatMain.js b/javascript/javascript-basic/src/js/components/ChatMain.js
new file mode 100644
index 00000000..d06d0082
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/ChatMain.js
@@ -0,0 +1,64 @@
+import styles from '../../scss/chat-main.scss';
+import { ChatBody } from './ChatBody';
+import { ChatInput } from './ChatInput';
+import { Chat } from '../Chat';
+import { createDivEl } from '../utils';
+import { ChatMenu } from './ChatMenu';
+
+class ChatMain {
+ constructor(channel) {
+ this.channel = channel;
+ this.body = null;
+ this.input = null;
+ this.menu = null;
+ this._create();
+ }
+
+ _create() {
+ const root = createDivEl({ className: styles['chat-main-root'] });
+
+ const main = createDivEl({ className: styles['chat-main'] });
+ root.appendChild(main);
+
+ this.body = new ChatBody(this.channel);
+ main.appendChild(this.body.element);
+
+ this.input = new ChatInput(this.channel);
+ main.appendChild(this.input.element);
+
+ this.menu = new ChatMenu(this.channel);
+ root.appendChild(this.menu.element);
+
+ Chat.getInstance().element.appendChild(root);
+ }
+
+ renderMessages(messageList, goToBottom = true, isPastMessage = false) {
+ this.body.renderMessages(messageList, goToBottom, isPastMessage);
+ }
+
+ removeMessage(messageId, isRequestId = false) {
+ this.body.removeMessage(messageId, isRequestId);
+ }
+
+ updateReadReceipt() {
+ this.body.updateReadReceipt();
+ }
+
+ updateTyping(memberList) {
+ this.input.updateTyping(memberList);
+ }
+
+ repositionScroll(height) {
+ this.body.repositionScroll(height);
+ }
+
+ updateBlockedList(user, isBlock) {
+ this.menu.updateBlockedList(user, isBlock);
+ }
+
+ updateMenu(channel) {
+ this.menu.updateMenu(channel);
+ }
+}
+
+export { ChatMain };
diff --git a/javascript/javascript-basic/src/js/components/ChatMenu.js b/javascript/javascript-basic/src/js/components/ChatMenu.js
new file mode 100644
index 00000000..a1528be6
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/ChatMenu.js
@@ -0,0 +1,185 @@
+import styles from '../../scss/chat-menu.scss';
+import { appendToFirst, createDivEl, errorAlert, isScrollBottom } from '../utils';
+import { DISPLAY_FLEX, DISPLAY_NONE } from '../const';
+import { Spinner } from './Spinner';
+import { ChatUserItem } from './ChatUserItem';
+import { SendBirdAction } from '../SendBirdAction';
+
+const Type = {
+ PARTICIPANTS: 'PARTICIPANTS',
+ MEMBERS: 'MEMBERS',
+ BLOCKED: 'BLOCKED'
+};
+
+class ChatMenu {
+ constructor(channel) {
+ this.channel = channel;
+ this.element = createDivEl({ className: styles['chat-menu-root'] });
+ this.listElement = null;
+ this.type = null;
+ this._createListElement();
+ this._createElement();
+ }
+
+ _createListElement() {
+ this.listElement = createDivEl({ className: styles['menu-list'] });
+
+ const title = createDivEl({ className: styles['list-title'] });
+ title.addEventListener('click', () => {
+ this.type = null;
+ this.list.innerHTML = '';
+ this.listElement.style.display = DISPLAY_NONE;
+ });
+ this.listElement.appendChild(title);
+ const backBtn = createDivEl({ className: styles['list-back'] });
+ title.appendChild(backBtn);
+ this.titleText = createDivEl({ className: styles['list-text'] });
+ title.appendChild(this.titleText);
+
+ this.list = createDivEl({ className: styles['list-body'] });
+ this.list.addEventListener('scroll', () => {
+ if (this.type === Type.PARTICIPANTS) {
+ if (isScrollBottom(this.list)) {
+ this._getParticipantList(this.type);
+ }
+ } else if (this.type === Type.BLOCKED) {
+ this._getBlockedList(this.type);
+ }
+ });
+ this.listElement.appendChild(this.list);
+
+ this.element.appendChild(this.listElement);
+ }
+
+ _createElement() {
+ const usersItem = createDivEl({ className: styles['menu-item'] });
+ const users = createDivEl({
+ className: styles['menu-users'],
+ content: this.channel.isOpenChannel() ? Type.PARTICIPANTS : Type.MEMBERS
+ });
+ usersItem.appendChild(users);
+ const arrowUser = createDivEl({ className: styles['menu-arrow'] });
+ usersItem.appendChild(arrowUser);
+ usersItem.addEventListener('click', () => {
+ this._renderList(users.textContent);
+ });
+ this.element.appendChild(usersItem);
+
+ const blockedItem = createDivEl({ className: styles['menu-item'] });
+ const blocked = createDivEl({ className: styles['menu-blocked'], content: Type.BLOCKED });
+ blockedItem.appendChild(blocked);
+ const arrowBlocked = createDivEl({ className: styles['menu-arrow'] });
+ blockedItem.appendChild(arrowBlocked);
+ blockedItem.addEventListener('click', () => {
+ this._renderList(blocked.textContent);
+ });
+ this.element.appendChild(blockedItem);
+ }
+
+ _renderList(listTitle) {
+ switch (listTitle) {
+ case Type.PARTICIPANTS:
+ this.type = Type.PARTICIPANTS;
+ this._getParticipantList(listTitle, true);
+ break;
+ case Type.MEMBERS:
+ this.type = Type.MEMBERS;
+ this._getMemberList(listTitle);
+ break;
+ case Type.BLOCKED:
+ this.type = Type.BLOCKED;
+ this._getBlockedList(listTitle, true);
+ break;
+ default:
+ this.titleText.innerHTML = '';
+ break;
+ }
+ }
+
+ _getParticipantList(listTitle, isInit = false) {
+ if (this.channel.isOpenChannel()) {
+ Spinner.start(this.listElement);
+ if (isInit) {
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ }
+ SendBirdAction.getInstance()
+ .getParticipantList(this.channel.url, isInit)
+ .then(participantList => {
+ participantList.forEach(user => {
+ const participantItem = new ChatUserItem({ user, hasEvent: false });
+ this.list.appendChild(participantItem.element);
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+ }
+
+ _getMemberList(listTitle) {
+ if (this.channel.isGroupChannel()) {
+ Spinner.start(this.listElement);
+ this.list.innerHTML = '';
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ this.channel.members.forEach(user => {
+ const memberItem = new ChatUserItem({ user, hasEvent: false });
+ this.list.appendChild(memberItem.element);
+ });
+ Spinner.remove();
+ }
+ }
+
+ _getBlockedList(listTitle, isInit = false) {
+ Spinner.start(this.listElement);
+ if (isInit) {
+ this.list.innerHTML = '';
+ this.titleText.innerHTML = listTitle;
+ this.listElement.style.display = DISPLAY_FLEX;
+ }
+ SendBirdAction.getInstance()
+ .getBlockedList(isInit)
+ .then(blockedList => {
+ blockedList.forEach(user => {
+ const blockedItem = new ChatUserItem({ user, hasEvent: true });
+ this.list.appendChild(blockedItem.element);
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ updateBlockedList(user, isBlock) {
+ if (this.list) {
+ if (isBlock) {
+ const blockedItem = new ChatUserItem({ user, hasEvent: true });
+ appendToFirst(this.list, blockedItem.element);
+ } else {
+ const items = this.list.childNodes;
+ for (let i = 0; i < items.length; i++) {
+ if (items[0].id === user.userId) {
+ this.list.removeChild(items[0]);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ updateMenu(channel) {
+ if (this.type === Type.MEMBERS) {
+ this.channel = channel;
+ this._getMemberList(this.type);
+ }
+ }
+
+ static get Type() {
+ return Type;
+ }
+}
+
+export { ChatMenu };
diff --git a/javascript/javascript-basic/src/js/components/ChatTopMenu.js b/javascript/javascript-basic/src/js/components/ChatTopMenu.js
new file mode 100644
index 00000000..0f692d3f
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/ChatTopMenu.js
@@ -0,0 +1,94 @@
+import styles from '../../scss/chat-top-menu.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { Chat } from '../Chat';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+import { UserList } from './UserList';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatTopMenu {
+ constructor(channel) {
+ this.channel = channel;
+ this.element = this._createElement(channel);
+ }
+
+ get chatTitle() {
+ const isOpenChannel = this.channel.isOpenChannel();
+ if (isOpenChannel) {
+ return `# ${this.channel.name}`;
+ } else {
+ return this.channel.members
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ');
+ }
+ }
+
+ _createElement(channel) {
+ const isOpenChannel = channel.isOpenChannel();
+
+ const root = createDivEl({ className: styles['chat-top'] });
+
+ this.title = createDivEl({
+ className: isOpenChannel ? styles['chat-title'] : [styles['chat-title'], styles['is-group']],
+ content: this.chatTitle
+ });
+ root.appendChild(this.title);
+
+ const button = createDivEl({ className: styles['chat-button'] });
+ if (!isOpenChannel) {
+ const invite = createDivEl({ className: styles['button-invite'] });
+ invite.addEventListener('click', () => {
+ UserList.getInstance().render(true);
+ });
+ button.appendChild(invite);
+ const hide = createDivEl({ className: styles['button-hide'] });
+ hide.addEventListener('click', () => {
+ SendBirdAction.getInstance()
+ .hide(channel.url)
+ .then(() => {
+ ChatLeftMenu.getInstance().removeGroupChannelItem(this.channel.url);
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ });
+ button.appendChild(hide);
+ }
+ const leave = createDivEl({ className: styles['button-leave'] });
+ leave.addEventListener('click', () => {
+ if (isOpenChannel) {
+ ChatLeftMenu.getInstance().removeOpenChannelItem(this.channel.url);
+ SendBirdAction.getInstance()
+ .exit(channel.url)
+ .then(() => {
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ } else {
+ SendBirdAction.getInstance()
+ .leave(channel.url)
+ .then(() => {
+ ChatLeftMenu.getInstance().removeGroupChannelItem(this.channel.url);
+ Chat.getInstance().renderEmptyElement();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+ });
+ button.appendChild(leave);
+ root.appendChild(button);
+ return root;
+ }
+
+ updateTitle(channel) {
+ this.channel = channel;
+ this.title.innerHTML = this.chatTitle;
+ }
+}
+
+export { ChatTopMenu };
diff --git a/javascript/javascript-basic/src/js/components/ChatUserItem.js b/javascript/javascript-basic/src/js/components/ChatUserItem.js
new file mode 100644
index 00000000..fcc51d66
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/ChatUserItem.js
@@ -0,0 +1,49 @@
+import styles from '../../scss/chat-user-item.scss';
+import { createDivEl, protectFromXSS } from '../utils';
+import { COLOR_RED } from '../const';
+import { UserBlockModal } from './UserBlockModal';
+import { SendBirdAction } from '../SendBirdAction';
+
+class ChatUserItem {
+ constructor({ user, hasEvent }) {
+ this.user = user;
+ this.hasEvent = hasEvent;
+ this.element = null;
+ this._create();
+ }
+
+ _create() {
+ this.element = createDivEl({ className: styles['chat-user-item'], id: this.user.userId });
+ if (this.hasEvent) {
+ this.element.addEventListener('mouseover', () => {
+ this._hoverOnUser(this.user.nickname, true);
+ });
+ this.element.addEventListener('mouseleave', () => {
+ this._hoverOnUser(this.user.nickname, false);
+ });
+ this.element.addEventListener('click', () => {
+ const userBlockModal = new UserBlockModal({ user: this.user, isBlock: false });
+ userBlockModal.render();
+ });
+ }
+
+ const image = createDivEl({ className: styles['user-image'], background: protectFromXSS(this.user.profileUrl) });
+ this.element.appendChild(image);
+
+ this.nickname = createDivEl({
+ className: SendBirdAction.getInstance().isCurrentUser(this.user)
+ ? [styles['user-nickname'], styles['is-user']]
+ : styles['user-nickname'],
+ content: protectFromXSS(this.user.nickname)
+ });
+ this.element.appendChild(this.nickname);
+ }
+
+ _hoverOnUser(nickname, hover) {
+ this.nickname.innerHTML = hover ? 'UNBLOCK' : protectFromXSS(nickname);
+ this.nickname.style.color = hover ? COLOR_RED : '';
+ this.nickname.style.opacity = hover ? '1' : '';
+ }
+}
+
+export { ChatUserItem };
diff --git a/javascript/javascript-basic/src/js/components/LeftListItem.js b/javascript/javascript-basic/src/js/components/LeftListItem.js
new file mode 100644
index 00000000..38d27277
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/LeftListItem.js
@@ -0,0 +1,147 @@
+import styles from '../../scss/list-item.scss';
+import {
+ addClass,
+ createDivEl,
+ getDataInElement,
+ protectFromXSS,
+ removeClass,
+ setDataInElement,
+ timestampFromNow
+} from '../utils';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+
+const KEY_MESSAGE_LAST_TIME = 'origin';
+
+class LeftListItem {
+ constructor({ channel, handler }) {
+ this.channel = channel;
+ this.element = this._createElement(handler);
+ }
+
+ get channelUrl() {
+ return this.channel.url;
+ }
+
+ get title() {
+ return this.channel.isOpenChannel()
+ ? `# ${this.channel.name}`
+ : this.channel.members
+ .map(member => {
+ return protectFromXSS(member.nickname);
+ })
+ .join(', ');
+ }
+
+ get lastMessagetime() {
+ if (this.channel.isOpenChannel() || !this.channel.lastMessage) {
+ return 0;
+ } else {
+ return this.channel.lastMessage.createdAt;
+ }
+ }
+
+ get lastMessageTimeText() {
+ if (this.channel.isOpenChannel() || !this.channel.lastMessage) {
+ return 0;
+ } else {
+ return LeftListItem.getTimeFromNow(this.channel.lastMessage.createdAt);
+ }
+ }
+
+ get lastMessageText() {
+ if (this.channel.isOpenChannel() || !this.channel.lastMessage) {
+ return '';
+ } else {
+ return this.channel.lastMessage.isFileMessage()
+ ? protectFromXSS(this.channel.lastMessage.name)
+ : protectFromXSS(this.channel.lastMessage.message);
+ }
+ }
+
+ get memberCount() {
+ return this.channel.isOpenChannel() ? '#' : this.channel.memberCount;
+ }
+
+ get unreadMessageCount() {
+ const count = this.channel.unreadMessageCount > 9 ? '+9' : this.channel.unreadMessageCount.toString();
+ return this.channel.isOpenChannel() ? 0 : count;
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['list-item'], id: this.channelUrl });
+ if (this.channel.isOpenChannel()) {
+ const itemTop = createDivEl({ className: styles['item-top'] });
+ const itemTopTitle = createDivEl({ className: styles['item-title'], content: this.title });
+ itemTop.appendChild(itemTopTitle);
+ item.appendChild(itemTop);
+ } else {
+ const itemTop = createDivEl({ className: styles['item-top'] });
+ const itemTopCount = createDivEl({ className: styles['item-count'], content: this.memberCount });
+ const itemTopTitle = createDivEl({ className: styles['item-title'], content: this.title });
+ itemTop.appendChild(itemTopCount);
+ itemTop.appendChild(itemTopTitle);
+ item.appendChild(itemTop);
+
+ const itemBottom = createDivEl({ className: styles['item-bottom'] });
+
+ const itemBottomMessage = createDivEl({ className: styles['item-message'] });
+ const itemBottomMessageText = createDivEl({
+ className: styles['item-message-text'],
+ content: this.lastMessageText
+ });
+ itemBottomMessage.appendChild(itemBottomMessageText);
+ const itemBottomMessageUnread = createDivEl({
+ className: [styles['item-message-unread'], styles.active],
+ content: this.unreadMessageCount
+ });
+ itemBottomMessage.appendChild(itemBottomMessageUnread);
+
+ const itemBottomTime = createDivEl({ className: styles['item-time'], content: this.lastMessageTimeText });
+ setDataInElement(itemBottomTime, KEY_MESSAGE_LAST_TIME, this.lastMessagetime);
+ itemBottom.appendChild(itemBottomMessage);
+ itemBottom.appendChild(itemBottomTime);
+ item.appendChild(itemBottom);
+ }
+
+ item.addEventListener('click', () => {
+ if (handler) handler();
+ });
+ return item;
+ }
+
+ static updateUnreadCount() {
+ const items = ChatLeftMenu.getInstance().groupChannelList.getElementsByClassName(styles['item-message-unread']);
+ if (items && items.length > 0) {
+ Array.prototype.slice.call(items).forEach(targetItemEl => {
+ const originTs = targetItemEl.textContent;
+ if (originTs === '0') {
+ removeClass(targetItemEl, styles.active);
+ } else {
+ addClass(targetItemEl, styles.active);
+ }
+ });
+ }
+ }
+
+ static updateLastMessageTime() {
+ const items = ChatLeftMenu.getInstance().groupChannelList.getElementsByClassName(styles['item-time']);
+ if (items && items.length > 0) {
+ Array.prototype.slice.call(items).forEach(targetItemEl => {
+ const originTs = parseInt(getDataInElement(targetItemEl, KEY_MESSAGE_LAST_TIME));
+ if (originTs) {
+ targetItemEl.innerHTML = LeftListItem.getTimeFromNow(originTs);
+ }
+ });
+ }
+ }
+
+ static getTimeFromNow(timestamp) {
+ return timestampFromNow(timestamp);
+ }
+
+ static getItemRootClassName() {
+ return styles['list-item'];
+ }
+}
+
+export { LeftListItem };
diff --git a/javascript/javascript-basic/src/js/components/List.js b/javascript/javascript-basic/src/js/components/List.js
new file mode 100644
index 00000000..d22e7b41
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/List.js
@@ -0,0 +1,87 @@
+import styles from '../../scss/list.scss';
+import { createDivEl, isScrollBottom } from '../utils';
+import { OpenChannelSearchBox } from './OpenChannelSearchBox';
+
+let instance = null;
+
+class List {
+ constructor(title, createSearchBox = false) {
+ if (instance) {
+ return instance;
+ }
+ this.createSearchBox = createSearchBox;
+ this.element = this._create(title);
+ this.scrollEventHandler = null;
+ this.closeEventHandler = null;
+ this.searchKeyword = '';
+ }
+
+ _create(title) {
+ const root = createDivEl({ className: styles['list-root'] });
+
+ const listBody = createDivEl({ className: styles['list-body'] });
+ root.appendChild(listBody);
+
+ const listTop = createDivEl({ className: styles['list-top'] });
+ listBody.appendChild(listTop);
+
+ const listTopTitle = createDivEl({ className: styles['list-title'], content: title });
+ listTop.appendChild(listTopTitle);
+ const listTopButton = createDivEl({ className: styles['list-button'] });
+ listTop.appendChild(listTopButton);
+ const listTopButtonExit = createDivEl({ className: styles['button-exit'] });
+ listTopButton.appendChild(listTopButtonExit);
+ listTopButtonExit.addEventListener('click', () => {
+ this.searchKeyword = '';
+ OpenChannelSearchBox.clearText();
+ const listContent = document.querySelector(`.${styles['list-content']}`);
+ if (this.closeEventHandler) {
+ this.closeEventHandler();
+ }
+ listContent.innerHTML = '';
+ root.parentElement.removeChild(this.element);
+ });
+ this.buttonRootElement = listTopButton;
+
+ if (this.createSearchBox) {
+ const searchBox = new OpenChannelSearchBox();
+ listBody.appendChild(searchBox.element);
+ }
+
+ const hr = createDivEl({ className: styles['list-hr'] });
+ listBody.appendChild(hr);
+
+ const listContent = createDivEl({ className: styles['list-content'] });
+ listBody.appendChild(listContent);
+ listContent.addEventListener('scroll', () => {
+ if (isScrollBottom(listContent)) {
+ if (this.scrollEventHandler) {
+ this.scrollEventHandler(false, this.searchKeyword);
+ }
+ }
+ });
+
+ return root;
+ }
+
+ close() {
+ const btnExit = document.querySelector(`.${styles['button-exit']}`);
+ if (btnExit) {
+ document.querySelector(`.${styles['button-exit']}`).click();
+ }
+ }
+
+ getRootElement() {
+ return document.querySelector(`.${styles['list-root']}`);
+ }
+
+ getRootClassName() {
+ return styles['list-root'];
+ }
+
+ getContentElement() {
+ return document.querySelector(`.${styles['list-content']}`);
+ }
+}
+
+export { List };
diff --git a/javascript/javascript-basic/src/js/components/Message.js b/javascript/javascript-basic/src/js/components/Message.js
new file mode 100644
index 00000000..16c9e43e
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/Message.js
@@ -0,0 +1,192 @@
+import styles from '../../scss/message.scss';
+import { createDivEl, isImage, protectFromXSS, setDataInElement, timestampToTime } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { COLOR_RED, MESSAGE_REQ_ID } from '../const';
+import { MessageDeleteModal } from './MessageDeleteModal';
+import { UserBlockModal } from './UserBlockModal';
+import { Chat } from '../Chat';
+
+class Message {
+ constructor({ channel, message }) {
+ this.channel = channel;
+ this.message = message;
+ this.element = this._createElement();
+ }
+
+ _createElement() {
+ if (this.message.isUserMessage()) {
+ return this._createUserElement();
+ } else if (this.message.isFileMessage()) {
+ return this._createFileElement();
+ } else if (this.message.isAdminMessage()) {
+ return this._createAdminElement();
+ } else {
+ console.error('Message is invalid data.');
+ return null;
+ }
+ }
+
+ _hoverOnNickname(nickname, hover) {
+ if (!SendBirdAction.getInstance().isCurrentUser(this.message.sender)) {
+ nickname.innerHTML = hover ? 'BLOCK ' : `${protectFromXSS(this.message.sender.nickname)} : `;
+ nickname.style.color = hover ? COLOR_RED : '';
+ nickname.style.opacity = hover ? '1' : '';
+ }
+ }
+
+ _hoverOnTime(time, hover) {
+ if (SendBirdAction.getInstance().isCurrentUser(this.message.sender)) {
+ time.innerHTML = hover ? 'DELETE' : timestampToTime(this.message.createdAt);
+ time.style.color = hover ? COLOR_RED : '';
+ time.style.opacity = hover ? '1' : '';
+ time.style.fontWeight = hover ? '600' : '';
+ }
+ }
+
+ _createUserElement() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const isCurrentUser = sendbirdAction.isCurrentUser(this.message.sender);
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ setDataInElement(root, MESSAGE_REQ_ID, this.message.reqId);
+
+ const messageContent = createDivEl({ className: styles['message-content'] });
+ const nickname = createDivEl({
+ className: isCurrentUser ? [styles['message-nickname'], styles['is-user']] : styles['message-nickname'],
+ content: `${protectFromXSS(this.message.sender.nickname)} : `
+ });
+ nickname.addEventListener('mouseover', () => {
+ this._hoverOnNickname(nickname, true);
+ });
+ nickname.addEventListener('mouseleave', () => {
+ this._hoverOnNickname(nickname, false);
+ });
+ nickname.addEventListener('click', () => {
+ if (!isCurrentUser) {
+ const userBlockModal = new UserBlockModal({ user: this.message.sender, isBlock: true });
+ userBlockModal.render();
+ }
+ });
+ messageContent.appendChild(nickname);
+
+ const msg = createDivEl({ className: styles['message-content'], content: protectFromXSS(this.message.message) });
+ messageContent.appendChild(msg);
+
+ const time = createDivEl({
+ className: isCurrentUser ? [styles.time, styles['is-user']] : styles.time,
+ content: timestampToTime(this.message.createdAt)
+ });
+ time.addEventListener('mouseover', () => {
+ this._hoverOnTime(time, true);
+ });
+ time.addEventListener('mouseleave', () => {
+ this._hoverOnTime(time, false);
+ });
+ time.addEventListener('click', () => {
+ if (isCurrentUser) {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message
+ });
+ messageDeleteModal.render();
+ }
+ });
+ messageContent.appendChild(time);
+
+ if (this.channel.isGroupChannel()) {
+ const count = sendbirdAction.getReadReceipt(this.channel, this.message);
+ const read = createDivEl({
+ className: count ? [styles.read, styles.active] : styles.read,
+ content: count
+ });
+ messageContent.appendChild(read);
+ }
+
+ root.appendChild(messageContent);
+ return root;
+ }
+
+ _createFileElement() {
+ const sendbirdAction = SendBirdAction.getInstance();
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ setDataInElement(root, MESSAGE_REQ_ID, this.message.reqId);
+
+ const messageContent = createDivEl({ className: styles['message-content'] });
+ const nickname = createDivEl({
+ className: sendbirdAction.isCurrentUser(this.message.sender)
+ ? [styles['message-nickname'], styles['is-user']]
+ : styles['message-nickname'],
+ content: `${protectFromXSS(this.message.sender.nickname)} : `
+ });
+ messageContent.appendChild(nickname);
+
+ const msg = createDivEl({
+ className: [styles['message-content'], styles['is-file']],
+ content: protectFromXSS(this.message.name)
+ });
+ msg.addEventListener('click', () => {
+ window.open(this.message.url);
+ });
+ messageContent.appendChild(msg);
+
+ const time = createDivEl({ className: styles.time, content: timestampToTime(this.message.createdAt) });
+ time.addEventListener('mouseover', () => {
+ this._hoverOnTime(time, true);
+ });
+ time.addEventListener('mouseleave', () => {
+ this._hoverOnTime(time, false);
+ });
+ time.addEventListener('click', () => {
+ const messageDeleteModal = new MessageDeleteModal({
+ channel: this.channel,
+ message: this.message
+ });
+ messageDeleteModal.render();
+ });
+ messageContent.appendChild(time);
+
+ if (this.channel.isGroupChannel()) {
+ const count = sendbirdAction.getReadReceipt(this.channel, this.message);
+ const read = createDivEl({
+ className: count ? [styles.read, styles.active] : styles.read,
+ content: count
+ });
+ messageContent.appendChild(read);
+ }
+
+ root.appendChild(messageContent);
+
+ if (isImage(this.message.type) && this.message.messageId) {
+ const imageContent = createDivEl({ className: styles['image-content'] });
+ imageContent.addEventListener('click', () => {
+ window.open(this.message.url);
+ });
+ const imageRender = document.createElement('img');
+ imageRender.className = styles['image-render'];
+ imageRender.src = protectFromXSS(this.message.url);
+ imageRender.onload = () => {
+ Chat.getInstance().main.repositionScroll(imageRender.offsetHeight);
+ };
+ imageContent.appendChild(imageRender);
+ root.appendChild(imageContent);
+ }
+
+ return root;
+ }
+
+ _createAdminElement() {
+ const root = createDivEl({ className: styles['chat-message'], id: this.message.messageId });
+ const msg = createDivEl({ className: styles['message-admin'], content: protectFromXSS(this.message.message) });
+ root.appendChild(msg);
+ return root;
+ }
+
+ static getRootElementClasasName() {
+ return styles['chat-message'];
+ }
+
+ static getReadReceiptElementClassName() {
+ return styles.active;
+ }
+}
+
+export { Message };
diff --git a/javascript/javascript-basic/src/js/components/MessageDeleteModal.js b/javascript/javascript-basic/src/js/components/MessageDeleteModal.js
new file mode 100644
index 00000000..cd473cc0
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/MessageDeleteModal.js
@@ -0,0 +1,45 @@
+import styles from '../../scss/message-delete-modal.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+import { Chat } from '../Chat';
+
+const title = 'Delete Message';
+const description = 'Are you Sure? Do you want to delete message?';
+const submitText = 'DELETE';
+
+class MessageDeleteModal extends Modal {
+ constructor({ channel, message }) {
+ super({ title, description, submitText });
+ this.channel = channel;
+ this.message = message;
+ this._createElement();
+ this._createHandler();
+ }
+
+ _createElement() {
+ const content = createDivEl({
+ className: styles['modal-message'],
+ content: this.message.isFileMessage() ? protectFromXSS(this.message.name) : protectFromXSS(this.message.message)
+ });
+ this.contentElement.appendChild(content);
+ }
+
+ _createHandler() {
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .deleteMessage({ channel: this.channel, message: this.message })
+ .then(() => {
+ Spinner.remove();
+ this.close();
+ Chat.getInstance().main.removeMessage(this.message.messageId);
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ };
+ }
+}
+
+export { MessageDeleteModal };
diff --git a/javascript/javascript-basic/src/js/components/Modal.js b/javascript/javascript-basic/src/js/components/Modal.js
new file mode 100644
index 00000000..7bf5887a
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/Modal.js
@@ -0,0 +1,63 @@
+import styles from '../../scss/modal.scss';
+import { createDivEl } from '../utils';
+import { Spinner } from './Spinner';
+import { body } from '../const';
+
+class Modal {
+ constructor({ title, description, submitText }) {
+ this.contentElement = null;
+ this.cancelHandler = null;
+ this.submitHandler = null;
+ this.element = this._create({ title, description, submitText });
+ }
+
+ _create({ title, description, submitText }) {
+ const root = createDivEl({ className: styles['modal-root'] });
+ const modal = createDivEl({ className: styles['modal-body'] });
+ root.appendChild(modal);
+
+ const titleText = createDivEl({ className: styles['modal-title'], content: title });
+ modal.appendChild(titleText);
+
+ const desc = createDivEl({ className: styles['modal-desc'], content: description });
+ modal.appendChild(desc);
+
+ this.contentElement = createDivEl({ className: styles['modal-content'] });
+ modal.appendChild(this.contentElement);
+
+ const bottom = createDivEl({ className: styles['modal-bottom'] });
+ modal.appendChild(bottom);
+ const cancel = createDivEl({ className: styles['modal-cancel'], content: 'CANCEL' });
+ cancel.addEventListener('click', () => {
+ if (this.cancelHandler) {
+ this.cancelHandler();
+ }
+ this.close();
+ });
+ bottom.appendChild(cancel);
+ const submit = createDivEl({ className: styles['modal-submit'], content: submitText });
+ submit.addEventListener('click', () => {
+ Spinner.start(modal);
+ if (this.submitHandler) {
+ this.submitHandler();
+ }
+ });
+ bottom.appendChild(submit);
+
+ return root;
+ }
+
+ close() {
+ if (body.contains(this.element)) {
+ body.removeChild(this.element);
+ }
+ }
+
+ render() {
+ if (!body.querySelector(`.${styles['modal-root']}`)) {
+ body.appendChild(this.element);
+ }
+ }
+}
+
+export { Modal };
diff --git a/javascript/javascript-basic/src/js/components/OpenChannelCreateModal.js b/javascript/javascript-basic/src/js/components/OpenChannelCreateModal.js
new file mode 100644
index 00000000..8cd94a25
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/OpenChannelCreateModal.js
@@ -0,0 +1,50 @@
+import styles from '../../scss/open-create-modal.scss';
+import { errorAlert } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+
+class OpenChannelCreateModal extends Modal {
+ constructor({ title, description, submitText }) {
+ super({ title, description, submitText });
+ this._createElement();
+ this._createHandler();
+ }
+
+ _createElement() {
+ this.input = document.createElement('input');
+ this.input.type = 'text';
+ this.input.placeholder = 'Please enter name of Open Channel.';
+ this.input.className = styles['modal-input'];
+ this.input.maxLength = 20;
+ this.contentElement.appendChild(this.input);
+ }
+
+ _createHandler() {
+ this.cancelHandler = () => {
+ this.input.value = '';
+ };
+
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .createOpenChannel(this.input.value)
+ .then(channel => {
+ SendBirdAction.getInstance()
+ .enter(channel.url)
+ .then(() => {
+ this.input.value = '';
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ };
+ }
+}
+
+export { OpenChannelCreateModal };
diff --git a/javascript/javascript-basic/src/js/components/OpenChannelItem.js b/javascript/javascript-basic/src/js/components/OpenChannelItem.js
new file mode 100644
index 00000000..de6834da
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/OpenChannelItem.js
@@ -0,0 +1,35 @@
+import styles from '../../scss/open-channel-item.scss';
+import { createDivEl, timestampToDateString } from '../utils';
+
+class OpenChannelItem {
+ constructor({ channel, handler }) {
+ this.channel = channel;
+ this.element = this._createElement(handler);
+ }
+
+ get title() {
+ return `# ${this.channel.name}`;
+ }
+
+ get channelUrl() {
+ return this.channel.url;
+ }
+
+ get createTimeString() {
+ return `Created on ${timestampToDateString(this.channel.createdAt)}`;
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['channel-item'], id: this.channelUrl });
+ const title = createDivEl({ className: styles['item-title'], content: this.title });
+ item.appendChild(title);
+ const desc = createDivEl({ className: styles['item-desc'], content: this.createTimeString });
+ item.appendChild(desc);
+ item.addEventListener('click', () => {
+ if (handler) handler();
+ });
+ return item;
+ }
+}
+
+export { OpenChannelItem };
diff --git a/javascript/javascript-basic/src/js/components/OpenChannelList.js b/javascript/javascript-basic/src/js/components/OpenChannelList.js
new file mode 100644
index 00000000..d6d3187d
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/OpenChannelList.js
@@ -0,0 +1,78 @@
+import { errorAlert } from '../utils';
+import { Spinner } from './Spinner';
+import { SendBirdAction } from '../SendBirdAction';
+import { OpenChannelItem } from './OpenChannelItem';
+import { List } from './List';
+import { body as targetEl } from '../const';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+
+let instance = null;
+
+class OpenChannelList extends List {
+ constructor() {
+ super('Open Channel List', true);
+ if (instance) {
+ return instance;
+ }
+
+ this.scrollEventHandler = this._getOpenChannelList;
+ this.searchKeyword = '';
+ instance = this;
+ }
+
+ _getOpenChannelList(isInit = false, urlKeyword = '') {
+ Spinner.start(this.element);
+
+ if (urlKeyword !== this.searchKeyword) {
+ this.searchKeyword = urlKeyword;
+ isInit = true;
+ }
+
+ const listContent = this.getContentElement();
+ if (isInit) {
+ listContent.innerHTML = '';
+ }
+
+ SendBirdAction.getInstance()
+ .getOpenChannelList(isInit, urlKeyword)
+ .then(openChannelList => {
+ openChannelList.forEach(channel => {
+ const handler = () => {
+ const existItem = ChatLeftMenu.getInstance().getChannelItem(channel.url);
+ if (existItem) {
+ existItem.click();
+ this.close();
+ } else {
+ SendBirdAction.getInstance()
+ .enter(item.channelUrl)
+ .then(() => {
+ this.close();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+ };
+ const item = new OpenChannelItem({ channel, handler });
+ listContent.appendChild(item.element);
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ render() {
+ if (!targetEl.querySelector(`.${this.getRootClassName()}`)) {
+ targetEl.appendChild(this.element);
+ this._getOpenChannelList(true);
+ }
+ }
+
+ static getInstance() {
+ return new OpenChannelList();
+ }
+}
+
+export { OpenChannelList };
diff --git a/javascript/javascript-basic/src/js/components/OpenChannelSearchBox.js b/javascript/javascript-basic/src/js/components/OpenChannelSearchBox.js
new file mode 100644
index 00000000..4cb01d81
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/OpenChannelSearchBox.js
@@ -0,0 +1,36 @@
+import styles from '../../scss/list.scss';
+import { createDivEl } from '../utils';
+import { KEY_ENTER, OPEN_CHANNEL_SEARCH_URL } from '../const';
+import { OpenChannelList } from './OpenChannelList';
+
+class OpenChannelSearchBox {
+ constructor() {
+ this.element = this._create();
+ }
+
+ _create() {
+ const searchChannelBox = createDivEl({ className: styles['list-search'] });
+ const searchInputBox = document.createElement('input');
+ searchInputBox.type = 'text';
+ searchInputBox.id = OPEN_CHANNEL_SEARCH_URL;
+ searchInputBox.className = styles['search-input'];
+ searchInputBox.placeholder = 'Search by ChannelUrl...';
+ searchInputBox.addEventListener('keydown', e => {
+ if (e.keyCode === KEY_ENTER) {
+ OpenChannelList.getInstance().scrollEventHandler(true, searchInputBox.value);
+ }
+ });
+
+ searchChannelBox.appendChild(searchInputBox);
+ return searchChannelBox;
+ }
+
+ static clearText() {
+ const textBox = document.querySelector(`#${OPEN_CHANNEL_SEARCH_URL}`);
+ if (textBox) {
+ textBox.value = '';
+ }
+ }
+}
+
+export { OpenChannelSearchBox };
diff --git a/javascript/javascript-basic/src/js/components/Spinner.js b/javascript/javascript-basic/src/js/components/Spinner.js
new file mode 100644
index 00000000..b7d83297
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/Spinner.js
@@ -0,0 +1,42 @@
+import styles from '../../scss/spinner.scss';
+import { createDivEl } from '../utils';
+
+let instance = null;
+
+class Spinner {
+ constructor() {
+ if (instance) {
+ return instance;
+ }
+ this.element = this._createSpinner();
+ instance = this;
+ }
+
+ _createSpinner() {
+ const item = createDivEl({ className: styles['sb-spinner'] });
+ const bubble = createDivEl({ className: styles['sb-spinner-bubble'] });
+ item.appendChild(bubble);
+ return item;
+ }
+
+ static start(target) {
+ const spinnerEl = Spinner.getInstance().element;
+ if (!target.contains(spinnerEl)) {
+ target.appendChild(spinnerEl);
+ }
+ }
+
+ static remove() {
+ const spinnerEl = Spinner.getInstance().element;
+ const targetEl = spinnerEl.parentElement;
+ if (targetEl && targetEl.contains(spinnerEl)) {
+ spinnerEl.parentElement.removeChild(spinnerEl);
+ }
+ }
+
+ static getInstance() {
+ return new Spinner();
+ }
+}
+
+export { Spinner };
diff --git a/javascript/javascript-basic/src/js/components/UserBlockModal.js b/javascript/javascript-basic/src/js/components/UserBlockModal.js
new file mode 100644
index 00000000..71ee70f4
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/UserBlockModal.js
@@ -0,0 +1,55 @@
+import styles from '../../scss/user-block-modal.scss';
+import { createDivEl, errorAlert, protectFromXSS } from '../utils';
+import { SendBirdAction } from '../SendBirdAction';
+import { Spinner } from './Spinner';
+import { Modal } from './Modal';
+import { Chat } from '../Chat';
+
+const blockTitle = 'Block User';
+const blockDescription = 'Are you Sure? Do you want to block this user?';
+const blockSubmitText = 'BLOCK';
+
+const unblockTitle = 'Unblock User';
+const unblockDescription = 'Are you Sure? Do you want to unblock this user?';
+const unblockSubmitText = 'UNBLOCK';
+
+class UserBlockModal extends Modal {
+ constructor({ user, isBlock = true }) {
+ isBlock
+ ? super({ title: blockTitle, description: blockDescription, submitText: blockSubmitText })
+ : super({ title: unblockTitle, description: unblockDescription, submitText: unblockSubmitText });
+ this.isBlock = isBlock;
+ this.user = user;
+ this._createElement();
+ this._createHandler();
+ }
+
+ _createElement() {
+ const content = createDivEl({ className: styles['modal-user'] });
+
+ const image = createDivEl({ className: styles['user-profile'], background: protectFromXSS(this.user.profileUrl) });
+ content.appendChild(image);
+
+ const nickname = createDivEl({ className: styles['user-nickname'], content: protectFromXSS(this.user.nickname) });
+ content.appendChild(nickname);
+
+ this.contentElement.appendChild(content);
+ }
+
+ _createHandler() {
+ this.submitHandler = () => {
+ SendBirdAction.getInstance()
+ .blockUser(this.user, this.isBlock)
+ .then(() => {
+ Chat.getInstance().main.updateBlockedList(this.user, this.isBlock);
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ };
+ }
+}
+
+export { UserBlockModal };
diff --git a/javascript/javascript-basic/src/js/components/UserItem.js b/javascript/javascript-basic/src/js/components/UserItem.js
new file mode 100644
index 00000000..799ec0cb
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/UserItem.js
@@ -0,0 +1,63 @@
+import styles from '../../scss/user-item.scss';
+import { createDivEl, protectFromXSS, timestampFromNow, toggleClass } from '../utils';
+
+class UserItem {
+ constructor({ user, handler }) {
+ this.user = user;
+ this.element = this._createElement(handler);
+ }
+
+ get userId() {
+ return this.user.userId;
+ }
+
+ get nickname() {
+ return protectFromXSS(this.user.nickname);
+ }
+
+ get profileUrl() {
+ return protectFromXSS(this.user.profileUrl);
+ }
+
+ get lastSeenTimeString() {
+ return this.user.lastSeenAt ? timestampFromNow(this.user.lastSeenAt) : '';
+ }
+
+ get isOnline() {
+ return this.user.connectionStatus === 'online';
+ }
+
+ _createElement(handler) {
+ const item = createDivEl({ className: styles['user-item'], id: this.userId });
+
+ const userInfo = createDivEl({ className: styles['user-info'] });
+ item.appendChild(userInfo);
+ const profile = createDivEl({ className: styles['user-profile'], background: this.profileUrl });
+ userInfo.appendChild(profile);
+ const nickname = createDivEl({ className: styles['user-nickname'], content: this.nickname });
+ userInfo.appendChild(nickname);
+ const isOnline = createDivEl({
+ className: this.isOnline ? [styles['user-online'], styles.active] : styles['user-online']
+ });
+ userInfo.appendChild(isOnline);
+
+ const userState = createDivEl({ className: styles['user-state'] });
+ item.appendChild(userState);
+ const lastSeenTime = createDivEl({ className: styles['user-time'], content: this.lastSeenTimeString });
+ userState.appendChild(lastSeenTime);
+ const selectIcon = createDivEl({ className: styles['user-select'] });
+ userState.appendChild(selectIcon);
+ item.addEventListener('click', () => {
+ toggleClass(item.querySelector(`.${UserItem.selectIconClassName}`), styles.active);
+ if (handler) handler();
+ });
+
+ return item;
+ }
+
+ static get selectIconClassName() {
+ return styles['user-select'];
+ }
+}
+
+export { UserItem };
diff --git a/javascript/javascript-basic/src/js/components/UserList.js b/javascript/javascript-basic/src/js/components/UserList.js
new file mode 100644
index 00000000..775d8c0d
--- /dev/null
+++ b/javascript/javascript-basic/src/js/components/UserList.js
@@ -0,0 +1,133 @@
+import styles from '../../scss/user-list.scss';
+import { createDivEl, errorAlert, appendToFirst } from '../utils';
+import { List } from './List';
+import { Spinner } from './Spinner';
+import { SendBirdAction } from '../SendBirdAction';
+import { UserItem } from './UserItem';
+import { body as targetEl } from '../const';
+import { Chat } from '../Chat';
+import { ChatLeftMenu } from '../ChatLeftMenu';
+import { LeftListItem } from './LeftListItem';
+
+let instance = null;
+
+class UserList extends List {
+ constructor() {
+ super('User List');
+ if (instance) {
+ return instance;
+ }
+
+ this.scrollEventHandler = this._getUserList;
+ this.closeEventHandler = this._close;
+ this.createBtn = this._addCreateBtn();
+ this.selectedUserIds = [];
+ instance = this;
+ }
+
+ _addCreateBtn() {
+ const createBtn = createDivEl({ className: styles['button-create'], content: 'CREATE' });
+ const oldCreateBtn = this.buttonRootElement.getElementsByClassName(styles['button-create'])[0];
+ if (oldCreateBtn) {
+ this.buttonRootElement.removeChild(oldCreateBtn);
+ }
+ appendToFirst(this.buttonRootElement, createBtn);
+ return createBtn;
+ }
+
+ _createChannel() {
+ SendBirdAction.getInstance()
+ .createGroupChannel(this.selectedUserIds)
+ .then(channel => {
+ Chat.getInstance().render(channel.url, false);
+ const handler = () => {
+ Chat.getInstance().render(channel.url, false);
+ ChatLeftMenu.getInstance().activeChannelItem(channel.url);
+ };
+ const item = new LeftListItem({ channel, handler });
+ ChatLeftMenu.getInstance().addGroupChannelItem(item.element, true);
+ ChatLeftMenu.getInstance().activeChannelItem(channel.url);
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ _inviteChannel() {
+ const channelUrl = Chat.getInstance().channel.url;
+ SendBirdAction.getInstance()
+ .inviteGroupChannel(channelUrl, this.selectedUserIds)
+ .then(() => {
+ Spinner.remove();
+ this.close();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ _updateCreateType(isInvite) {
+ this.createBtn = this._addCreateBtn();
+ this.createBtn.innerHTML = isInvite ? 'INVITE' : 'CREATE';
+ this.createBtn.addEventListener('click', () => {
+ Spinner.start(this.element);
+ if (isInvite) {
+ this._inviteChannel();
+ } else {
+ this._createChannel();
+ }
+ });
+ }
+
+ _getUserList(isInit = false) {
+ Spinner.start(this.element);
+ const sendbirdAction = SendBirdAction.getInstance();
+ const listContent = this.getContentElement();
+ sendbirdAction
+ .getUserList(isInit)
+ .then(userList => {
+ userList.forEach(user => {
+ if (!sendbirdAction.isCurrentUser(user)) {
+ const handler = () => {
+ this._toggleUserId(item.userId);
+ };
+ const item = new UserItem({ user, handler });
+ listContent.appendChild(item.element);
+ }
+ });
+ Spinner.remove();
+ })
+ .catch(error => {
+ errorAlert(error.message);
+ });
+ }
+
+ _toggleUserId(userId) {
+ const index = this.selectedUserIds.indexOf(userId);
+ if (index > -1) {
+ this.selectedUserIds.splice(index, 1);
+ } else {
+ this.selectedUserIds.push(userId);
+ }
+ }
+
+ _close() {
+ this.selectedUserIds = [];
+ }
+
+ render(isInvite = false) {
+ if (!targetEl.querySelector(`.${this.getRootClassName()}`)) {
+ this._updateCreateType(isInvite);
+ targetEl.appendChild(this.element);
+ this._getUserList(true);
+ }
+ }
+
+ static getInstance() {
+ return new UserList();
+ }
+}
+
+export { UserList };
diff --git a/javascript/javascript-basic/src/js/const.js b/javascript/javascript-basic/src/js/const.js
new file mode 100644
index 00000000..6954d419
--- /dev/null
+++ b/javascript/javascript-basic/src/js/const.js
@@ -0,0 +1,14 @@
+export const APP_ID = '9DA1B1F4-0BE6-4DA8-82C5-2E81DAB56F23';
+export const USER_ID = 'user_id';
+export const DISPLAY_NONE = 'none';
+export const DISPLAY_BLOCK = 'block';
+export const DISPLAY_FLEX = 'flex';
+export const ACTIVE_CLASSNAME = 'active';
+export const KEY_ENTER = 13;
+export const FILE_ID = 'attach_file_id';
+export const UPDATE_INTERVAL_TIME = 5 * 1000;
+export const COLOR_RED = '#DC5960';
+export const MESSAGE_REQ_ID = 'reqId';
+export const OPEN_CHANNEL_SEARCH_URL = 'search_open_channel';
+
+export const body = document.querySelector('body');
diff --git a/javascript/javascript-basic/src/js/index.js b/javascript/javascript-basic/src/js/index.js
new file mode 100644
index 00000000..5cbeefcf
--- /dev/null
+++ b/javascript/javascript-basic/src/js/index.js
@@ -0,0 +1,36 @@
+import { isEmpty, setCookie, getCookie } from './utils';
+import { USER_ID, KEY_ENTER } from './const';
+
+const userIdEl = document.querySelector('#user_id');
+const nicknameEl = document.querySelector('#user_nickname');
+const buttonEl = document.querySelector('#login-button');
+
+document.addEventListener('DOMContentLoaded', () => {
+ const cookieUserId = getCookie(USER_ID);
+ if (cookieUserId) {
+ userIdEl.value = cookieUserId;
+ }
+});
+
+nicknameEl.addEventListener('keydown', e => {
+ if (e.which === KEY_ENTER) {
+ login();
+ }
+});
+
+buttonEl.addEventListener('click', () => {
+ login();
+});
+
+const login = () => {
+ const userId = userIdEl.value.trim();
+ const nickname = nicknameEl.value.trim();
+ if (isEmpty(nickname)) {
+ alert('Please enter user nickname');
+ return;
+ }
+ userIdEl.value = '';
+ nicknameEl.value = '';
+ setCookie(USER_ID, userId);
+ window.location.href = `chat.html?userid=${encodeURIComponent(userId)}&nickname=${encodeURIComponent(nickname)}`;
+};
diff --git a/javascript/javascript-basic/src/js/main.js b/javascript/javascript-basic/src/js/main.js
new file mode 100644
index 00000000..679d42a8
--- /dev/null
+++ b/javascript/javascript-basic/src/js/main.js
@@ -0,0 +1,104 @@
+import { getVariableFromUrl, isEmpty, redirectToIndex } from './utils';
+import { SendBirdAction } from './SendBirdAction';
+import { ChatLeftMenu } from './ChatLeftMenu';
+import { Chat } from './Chat';
+import { Spinner } from './components/Spinner';
+import { body, UPDATE_INTERVAL_TIME } from './const';
+import { SendBirdConnection } from './SendBirdConnection';
+import { SendBirdEvent } from './SendBirdEvent';
+import { LeftListItem } from './components/LeftListItem';
+
+const sb = new SendBirdAction();
+
+const chatLeft = new ChatLeftMenu();
+const chat = new Chat();
+
+Spinner.start(body);
+
+const createConnectionHandler = () => {
+ const connectionManager = new SendBirdConnection();
+ connectionManager.onReconnectStarted = () => {
+ Spinner.start(body);
+ console.log('[SendBird JS SDK] Reconnect : Started');
+ connectionManager.channel = chat.channel;
+ };
+ connectionManager.onReconnectSucceeded = () => {
+ console.log('[SendBird JS SDK] Reconnect : Succeeded');
+ chatLeft.clear();
+ chatLeft.updateUserInfo(SendBirdAction.getInstance().getCurrentUser());
+ chatLeft.getGroupChannelList(true);
+ Spinner.start(body);
+ chat.refresh(connectionManager.channel);
+ };
+ connectionManager.onReconnectFailed = () => {
+ console.log('[SendBird JS SDK] Reconnect : Failed');
+ connectionManager.remove();
+ redirectToIndex('SendBird Reconnect Failed...');
+ };
+};
+
+const createChannelEvent = () => {
+ const channelEvent = new SendBirdEvent();
+ channelEvent.onChannelChanged = channel => {
+ if(channel._autoMarkAsRead) {
+ channel.markAsRead();
+ }
+ chatLeft.updateItem(channel, true);
+ };
+ channelEvent.onUserEntered = (openChannel, user) => {
+ if (SendBirdAction.getInstance().isCurrentUser(user)) {
+ const handler = () => {
+ chat.render(openChannel.url);
+ ChatLeftMenu.getInstance().activeChannelItem(openChannel.url);
+ };
+ const item = new LeftListItem({ channel: openChannel, handler });
+ chatLeft.addOpenChannelItem(item.element);
+ chat.render(openChannel.url);
+ }
+ };
+ channelEvent.onUserJoined = (groupChannel, user) => {
+ const handler = () => {
+ chat.render(groupChannel.url, false);
+ ChatLeftMenu.getInstance().activeChannelItem(groupChannel.url);
+ };
+ const item = new LeftListItem({ channel: groupChannel, handler });
+ chatLeft.addGroupChannelItem(item.element);
+ chat.updateChatInfo(groupChannel);
+ };
+ channelEvent.onUserLeft = (groupChannel, user) => {
+ if (SendBirdAction.getInstance().isCurrentUser(user)) {
+ chatLeft.removeGroupChannelItem(groupChannel.url);
+ } else {
+ chatLeft.updateItem(groupChannel);
+ }
+ chat.updateChatInfo(groupChannel);
+ };
+ channelEvent.onChannelHidden = groupChannel => {
+ chatLeft.removeGroupChannelItem(groupChannel.url);
+ };
+};
+
+const updateGroupChannelTime = () => {
+ setInterval(() => {
+ LeftListItem.updateLastMessageTime();
+ }, UPDATE_INTERVAL_TIME);
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const { userid, nickname } = getVariableFromUrl();
+ if (isEmpty(userid) || isEmpty(nickname)) {
+ redirectToIndex('UserID and Nickname must be required.');
+ }
+ sb
+ .connect(userid, nickname)
+ .then(user => {
+ chatLeft.updateUserInfo(user);
+ createConnectionHandler();
+ createChannelEvent();
+ updateGroupChannelTime();
+ chatLeft.getGroupChannelList(true);
+ })
+ .catch(() => {
+ redirectToIndex('SendBird connection failed.');
+ });
+});
diff --git a/javascript/javascript-basic/src/js/utils.js b/javascript/javascript-basic/src/js/utils.js
new file mode 100644
index 00000000..3ce548de
--- /dev/null
+++ b/javascript/javascript-basic/src/js/utils.js
@@ -0,0 +1,179 @@
+import moment from 'moment';
+
+export const timestampToTime = timestamp => {
+ const now = new Date().getTime();
+ const nowDate = moment.unix(now.toString().length === 13 ? now / 1000 : now).format('MM/DD');
+
+ let date = moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('MM/DD');
+ if (date === 'Invalid date') {
+ date = '';
+ }
+
+ return nowDate === date
+ ? moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('HH:mm')
+ : date;
+};
+
+export const timestampToDateString = timestamp => {
+ return moment.unix(timestamp.toString().length === 13 ? timestamp / 1000 : timestamp).format('LL');
+};
+
+export const timestampFromNow = timestamp => {
+ return moment(timestamp).fromNow();
+};
+
+export const isUrl = urlString => {
+ const regex = /^(http|https):\/\/[^ "]+$/;
+ return regex.test(urlString);
+};
+
+export const isImage = fileType => {
+ const regex = /^image\/.+$/;
+ return regex.test(fileType);
+};
+
+export const isEmpty = value => {
+ return value === null || value === undefined || value.length === 0;
+};
+
+export const isNull = value => {
+ try {
+ return value === null;
+ } catch (e) {
+ return false;
+ }
+};
+
+export const setCookie = (key, value) => {
+ document.cookie = `${key}=${value}; expires=Fri, 31 Dec 9999 23:59:59 GMT`;
+};
+
+export const getCookie = key => {
+ let name = `${key}=`;
+ let ca = document.cookie.split(';');
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i];
+ if (!c) continue;
+ while (c.charAt(0) === ' ') {
+ c = c.substring(1);
+ }
+ if (c.indexOf(name) === 0) {
+ return c.substring(name.length, c.length);
+ }
+ }
+ return '';
+};
+
+export const getVariableFromUrl = () => {
+ let vars = {};
+ let hashes = window.location.href.slice(window.location.href.indexOf('?') + 1).split('&');
+ for (let i = 0; i < hashes.length; i++) {
+ let hash = hashes[i].split('=');
+ vars[hash[0]] = hash[1];
+ }
+ return vars;
+};
+
+export const errorAlert = (message, reload = true) => {
+ alert(message);
+ if (reload) {
+ location.reload(true);
+ }
+};
+
+export const redirectToIndex = message => {
+ if (message) {
+ errorAlert(message, false);
+ }
+ window.location.href = 'index.html';
+};
+
+export const setDataInElement = (target, key, data) => {
+ target.dataset[`${key}`] = data;
+};
+
+export const getDataInElement = (target, key) => {
+ return target.dataset[`${key}`];
+};
+
+export const createDivEl = ({ id, className, content, background }) => {
+ const el = document.createElement('div');
+ if (id) {
+ el.id = id;
+ }
+ if (className) {
+ el.className = Array.isArray(className) ? className.join(' ') : className;
+ }
+ if (content) {
+ el.innerHTML = content;
+ }
+ if (background) {
+ el.style.backgroundImage = `url(${background})`;
+ }
+ return el;
+};
+
+export const isScrollBottom = target => {
+ return target.scrollTop + target.offsetHeight >= target.scrollHeight;
+};
+
+export const appendToFirst = (target, newElement) => {
+ if (target.childNodes.length > 0) {
+ target.insertBefore(newElement, target.childNodes[0]);
+ } else {
+ target.appendChild(newElement);
+ }
+};
+
+const hasClass = (target, className) => {
+ return target.classList
+ ? target.classList.contains(className)
+ : new RegExp('(^| )' + className + '( |$)', 'gi').test(target.className);
+};
+
+export const addClass = (target, className) => {
+ if (target.classList) {
+ if (!(className in target.classList)) {
+ target.classList.add(className);
+ }
+ } else {
+ if (target.className.indexOf(className) < 0) {
+ target.className += ` ${className}`;
+ }
+ }
+};
+
+export const removeClass = (target, className) => {
+ if (target.classList) {
+ target.classList.remove(className);
+ } else {
+ target.className = target.className.replace(
+ new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'),
+ ''
+ );
+ }
+};
+
+export const toggleClass = (target, className) => {
+ hasClass(target, className) ? removeClass(target, className) : addClass(target, className);
+};
+
+export const uuid4 = () => {
+ let d = new Date().getTime();
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = ((d + Math.random() * 16) % 16) | 0;
+ d = Math.floor(d / 16);
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
+ });
+};
+
+export const protectFromXSS = text => {
+ return typeof text === 'string'
+ ? text
+ .replace(/\&/g, '&')
+ .replace(/\/g, '>')
+ .replace(/\"/g, '"')
+ .replace(/\'/g, ''')
+ : text;
+};
diff --git a/javascript/javascript-basic/src/scss/_animation.scss b/javascript/javascript-basic/src/scss/_animation.scss
new file mode 100644
index 00000000..11f013c9
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/_animation.scss
@@ -0,0 +1,32 @@
+// Mixin
+@mixin keyframes($name) {
+ @-webkit-keyframes #{$name} { @content; }
+ @-moz-keyframes #{$name} { @content; }
+ @-o-keyframes #{$name} { @content; }
+ @-ms-keyframes #{$name} { @content; }
+ @keyframes #{$name} { @content; }
+}
+
+@mixin transform-scale($size) {
+ -webkit-transform: scale($size);
+ -moz-transform: scale($size);
+ -ms-transform: scale($size);
+ -o-transform: scale($size);
+ transform: scale($size);
+}
+
+@mixin animation($animation...) {
+ -webkit-animation: $animation;
+ -moz-animation: $animation;
+ -o-animation: $animation;
+ -ms-animation: $animation;
+ animation: $animation;
+}
+
+@mixin animation-delay($delay) {
+ -webkit-animation-delay: $delay;
+ -moz-animation-delay: $delay;
+ -o-animation-delay: $delay;
+ -ms-animation-delay: $delay;
+ animation-delay: $delay;
+}
diff --git a/javascript/javascript-basic/src/scss/_common.scss b/javascript/javascript-basic/src/scss/_common.scss
new file mode 100644
index 00000000..9727b042
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/_common.scss
@@ -0,0 +1,10 @@
+@import 'normalize';
+@import 'variables';
+@import 'mixins';
+@import 'icons';
+
+body {
+ display: flex;
+ font-family: $font-family-exo2;
+ -webkit-font-smoothing: antialiased;
+}
diff --git a/javascript/javascript-basic/src/scss/_icons.scss b/javascript/javascript-basic/src/scss/_icons.scss
new file mode 100644
index 00000000..01c41b28
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/_icons.scss
@@ -0,0 +1,40 @@
+// Icons
+$ic-prefix: 'https://dxstmhyqfqr1o.cloudfront.net/web-basic/';
+$ic-input-user: 'icon-username-landing.svg';
+$ic-profile-default: 'image-profile.svg';
+$ic-add-normal: 'icon-add-normal.png';
+$ic-add-over: 'icon-add-over.png';
+$ic-close: 'icon-close.png';
+$ic-enter: 'icon-enter.png';
+$ic-check-unselect: 'icon-check-unselect.png';
+$ic-check-select: 'icon-check-select.png';
+$ic-empty-chat: 'img-empty.svg';
+
+$ic-group: 'icon-group.png';
+$ic-hide-normal: 'icon-hide-normal.png';
+$ic-hide: 'icon-hide.png';
+$ic-group-add-normal: 'icon-group-add-normal.png';
+$ic-group-add: 'icon-group-add.png';
+$ic-leave-normal: 'icon-leave-normal.png';
+$ic-leave: 'icon-leave.png';
+$ic-attach-file-normal: 'icon-attach-file-normal.png';
+$ic-attach-file: 'icon-attach-file.png';
+$ic-arrow-normal: 'icon-arrow-nomal.png';
+$ic-arrow: 'icon-arrow.png';
+$ic-back: 'icon-back.png';
+
+$ic-search: 'icon-search-nomal.png';
+$ic-search-over: 'icon-search-over.png';
+
+@mixin icon($url, $size: cover, $position: center) {
+ background-image: url($ic-prefix + $url);
+ background-position: $position;
+ background-size: $size;
+ background-repeat: no-repeat;
+}
+
+@mixin imageMessage() {
+ background-position: center;
+ background-size: 160px 160px;
+ background-repeat: no-repeat;
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic/src/scss/_mixins.scss b/javascript/javascript-basic/src/scss/_mixins.scss
new file mode 100644
index 00000000..a954ed4b
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/_mixins.scss
@@ -0,0 +1,4 @@
+@import 'mixins/border-radius';
+@import 'mixins/state';
+@import 'mixins/transform';
+@import 'mixins/reset';
diff --git a/javascript/javascript-basic/src/scss/_normalize.scss b/javascript/javascript-basic/src/scss/_normalize.scss
new file mode 100644
index 00000000..08e68694
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/_normalize.scss
@@ -0,0 +1,450 @@
+/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
+
+/* Document
+ ========================================================================== */
+
+/**
+ * 1. Correct the line height in all browsers.
+ * 2. Prevent adjustments of font size after orientation changes in
+ * IE on Windows Phone and in iOS.
+ */
+
+html {
+ line-height: 1.15; /* 1 */
+ -ms-text-size-adjust: 100%; /* 2 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/* Sections
+ ========================================================================== */
+
+/**
+ * Remove the margin in all browsers (opinionated).
+ */
+
+body {
+ margin: 0;
+}
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+article,
+aside,
+footer,
+header,
+nav,
+section {
+ display: block;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in IE.
+ */
+
+figcaption,
+figure,
+main {
+ /* 1 */
+ display: block;
+}
+
+/**
+ * Add the correct margin in IE 8.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box; /* 1 */
+ height: 0; /* 1 */
+ overflow: visible; /* 2 */
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+pre {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * 1. Remove the gray background on active links in IE 10.
+ * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
+ */
+
+a {
+ background-color: transparent; /* 1 */
+ -webkit-text-decoration-skip: objects; /* 2 */
+}
+
+/**
+ * 1. Remove the bottom border in Chrome 57- and Firefox 39-.
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none; /* 1 */
+ text-decoration: underline; /* 2 */
+ text-decoration: underline dotted; /* 2 */
+}
+
+/**
+ * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
+ */
+
+b,
+strong {
+ font-weight: inherit;
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+samp {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/**
+ * Add the correct font style in Android 4.3-.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Add the correct background and color in IE 9-.
+ */
+
+mark {
+ background-color: #ff0;
+ color: #000;
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+audio,
+video {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in iOS 4-7.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Remove the border on images inside links in IE 10-.
+ */
+
+img {
+ border-style: none;
+}
+
+/**
+ * Hide the overflow in IE.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change the font styles in all browsers (opinionated).
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: sans-serif; /* 1 */
+ font-size: 100%; /* 1 */
+ line-height: 1.15; /* 1 */
+ margin: 0; /* 2 */
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input {
+ /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select {
+ /* 1 */
+ text-transform: none;
+}
+
+/**
+ * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
+ * controls in Android 4.
+ * 2. Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+html [type='button'], /* 1 */
+[type='reset'],
+[type='submit'] {
+ -webkit-appearance: button; /* 2 */
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type='button']::-moz-focus-inner,
+[type='reset']::-moz-focus-inner,
+[type='submit']::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type='button']:-moz-focusring,
+[type='reset']:-moz-focusring,
+[type='submit']:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Correct the padding in Firefox.
+ */
+
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box; /* 1 */
+ color: inherit; /* 2 */
+ display: table; /* 1 */
+ max-width: 100%; /* 1 */
+ padding: 0; /* 3 */
+ white-space: normal; /* 1 */
+}
+
+/**
+ * 1. Add the correct display in IE 9-.
+ * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ display: inline-block; /* 1 */
+ vertical-align: baseline; /* 2 */
+}
+
+/**
+ * Remove the default vertical scrollbar in IE.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10-.
+ * 2. Remove the padding in IE 10-.
+ */
+
+[type='checkbox'],
+[type='radio'] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type='number']::-webkit-inner-spin-button,
+[type='number']::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type='search'] {
+ -webkit-appearance: textfield; /* 1 */
+ outline-offset: -2px; /* 2 */
+}
+
+/**
+ * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
+ */
+
+[type='search']::-webkit-search-cancel-button,
+[type='search']::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button; /* 1 */
+ font: inherit; /* 2 */
+}
+
+/* Interactive
+ ========================================================================== */
+
+/*
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in Edge, IE, and Firefox.
+ */
+
+details, /* 1 */
+menu {
+ display: block;
+}
+
+/*
+ * Add the correct display in all browsers.
+ */
+
+summary {
+ display: list-item;
+}
+
+/* Scripting
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+canvas {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in IE.
+ */
+
+template {
+ display: none;
+}
+
+/* Hidden
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 10-.
+ */
+
+[hidden] {
+ display: none;
+}
diff --git a/javascript/javascript-basic/src/scss/_variables.scss b/javascript/javascript-basic/src/scss/_variables.scss
new file mode 100644
index 00000000..53f99485
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/_variables.scss
@@ -0,0 +1,39 @@
+// Color
+$color-transparent: transparent !default;
+
+$color-black-border: #2C2D30 !default;
+$color-black: #000000 !default;
+$color-black-text: #555555 !default;
+$color-black-text-light: #abb8c4 !default;
+
+$color-gray-admin: #e8ecef !default;
+$color-gray-dark: #dedede !default;
+$color-gray: #e3e3e3 !default;
+$color-gray-light: #F8F8F8 !default;
+
+$color-white: #ffffff !default;
+
+$color-blue-dark: #328fe6 !default;
+$color-blue: #32c5e6 !default;
+
+
+$color-purple-darker: #463c66 !default;
+$color-purple-dark: #4E4273 !default;
+$color-purple: #6e5baa !default;
+$color-purple-light: #6742d6 !default;
+
+$color-purple-deep: #673AB7 !default;
+
+$color-purple-text-dark: #7F6DA0 !default;
+$color-purple-text: #c7b0ff !default;
+$color-purple-text-light: #A08DCE !default;
+
+$color-green-online: #00C853 !default;
+
+$color-red: #DC5960 !default;
+
+$color-chat-border: #e0e2e5 !default;
+$color-chat-select: #f8f9fa !default;
+
+// Font
+$font-family-exo2: 'Exo 2';
diff --git a/javascript/javascript-basic/src/scss/chat-body.scss b/javascript/javascript-basic/src/scss/chat-body.scss
new file mode 100644
index 00000000..71347597
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat-body.scss
@@ -0,0 +1,13 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-body {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 10px 0;
+}
diff --git a/javascript/javascript-basic/src/scss/chat-input.scss b/javascript/javascript-basic/src/scss/chat-input.scss
new file mode 100644
index 00000000..089761eb
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat-input.scss
@@ -0,0 +1,74 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-input {
+ display: flex;
+ padding: 20px;
+ border-top: 1px solid $color-chat-border;
+ background-color: $color-white;
+
+ & > .typing-field {
+ display: none;
+ position: absolute;
+ bottom: 79px;
+ left: 220px;
+ width: calc(100vw - 220px - 240px);
+ padding: 6px 20px;
+ box-sizing: border-box;
+ background-color: rgba(0, 0, 0, 0.1);
+ color: $color-black-text;
+ opacity: 0.4;
+ vertical-align: middle;
+ font-size: 13px;
+ font-style: italic;
+ }
+
+ & > .input-file {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ border-right: 0;
+ background-color: $color-white;
+ cursor: pointer;
+ @include border-left-radius(4px);
+ @include icon($ic-attach-file-normal, 20px 20px, center center);
+ @include hover-focus {
+ border: 1px solid $color-black-border;
+ @include icon($ic-attach-file, 20px 20px, center center);
+ }
+ }
+
+ & > .input-text {
+ display: flex;
+ font-size: 15px;
+ width: 100%;
+ height: 38px;
+ padding: 7px 8px 6px 8px;
+ box-sizing: border-box;
+ border: 1px solid $color-chat-border;
+ background-color: $color-white;
+ @include border-right-radius(4px);
+ @include hover-focus-active {
+ border: 1px solid $color-black-border;
+ }
+
+ & > .input-text-area {
+ width: 100%;
+ outline: none;
+ border: 0;
+ resize: none;
+ line-height: 1.4;
+ background-color: $color-white;
+ overflow: hidden;
+ @include hover-focus {
+ outline: none;
+ border: 0;
+ resize: none;
+ padding-top: 2px;
+ line-height: 1.4;
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/chat-main.scss b/javascript/javascript-basic/src/scss/chat-main.scss
new file mode 100644
index 00000000..34279652
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat-main.scss
@@ -0,0 +1,18 @@
+@import 'mixins';
+@import 'variables';
+
+.chat-main-root {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 0;
+
+ & > .chat-main {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 100%;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/chat-menu.scss b/javascript/javascript-basic/src/scss/chat-menu.scss
new file mode 100644
index 00000000..b54403ae
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat-menu.scss
@@ -0,0 +1,97 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-menu-root {
+ display: flex;
+ flex-direction: column;
+ width: 240px;
+ min-width: 240px;
+ max-width: 240px;
+ background-color: $color-white;
+ box-sizing: border-box;
+ border-left: 1px solid $color-chat-border;
+ color: $color-black-border;
+ padding: 0;
+
+ & > .menu-item {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ align-content: center;
+ padding: 10px 20px;
+ border-bottom: 1px solid $color-chat-border;
+ cursor: pointer;
+
+ & > .menu-users,
+ & > .menu-blocked {
+ display: flex;
+ opacity: 0.6;
+ }
+
+ & > .menu-arrow {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ @include icon($ic-arrow-normal, 26px 26px, center center);
+ }
+
+ @include hover-focus {
+ background-color: $color-chat-select;
+
+ & > .menu-users,
+ & > .menu-blocked {
+ opacity: 1;
+ }
+
+ & > .menu-arrow {
+ @include icon($ic-arrow, 26px 26px, center center);
+ }
+ }
+ }
+
+ & > .menu-list {
+ display: none;
+ flex-direction: column;
+ position: absolute;
+ width: 239px;
+ height: calc(100% - 77px);
+ background: $color-white;
+ z-index: 999;
+
+ & > .list-title {
+ display: flex;
+ align-items: center;
+ align-content: center;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ color: $color-black-border;
+ border-bottom: 1px solid $color-chat-border;
+ cursor: pointer;
+ @include hover-focus {
+ background-color: $color-chat-select;
+ }
+
+ & > .list-back {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ @include icon($ic-back, 24px 24px, 0 center);
+ }
+
+ & > .list-text {
+ display: flex;
+ }
+ }
+
+ & > .list-body {
+ display: block;
+ flex-direction: column;
+ height: 100%;
+ max-height: calc(100% - 56px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/chat-top-menu.scss b/javascript/javascript-basic/src/scss/chat-top-menu.scss
new file mode 100644
index 00000000..239bb56c
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat-top-menu.scss
@@ -0,0 +1,81 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-top {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ height: 80px;
+ box-sizing: border-box;
+ padding: 15px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-chat-border;
+ color: $color-black-border;
+
+ & > .chat-title {
+ max-width: 800px;
+ font-size: 20px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+ & > .chat-title.is-group {
+ padding-left: 34px;
+ @include icon($ic-group, 27px 27px, 0 center);
+ }
+
+ & > .chat-button {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ width: 150px;
+ margin-left: 20px;
+
+ & > .button-invite {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ margin-right: 10px;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-group-add-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-group-add, 20px 20px, center center);
+ }
+ }
+
+ & > .button-hide {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ margin-right: 10px;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-hide-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-hide, 20px 20px, center center);
+ }
+ }
+
+ & > .button-leave {
+ width: 36px;
+ height: 36px;
+ border: 1px solid $color-chat-border;
+ cursor: pointer;
+ @include border-radius(4px);
+ @include icon($ic-leave-normal, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include icon($ic-leave, 20px 20px, center center);
+ }
+ }
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/chat-user-item.scss b/javascript/javascript-basic/src/scss/chat-user-item.scss
new file mode 100644
index 00000000..649deefb
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat-user-item.scss
@@ -0,0 +1,34 @@
+@import 'mixins';
+@import 'variables';
+
+.chat-user-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 10px 20px;
+ cursor: pointer;
+
+ & > .user-image {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ margin-right: 10px;
+ background-size: 36px 36px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ @include border-radius(50%);
+ }
+
+ & > .user-nickname {
+ width: 154px;
+ max-width: 154px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+ & > .user-nickname.is-user {
+ font-weight: 600;
+ color: $color-purple-deep;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/chat.scss b/javascript/javascript-basic/src/scss/chat.scss
new file mode 100644
index 00000000..9c9242ae
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/chat.scss
@@ -0,0 +1,51 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-empty {
+ display: flex;
+ width: 100%;
+ height: 100%;
+
+ & > .empty-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: auto;
+ text-align: center;
+ color: $color-black-text-light;
+ @include transform-translate(0, -50%);
+
+ & > .content-title {
+ display: flex;
+ font-size: 28px;
+ }
+
+ & > .content-image {
+ display: flex;
+ width: 80px;
+ height: 80px;
+ padding: 8px;
+ @include icon($ic-empty-chat, 80px 80px, center center);
+ }
+
+ & > .content-desc {
+ display: flex;
+ font-size: 14px;
+ white-space: pre;
+ }
+ }
+}
+
+.logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+}
+
+.chat-root {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 100%;
+ height: 100%;
+}
diff --git a/javascript/javascript-basic/src/scss/index.scss b/javascript/javascript-basic/src/scss/index.scss
new file mode 100644
index 00000000..fcf7f2d3
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/index.scss
@@ -0,0 +1,136 @@
+@import 'common';
+
+body {
+ background-color: $color-purple;
+}
+
+.logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-width: 900px;
+ min-height: 650px;
+ font-family: $font-family-exo2;
+
+ .logo-image {
+ background-color: $color-white;
+ border-radius: 50%;
+ }
+
+ & > .top {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 80px;
+ color: $color-white;
+ & > .logo {
+ display: flex;
+ align-items: center;
+ & > .logo-image {
+ display: flex;
+ align-items: center;
+ }
+ }
+ & > .title {
+ display: flex;
+ align-items: center;
+ & > .title-company {
+ display: flex;
+ align-items: center;
+ font-size: 30px;
+ font-weight: 600;
+ margin: 0 10px;
+ }
+ & > .title-desc {
+ display: flex;
+ align-items: center;
+ font-size: 26px;
+ font-weight: 200;
+ }
+ }
+ }
+
+ & > .login {
+ display: flex;
+ margin-top: 40px;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ & > .desc {
+ display: flex;
+ color: $color-purple-text;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ & > .download {
+ display: flex;
+ text-align: center;
+ margin: 20px 0;
+ & > .download-sample {
+ color: $color-purple-text;
+ cursor: pointer;
+ @include hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+ & > .form {
+ display: flex;
+ flex-direction: column;
+ margin-top: 10px;
+ & > .form-input {
+ display: flex;
+ margin-top: 10px;
+ border: 2px solid $color-white;
+ padding: 0 10px 0 40px;
+ width: 300px;
+ height: 50px;
+ font-size: 16px;
+ color: $color-black-text;
+ @include border-radius(2px);
+ @include icon($ic-input-user, 20px 20px, 10px center);
+ @include hover-focus {
+ border: 2px solid $color-blue;
+ }
+ }
+ & > .button {
+ display: flex;
+ justify-content: center;
+ margin-top: 10px;
+ width: 100%;
+ height: 48px;
+ background-color: $color-blue;
+ color: $color-white;
+ font-size: 16px;
+ font-weight: 700;
+ border: 0;
+ @include border-radius(2px);
+ cursor: pointer;
+ @include hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ & > .image {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ margin-top: 50px;
+ color: $color-white;
+ }
+}
+
+#login-button {
+ align-items: center;
+}
diff --git a/javascript/javascript-basic/src/scss/list-item.scss b/javascript/javascript-basic/src/scss/list-item.scss
new file mode 100644
index 00000000..a476e87f
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/list-item.scss
@@ -0,0 +1,84 @@
+@import 'mixins';
+@import 'variables';
+
+.list-item {
+ display: flex;
+ flex-direction: column;
+ padding: 6px 20px;
+ cursor: pointer;
+ @include hover {
+ background-color: $color-purple-darker;
+ }
+ & > .item-top {
+ display: flex;
+ color: $color-purple-text-light;
+ & > .item-count {
+ width: 18px;
+ height: 18px;
+ box-sizing: border-box;
+ border: 1px solid $color-purple-text-light;
+ align-items: center;
+ align-content: center;
+ text-align: center;
+ margin-right: 8px;
+ font-size: 13px;
+ line-height: 17px;
+ }
+ & > .item-title {
+ width: 100%;
+ max-width: 150px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ & > .item-bottom {
+ display: flex;
+ color: $color-purple-text-dark;
+ justify-content: space-between;
+ flex-direction: column;
+ font-size: 14px;
+ margin-top: 4px;
+ padding-left: 26px;
+ & > .item-message {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ & > .item-message-text {
+ width: 130px;
+ max-width: 130px;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ color: $color-purple-text-light;
+ opacity: 0.7;
+ }
+ & > .item-message-unread {
+ display: none;
+ width: 16px;
+ height: 16px;
+ background-color: $color-red;
+ text-align: center;
+ color: $color-white;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+ @include border-radius(50%);
+ }
+ & > .item-message-unread.active {
+ display: block;
+ }
+ }
+ & > .item-time {
+ display: flex;
+ font-size: 11px;
+ }
+ }
+}
+
+.list-item.active {
+ & > .item-top {
+ color: $color-purple-text;
+ font-weight: 600;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/list.scss b/javascript/javascript-basic/src/scss/list.scss
new file mode 100644
index 00000000..7365ce94
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/list.scss
@@ -0,0 +1,106 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.list-root {
+ min-width: 980px;
+ width: 100vw;
+ height: 100vh;
+ max-height: 100vh;
+ overflow: hidden;
+ position: absolute;
+ z-index: 9999;
+ background-color: $color-white;
+ font-family: $font-family-exo2;
+ & > .list-body {
+ max-width: 700px;
+ min-width: 500px;
+ width: 100%;
+ height: 100%;
+ margin: 70px auto 50px auto;
+ display: flex;
+ box-sizing: border-box;
+ flex-direction: column;
+ & > .list-top {
+ width: 100%;
+ height: 70px;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ & > .list-title {
+ display: flex;
+ font-size: 30px;
+ font-weight: 700;
+ margin-left: 20px;
+ }
+ & > .list-button {
+ display: flex;
+ flex-direction: row;
+ margin-right: 20px;
+
+ & > .button-exit {
+ width: 36px;
+ height: 36px;
+ text-align: center;
+ justify-content: center;
+ display: flex;
+ line-height: 36px;
+ cursor: pointer;
+ border: 1px solid $color-black-border;
+ @include border-radius(4px);
+ @include icon($ic-close, 20px 20px, center center);
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-gray;
+ background-color: $color-gray;
+ }
+ }
+ }
+ }
+ & > .list-hr {
+ height: 0;
+ margin: 8px 20px;
+ border-top: 1px solid $color-gray;
+ }
+
+ & > .list-search {
+ box-sizing: border-box;
+ padding: 10px 20px;
+ overflow: hidden;
+ & > .search-input {
+ font-size: 18px;
+ font-family: $font-family-exo2;
+ box-sizing: border-box;
+ width: calc(100% - 40px);
+ height: 42px;
+ margin: 0 20px;
+ padding-left: 44px;
+ outline: none;
+ border: 1px solid $color-gray;
+ @include border-radius(4px);
+ @include icon($ic-search, 26px 26px, 8px center);
+ @include hover-focus {
+ @include icon($ic-search-over, 26px 26px, 8px center);
+ border: 1px solid $color-purple-light;
+ font-weight: 300;
+ }
+ &::placeholder {
+ color: $color-gray;
+ font-size: 18px;
+ font-weight: 300;
+ }
+ }
+ }
+
+ & > .list-content {
+ box-sizing: border-box;
+ padding: 10px 20px;
+ max-height: calc(100vh - 205px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/main.scss b/javascript/javascript-basic/src/scss/main.scss
new file mode 100644
index 00000000..824553ff
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/main.scss
@@ -0,0 +1,134 @@
+@import 'common';
+.body {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ min-width: 980px;
+ font-family: $font-family-exo2;
+ & > .body-left {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 220px;
+ height: 100vh;
+ background-color: $color-purple-dark;
+ & > .body-left-top {
+ display: flex;
+ padding: 20px;
+ justify-content: center;
+ & > .top-logo {
+ display: flex;
+ align-items: center;
+ & > .logo-image {
+ display: flex;
+ align-items: center;
+ }
+ }
+ & > .top-text {
+ color: $color-white;
+ display: flex;
+ align-items: center;
+ font-size: 30px;
+ font-weight: 600;
+ margin-left: 5px;
+ }
+ }
+ & > .body-left-list {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 170px);
+ color: $color-purple-text-dark;
+ .icon-create-chat {
+ width: 20px;
+ height: 20px;
+ @include icon($ic-add-normal, 17px 17px, center center);
+ @include hover {
+ cursor: pointer;
+ background-color: $color-purple-text-light;
+ }
+ }
+ & > .chat-type {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ font-family: $font-family-exo2;
+ font-weight: 400;
+ font-size: 12px;
+ line-height: 20px;
+ padding: 8px 20px;
+ & > .chat-type-title {
+ @include hover {
+ cursor: pointer;
+ font-weight: 600;
+ color: $color-purple-text-light;
+ }
+ }
+ }
+ & > .chat-list {
+ flex-direction: column;
+ width: 100%;
+ max-height: 450px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ box-sizing: border-box;
+ & > .default-item {
+ display: block;
+ padding: 10px;
+ margin: 0 20px;
+ color: $color-purple-text-light;
+ font-size: 16px;
+ border: 1px dashed $color-purple-text-light;
+ @include border-radius(4px);
+ }
+ }
+ & > .chat-list.chat-list-group {
+ max-height: calc(100% - 130px);
+ }
+ }
+ & > .body-left-bottom {
+ display: flex;
+ padding: 20px;
+ background-color: $color-purple-darker;
+ & > .bottom-profile {
+ display: flex;
+ height: 40px;
+ align-items: center;
+ & > .image-profile {
+ display: flex;
+ align-items: center;
+ @include border-radius(50%);
+ }
+ }
+ & > .bottom-nickname {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 10px;
+ & > .nickname-title {
+ display: flex;
+ color: $color-purple-text-dark;
+ font-size: 14px;
+ }
+ & > .nickname-content {
+ display: inline-block;
+ max-width: 150px;
+ height: 18px;
+ color: $color-purple-text-light;
+ font-size: 16px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -ms-text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+ }
+ & > .body-center {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 500px;
+ height: 100%;
+ background-color: $color-white;
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic/src/scss/message-delete-modal.scss b/javascript/javascript-basic/src/scss/message-delete-modal.scss
new file mode 100644
index 00000000..57c4db82
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/message-delete-modal.scss
@@ -0,0 +1,14 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-message {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px;
+ width: 100%;
+ border: 1px solid $color-red;
+ background-color: $color-white;
+ font-size: 18px;
+ margin: 10px 0;
+ @include border-radius(4px);
+}
diff --git a/javascript/javascript-basic/src/scss/message.scss b/javascript/javascript-basic/src/scss/message.scss
new file mode 100644
index 00000000..e0baa648
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/message.scss
@@ -0,0 +1,91 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.chat-message {
+ display: block;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0 20px;
+ margin-bottom: 8px;
+ border: 1px solid transparent;
+
+ & > .message-content {
+ display: inline;
+ & > .message-nickname {
+ align-items: center;
+ display: inline;
+ justify-content: flex-start;
+ flex-direction: column;
+ cursor: pointer;
+ }
+ & > .message-nickname.is-user {
+ font-weight: 600;
+ color: $color-purple-deep;
+ cursor: initial;
+ }
+
+ & > .message-content {
+ display: inline;
+ white-space: pre-line;
+ }
+ & > .message-content.is-file {
+ cursor: pointer;
+ @include hover-focus {
+ color: $color-blue-dark;
+ }
+ }
+
+ & > .time {
+ display: inline;
+ margin-left: 8px;
+ font-size: 12px;
+ opacity: 0.5;
+ }
+ & > .time.is-user {
+ cursor: pointer;
+ }
+
+ & > .read {
+ display: none;
+ vertical-align: middle;
+ text-align: center;
+ width: 18px;
+ height: 18px;
+ line-height: 17px;
+ margin-left: 8px;
+ font-size: 12px;
+ color: $color-white;
+ font-weight: 500;
+ @include border-radius(50%);
+ background: $color-red;
+ }
+ & > .read.active {
+ display: inline-block;
+ }
+ }
+
+ & > .image-content {
+ display: block;
+ border-left: 2px solid $color-black-text;
+ padding-left: 10px;
+ margin-top: 8px;
+ cursor: pointer;
+ & > .image-render {
+ display: inline;
+ max-width: 300px;
+ max-height: 300px;
+ }
+ }
+
+ & > .message-admin {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-style: italic;
+ color: $color-black-text;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/mixins/_border-radius.scss b/javascript/javascript-basic/src/scss/mixins/_border-radius.scss
new file mode 100644
index 00000000..95bb40a1
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/mixins/_border-radius.scss
@@ -0,0 +1,59 @@
+@mixin border-radius($radius) {
+ border-radius: $radius;
+ -webkit-border-radius: $radius;
+ -moz-border-radius: $radius;
+ -ms-border-radius: $radius;
+ -o-border-radius: $radius;
+}
+
+@mixin border-top-radius($radius) {
+ border-top-right-radius: $radius;
+ border-top-left-radius: $radius;
+ -webkit-border-top-right-radius: $radius;
+ -webkit-border-top-left-radius: $radius;
+ -moz-border-top-right-radius: $radius;
+ -moz-border-top-left-radius: $radius;
+ -ms-border-top-right-radius: $radius;
+ -ms-border-top-left-radius: $radius;
+ -o-border-top-right-radius: $radius;
+ -o-border-top-left-radius: $radius;
+}
+
+@mixin border-right-radius($radius) {
+ border-bottom-right-radius: $radius;
+ border-top-right-radius: $radius;
+ -webkit-border-bottom-right-radius: $radius;
+ -webkit-border-top-right-radius: $radius;
+ -moz-border-bottom-right-radius: $radius;
+ -moz-border-top-right-radius: $radius;
+ -ms-border-bottom-right-radius: $radius;
+ -ms-border-top-right-radius: $radius;
+ -o-border-bottom-right-radius: $radius;
+ -o-border-top-right-radius: $radius;
+}
+
+@mixin border-bottom-radius($radius) {
+ border-bottom-right-radius: $radius;
+ border-bottom-left-radius: $radius;
+ -webkit-border-bottom-right-radius: $radius;
+ -webkit-border-bottom-left-radius: $radius;
+ -moz-border-bottom-right-radius: $radius;
+ -moz-border-bottom-left-radius: $radius;
+ -ms-border-bottom-right-radius: $radius;
+ -ms-border-bottom-left-radius: $radius;
+ -o-border-bottom-right-radius: $radius;
+ -o-border-bottom-left-radius: $radius;
+}
+
+@mixin border-left-radius($radius) {
+ border-bottom-left-radius: $radius;
+ border-top-left-radius: $radius;
+ -webkit-border-bottom-left-radius: $radius;
+ -webkit-border-top-left-radius: $radius;
+ -moz-border-bottom-left-radius: $radius;
+ -moz-border-top-left-radius: $radius;
+ -ms-border-bottom-left-radius: $radius;
+ -ms-border-top-left-radius: $radius;
+ -o-border-bottom-left-radius: $radius;
+ -o-border-top-left-radius: $radius;
+}
diff --git a/javascript/javascript-basic/src/scss/mixins/_reset.scss b/javascript/javascript-basic/src/scss/mixins/_reset.scss
new file mode 100644
index 00000000..eac43c4c
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/mixins/_reset.scss
@@ -0,0 +1,9 @@
+@mixin reset {
+ margin: 0;
+ padding: 0;
+ font-size: 100%;
+ line-height: 1;
+ width: auto;
+ height: auto;
+ box-sizing: initial;
+}
diff --git a/javascript/javascript-basic/src/scss/mixins/_state.scss b/javascript/javascript-basic/src/scss/mixins/_state.scss
new file mode 100644
index 00000000..94e4cea3
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/mixins/_state.scss
@@ -0,0 +1,58 @@
+@mixin hover {
+ &:hover { @content; }
+}
+
+@mixin plain-hover {
+ &,
+ &:hover { @content; }
+}
+
+@mixin focus {
+ &:focus { @content; }
+}
+
+@mixin plain-focus {
+ &,
+ &:focus { @content; }
+}
+
+@mixin hover-focus {
+ &:hover,
+ &:focus { @content; }
+}
+
+@mixin plain-hover-focus {
+ &,
+ &:hover,
+ &:focus { @content; }
+}
+
+@mixin hover-focus-active {
+ &:hover,
+ &:focus,
+ &:active { @content; }
+}
+
+@mixin after {
+ &::after {
+ @content
+ }
+}
+
+@mixin before {
+ &::before {
+ @content
+ }
+}
+
+@mixin before-after {
+ &::after, &::before {
+ @content
+ }
+}
+
+@mixin plain-before-after {
+ &, &::after, &::before {
+ @content
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/mixins/_transform.scss b/javascript/javascript-basic/src/scss/mixins/_transform.scss
new file mode 100644
index 00000000..026f6d1f
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/mixins/_transform.scss
@@ -0,0 +1,7 @@
+@mixin transform-translate($x, $y) {
+ -webkit-transform: translate($x, $y);
+ -moz-transform: translate($x, $y);
+ -ms-transform: translate($x, $y);
+ -o-transform: translate($x, $y);
+ transform: translate($x, $y);
+}
diff --git a/javascript/javascript-basic/src/scss/modal.scss b/javascript/javascript-basic/src/scss/modal.scss
new file mode 100644
index 00000000..3590e65d
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/modal.scss
@@ -0,0 +1,74 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-root {
+ display: flex;
+ position: absolute;
+ width: 100vw;
+ height: 100vh;
+ z-index: 9999;
+ background-color: rgba(0, 0, 0, 0.5);
+
+ & > .modal-body {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ width: 450px;
+ background-color: $color-white;
+ margin: auto;
+ padding: 24px;
+ @include border-radius(4px);
+ @include transform-translate(0, -50%);
+
+ & > .modal-title {
+ display: flex;
+ font-size: 26px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ }
+
+ & > .modal-desc {
+ display: flex;
+ color: $color-black-text;
+ font-size: 14px;
+ font-weight: 300;
+ }
+
+ & > .modal-content {
+ display: flex;
+ margin: 10px 0;
+ }
+
+ & > .modal-bottom {
+ display: flex;
+ justify-content: flex-end;
+ & > .modal-cancel {
+ display: flex;
+ margin-right: 12px;
+ color: $color-black-text-light;
+ border: 1px solid $color-black-text-light;
+ cursor: pointer;
+ padding: 6px;
+ @include border-radius(4px);
+ @include hover-focus {
+ color: $color-purple-light;
+ border: 1px solid $color-purple-light;
+ }
+ }
+ & > .modal-submit {
+ display: flex;
+ color: $color-white;
+ background-color: $color-blue;
+ border: 1px solid $color-blue;
+ cursor: pointer;
+ padding: 6px;
+ font-weight: 600;
+ @include border-radius(4px);
+ @include hover-focus {
+ background-color: $color-blue-dark;
+ border: 1px solid $color-blue-dark;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-basic/src/scss/open-channel-item.scss b/javascript/javascript-basic/src/scss/open-channel-item.scss
new file mode 100644
index 00000000..b6770cd2
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/open-channel-item.scss
@@ -0,0 +1,32 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.channel-item {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 50px 8px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-gray-dark;
+ cursor: pointer;
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-purple-light;
+ @include icon($ic-enter, 24px 24px, calc(100% - 20px) center);
+ @include border-radius(2px);
+ }
+
+ & > .item-title {
+ display: flex;
+ font-size: 20px;
+ font-weight: 500;
+ }
+
+ & > .item-desc {
+ display: flex;
+ font-size: 14px;
+ font-weight: 300;
+ color: #353535;
+ margin-top: 6px;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/open-create-modal.scss b/javascript/javascript-basic/src/scss/open-create-modal.scss
new file mode 100644
index 00000000..2f30f112
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/open-create-modal.scss
@@ -0,0 +1,19 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-input {
+ display: flex;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $color-gray-dark;
+ background-color: $color-gray-light;
+ font-size: 18px;
+ padding: 2px 10px;
+ margin: 10px 0;
+ @include border-radius(4px);
+ @include focus {
+ outline: none;
+ background-color: $color-white;
+ border: 1px solid $color-purple-light;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/spinner.scss b/javascript/javascript-basic/src/scss/spinner.scss
new file mode 100644
index 00000000..7bf66632
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/spinner.scss
@@ -0,0 +1,71 @@
+@import 'mixins';
+@import 'variables';
+
+.sb-spinner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0.6;
+ background-color: $color-white;
+ flex-direction: column;
+ justify-content: center;
+ display: flex;
+
+ .sb-spinner-bubble {
+ color: $color-black;
+ font-size: 10px;
+ margin: 80px auto;
+ position: relative;
+ text-indent: -9999em;
+ -webkit-animation-delay: -0.16s;
+ animation-delay: -0.16s;
+ @include transform-translate(0, -2em);
+ @include plain-before-after {
+ @include border-radius(50%);
+ width: 1.5em;
+ height: 1.5em;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+ -webkit-animation: load7 1.8s infinite ease-in-out;
+ animation: load7 1.8s infinite ease-in-out;
+ }
+ @include before-after {
+ content: '';
+ position: absolute;
+ top: 0;
+ }
+ @include before {
+ left: -3.5em;
+ -webkit-animation-delay: -0.32s;
+ animation-delay: -0.32s;
+ }
+ @include after {
+ left: 3.5em;
+ }
+ }
+
+}
+
+@-webkit-keyframes load7 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 2.5em 0 -1.3em;
+ }
+ 40% {
+ box-shadow: 0 2.5em 0 0;
+ }
+}
+@keyframes load7 {
+ 0%,
+ 80%,
+ 100% {
+ box-shadow: 0 2.5em 0 -1.3em;
+ }
+ 40% {
+ box-shadow: 0 2.5em 0 0;
+ }
+}
+
diff --git a/javascript/javascript-basic/src/scss/user-block-modal.scss b/javascript/javascript-basic/src/scss/user-block-modal.scss
new file mode 100644
index 00000000..9df0631c
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/user-block-modal.scss
@@ -0,0 +1,34 @@
+@import 'mixins';
+@import 'variables';
+
+.modal-user {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px;
+ width: 100%;
+ border: 1px solid $color-red;
+ background-color: $color-white;
+ font-size: 18px;
+ margin: 10px 0;
+ @include border-radius(4px);
+
+ & > .user-profile {
+ display: flex;
+ width: 36px;
+ height: 36px;
+ margin-right: 10px;
+ background-size: 36px 36px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ @include border-radius(50%);
+ }
+
+ & > .user-nickname {
+ width: 330px;
+ max-width: 330px;
+ white-space: nowrap;
+ overflow: hidden;
+ -ms-text-overflow: ellipsis;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/user-item.scss b/javascript/javascript-basic/src/scss/user-item.scss
new file mode 100644
index 00000000..de417bfd
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/user-item.scss
@@ -0,0 +1,77 @@
+@import 'mixins';
+@import 'variables';
+@import 'icons';
+
+.user-item {
+ display: flex;
+ padding: 8px 20px 8px 20px;
+ border: 1px solid transparent;
+ border-bottom: 1px solid $color-gray-dark;
+ justify-content: space-between;
+ cursor: pointer;
+ @include hover-focus {
+ cursor: pointer;
+ border: 1px solid $color-purple-light;
+ @include border-radius(2px);
+ }
+
+ & > .user-info {
+ display: flex;
+ align-items: center;
+
+ & > .user-profile {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ @include icon($ic-profile-default, 40px 40px, center center);
+ }
+
+ & > .user-nickname {
+ margin: 0 10px;
+ font-size: 18px;
+ max-width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ -ms-text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ & > .user-online {
+ display: flex;
+ width: 8px;
+ height: 8px;
+ border: 1px solid $color-black-text-light;
+ background-color: $color-black-text-light;
+ opacity: 0.4;
+ @include border-radius(50%);
+ }
+ & > .user-online.active {
+ border: 1px solid $color-green-online;
+ background-color: $color-green-online;
+ opacity: 1;
+ }
+ }
+
+ & > .user-state {
+ display: flex;
+ align-items: center;
+
+ & > .user-time {
+ display: flex;
+ color: $color-black-text-light;
+ margin-right: 10px;
+ }
+
+ & > .user-select {
+ display: flex;
+ width: 30px;
+ height: 30px;
+ opacity: 0.4;
+ @include icon($ic-check-unselect, 30px 30px, center center);
+ }
+ & > .user-select.active {
+ opacity: 1;
+ @include icon($ic-check-select, 30px 30px, center center);
+ }
+ }
+}
diff --git a/javascript/javascript-basic/src/scss/user-list.scss b/javascript/javascript-basic/src/scss/user-list.scss
new file mode 100644
index 00000000..bc99f7b4
--- /dev/null
+++ b/javascript/javascript-basic/src/scss/user-list.scss
@@ -0,0 +1,23 @@
+@import 'mixins';
+@import 'variables';
+
+.button-create {
+ width: 80px;
+ height: 36px;
+ text-align: center;
+ justify-content: center;
+ display: flex;
+ line-height: 36px;
+ font-weight: 600;
+ color: $color-white;
+ cursor: pointer;
+ background-color: $color-blue;
+ border: 1px solid $color-blue;
+ margin-right: 12px;
+ @include border-radius(4px);
+ @include hover-focus {
+ cursor: pointer;
+ background-color: $color-blue-dark;
+ border: 1px solid $color-blue-dark;
+ }
+}
diff --git a/javascript/javascript-basic/webpack.config.js b/javascript/javascript-basic/webpack.config.js
new file mode 100644
index 00000000..ca1f8f99
--- /dev/null
+++ b/javascript/javascript-basic/webpack.config.js
@@ -0,0 +1,75 @@
+'use strict';
+const path = require('path');
+const webpack = require('webpack');
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
+
+const PRODUCTION = 'production';
+
+module.exports = () => {
+ const config = {
+ entry: {
+ index: ['babel-polyfill', './src/js/index.js', './src/scss/index.scss'],
+ main: ['babel-polyfill', './src/js/main.js', './src/scss/main.scss']
+ },
+ output: {
+ path: path.resolve(__dirname, './dist'),
+ filename: 'sample.[name].js',
+ library: '[name]',
+ libraryExport: 'default',
+ libraryTarget: 'umd',
+ publicPath: 'dist'
+ },
+ devtool: 'cheap-eval-source-map',
+ devServer: {
+ publicPath: '/dist/',
+ compress: true,
+ port: 9000
+ },
+ module: {
+ rules: [
+ {
+ // SCSS
+ test: /\.scss$/,
+ use: ExtractTextPlugin.extract({
+ fallback: 'style-loader',
+ use: [
+ {
+ loader: 'css-loader',
+ options: {
+ module: true,
+ minimize: process.env.WEBPACK_MODE === PRODUCTION,
+ // sourceMap: true,
+ localIdentName: '[local]'
+ }
+ },
+ {
+ loader: 'sass-loader'
+ }
+ ]
+ })
+ },
+ {
+ // ESLint
+ enforce: 'pre',
+ test: /\.js$/,
+ exclude: /node_modules/,
+ loader: 'eslint-loader',
+ options: { failOnError: true }
+ },
+ {
+ // ES6
+ test: /\.js$/,
+ loader: 'babel-loader',
+ exclude: '/node_modules/'
+ }
+ ]
+ },
+ plugins: [
+ new ExtractTextPlugin({
+ filename: 'sample.[name].css'
+ })
+ ]
+ };
+
+ return config;
+};
diff --git a/javascript/javascript-live-chat/.babelrc b/javascript/javascript-live-chat/.babelrc
new file mode 100644
index 00000000..a659ff45
--- /dev/null
+++ b/javascript/javascript-live-chat/.babelrc
@@ -0,0 +1,10 @@
+{
+ "presets": [
+ "@babel/preset-env"
+ ],
+ "env": {
+ "test": {
+ "presets": ["@babel/preset-env"]
+ }
+ }
+}
\ No newline at end of file
diff --git a/javascript/javascript-live-chat/.eslintrc.js b/javascript/javascript-live-chat/.eslintrc.js
new file mode 100644
index 00000000..503cd056
--- /dev/null
+++ b/javascript/javascript-live-chat/.eslintrc.js
@@ -0,0 +1,20 @@
+module.exports = {
+ 'env': {
+ 'browser': true,
+ 'commonjs': true,
+ 'es6': true
+ },
+ 'extends': 'eslint:recommended',
+ 'parserOptions': {
+ 'sourceType': 'module'
+ },
+ 'parser': 'babel-eslint',
+ 'rules': {
+ 'indent': ['error', 2],
+ 'semi': 1,
+ 'no-console': 1,
+ 'camelcase': 1,
+ 'no-unused-vars': 1,
+ 'no-useless-escape': 1
+ }
+};
diff --git a/javascript/javascript-live-chat/.prettierignore b/javascript/javascript-live-chat/.prettierignore
new file mode 100644
index 00000000..52999c0b
--- /dev/null
+++ b/javascript/javascript-live-chat/.prettierignore
@@ -0,0 +1,2 @@
+README.md
+.eslintrc.js
\ No newline at end of file
diff --git a/javascript/javascript-live-chat/.prettierrc b/javascript/javascript-live-chat/.prettierrc
new file mode 100644
index 00000000..f65aabcb
--- /dev/null
+++ b/javascript/javascript-live-chat/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "printWidth": 120
+}
\ No newline at end of file
diff --git a/javascript/javascript-live-chat/README.md b/javascript/javascript-live-chat/README.md
new file mode 100644
index 00000000..8e6b6f71
--- /dev/null
+++ b/javascript/javascript-live-chat/README.md
@@ -0,0 +1,148 @@
+# Sendbird JavaScript Chat Sample
+This is a sample chat built using the [Sendbird SDK](https://github.com/sendbird/SendBird-SDK-JavaScript). It can be used to add a functional chat to any website.
+
+
+## [Demo](https://sample.sendbird.com/livechat/)
+
+You can try out a live demo from the link [here](https://sample.sendbird.com/livechat/). Choose any 'User ID' and 'Nickname' to log in and participate in chats.
+
+
+## Setup
+1. The `body` must have a `div` element whose id is `sb_chat`. we recommend width and height to 400px or over both.
+
+```html
+
+
+
+```
+
+2. Import the [`SendBird SDK`](https://github.com/sendbird/SendBird-SDK-JavaScript).
+3. Import the `liveChat.SendBird.js` file.
+```javascript
+
+
+```
+
+
+## Customizing the sample
+If you refresh your browser window, you need to reconnect to SendBird. To retain connection on browser refresh, you must implement an appropriate `event handler`.
+
+If you wish to issue an `access_token` for your user, modify the `connect function` in `src/sendbird.js`.
+
+> Require that you have Node v8.x+ installed.
+
+> `node-sass` package requires XCode developer tools (MacOS only) and Node.js version matching. If you have any trouble in the installation, see https://www.npmjs.com/package/node-sass.
+
+1. Install npm
+```bash
+npm install
+```
+
+2. Modify files.
+```bash
+npm run start:dev
+```
+
+3. Start sample.
+```bash
+npm start
+```
+
+
+## Advanced
+### Connect other APP or Channel
+If you want to connect other application or channel, you need to change variable `appId` or `channelUrl` in `index.html`.
+
+```html
+...
+
+
+
+
+
+