community: add streaming-subscription-chat example

GITHUB_PR_NUMBER: 8588
GITHUB_PR_URL: https://github.com/hasura/graphql-engine/pull/8588

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4731
Co-authored-by: arjunyel <11153289+arjunyel@users.noreply.github.com>
GitOrigin-RevId: 0db7eabcc8a05a2c98f50cd767c38757d534d648
This commit is contained in:
hasura-bot 2022-06-28 17:25:04 +03:00
parent 4d21f4d86b
commit bfe4c48245
51 changed files with 29035 additions and 0 deletions

View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,15 @@
FROM node:16 as builder
ENV NODE_ENV=PRODUCTION
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:16-alpine
RUN npm -g install serve
WORKDIR /app
COPY --from=builder /app/build .
CMD ["serve", "-s", "-p", "3000"]

View File

@ -0,0 +1,192 @@
# Realtime Chat using Streaming Subscriptions
This is the source code for a fully working group chat app that uses [streaming subscriptions](https://hasura.io/docs/latest/graphql/core/databases/postgres/subscriptions/streaming/index/) in Hasura GraphQL Engine. It is built using React and Apollo.
Run this example with Docker: `docker compose up -d --build`
[![Edit chat-app](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/hasura/graphql-engine/tree/master/community/sample-apps/realtime-chat?fontsize=14)
- [Fully working app](https://realtime-chat.demo.hasura.io/)
- [Backend](https://realtime-chat.demo.hasura.io/console)
Adapted from the [original blogpost by Rishichandra Wawhal](https://hasura.io/blog/building-a-realtime-chat-app-with-graphql-subscriptions-d68cd33e73f).
[![Deploy to Hasura Cloud](https://graphql-engine-cdn.hasura.io/img/deploy_to_hasura.png)](https://cloud.hasura.io/signup)
## TLDR
- Hasura allows us to build a real-time GraphQL API without writing any backend code.
- Using [streaming subscriptions](https://hasura.io/docs/latest/graphql/core/databases/postgres/subscriptions/streaming/index/) we fetch the last 100 messages then stream new messages.
- Every two seconds our frontend runs a user_online mutation to populate an online users list.
- A user_typing mutation is run whenever we type a few characters and a subscription is used to fetch the last typing user.
## Introduction
Hasura allows us to instantly create a real-time GraphQL API from our data. In this tutorial we walk through creating a group chat application, without needing to write any backend code, using React and Apollo. The focus is on data models that we store in Postgres rather than full chat functionality.
## Data Modelling
### Users
When a user signs up we insert their chosen username and generate their ID. We also track when they last typed and were seen.
```sql
user (
id SERIAL PRIMARY KEY
username TEXT UNIQUE
last_seen timestamp with time zone
last_typed timestamp with time zone
)
```
### Messages
For our tutorial we will just be inserting messages, not editing or deleting, but if we wanted to in the future Hasura will autogenerate the mutations for us. We could also extend this by adding features such as multiple different chatrooms.
```sql
message (
id SERIAL NOT NULL PRIMARY KEY
"text" TEXT NOT NULL
username INT FOREIGN KEY REFERENCES user(username) NOT NULL
"timestamp" timestamp with time zone DEFAULT now() NOT NULL
)
```
### Online users
To query the users online we create a Postgres view that fetches all users with last_seen less than 10 seconds ago.
```sql
CREATE OR REPLACE VIEW public."user_online" AS
SELECT "user".id,
"user".username,
"user".last_typed,
"user".last_seen
FROM "user"
WHERE ("user".last_seen > (now() - '00:00:10'::interval));
```
### Typing Indicator
To query the last person typing we create a similar view with last_typed within the past 2 seconds.
```sql
CREATE OR REPLACE VIEW public."user_typing" AS
SELECT "user".id,
"user".username,
"user".last_typed,
"user".last_seen
FROM "user"
WHERE ("user".last_typed > (now() - '00:00:02'::interval));
```
## Frontend
### Creating a user
At user signup we insert a single row into the users table
```gql
mutation ($username: String!) {
insert_user_one(object: { username: $username }) {
id
username
}
}
```
We take the returned id and username and store it in our app's state management.
### User online events
Every two seconds we run a mutation to update our user's last_seen value. For example, with React using UseEffect or componentDidMount we could call the mutation inside a setInterval. [Be mindful when using setInterval inside a React hook.](https://overreacted.io/making-setinterval-declarative-with-react-hooks/)
```gql
mutation ($userId: Int!) {
update_user_by_pk(pk_columns: { id: $userId }, _set: { last_seen: "now()" }) {
id
}
}
```
### Subscribing to new messages
Using [streaming subscriptions](https://hasura.io/docs/latest/graphql/core/databases/postgres/subscriptions/streaming/index/) we fetch the last N messages (the example uses 100). Then new messages are streamed using graphql-ws.
```gql
# We can pass in how far back we want to fetch messages
subscription ($last_received_ts: timestamptz) {
message_stream(
cursor: { initial_value: { timestamp: $last_received_ts } }
batch_size: 100
) {
id
username
text
timestamp
}
}
```
With a bit of magic from streaming subscriptions we saved our users a ton of data by only fetching new messages.
### Sending messages
A user sends a message by inserting a row into the messages table. In the future if we setup [JWT based authentication in the future](https://hasura.io/docs/latest/graphql/core/auth/authentication/jwt.html) we can automatically [set the username from the JWT custom claims](https://hasura.io/docs/latest/graphql/core/auth/authorization/roles-variables.html#dynamic-session-variables).
```gql
mutation insert_message($message: message_insert_input!) {
insert_message_one(object: $message) {
id
timestamp
text
username
}
}
```
### UI Typing Indicator
1. Similar to the user online events, when a user types a few characters we run a mutation on their last_typing timestamp.
```gql
mutation ($userId: Int!) {
update_user_by_pk(
pk_columns: { id: $userId }
_set: { last_typed: "now()" }
) {
id
}
}
```
1. Then we subscribe to our Postgres view of the last user typing, making sure to exclude ourselves.
```gql
subscription ($selfId: Int) {
user_typing(
where: { id: { _neq: $selfId } }
limit: 1
order_by: { last_typed: desc }
) {
last_typed
username
}
}
```
### Online Users list
We can easily get a list of all users online by subscribing to the user_online view we created.
```gql
subscription {
user_online(order_by: { username: asc }) {
id
username
}
}
```

View File

@ -0,0 +1,37 @@
services:
postgres:
image: postgres:14
restart: always
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgrespassword
graphql-engine:
image: hasura/graphql-engine:v2.7.0.cli-migrations-v3
ports:
- "8080:8080"
depends_on:
- "postgres"
restart: always
environment:
## postgres database to store Hasura metadata
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
## enable debugging mode. It is recommended to disable this in production
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_EXPERIMENTAL_FEATURES: "streaming_subscriptions"
volumes:
- ./hasura/metadata:/hasura-metadata
- ./hasura/migrations:/hasura-migrations
frontend:
build:
dockerfile: ./Dockerfile
context: .
ports:
- "3000:3000"
depends_on:
- "graphql"
volumes:
db_data:

View File

@ -0,0 +1,19 @@
version: 3
endpoint: http://localhost:8080/
api_paths:
v1_query: v1/query
v2_query: v2/query
v1_metadata: v1/metadata
graphql: v1/graphql
config: v1alpha1/config
pg_dump: v1alpha1/pg_dump
version: v1/version
metadata_directory: metadata
migrations_directory: migrations
seeds_directory: seeds
actions:
kind: synchronous
handler_webhook_baseurl: http://localhost:3000
codegen:
framework: ""
output_dir: ""

View File

@ -0,0 +1,6 @@
actions: []
custom_types:
enums: []
input_objects: []
objects: []
scalars: []

View File

@ -0,0 +1,16 @@
- name: default
kind: postgres
configuration:
connection_info:
database_url:
from_env: PG_DATABASE_URL
pool_settings:
idle_timeout: 180
max_connections: 50
retries: 1
tables:
- "!include default/tables/public_message.yaml"
- "!include default/tables/public_user.yaml"
- "!include default/tables/public_user_online.yaml"
- "!include default/tables/public_user_typing.yaml"
functions: []

View File

@ -0,0 +1,3 @@
table:
name: message
schema: public

View File

@ -0,0 +1,3 @@
table:
name: user
schema: public

View File

@ -0,0 +1,3 @@
table:
name: user_online
schema: public

View File

@ -0,0 +1,3 @@
table:
name: user_typing
schema: public

View File

@ -0,0 +1,53 @@
CREATE TABLE public.message (
id integer NOT NULL,
username text NOT NULL,
text text NOT NULL,
"timestamp" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE SEQUENCE public.message_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.message_id_seq OWNED BY public.message.id;
CREATE TABLE public."user" (
id integer NOT NULL,
username text NOT NULL,
last_typed timestamp with time zone,
last_seen timestamp with time zone
);
COMMENT ON TABLE public."user" IS 'This table stores user data';
CREATE SEQUENCE public.user_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id;
CREATE VIEW public.user_online AS
SELECT "user".id,
"user".username,
"user".last_typed,
"user".last_seen
FROM public."user"
WHERE ("user".last_seen > (now() - '00:00:10'::interval));
CREATE VIEW public.user_typing AS
SELECT "user".id,
"user".username,
"user".last_typed,
"user".last_seen
FROM public."user"
WHERE ("user".last_typed > (now() - '00:00:02'::interval));
ALTER TABLE ONLY public.message ALTER COLUMN id SET DEFAULT nextval('public.message_id_seq'::regclass);
ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass);
ALTER TABLE ONLY public.message
ADD CONSTRAINT message_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public."user"
ADD CONSTRAINT user_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public."user"
ADD CONSTRAINT user_username_key UNIQUE (username);
ALTER TABLE ONLY public.message
ADD CONSTRAINT message_username_fkey FOREIGN KEY (username) REFERENCES public."user"(username);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"name": "streaming-subscription-chat",
"version": "0.1.0",
"private": true,
"dependencies": {
"@apollo/client": "^3.6.8",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.2.0",
"moment": "^2.29.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"graphql-ws": "^5.9.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"devDependencies": {
"eslint-plugin-graphql": "^4.0.0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<link href="https://afeld.github.io/emoji-css/emoji.css" rel="stylesheet">
<link href="https://use.fontawesome.com/releases/v5.0.7/css/all.css" rel="stylesheet" crossorigin="anonymous">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Realtime group chat | Powered by Hasura</title>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-129818961-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-129818961-1');
</script>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "Realtime Group Chat",
"name": "Realtime Group Chat Powered by Hasura",
"icons": [
{
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-png"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,666 @@
@import url('https://fonts.googleapis.com/css?family=Raleway:400,600');
@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400');
body {
font-family: 'Open Sans';
font-size: 16px;
margin: 0;
}
.noPadd
{
padding-left: 0;
padding-right: 0;
}
.removePaddLeft {
padding-left: 0;
}
.addPaddTop
{
padding-top: 10px;
clear: both;
}
/* Landing section */
.wd10 {
width: 10%;
display: inline-block;
}
.wd90 {
width: 90%;
display: inline-block;
}
.removePaddBottom {
padding-bottom: 0 !important;
}
.gradientBgColor {
background-color: #a0b4cc;
/* Safari 4-5, Chrome 1-9 */
background: -webkit-gradient(
linear,
0% 0%,
0% 100%,
from(#a0b4cc),
to(#c2a899)
);
/* Safari 5.1, Chrome 10+ */
background: -webkit-linear-gradient(top, #a0b4cc, #c2a899);
/* Firefox 3.6+ */
background: -moz-linear-gradient(top, #a0b4cc, #c2a899);
/* IE 10 */
background: -ms-linear-gradient(top, #a0b4cc, #c2a899);
/* Opera 11.10+ */
background: -o-linear-gradient(top, #a0b4cc, #c2a899);
}
.bgImage
{
position: fixed;
left: 0;
width: 100%;
height: 100vh;
background-image: url('./images/chat-app-bg.jpg');
background-position: center;
background-size: cover;
background-position: 0 0;
background-repeat: no-repeat;
}
.bgImage::before
{
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-image: linear-gradient(to bottom right,#000,#a92101);
opacity: .9;
}
.minHeight {
width: 100%;
height: 100vh;
}
.headerWrapper {
padding: 30px 0;
padding-left: 75px;
min-height: 15vh
}
.headerDescription {
font-size: 20px;
color: #fff;
font-family: "Raleway";
font-weight: 700;
z-index: 100;
position: relative;
}
.headerDescription a {
color: #fff;
}
.headerDescription a:hover {
text-decoration: none;
border-bottom: 1px solid #fff;
}
.loginBtn {
text-align: right;
padding-right: 75px;
}
.loginBtn button {
background-color: #f93c18;
padding: 10px 30px;
border: 0;
color: #fff;
font-family: "raleway";
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
border-radius: 25px;
text-transform: uppercase;
}
.loginBtn button:hover {
background-color: #e0270e;
}
.loginBtn button:focus {
outline: none;
}
.mainWrapper {
padding-left: 75px;
width: 100%;
float: left;
display: flex;
align-items: center;
}
.description {
font-size: 15px;
padding-bottom: 10px;
}
.appstackWrapper {
margin-top: 20px;
background-color: #fff;
border-radius: 5px;
padding: 30px;
-webkit-box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
-moz-box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.1);
width: 100%;
float: left;
color: #606060;
}
.arrow {
position: absolute;
right: -100px;
top: 10px;
z-index: 1;
}
.arrow img {
width: 120px;
display: inline-block;
}
.appStack {
width: 100%;
float: left;
padding-bottom: 20px;
}
.appStack i {
font-size: 16px;
}
.checkBox
{
color: #00bc00;
font-size: 22px !important;
}
.appStackIconWrapper {
width: 100%;
float: left;
display: flex;
align-items: center;
}
.appStackIcon img {
width: 70%;
}
.formGroupWrapper
{
padding-top: 20px;
width: 100%;
float: left;
}
.inputGroup
{
width: 100%;
}
.inputGroup input
{
width: 68% !important;
display: inline-block;
height: 40px;
}
.inputGroup .groupAppend
{
width: 32% !important;
display: inline-block;
}
.groupAppend button
{
width: 100%;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
border: 0;
height: 40px;
text-align: center;
background-color: #f93c18;
color: #fff;
font-family: "raleway";
font-size: 14px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.groupAppend button:hover
{
color: #fff;
background-color: #e0270e;
}
.groupAppend button:focus
{
outline: none;
}
.footer {
padding-top: 10px;
text-align: center;
clear: both;
font-size: 14px;
color: #fff;
}
.footer a {
color: #fff;
}
.footer a:hover {
text-decoration: none;
border-bottom: 1px solid #fff;
}
.footer i {
color: #ed2908;
margin: 0 5px;
}
.tutorialImg {
position: absolute;
right: 0;
text-align: right;
}
.tutorialImg img {
width: 95%;
display: inline-block;
}
/* Landing section */
.app {
text-align: left;
}
.messageOdd {
font-size: 18px;
padding-left: 5px;
}
.message {
font-size: 16px;
background-color: #fff;
padding-left: 5px;
margin: 20px;
border-radius: 5px;
width: 50%;
padding: 5px;
}
.selfMessage {
font-size: 16px;
background-color: #eee;
padding-left: 5px;
margin: 20px;
border-radius: 5px;
width: 50%;
padding: 5px;
float: right;
}
.newMessageEven {
font-size: 18px;
background-color: #98FB98;
padding-left: 5px;
}
.newMessageOdd {
font-size: 18px;
background-color: #8FBC8F;
padding-left: 5px;
}
.messageWrapperNew {
padding-bottom: 75px;
}
.banner {
position: -webkit-sticky;
position: sticky;
top: 0;
align-self: flex-start;
background-color: #20c40f;
font-size: 18px;
cursor: pointer;
font-weight: 400;
padding: 10px 0;
text-align: center;
font-family: 'raleway';
color: #fff;
}
.oldNewSeparator {
font-size: 18px;
text-align: center;
margin-bottom: 15px;
}
#chatbox {
overflow: auto;
height: calc(100vh - 90px);
background-color: #f8f9f9;
}
.textboxWrapper {
text-align: center;
position: fixed;
bottom: 87px;
width: 75%;
background-color: #fff;
padding: 1%;
}
.textbox {}
.sendButton {
width: 20%;
background-color:'green';
}
.login {
text-align: center;
}
.loginTextbox {
font-size: 16px;
height: 43px;
width: 74%;
margin-right: 1%;
font-weight: 300;
border: 1px solid #ececec;
border-radius: 5px;
padding: 0;
padding-left: 10px;
display: inline-block;
}
.typoTextbox {
font-size: 16px;
height: 43px;
width: 75%;
margin-right: 1%;
font-weight: 300;
border: 1px solid #ececec;
border-radius: 5px;
padding: 0;
padding-left: 10px;
display: inline-block;
background-color: #f6f6f7;
}
.loginTextbox:focus, .typoTextbox:focus {
outline: none;
border-color: #016d95;
}
.typoTextbox:focus {
outline: none;
border-color: #bbbdbd;
}
.loginButton {
height: 45px;
width: 21%;
display: inline-block;
border-radius: 5px;
background-color: 'green' !important;
cursor: pointer;
font-size: 16px;
margin-right: 1%;
}
.typoButton {
height: 45px;
width: 20%;
display: inline-block;
border-radius: 5px;
background-color: #ffca27;
cursor: pointer;
font-size: 16px;
margin-right: 1%;
border: 0;
color: #222;
}
.typoButton:hover {
background-color: #dba203;
}
.loginButton:focus, .typoButton:focus {
outline: none;
}
.loginButton:hover {
background-color: #f6f6f6;
}
.loginHeading {
text-align: center;
font-family: 'Raleway';
margin-top: 0;
padding-bottom: 20px;
}
.loginWrapper {
width: 450px;
padding: 30px;
margin: 0 auto;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border: 1px solid #ececec;
border-radius: 5px;
background-color: #f6f6f6;
}
.errorMessage {
text-align: center;
color: 'red';
padding-bottom: 10px;
}
.wd25 {
width: 25%;
display: inline-block;
}
.wd75 {
width: 75%;
display: inline-block;
}
.onlineUsers {
background-color: #4f5050;
height: 100vh;
overflow: auto;
}
.messageName, .messsageTime {
width: 49%;
display: inline-block;
}
.messageName {
color: #1d5d01;
}
.messsageTime {
text-align: right;
padding-right: 5px;
font-size: 12px;
color: #01999b;
}
.userList {
margin-top: 0;
margin-bottom: 0;
-webkit-padding-start: 0px;
}
.userList li {
padding: 10px;
border-bottom: 1px solid #444;
color: #fff;
}
.chatWrapper {
display: flex;
height: 100vh;
}
.userListHeading {
font-weight: 600;
padding: 15px 10px;
margin-top: 0;
margin-bottom: 0;
background-color: #222;
color: #fff;
}
.typingIndicator {
text-align: left;
padding-bottom: 10px;
padding-left: 1%;
}
.displayFlex
{
display: flex;
align-items: center;
}
.hasura-logo {
display: flex;
align-items: center;
justify-content: center;
}
.hasura-logo a {
padding: 0 10px;
}
.hasura-logo a:hover {
text-decoration: none;
}
.hasura-logo img {
height: 30px;
cursor: pointer;
}
.App-header {
background-color: #222;
height: 50px;
color: white;
text-align: left;
}
.App-footer{
position: fixed;
bottom: 0;
text-align: center;
width: 100%;
padding-bottom: 10px;
padding-top: 10px;
background-color: #fff;
border-top: 1px solid #ccc;
}
.footer-small-text{
font-size: 12px;
}
@media (max-width: 767px) {
.headerDescription
{
text-align: center;
}
.headerWrapper {
display: block !important;
}
.description {
text-align: center;
padding-bottom: 10px;
}
.loginBtn {
display: flex;
justify-content: center;
}
.wd75
{
width: 100%;
}
.message
{
width: 90%;
}
.textboxWrapper
{
width: 100%;
}
.mobileview
{
position: absolute;
right: 0px;
bottom: 152px;
width: 50%;
}
.mobileuserListHeading
{
font-size: 14px;
background-color: #222;
color: #fff;
font-weight: 600;
margin-bottom: 0;
padding: 5px;
}
.mobileuserListHeading i
{
margin-left: 10px;
}
.mobileUserList
{
background-color: #4f5050;
padding-inline-start: 0px;
-webkit-padding-start: 0px;
-moz-padding-start: 0px;
-o-padding-start: 0px;
margin-bottom: 0;
}
.mobileUserList li
{
list-style-type: none;
color: #fff;
padding: 5px;
font-size: 12px;
border-bottom: 1px solid #444;
}
.hasura-logo a {
padding: 0 0px;
font-size: 12px;
}
}
@media (max-width: 991px) {
.headerWrapper {
display: flex;
padding-left: 0;
height: auto;
}
.mainWrapper {
min-height: auto;
padding-left: 0;
height: auto;
}
.minHeight {
height: auto;
min-height: 100vh;
}
.loginBtn {
padding-right: 0;
}
.appstackWrapper {
padding: 20px;
}
.appStack {
display: flex;
}
.flexWidth {
flex: 1;
}
.description {
padding-left: 10px;
}
.appStackIconWrapper {
padding-left: 10px;
}
.inputGroup input
{
width: 63% !important;
}
.inputGroup .groupAppend
{
width: 37% !important;
}
.groupAppend button {
font-size: 13px;
letter-spacing: 0;
}
}

View File

@ -0,0 +1,9 @@
import React from 'react';
import Main from './components/Main';
import './App.css' ;
const App = () => {
return <div className="app"> <Main/> </div>;
}
export default App;

View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View File

@ -0,0 +1,11 @@
import '../App.css';
const Banner = (props) => {
return (
<div className="banner" onClick={props.scrollToNewMessage}>
You have {props.numOfNewMessages} new message(s)
</div>
);
};
export default Banner;

View File

@ -0,0 +1,73 @@
import { gql } from '@apollo/client';
import ChatWrapper from './ChatWrapper';
import '../App.css';
import { useInterval } from '../hooks/useInterval';
const emitOnlineEvent = gql`
mutation ($userId: Int!) {
update_user_by_pk(
pk_columns: { id: $userId }
_set: { last_seen: "now()" }
) {
id
}
}
`;
function Chat(props) {
/**
* Every 3 seconds emit an online event
*/
useInterval(async () => {
await props.client.mutate({
mutation: emitOnlineEvent,
variables: {
userId: props.userId,
},
});
}, 3000);
return (
<div>
<ChatWrapper userId={props.userId} username={props.username} />
<footer className="App-footer">
<div className="hasura-logo">
<img
src="https://graphql-engine-cdn.hasura.io/img/powered_by_hasura_black.svg"
onClick={() => window.open('https://hasura.io')}
alt="Powered by Hasura"
/>
&nbsp; | &nbsp;
<a
href="https://realtime-chat.hasura.app/console"
target="_blank"
rel="noopener noreferrer"
>
Backend
</a>
&nbsp; | &nbsp;
<a
href="https://github.com/hasura/graphql-engine/tree/master/community/sample-apps/realtime-chat"
target="_blank"
rel="noopener noreferrer"
>
Source
</a>
&nbsp; | &nbsp;
<a
href="https://hasura.io/blog/building-a-realtime-chat-app-with-graphql-subscriptions-d68cd33e73f"
target="_blank"
rel="noopener noreferrer"
>
Blogpost
</a>
</div>
<div className="footer-small-text">
<span>(The database resets every 24 hours)</span>
</div>
</footer>
</div>
);
}
export default Chat;

View File

@ -0,0 +1,32 @@
import { useState } from 'react';
import RenderMessages from './RenderMessages';
import Textbox from './Textbox';
import OnlineUsers from './OnlineUsers';
import '../App.css';
export default function RenderMessagesProxy(props) {
const [mutationCallback, setMutationCallback] = useState(null);
return (
<div className="chatWrapper">
<div className="wd25 hidden-xs">
<OnlineUsers userId={props.userId} username={props.username} />
</div>
<div className="mobileview visible-xs">
<OnlineUsers userId={props.userId} username={props.username} />
</div>
<div className="wd75">
<RenderMessages
setMutationCallback={setMutationCallback}
username={props.username}
userId={props.userId}
/>
<Textbox
username={props.username}
mutationCallback={mutationCallback}
userId={props.userId}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,222 @@
import PropTypes from 'prop-types';
import { gql, useMutation } from '@apollo/client';
import '../App.css';
import reactLogo from '../images/React-logo.png';
import graphql from '../images/graphql.png';
import hasuraLogo from '../images/green-logo-white.svg';
import apolloLogo from '../images/apollo.png';
import rightImg from '../images/chat-app.png';
const addUser = gql`
mutation ($username: String!) {
insert_user_one(object: { username: $username }) {
id
username
}
}
`;
const LandingPage = (props) => {
const [addUserHandler, { loading }] = useMutation(addUser, {
variables: {
username: props.username,
},
onCompleted: (data) => {
props.login(data.insert_user_one.id);
},
onError: () => {
alert('Please try again with a different username.');
props.setUsername('');
},
});
const handleKeyPress = (key, mutate, loading) => {
if (!loading && key.charCode === 13) {
mutate();
}
};
return (
<div className="container-fluid minHeight">
<div className="bgImage"></div>
<div>
<div className="headerWrapper">
<div className="headerDescription">Realtime Chat App</div>
</div>
<div className="mainWrapper">
<div className="col-md-5 col-sm-6 col-xs-12 noPadd">
<div className="appstackWrapper">
<div className="appStack">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="em em---1" />
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description">
Try out a realtime app that uses
</div>
<div className="appStackIconWrapper">
<div className="col-md-4 col-sm-4 col-xs-4 noPadd">
<div className="appStackIcon">
<img
className="img-responsive"
src={reactLogo}
alt="React logo"
/>
</div>
</div>
<div className="col-md-8 col-sm-8 col-xs-8 noPadd">
<div className="appStackIcon">
<img
className="img-responsive"
src={graphql}
alt="GraphQL logo"
/>
</div>
</div>
</div>
</div>
</div>
<div className="appStack">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="em em-rocket" />
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description">Powered by</div>
<div className="appStackIconWrapper">
<div className="col-md-4 col-sm-4 col-xs-4 noPadd">
<div className="appStackIcon">
<img
className="img-responsive"
src={apolloLogo}
alt="apollo logo"
/>
</div>
</div>
<div className="col-md-4 col-sm-4 col-xs-4 noPadd">
<div className="appStackIcon">
<img
className="img-responsive"
src={hasuraLogo}
alt="Hasura logo"
/>
</div>
</div>
</div>
</div>
</div>
<div className="appStack">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="em em-sunglasses" />
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description removePaddBottom">
Explore the Hasura GraphQL backend and try out some queries
& mutations
</div>
</div>
</div>
<div className="appStack removePaddBottom">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="fas fa-check-square checkBox"></i>
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description removePaddBottom">
What you get...
</div>
<div className="addPaddTop">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="em em-hammer_and_wrench"></i>
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description removePaddBottom">
Source code
</div>
</div>
</div>
<div className="addPaddTop">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="em em-closed_lock_with_key"></i>
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description removePaddBottom">
Access to GraphQL Backend
</div>
</div>
</div>
<div className="addPaddTop">
<div className="col-md-1 col-sm-1 col-xs-2 removePaddLeft flexWidth">
<i className="em em-zap" />
</div>
<div className="col-md-11 col-sm-11 col-xs-10 noPadd">
<div className="description removePaddBottom">
Full Tutorial
</div>
</div>
</div>
</div>
</div>
</div>
<div className="formGroupWrapper">
<div className="input-group inputGroup">
<input
type="text"
className="form-control"
placeholder="Enter your username"
value={props.username}
onChange={(e) => props.setUsername(e.target.value)}
onKeyPress={(key) =>
handleKeyPress(key, addUserHandler, loading)
}
disabled={loading}
/>
<div className="input-group-append groupAppend">
<button
className="btn btn-outline-secondary"
type="submit"
onClick={(e) => {
e.preventDefault();
if (props.username.match(/^[a-z0-9_-]{3,15}$/g)) {
addUserHandler();
} else {
alert(
'Invalid username. Spaces and special characters not allowed. Please try again'
);
props.setUsername('');
}
}}
disabled={loading || props.username === ''}
>
{loading ? 'Please wait ...' : 'Get Started'}
</button>
</div>
</div>
</div>
{/*
<div className="footer">
Built with
<i className="fas fa-heart" />
by{" "}
<a
href="https://hasura.io/"
target="_blank"
rel="noopener noreferrer"
>
Hasura
</a>
</div>
*/}
</div>
<div className="tutorialImg col-md-6 col-sm-6 col-xs-12 hidden-xs noPadd">
<img className="img-responsive" src={rightImg} alt="View" />
</div>
</div>
</div>
</div>
);
};
LandingPage.propTypes = {
auth: PropTypes.object,
isAuthenticated: PropTypes.bool,
};
export default LandingPage;

View File

@ -0,0 +1,35 @@
import { useState } from 'react';
import { ApolloConsumer } from '@apollo/client';
import Chat from './Chat';
import LandingPage from './LandingPage';
import '../App.css';
export default function Main() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [username, setUsername] = useState('');
const [userId, setUserId] = useState(null);
// check usernme and perform login
const login = (id) => {
setIsLoggedIn(true);
setUserId(id);
};
return (
<div className="app">
{!isLoggedIn ? (
<LandingPage
setUsername={setUsername}
login={login}
username={username}
/>
) : (
<ApolloConsumer>
{(client) => {
return <Chat userId={userId} username={username} client={client} />;
}}
</ApolloConsumer>
)}
</div>
);
}

View File

@ -0,0 +1,26 @@
import '../App.js';
import '../App.css';
import moment from 'moment';
export default function MessageList(props) {
return (
<div className={props.isNew ? 'messageWrapperNew' : 'messageWrapper'}>
{props.messages.map((m) => {
return (
<div key={m.id} className="message">
<div className="messageNameTime">
<div className="messageName">
<b>{m.username}</b>
</div>
<div className="messsageTime">
<i>{moment(m.timestamp).fromNow()} </i>
</div>
</div>
<div className="messageText">{m.text}</div>
</div>
);
})}
<div style={{ height: 0 }} id="lastMessage"></div>
</div>
);
}

View File

@ -0,0 +1,51 @@
import { useState } from 'react';
import { gql, useSubscription } from '@apollo/client';
const fetchOnlineUsersSubscription = gql`
subscription {
user_online(order_by: { username: asc }) {
id
username
}
}
`;
function OnlineUsers() {
const [showMobileView, setMobileView] = useState(false);
const { data } = useSubscription(fetchOnlineUsersSubscription);
const toggleMobileView = () => {
setMobileView(!showMobileView);
};
const subscriptionData = (isMobileView) => (
<div>
<p
className={isMobileView ? 'mobileuserListHeading' : 'userListHeading'}
onClick={toggleMobileView}
>
Online Users ({!data?.user_online ? 0 : data?.user_online.length}){' '}
{isMobileView && <i className="fa fa-angle-up"></i>}
</p>
{((isMobileView && showMobileView) || !isMobileView) && (
<ul className={isMobileView ? 'mobileUserList' : 'userList'}>
{data?.user_online.map((u) => {
return <li key={u.id}>{u.username}</li>;
})}
</ul>
)}
</div>
);
return (
<div>
<div className="onlineUsers hidden-xs">{subscriptionData(false)}</div>
<div className="mobileonlineUsers visible-xs">
{subscriptionData(true)}
</div>
</div>
);
}
export default OnlineUsers;

View File

@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from "react";
import { gql, useSubscription } from "@apollo/client";
import "../App.js";
import Banner from "./Banner";
import MessageList from "./MessageList";
const fetchMessages = gql`
# We can pass in how far back we want to fetch messages
subscription ($last_received_ts: timestamptz) {
message_stream(
cursor: { initial_value: { timestamp: $last_received_ts } }
batch_size: 100
) {
id
username
text
timestamp
}
}
`;
export default function RenderMessages({
setMutationCallback,
username,
userId,
}) {
const [messages, setMessages] = useState([]);
const [newMessages, setNewMessages] = useState([]);
const [bottom, setBottom] = useState(true);
// add old (read) messages to state
const addOldMessages = (newMessages) => {
const oldMessages = [...messages, ...newMessages];
setMessages(oldMessages);
setNewMessages([]);
};
useSubscription(fetchMessages, {
variables: {
// Arbitrary large date to fetch all messages
last_received_ts: "2018-08-21T19:58:46.987552+00:00",
},
onSubscriptionData: async ({ subscriptionData }) => {
if (subscriptionData) {
if (!isViewScrollable()) {
addOldMessages(subscriptionData.data.message_stream);
} else {
if (bottom) {
addOldMessages(subscriptionData.data.message_stream);
} else {
addNewMessages(subscriptionData.data.message_stream);
}
}
}
},
});
// scroll to bottom
const scrollToBottom = () => {
document
?.getElementById("lastMessage")
?.scrollIntoView({ behavior: "instant" });
};
// scroll to the new message
const scrollToNewMessage = () => {
document
?.getElementById("newMessage")
?.scrollIntoView({ behavior: "instant" });
};
if (newMessages.length === 0) {
scrollToBottom();
}
// add message to state when text is entered
const mutationCallback = useCallback(() => {
return (newMessage) => {
const allMessages = [...messages, ...newMessages];
allMessages.push(newMessage);
setMessages(messages);
setNewMessages([]);
};
}, [messages, newMessages]);
// scroll handler
const handleScroll = useCallback(() => {
return (e) => {
const windowHeight =
"innerHeight" in window
? window.innerHeight
: document.documentElement.offsetHeight;
const body = document.getElementById("chatbox");
const html = document.documentElement;
const docHeight = Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
const windowBottom = windowHeight + window.pageYOffset;
if (windowBottom >= docHeight) {
setBottom(true);
} else {
if (bottom) {
setBottom(false);
}
}
};
}, [bottom]);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
setMutationCallback(mutationCallback);
}, [setMutationCallback, mutationCallback]);
// add new (unread) messages to state
const addNewMessages = (incomingMessages) => {
const allNewMessages = [...newMessages];
incomingMessages.forEach((m) => {
// do not add new messages from self
if (m.username !== username) {
allNewMessages.push(m);
}
});
setNewMessages(newMessages);
};
// check if the view is scrollable
const isViewScrollable = () => {
const isInViewport = (elem) => {
const bounding = elem.getBoundingClientRect();
return (
bounding.top >= 0 &&
bounding.left >= 0 &&
bounding.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
bounding.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
};
if (document.getElementById("lastMessage")) {
return !isInViewport(document.getElementById("lastMessage"));
}
return false;
};
return (
<div id="chatbox">
{/* show "unread messages" banner if not at bottom */}
{!bottom && newMessages.length > 0 && isViewScrollable() ? (
<Banner
scrollToNewMessage={scrollToNewMessage}
numOfNewMessages={newMessages.length}
/>
) : null}
{/* Render old messages */}
<MessageList messages={messages} isNew={false} username={username} />
{/* Show old/new message separation */}
<div id="newMessage" className="oldNewSeparator">
{newMessages.length !== 0 ? "New messages" : null}
</div>
{/* render new messages */}
<MessageList messages={newMessages} isNew={true} username={username} />
{/* Bottom div to scroll to */}
</div>
);
}

View File

@ -0,0 +1,99 @@
import { useState } from 'react';
import { gql, useMutation } from '@apollo/client';
import TypingIndicator from './TypingIndicator';
import '../App.css';
const insertMessage = gql`
mutation insert_message($message: message_insert_input!) {
insert_message_one(object: $message) {
id
timestamp
text
username
}
}
`;
const emitTypingEventGql = gql`
mutation ($userId: Int!) {
update_user_by_pk(
pk_columns: { id: $userId }
_set: { last_typed: "now()" }
) {
id
}
}
`;
export default function Textbox(props) {
const [text, setText] = useState('');
const [insertMessageHandler, { client }] = useMutation(insertMessage, {
variables: {
message: {
username: props.username,
text: text,
},
update: (cache, { data: { insert_message_one } }) => {
props.mutationCallback({
id: insert_message_one.id,
timestamp: insert_message_one.timestamp,
username: insert_message_one.username,
text: insert_message_one.text,
});
},
},
});
const handleTyping = (text, mutate) => {
const textLength = text.length;
if ((textLength !== 0 && textLength % 5 === 0) || textLength === 1) {
emitTypingEvent(mutate);
}
setText(text);
};
const form = (sendMessage, client) => {
return (
<form onSubmit={sendMessage}>
<div className="textboxWrapper">
<TypingIndicator userId={props.userId} />
<input
id="textbox"
className="textbox typoTextbox"
value={text}
autoFocus={true}
onChange={(e) => {
handleTyping(e.target.value, client.mutate);
}}
autoComplete="off"
/>
<button className="sendButton typoButton" onClick={sendMessage}>
{' '}
Send{' '}
</button>
</div>
</form>
);
};
const emitTypingEvent = async (mutate) => {
if (props.userId) {
await mutate({
mutation: emitTypingEventGql,
variables: {
userId: props.userId,
},
});
}
};
const sendMessage = (e) => {
e.preventDefault();
if (text === '') {
return;
}
insertMessageHandler();
setText('');
};
return form(sendMessage, client);
}

View File

@ -0,0 +1,39 @@
import { gql, useSubscription } from '@apollo/client';
import '../App.css';
const getUserTyping = gql`
subscription ($selfId: Int) {
user_typing(
where: { id: { _neq: $selfId } }
limit: 1
order_by: { last_typed: desc }
) {
last_typed
username
}
}
`;
function TypingIndicator(props) {
const { data, loading, error } = useSubscription(getUserTyping, {
variables: {
selfId: props.userId,
},
});
if (loading) {
return '';
}
if (error) {
return '';
}
return (
<div className="typingIndicator">
{data?.user_typing?.length === 0
? ''
: `${data.user_typing[0].username} is typing ...`}
</div>
);
}
export default TypingIndicator;

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,22 @@
// Credit to Dan Abramov https://overreacted.io/making-setinterval-declarative-with-react-hooks/
import { useEffect, useRef } from 'react';
export function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 220 80" style="enable-background:new 0 0 220 80;" xml:space="preserve">
<style type="text/css">
.st0{fill:#102954;}
.st1{fill:#FFFFFF;}
</style>
<g>
<g>
<g>
<path class="st0" d="M67.4,26.8c2.1-5.2,2.2-16-0.7-24.4l0,0c-0.7-1.5-3-1.1-3.1,0.7v0.6c-0.5,7.9-3.4,12.2-7.6,14.2
c-0.7,0.3-1.8,0.2-2.5-0.2c-5.1-3.2-11-5.1-17.5-5.1s-12.4,1.9-17.5,5.1c-0.7,0.4-1.5,0.5-2.2,0.2C12,16.3,8.9,11.5,8.4,3.6V3.1
c0-1.6-2.3-2.1-3.1-0.7c-3,8.3-2.9,19.1-0.7,24.4c1.1,2.6,1.1,5.6,0.2,8.3c-1.2,3.4-1.8,7.2-1.7,11c0.3,17.4,15.1,32.2,32.4,32.4
c18.3,0.2,33.3-14.6,33.3-32.9c0-3.7-0.6-7.2-1.7-10.5C66.3,32.4,66.4,29.4,67.4,26.8z"/>
</g>
<ellipse class="st1" cx="36" cy="45.5" rx="25" ry="25"/>
<path class="st0" d="M39.9,42.9L34,33.8c-1-1.5-2.9-1.9-4.4-1c-0.9,0.6-1.5,1.6-1.5,2.7c0,0.6,0.2,1.2,0.6,1.7l4,6.2
c0.3,0.5,0.2,1.1-0.1,1.5l-6.2,6.8c-1.1,1.3-1.1,3.3,0.2,4.5c0.6,0.6,1.4,0.8,2.2,0.8c0.9,0,1.7-0.4,2.3-1.1l4.6-5.4
c0.3-0.4,1-0.4,1.3,0.1l3.3,4.7c0.2,0.3,0.5,0.7,0.9,1c1.1,0.8,2.5,0.7,3.5,0.1l0,0c0.9-0.6,1.5-1.6,1.5-2.7
c0-0.6-0.2-1.2-0.5-1.7L39.9,42.9z"/>
</g>
<g>
<path class="st0" d="M96.8,45.6h-3.9c-0.6,0-1.1-0.5-1.1-1.1V32.2c0-0.6-0.5-1.1-1.1-1.1h-3.4c-0.6,0-1.1,0.5-1.1,1.1v31.3
c0,0.6,0.5,1.1,1.1,1.1h3.4c0.6,0,1.1-0.5,1.1-1.1V51.3c0-0.6,0.5-1.1,1.1-1.1h3.9c0.6,0,1.1,0.5,1.1,1.1v12.2
c0,0.6,0.5,1.1,1.1,1.1h3.4c0.6,0,1.1-0.5,1.1-1.1V32.2c0-0.6-0.5-1.1-1.1-1.1H99c-0.6,0-1.1,0.5-1.1,1.1v12.3
C97.9,45.1,97.4,45.6,96.8,45.6z"/>
<path class="st0" d="M114.2,32l-5.5,31.3c-0.1,0.6,0.4,1.2,1,1.2h3.4c0.5,0,1-0.4,1-0.9l0.9-5.7c0.1-0.5,0.5-0.9,1-0.9h4.3
c0.5,0,1,0.4,1,0.9l1,5.8c0.1,0.5,0.5,0.9,1,0.9h3.5c0.7,0,1.2-0.6,1-1.3L122,32c-0.1-0.5-0.5-0.9-1-0.9h-5.8
C114.7,31.1,114.3,31.5,114.2,32z M119.3,52.3h-2.2c-0.7,0-1.1-0.6-1-1.2l1-11.5c0.2-1.2,1.9-1.2,2.1,0l1.1,11.5
C120.4,51.7,119.9,52.3,119.3,52.3z"/>
<path class="st0" d="M143,45.2h-3.8c-0.7,0-1.1-0.3-1.1-1.1v-7.2c0-0.7,0.4-1.1,1.1-1.1h2.2c0.7,0,1.1,0.3,1.1,1.1v3.4
c0,0.6,0.5,1.1,1.1,1.1h3.5c0.6,0,1.1-0.5,1.1-1.1v-4.2c0-3.3-1.8-5-5.3-5h-5.1c-3.5,0-5.3,1.7-5.3,5v8.6c0,3.3,1.8,5.1,5.2,5.1
h3.8c0.7,0,1.1,0.3,1.1,1.1v8c0,0.7-0.3,1.1-1.1,1.1h-2.2c-0.7,0-1.1-0.3-1.1-1.1v-3.4c0-0.6-0.5-1.1-1.1-1.1h-3.5
c-0.6,0-1.1,0.5-1.1,1.1v4.2c0,3.3,1.8,5,5.3,5h5c3.5,0,5.3-1.7,5.3-5v-9.4C148.2,46.9,146.4,45.2,143,45.2z"/>
<path class="st0" d="M164,58.8c0,0.7-0.3,1.1-1.1,1.1h-3c-0.7,0-1.1-0.3-1.1-1.1V32.2c0-0.6-0.5-1.1-1.1-1.1h-3.5
c-0.6,0-1.1,0.5-1.1,1.1v27.3c0,3.3,1.8,5,5.3,5h5.8c3.5,0,5.3-1.7,5.3-5V32.2c0-0.6-0.5-1.1-1.1-1.1h-3.3c-0.6,0-1.1,0.5-1.1,1.1
L164,58.8L164,58.8z"/>
<path class="st0" d="M191.8,46.3V36.1c0-3.3-1.8-5-5.3-5h-10c-0.6,0-1.1,0.5-1.1,1.1v31.3c0,0.6,0.5,1.1,1.1,1.1h3.4
c0.6,0,1.1-0.5,1.1-1.1v-11c0-0.6,0.5-1.1,1.1-1.1l0,0c0.4,0,0.8,0.3,1,0.7l4.4,11.8c0.2,0.4,0.6,0.7,1,0.7h3.7
c0.7,0,1.3-0.7,1-1.4L189,52.3c-0.2-0.5,0.1-1.1,0.6-1.4C190.9,50.1,191.8,48.5,191.8,46.3z M186.2,36.9v8.8
c0,0.7-0.4,1.1-1.1,1.1H182c-0.6,0-1.1-0.5-1.1-1.1v-8.8c0-0.6,0.5-1.1,1.1-1.1h3.1C185.8,35.8,186.2,36.2,186.2,36.9z"/>
<path class="st0" d="M210.2,31.1h-5.8c-0.5,0-1,0.4-1,0.9l-5.5,31.3c-0.1,0.6,0.4,1.2,1,1.2h3.4c0.5,0,1-0.4,1-0.9l0.9-5.7
c0.1-0.5,0.5-0.9,1-0.9h4.3c0.5,0,1,0.4,1,0.9l1,5.8c0.1,0.5,0.5,0.9,1,0.9h3.5c0.7,0,1.2-0.6,1-1.3L211.2,32
C211.1,31.5,210.7,31.1,210.2,31.1z M208.4,52.3h-2.1c-0.7,0-1.1-0.6-1-1.2l1-10.5c0.2-1.2,1.9-1.2,2.1,0l1.1,10.5
C209.6,51.7,209.1,52.3,208.4,52.3z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}

View File

@ -0,0 +1,68 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
split,
HttpLink,
} from "@apollo/client";
import { getMainDefinition } from "@apollo/client/utilities";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { createClient } from "graphql-ws";
import reportWebVitals from "./reportWebVitals";
const scheme = (proto) => {
return window.location.protocol === "https:" ? `${proto}s` : proto;
};
const HASURA_GRAPHQL_ENGINE_HOSTNAME = "localhost:8080";
export const GRAPHQL_ENDPOINT = `${scheme(
"http"
)}://${HASURA_GRAPHQL_ENGINE_HOSTNAME}/v1/graphql`;
export const WEBSOCKET_ENDPOINT = `${scheme(
"ws"
)}://${HASURA_GRAPHQL_ENGINE_HOSTNAME}/v1/graphql`;
const httpLink = new HttpLink({ uri: GRAPHQL_ENDPOINT });
const wsLink = new GraphQLWsLink(
createClient({
url: WEBSOCKET_ENDPOINT,
})
);
const link = split(
// split based on operation type
({ query }) => {
const { kind, operation } = getMainDefinition(query);
return kind === "OperationDefinition" && operation === "subscription";
},
wsLink,
httpLink
);
// Instantiate client
const client = new ApolloClient({
link,
cache: new InMemoryCache({
addTypename: false,
}),
});
const container = document.getElementById("root");
const root = createRoot(container);
root.render(
<React.StrictMode>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,13 @@
const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;