mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-09-11 10:46:25 +03:00
community: update sample realtime-chat dependencies
GITHUB_PR_NUMBER: 7844 GITHUB_PR_URL: https://github.com/hasura/graphql-engine/pull/7844 PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2964 Co-authored-by: arjunyel <11153289+arjunyel@users.noreply.github.com> GitOrigin-RevId: 34c4054e24fc1fa6c44c6c8a84db5b0d887faddb
This commit is contained in:
parent
23e1cb218a
commit
c5cedbd84b
@ -1,4 +1,4 @@
|
||||
FROM node:carbon as builder
|
||||
FROM node:16 as builder
|
||||
ENV NODE_ENV=PRODUCTION
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
@ -6,9 +6,9 @@ RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:8-alpine
|
||||
FROM node:16-alpine
|
||||
RUN npm -g install serve
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/build .
|
||||
CMD ["serve", "-s", "-p", "8080"]
|
||||
CMD ["serve", "-s", "-p", "3000"]
|
@ -2,10 +2,218 @@
|
||||
|
||||
This is the source code for a fully working group chat app that uses subscriptions 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.app/)
|
||||
- [Backend](https://realtime-chat.demo.hasura.app/console)
|
||||
|
||||
For a complete tutorial about data modelling, [check out this blog](https://hasura.io/blog/building-a-realtime-chat-app-with-graphql-subscriptions-d68cd33e73f).
|
||||
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 realtime GraphQL API without writing any backend code.
|
||||
|
||||
- To save bandwith we use a subscription to listen for message events, then run a query to fetch only the 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 few characters and a subscription is used to fetch the last typing user.
|
||||
|
||||
## Introduction
|
||||
|
||||
Hasura allows us to instantly create a realtime 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
|
||||
|
||||
We can use Graphql subscriptions in two ways:
|
||||
|
||||
1. Subscribe to a query and render all the data that comes back.
|
||||
|
||||
1. Use the subscription as an event notification then run our own custom fetching logic.
|
||||
|
||||
Option two is best for our chatroom use case because we can do some work to just fetch new messages. Otherwise every new message we would also come with all previous ones.
|
||||
|
||||
1. On load we fetch all existing messages by setting $last_received_id to -1 and $last_received_ts to a timestamp well into the past such as '2018-08-21T19:58:46.987552+00:00'
|
||||
|
||||
```gql
|
||||
query ($last_received_id: Int, $last_received_ts: timestamptz) {
|
||||
message(
|
||||
order_by: { timestamp: asc }
|
||||
where: {
|
||||
_and: {
|
||||
id: { _neq: $last_received_id }
|
||||
timestamp: { _gte: $last_received_ts }
|
||||
}
|
||||
}
|
||||
) {
|
||||
id
|
||||
text
|
||||
username
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. Now we subscribe to new message events
|
||||
|
||||
```gql
|
||||
subscription {
|
||||
message(order_by: { id: desc }, limit: 1) {
|
||||
id
|
||||
username
|
||||
text
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. Once we detect a new message we call our message query with our last known id and timestamp from our client state.
|
||||
|
||||
With a bit of magic from realtime 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
37
community/sample-apps/realtime-chat/docker-compose.yml
Normal file
37
community/sample-apps/realtime-chat/docker-compose.yml
Normal 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.0.10.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"
|
||||
volumes:
|
||||
- ./hasura/metadata:/hasura-metadata
|
||||
- ./hasura/migrations:/hasura-migrations
|
||||
frontend:
|
||||
build:
|
||||
dockerfile: ./Dockerfile
|
||||
context: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
depends_on:
|
||||
- 'graphql'
|
||||
volumes:
|
||||
db_data:
|
||||
|
@ -3,7 +3,7 @@
|
||||
configuration:
|
||||
connection_info:
|
||||
database_url:
|
||||
from_env: SAMPLE_APPS_DATABASE_URL
|
||||
from_env: PG_DATABASE_URL
|
||||
pool_settings:
|
||||
idle_timeout: 180
|
||||
max_connections: 50
|
||||
|
45952
community/sample-apps/realtime-chat/package-lock.json
generated
45952
community/sample-apps/realtime-chat/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,31 +3,42 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"apollo-boost": "^0.1.10",
|
||||
"apollo-client-preset": "^1.0.8",
|
||||
"apollo-link-schema": "^1.1.0",
|
||||
"apollo-link-ws": "^1.0.8",
|
||||
"apollo-utilities": "^1.0.16",
|
||||
"graphql": "^0.13.2",
|
||||
"graphql-tag": "^2.9.2",
|
||||
"graphql-tools": "^3.0.5",
|
||||
"moment": "^2.22.2",
|
||||
"react": "^16.4.1",
|
||||
"react-apollo": "^2.1.9",
|
||||
"react-dom": "^16.4.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-scripts": "^1.1.1",
|
||||
"subscriptions-transport-ws": "^0.9.12"
|
||||
"@apollo/client": "^3.5.5",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"moment": "^2.29.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"graphql-ws": "^5.5.5",
|
||||
"web-vitals": "^2.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --env=jsdom",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-graphql": "^1.5.0",
|
||||
"eslint": "^5.1.0",
|
||||
"eslint-plugin-react": "^7.10.0"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -555,7 +555,7 @@ body {
|
||||
.headerWrapper {
|
||||
display: block !important;
|
||||
}
|
||||
.000scription {
|
||||
.description {
|
||||
text-align: center;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import '../App.css';
|
||||
|
||||
const Banner = (props) => {
|
||||
@ -9,4 +8,4 @@ const Banner = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Banner;
|
||||
export default Banner;
|
||||
|
@ -1,121 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Subscription } from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
import { gql } from '@apollo/client';
|
||||
import ChatWrapper from './ChatWrapper';
|
||||
import '../App.css';
|
||||
|
||||
const subscribeToNewMessages = gql`
|
||||
subscription {
|
||||
message ( order_by: {id:desc} limit: 1) {
|
||||
id
|
||||
username
|
||||
text
|
||||
timestamp
|
||||
} }
|
||||
`;
|
||||
import { useInterval } from '../hooks/useInterval';
|
||||
|
||||
const emitOnlineEvent = gql`
|
||||
mutation ($userId:Int!){
|
||||
update_user (
|
||||
_set: {
|
||||
last_seen: "now()"
|
||||
}
|
||||
where: {
|
||||
id: {
|
||||
_eq: $userId
|
||||
}
|
||||
}
|
||||
mutation ($userId: Int!) {
|
||||
update_user_by_pk(
|
||||
pk_columns: { id: $userId }
|
||||
_set: { last_seen: "now()" }
|
||||
) {
|
||||
affected_rows
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
class Chat extends React.Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: props.username,
|
||||
refetch: null
|
||||
};
|
||||
}
|
||||
|
||||
// set refetch function (coming from child <Query> component) using callback
|
||||
setRefetch = (refetch) => {
|
||||
this.setState({
|
||||
refetch
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async componentDidMount() {
|
||||
// Emit and event saying the user is online every 5 seconds
|
||||
setInterval(
|
||||
async () => {
|
||||
await this.props.client.mutate({
|
||||
mutation: emitOnlineEvent,
|
||||
variables: {
|
||||
userId: this.props.userId
|
||||
}
|
||||
});
|
||||
function Chat(props) {
|
||||
/**
|
||||
* Every 3 seconds emit an online event
|
||||
*/
|
||||
useInterval(async () => {
|
||||
await props.client.mutate({
|
||||
mutation: emitOnlineEvent,
|
||||
variables: {
|
||||
userId: props.userId,
|
||||
},
|
||||
3000
|
||||
);
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
/*
|
||||
Subscription is used only for event notification
|
||||
No data is bound to the subscription component
|
||||
As soon as an event occurs, the refetch() of the child component is called
|
||||
*/
|
||||
render () {
|
||||
const { refetch, username } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<Subscription
|
||||
subscription={subscribeToNewMessages}
|
||||
>
|
||||
{
|
||||
({data, error, loading}) => {
|
||||
if (error || (data && data.message === null)) {
|
||||
console.error(error || `Unexpected response: ${data}`);
|
||||
return "Error";
|
||||
}
|
||||
if (refetch) {
|
||||
refetch();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</Subscription>
|
||||
<ChatWrapper
|
||||
refetch={refetch}
|
||||
setRefetch={this.setRefetch}
|
||||
userId={this.props.userId}
|
||||
username={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"/>
|
||||
|
|
||||
<a href="https://realtime-chat.hasura.app/console" target="_blank" rel="noopener noreferrer">
|
||||
Backend
|
||||
</a>
|
||||
|
|
||||
<a href="https://github.com/hasura/graphql-engine/tree/master/community/sample-apps/realtime-chat" target="_blank" rel="noopener noreferrer">
|
||||
Source
|
||||
</a>
|
||||
|
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
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"
|
||||
/>
|
||||
|
|
||||
<a
|
||||
href="https://realtime-chat.hasura.app/console"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Backend
|
||||
</a>
|
||||
|
|
||||
<a
|
||||
href="https://github.com/hasura/graphql-engine/tree/master/community/sample-apps/realtime-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
|
|
||||
<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;
|
||||
|
@ -1,55 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import RenderMessages from './RenderMessages';
|
||||
import Textbox from './Textbox'
|
||||
import Textbox from './Textbox';
|
||||
import OnlineUsers from './OnlineUsers';
|
||||
import "../App.css";
|
||||
import '../App.css';
|
||||
|
||||
export default class RenderMessagesProxy extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
refetch: null
|
||||
}
|
||||
}
|
||||
export default function RenderMessagesProxy(props) {
|
||||
const [mutationCallback, setMutationCallback] = useState(null);
|
||||
|
||||
// Set mutation callback. For instantly adding messages to state after mutation
|
||||
setMutationCallback = (mutationCallback) => {
|
||||
this.setState({
|
||||
mutationCallback
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="chatWrapper">
|
||||
<div className="wd25 hidden-xs">
|
||||
<OnlineUsers
|
||||
userId={this.props.userId}
|
||||
username={this.props.username}
|
||||
/>
|
||||
</div>
|
||||
<div className="mobileview visible-xs">
|
||||
<OnlineUsers
|
||||
userId={this.props.userId}
|
||||
username={this.props.username}
|
||||
/>
|
||||
</div>
|
||||
<div className="wd75">
|
||||
<RenderMessages
|
||||
refetch={this.props.refetch}
|
||||
setRefetch={this.props.setRefetch}
|
||||
setMutationCallback={this.setMutationCallback}
|
||||
username={this.props.username}
|
||||
userId={this.props.userId}
|
||||
|
||||
/>
|
||||
<Textbox
|
||||
username={this.props.username}
|
||||
mutationCallback={this.state.mutationCallback}
|
||||
userId={this.props.userId}
|
||||
/>
|
||||
</div>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -1,205 +1,196 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import gql from 'graphql-tag';
|
||||
import { Mutation } from 'react-apollo';
|
||||
import "../App.css";
|
||||
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 (
|
||||
objects: [{
|
||||
username: $username
|
||||
}]
|
||||
) {
|
||||
returning {
|
||||
id
|
||||
username
|
||||
}
|
||||
insert_user_one(object: { username: $username }) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LandingPage = (props) => {
|
||||
const reactLogo = require("../images/React-logo.png");
|
||||
const graphql = require("../images/graphql.png");
|
||||
const hasuraLogo = require("../images/green-logo-white.svg");
|
||||
const apolloLogo = require("../images/apollo.png");
|
||||
const rightImg = require("../images/chat-app.png");
|
||||
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 (
|
||||
<Mutation
|
||||
mutation={addUser}
|
||||
variables={{
|
||||
username: props.username
|
||||
}}
|
||||
onCompleted={(data) => {
|
||||
props.login(data.insert_user.returning[0].id);
|
||||
}}
|
||||
onError={() => {
|
||||
alert('Please try again with a different username.')
|
||||
props.setUsername('');
|
||||
}}
|
||||
>
|
||||
{
|
||||
(insert_user, { data, loading, error}) => {
|
||||
return (
|
||||
<div className="container-fluid minHeight">
|
||||
<div className="bgImage">
|
||||
</div>
|
||||
<div>
|
||||
<div className="headerWrapper">
|
||||
<div className="headerDescription">
|
||||
Realtime Chat App
|
||||
<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 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, insert_user, loading)}
|
||||
disabled={loading}
|
||||
</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 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)) {
|
||||
insert_user();
|
||||
} 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="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" />
|
||||
@ -213,23 +204,19 @@ const LandingPage = (props) => {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Mutation>
|
||||
</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
|
||||
isAuthenticated: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
|
@ -1,66 +1,35 @@
|
||||
import { ApolloConsumer } from 'react-apollo';
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { ApolloConsumer } from '@apollo/client';
|
||||
import Chat from './Chat';
|
||||
// import Login from './Login';
|
||||
import LandingPage from './LandingPage';
|
||||
import '../App.css';
|
||||
|
||||
export default class Main extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
isLoggedIn: false,
|
||||
username:"",
|
||||
userId: null
|
||||
};
|
||||
}
|
||||
|
||||
// set username
|
||||
setUsername = (username) => {
|
||||
this.setState({
|
||||
username
|
||||
})
|
||||
}
|
||||
export default function Main() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [userId, setUserId] = useState(null);
|
||||
|
||||
// check usernme and perform login
|
||||
login = (id) => {
|
||||
this.setState({
|
||||
isLoggedIn: true,
|
||||
userId: id
|
||||
})
|
||||
}
|
||||
const login = (id) => {
|
||||
setIsLoggedIn(true);
|
||||
setUserId(id);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { username, isLoggedIn, userId } = this.state;
|
||||
// Login if not logged in and head to chat
|
||||
return (
|
||||
<div className="app">
|
||||
{
|
||||
!isLoggedIn ? (
|
||||
<LandingPage
|
||||
setUsername={this.setUsername}
|
||||
login={this.login}
|
||||
username={username}
|
||||
/>
|
||||
) : (
|
||||
<ApolloConsumer>
|
||||
{
|
||||
(client) => {
|
||||
return (
|
||||
<Chat
|
||||
userId={userId}
|
||||
username={username}
|
||||
client={client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
</ApolloConsumer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="app">
|
||||
{!isLoggedIn ? (
|
||||
<LandingPage
|
||||
setUsername={setUsername}
|
||||
login={login}
|
||||
username={username}
|
||||
/>
|
||||
) : (
|
||||
<ApolloConsumer>
|
||||
{(client) => {
|
||||
return <Chat userId={userId} username={username} client={client} />;
|
||||
}}
|
||||
</ApolloConsumer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,39 +1,26 @@
|
||||
import React from 'react';
|
||||
import '../App.js';
|
||||
import '../App.css';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class MessageList extends React.Component {
|
||||
render() {
|
||||
const { isNew } = this.props;
|
||||
|
||||
return (
|
||||
<div className={isNew ? "messageWrapperNew" : "messageWrapper"}>
|
||||
{
|
||||
this.props.messages.map((m, i) => {
|
||||
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>
|
||||
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
|
||||
style={{ "height": 0 }}
|
||||
id="lastMessage"
|
||||
>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -1,87 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Subscription } from 'react-apollo';
|
||||
import moment from 'moment';
|
||||
import gql from 'graphql-tag';
|
||||
import { useState } from 'react';
|
||||
import { gql, useSubscription } from '@apollo/client';
|
||||
|
||||
const fetchOnlineUsersSubscription = gql`
|
||||
subscription {
|
||||
user_online (
|
||||
order_by: {username:asc}
|
||||
) {
|
||||
user_online(order_by: { username: asc }) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
class OnlineUsers extends React.Component {
|
||||
function OnlineUsers() {
|
||||
const [showMobileView, setMobileView] = useState(false);
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
time: moment().subtract(10, 'seconds').format(),
|
||||
refetch: null,
|
||||
showMobileView: false
|
||||
}
|
||||
}
|
||||
const { data } = useSubscription(fetchOnlineUsersSubscription);
|
||||
|
||||
toggleMobileView = () => {
|
||||
this.setState({
|
||||
showMobileView: !this.state.showMobileView
|
||||
});
|
||||
}
|
||||
const toggleMobileView = () => {
|
||||
setMobileView(!showMobileView);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
const subscriptionData = (isMobileView) => (
|
||||
<Subscription
|
||||
subscription={fetchOnlineUsersSubscription}
|
||||
const subscriptionData = (isMobileView) => (
|
||||
<div>
|
||||
<p
|
||||
className={isMobileView ? 'mobileuserListHeading' : 'userListHeading'}
|
||||
onClick={toggleMobileView}
|
||||
>
|
||||
{
|
||||
({data, error, loading }) => {
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (error) { return "Error loading online users"; }
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className={ isMobileView ? "mobileuserListHeading" : "userListHeading"}
|
||||
onClick={this.toggleMobileView}
|
||||
>
|
||||
Online Users ({!data.user_online ? 0 : data.user_online.length}) { isMobileView && (<i className="fa fa-angle-up"></i>)}
|
||||
</p>
|
||||
{
|
||||
((isMobileView && this.state.showMobileView) || !isMobileView) &&
|
||||
(
|
||||
<ul className={isMobileView ? "mobileUserList" : "userList"}>
|
||||
{
|
||||
data.user_online.map((u) => {
|
||||
return <li key={u.id}>{u.username}</li>
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Subscription>
|
||||
);
|
||||
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>
|
||||
return (
|
||||
<div>
|
||||
<div className="onlineUsers hidden-xs">{subscriptionData(false)}</div>
|
||||
<div className="mobileonlineUsers visible-xs">
|
||||
{subscriptionData(true)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnlineUsers;
|
||||
|
@ -1,24 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Query } from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { gql, useQuery, useSubscription } from '@apollo/client';
|
||||
import '../App.js';
|
||||
import Banner from './Banner';
|
||||
import MessageList from './MessageList';
|
||||
|
||||
const fetchMessages = gql`
|
||||
query ($last_received_id: Int, $last_received_ts: timestamptz){
|
||||
message (
|
||||
order_by: {timestamp:asc}
|
||||
query ($last_received_id: Int, $last_received_ts: timestamptz) {
|
||||
message(
|
||||
order_by: { timestamp: asc }
|
||||
where: {
|
||||
_and: {
|
||||
id: {
|
||||
_neq: $last_received_id
|
||||
},
|
||||
timestamp: {
|
||||
_gte: $last_received_ts
|
||||
}
|
||||
id: { _neq: $last_received_id }
|
||||
timestamp: { _gte: $last_received_ts }
|
||||
}
|
||||
|
||||
}
|
||||
) {
|
||||
id
|
||||
@ -29,236 +23,203 @@ const fetchMessages = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default class RenderMessages extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
messages: [],
|
||||
newMessages: [],
|
||||
error: null,
|
||||
const subscribeToNewMessages = gql`
|
||||
subscription {
|
||||
message(order_by: { id: desc }, limit: 1) {
|
||||
id
|
||||
username
|
||||
text
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async componentWillMount() {
|
||||
// set mutation callback to update messages in state after mutation
|
||||
this.props.setMutationCallback(this.mutationCallback);
|
||||
}
|
||||
export default function RenderMessages({
|
||||
setMutationCallback,
|
||||
username,
|
||||
userId,
|
||||
}) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [newMessages, setNewMessages] = useState([]);
|
||||
const [bottom, setBottom] = useState(true);
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
// add scroll listener on mount
|
||||
window.addEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.state.newMessages.length === 0) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// remove scroll listener on unmount
|
||||
window.removeEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
// add old (read) messages to state
|
||||
const addOldMessages = (newMessages) => {
|
||||
const oldMessages = [...messages, ...newMessages];
|
||||
setMessages(oldMessages);
|
||||
setNewMessages([]);
|
||||
};
|
||||
|
||||
// get appropriate query variables
|
||||
getLastReceivedVars = () => {
|
||||
const { messages, newMessages } = this.state;
|
||||
const getLastReceivedVars = () => {
|
||||
if (newMessages.length === 0) {
|
||||
if (messages.length !== 0) {
|
||||
return {
|
||||
last_received_id: messages[messages.length - 1].id,
|
||||
last_received_ts: messages[messages.length - 1].timestamp
|
||||
}
|
||||
last_received_ts: messages[messages.length - 1].timestamp,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
last_received_id: -1,
|
||||
last_received_ts: "2018-08-21T19:58:46.987552+00:00"
|
||||
}
|
||||
last_received_ts: '2018-08-21T19:58:46.987552+00:00',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
last_received_id: newMessages[newMessages.length - 1].id,
|
||||
last_received_ts: newMessages[newMessages.length - 1].timestamp
|
||||
}
|
||||
last_received_ts: newMessages[newMessages.length - 1].timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// add new (unread) messages to state
|
||||
addNewMessages = (messages) => {
|
||||
const newMessages = [...this.state.newMessages];
|
||||
messages.forEach((m) => {
|
||||
// do not add new messages from self
|
||||
if (m.username !== this.props.username) {
|
||||
newMessages.push(m);
|
||||
const { loading, refetch } = useQuery(fetchMessages, {
|
||||
variables: getLastReceivedVars(),
|
||||
onCompleted: (data) => {
|
||||
const receivedMessages = data.message;
|
||||
|
||||
// load all messages to state in the beginning
|
||||
if (receivedMessages.length !== 0) {
|
||||
if (messages.length === 0) {
|
||||
addOldMessages(receivedMessages);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
newMessages
|
||||
})
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// add old (read) messages to state
|
||||
addOldMessages = (messages) => {
|
||||
const oldMessages = [ ...this.state.messages, ...messages];
|
||||
this.setState({
|
||||
messages: oldMessages,
|
||||
newMessages: []
|
||||
})
|
||||
}
|
||||
|
||||
// add message to state when text is entered
|
||||
mutationCallback = (message) => {
|
||||
const messages = [ ...this.state.messages, ...this.state.newMessages ];
|
||||
messages.push(message);
|
||||
this.setState({
|
||||
messages,
|
||||
newMessages: []
|
||||
});
|
||||
}
|
||||
|
||||
// custom refetch to be passed to parent for refetching on event occurance
|
||||
refetch = async() => {
|
||||
if (!this.state.loading) {
|
||||
const resp = await this.state.refetch(this.getLastReceivedVars());
|
||||
if (resp.data) {
|
||||
if (!this.isViewScrollable()) {
|
||||
this.addOldMessages(resp.data.message);
|
||||
} else {
|
||||
if (this.state.bottom) {
|
||||
this.addOldMessages(resp.data.message);
|
||||
useSubscription(subscribeToNewMessages, {
|
||||
onSubscriptionData: async () => {
|
||||
if (!loading) {
|
||||
const resp = await refetch(getLastReceivedVars());
|
||||
if (resp.data) {
|
||||
if (!isViewScrollable()) {
|
||||
addOldMessages(resp.data.message);
|
||||
} else {
|
||||
this.addNewMessages(resp.data.message);
|
||||
if (bottom) {
|
||||
addOldMessages(resp.data.message);
|
||||
} else {
|
||||
addNewMessages(resp.data.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// scroll to bottom
|
||||
scrollToBottom = () => {
|
||||
document.getElementById('lastMessage').scrollIntoView({ behavior: "instant" });
|
||||
}
|
||||
const scrollToBottom = () => {
|
||||
document
|
||||
?.getElementById('lastMessage')
|
||||
?.scrollIntoView({ behavior: 'instant' });
|
||||
};
|
||||
|
||||
// scroll to the new message
|
||||
scrollToNewMessage = () => {
|
||||
document.getElementById('newMessage').scrollIntoView({ behavior: "instant" });
|
||||
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
|
||||
handleScroll = (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) {
|
||||
this.setState({
|
||||
bottom: true
|
||||
})
|
||||
} else {
|
||||
if (this.state.bottom) {
|
||||
this.setState({
|
||||
bottom: false
|
||||
});
|
||||
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
|
||||
isViewScrollable = () => {
|
||||
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)
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { messages, newMessages, bottom } = this.state;
|
||||
// set refetch in parent component for refetching data whenever an event occurs
|
||||
if (!this.props.refetch && this.state.refetch) {
|
||||
this.props.setRefetch(this.refetch);
|
||||
}
|
||||
return (
|
||||
<div id="chatbox">
|
||||
<Query
|
||||
query={fetchMessages}
|
||||
variables={this.getLastReceivedVars()}
|
||||
>
|
||||
{
|
||||
({ data, loading, error, refetch}) => {
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (error) {
|
||||
return "Error: " + error;
|
||||
}
|
||||
// set refetch in local state to make a custom refetch
|
||||
if (!this.state.refetch) {
|
||||
this.setState({
|
||||
refetch
|
||||
});
|
||||
}
|
||||
const receivedMessages = data.message;
|
||||
|
||||
// load all messages to state in the beginning
|
||||
if (receivedMessages.length !== 0) {
|
||||
if (messages.length === 0) {
|
||||
this.addOldMessages(receivedMessages);
|
||||
}
|
||||
}
|
||||
|
||||
// return null; real rendering happens below
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</Query>
|
||||
{ /* show "unread messages" banner if not at bottom */}
|
||||
{
|
||||
(!bottom && newMessages.length > 0 && this.isViewScrollable()) ?
|
||||
<Banner
|
||||
scrollToNewMessage={this.scrollToNewMessage}
|
||||
numOfNewMessages={newMessages.length}
|
||||
/> : null
|
||||
}
|
||||
|
||||
{ /* Render old messages */}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isNew={false}
|
||||
username={this.props.username}
|
||||
return (
|
||||
<div id="chatbox">
|
||||
{/* show "unread messages" banner if not at bottom */}
|
||||
{!bottom && newMessages.length > 0 && isViewScrollable() ? (
|
||||
<Banner
|
||||
scrollToNewMessage={scrollToNewMessage}
|
||||
numOfNewMessages={newMessages.length}
|
||||
/>
|
||||
{ /* Show old/new message separation */}
|
||||
<div
|
||||
id="newMessage"
|
||||
className="oldNewSeparator"
|
||||
>
|
||||
{
|
||||
newMessages.length !== 0 ?
|
||||
"New messages" :
|
||||
null
|
||||
}
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
|
||||
{ /* render new messages */}
|
||||
<MessageList
|
||||
messages={newMessages}
|
||||
isNew={true}
|
||||
username={this.props.username}
|
||||
/>
|
||||
{ /* Bottom div to scroll to */}
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
@ -1,132 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Mutation } from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
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 (
|
||||
objects: [$message]
|
||||
) {
|
||||
returning {
|
||||
id
|
||||
timestamp
|
||||
text
|
||||
username
|
||||
}
|
||||
mutation insert_message($message: message_insert_input!) {
|
||||
insert_message_one(object: $message) {
|
||||
id
|
||||
timestamp
|
||||
text
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const emitTypingEvent = gql`
|
||||
mutation ($userId: Int) {
|
||||
update_user (
|
||||
_set: {
|
||||
last_typed: "now()"
|
||||
}
|
||||
where: {
|
||||
id: {
|
||||
_eq: $userId
|
||||
}
|
||||
}
|
||||
const emitTypingEventGql = gql`
|
||||
mutation ($userId: Int!) {
|
||||
update_user_by_pk(
|
||||
pk_columns: { id: $userId }
|
||||
_set: { last_typed: "now()" }
|
||||
) {
|
||||
affected_rows
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default class Textbox extends React.Component {
|
||||
export default function Textbox(props) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
constructor(props) {
|
||||
super()
|
||||
this.state = {
|
||||
text: ""
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
handleTyping = (text, mutate) => {
|
||||
const handleTyping = (text, mutate) => {
|
||||
const textLength = text.length;
|
||||
if ((textLength !== 0 && textLength % 5 === 0) || textLength === 1) {
|
||||
this.emitTypingEvent(mutate);
|
||||
emitTypingEvent(mutate);
|
||||
}
|
||||
this.setState({ text });
|
||||
}
|
||||
setText(text);
|
||||
};
|
||||
|
||||
emitTypingEvent = async (mutate) => {
|
||||
if (this.props.userId) {
|
||||
await mutate({
|
||||
mutation: emitTypingEvent,
|
||||
variables: {
|
||||
userId: this.props.userId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Mutation component. Add message to the state of <RenderMessages> after mutation.
|
||||
return (
|
||||
<Mutation
|
||||
mutation={insertMessage}
|
||||
variables={{
|
||||
message: {
|
||||
username: this.props.username,
|
||||
text: this.state.text
|
||||
}
|
||||
}}
|
||||
update={(cache, { data: { insert_message }}) => {
|
||||
this.props.mutationCallback(
|
||||
{
|
||||
id: insert_message.returning[0].id,
|
||||
timestamp: insert_message.returning[0].timestamp,
|
||||
username: insert_message.returning[0].username,
|
||||
text: insert_message.returning[0].text,
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
(insert_message, { data, loading, error, client}) => {
|
||||
const sendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
if (this.state.text === '') {
|
||||
return;
|
||||
}
|
||||
insert_message();
|
||||
this.setState({
|
||||
text: ""
|
||||
});
|
||||
}
|
||||
return this.form(sendMessage, client);
|
||||
}
|
||||
}
|
||||
|
||||
</Mutation>
|
||||
)
|
||||
}
|
||||
|
||||
form = (sendMessage, client) => {
|
||||
const form = (sendMessage, client) => {
|
||||
return (
|
||||
<form onSubmit={sendMessage}>
|
||||
<div className="textboxWrapper">
|
||||
<TypingIndicator userId={this.props.userId} />
|
||||
<TypingIndicator userId={props.userId} />
|
||||
<input
|
||||
id="textbox"
|
||||
className="textbox typoTextbox"
|
||||
value={this.state.text}
|
||||
value={text}
|
||||
autoFocus={true}
|
||||
onChange={(e) => {
|
||||
this.handleTyping(e.target.value, client.mutate);
|
||||
handleTyping(e.target.value, client.mutate);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
className="sendButton typoButton"
|
||||
onClick={sendMessage}
|
||||
> Send </button>
|
||||
<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);
|
||||
}
|
||||
|
@ -1,51 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Subscription } from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
import { gql, useSubscription } from '@apollo/client';
|
||||
import '../App.css';
|
||||
|
||||
const getUserTyping = gql`
|
||||
subscription ($selfId: Int ) {
|
||||
user_typing (
|
||||
where: {
|
||||
id: {
|
||||
_neq: $selfId
|
||||
}
|
||||
},
|
||||
subscription ($selfId: Int) {
|
||||
user_typing(
|
||||
where: { id: { _neq: $selfId } }
|
||||
limit: 1
|
||||
order_by: {last_typed:desc}
|
||||
){
|
||||
order_by: { last_typed: desc }
|
||||
) {
|
||||
last_typed
|
||||
username
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
class TypingIndicator extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="typingIndicator">
|
||||
<Subscription
|
||||
subscription={getUserTyping}
|
||||
variables={{
|
||||
selfId: this.props.userId
|
||||
}}
|
||||
>
|
||||
{
|
||||
({ data, loading, error}) => {
|
||||
if (loading) { return ""; }
|
||||
if (error) { return ""; }
|
||||
if (data.user_typing.length === 0) {
|
||||
return "";
|
||||
} else {
|
||||
return `${data.user_typing[0].username} is typing ...`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</Subscription>
|
||||
</div>
|
||||
)
|
||||
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;
|
||||
|
22
community/sample-apps/realtime-chat/src/hooks/useInterval.js
Normal file
22
community/sample-apps/realtime-chat/src/hooks/useInterval.js
Normal 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]);
|
||||
}
|
@ -1,31 +1,77 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ApolloProvider } from 'react-apollo';
|
||||
import App from './App';
|
||||
import ApolloClient from 'apollo-client';
|
||||
import { HttpLink } from 'apollo-link-http';
|
||||
import { WebSocketLink } from 'apollo-link-ws';
|
||||
import { SubscriptionClient } from 'subscriptions-transport-ws';
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import { split } from 'apollo-link';
|
||||
import { getMainDefinition } from 'apollo-utilities';
|
||||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
ApolloProvider,
|
||||
split,
|
||||
HttpLink,
|
||||
} from '@apollo/client';
|
||||
import { getMainDefinition } from '@apollo/client/utilities';
|
||||
import { ApolloLink, Observable } from '@apollo/client/core';
|
||||
|
||||
import { print } from 'graphql';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
// Create graphql-ws Apollo adapter
|
||||
class WebSocketLink extends ApolloLink {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.client = createClient(options);
|
||||
}
|
||||
|
||||
request(operation) {
|
||||
return new Observable((sink) => {
|
||||
return this.client.subscribe(
|
||||
{ ...operation, query: print(operation.query) },
|
||||
{
|
||||
next: sink.next.bind(sink),
|
||||
complete: sink.complete.bind(sink),
|
||||
error: (err) => {
|
||||
if (Array.isArray(err))
|
||||
// GraphQLError[]
|
||||
return sink.error(
|
||||
new Error(err.map(({ message }) => message).join(', '))
|
||||
);
|
||||
|
||||
if (err instanceof CloseEvent)
|
||||
return sink.error(
|
||||
new Error(
|
||||
`Socket closed with event ${err.code} ${err.reason || ''}` // reason will be available on clean closes only
|
||||
)
|
||||
);
|
||||
|
||||
return sink.error(err);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scheme = (proto) => {
|
||||
return window.location.protocol === 'https:' ? `${proto}s` : proto;
|
||||
}
|
||||
const HASURA_GRAPHQL_ENGINE_HOSTNAME = 'realtime-chat.hasura.app';
|
||||
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 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`;
|
||||
|
||||
// Make WebSocketLink with appropriate url
|
||||
const mkWsLink = (uri) => {
|
||||
const splitUri = uri.split('//');
|
||||
const subClient = new SubscriptionClient(
|
||||
WEBSOCKET_ENDPOINT,
|
||||
{ reconnect: true }
|
||||
);
|
||||
const subClient = {
|
||||
url: WEBSOCKET_ENDPOINT,
|
||||
options: {
|
||||
reconnect: true,
|
||||
},
|
||||
};
|
||||
return new WebSocketLink(subClient);
|
||||
}
|
||||
};
|
||||
|
||||
// Make HttpLink
|
||||
const httpLink = new HttpLink({ uri: GRAPHQL_ENDPOINT });
|
||||
@ -44,13 +90,20 @@ const link = split(
|
||||
const client = new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache({
|
||||
addTypename: false
|
||||
})
|
||||
})
|
||||
addTypename: false,
|
||||
}),
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
(<ApolloProvider client={client}>
|
||||
<App />
|
||||
</ApolloProvider>),
|
||||
<React.StrictMode>
|
||||
<ApolloProvider client={client}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
@ -1,117 +0,0 @@
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
// cached resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not local host. Just register service worker
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the old content will have been purged and
|
||||
// the fresh content will have been added to the cache.
|
||||
// It's the perfect time to display a "New content is
|
||||
// available; please refresh." message in your web app.
|
||||
console.log('New content is available; please refresh.');
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
13
community/sample-apps/realtime-chat/src/reportWebVitals.js
Normal file
13
community/sample-apps/realtime-chat/src/reportWebVitals.js
Normal 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;
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user