diff --git a/.exrc b/.exrc new file mode 100644 index 00000000..7b7ac767 --- /dev/null +++ b/.exrc @@ -0,0 +1 @@ +set shiftwidth=2 diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index b06cb8ae..426c33cf 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -1,16 +1,21 @@ -name: Build and Test +name: "Build, Test, Deploy" on: pull_request: #{ types: [ready_for_review] } push: { branches: [master] } #release: { types: [published] } +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + BUILD_PATH: /tmp/archive.tgz jobs: bat: runs-on: ubuntu-latest - if: false strategy: - matrix: { node: [12, 14] } + matrix: { node: [14] } steps: - name: Clone repo @@ -29,7 +34,7 @@ jobs: - name: Start cms run: cd cms; npm run develop &> /tmp/cms.log & - env: { INITIALIZE_DATA: 'true' } + env: { INITIALIZE_DATA: "true" } - name: Check cms running uses: actions/github-script@v5 @@ -41,7 +46,7 @@ jobs: const http = require('http'); const {readFileSync} = require('fs'); core.info('waiting 1 minute for strapi data initialization to complete ..'); - await new Promise(zzz => setTimeout(zzz, 60 * 1000)); + await new Promise(zzz => setTimeout(zzz, 2 * 60 * 1000)); http .request(`http://localhost:${process.env.PORT}/_health`, { method: 'HEAD' }, r => { if (r.statusCode === 204 && r.headers.strapi === 'You are so French!') @@ -81,7 +86,10 @@ jobs: run: cd app && npm i --production - name: Start app - run: cd app; npm run dev &>/tmp/app.log & + run: | + cd app + npm run dev &>/tmp/app.log & + echo $! > /tmp/app.pid - name: Check app running uses: actions/github-script@v5 @@ -123,37 +131,77 @@ jobs: # echo "app successfully started" # env: { PORT: 3000 } - # - name: Build app (scalable) - # run: cd app; npm run build - # env: { NEXT_PUBLIC_STRAPI_API_URL: 'http://localhost:1337' } - - # - name: Build non-scalable app build archive - # run: cd app; tar zcvf /tmp/site.tgz out - - # - name: Upload archive - # uses: actions/upload-artifact@v2 - # with: - # name: non_scalable_build - # path: /tmp/site.tgz - - # deploy: - # runs-on: ubuntu-latest - # if: github.event_name == 'release' - # needs: [bat] - - # steps: - # - name: Get non-scalable build - # uses: actions/download-artifact@v2 - # with: - # name: non_scalable_build - # path: /tmp/ - - # - name: Deploy - # uses: debdutdeb/rc4community.deploy.action@main - # with: - # remote_host: ${{ secrets.REMOTE_HOST }} - # remote_host_port: ${{ secrets.REMOTE_PORT }} - # ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} - # remote_user: ${{ secrets.SSH_USER }} - # source: /tmp/site.tgz - # destination: ${{ secrets.DEPLOY_DESTINATION }} + - name: Build prod app + run: | + pkill -9 -F /tmp/app.pid + cd app; npm run build + env: { NEXT_PUBLIC_STRAPI_API_URL: "http://localhost:1337" } + + - name: Generate archive + run: | + cd app + echo $GITHUB_SHA > /tmp/.github_sha + echo $GITHUB_REF > /tmp/.github_ref + tar zcvf ${{ env.BUILD_PATH }} \ + .next \ + ecosystem.config.js \ + next.config.js \ + package-lock.json \ + package.json \ + /tmp/.github_sha \ + /tmp/.github_ref + + - name: Upload archive + if: ${{ github.event_name == 'push' }} + uses: actions/upload-artifact@v3 + with: + name: build + path: ${{ env.BUILD_PATH }} + + deploy: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' }} # don't wanna run on prs + needs: [bat] + + steps: + - name: Download build archive + uses: actions/download-artifact@v3 + with: + name: build + path: /tmp/ + + # - name: Push archive + # uses: easingthemes/ssh-deploy@main + # env: + # SOURCE: ${{ env.BUILD_PATH }} + # TARGET: ${{ secrets.TARGET }} + # REMOTE_HOST: ${{ secrets.REMOTE_HOST }} + # REMOTE_USER: ${{ secrets.REMOTE_USER }} + # REMOTE_PORT: ${{ secrets.REMOTE_PORT }} + # SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + # + # - name: Start remote service + # uses: appleboy/ssh-action@master + # with: + # host: ${{ secrets.REMOTE_HOST }} + # port: ${{ secrets.REMOTE_PORT }} + # username: ${{ secrets.REMOTE_USER }} + # key: ${{ secrets.SSH_PRIVATE_KEY }} + # script: | + # target=${{ secrets.TARGET }} + # build_path=${{ secrets.BUILD_PATH }} + # cd $target + # tar zxf $build_path + # rm -f $build_path + # npm install --production + # pm2 reload ecosystem.config.js + + - name: Push archive and start service + uses: debdutdeb/rc4community.deploy.action@main + with: + remote_host: ${{ secrets.REMOTE_HOST }} + remote_port: ${{ secrets.REMOTE_PORT }} + remote_user: ${{ secrets.REMOTE_USER }} + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} + source: ${{ env.BUILD_PATH }} + target: ${{ secrets.TARGET }} diff --git a/README.md b/README.md index e5d689c4..67f4f6a9 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,24 @@ ![build and grow massive online communities with rocket.chat](./assets/readme-banner.png) ![build and grow massive online communities with rocket.chat](./assets/readme-hero.png) + +

🚀 Features 🚀

+ +

🚀 Community Builder quick start 🚀

Customize - Deploy - Manage

@@ -16,6 +34,7 @@ gitpod.io/#https://github.com/RocketChat/RC4Community Start designing and customizing your community management system! +

🚀 Developer quick start 🚀

Development - Build - Production

@@ -25,10 +44,10 @@ During development, our data provider is a headless CMS, strapi. Note that it is used only during development and build time, not during production. -By default, strapi listens on port 1337. If you're using WSL2 on Windows and also running Docker Desktop, port 1337 may not be available on your system. To use port 3000 instead, change this line in `config/server.js` file: +By default, strapi listens on port 1337. If you're using WSL2 on Windows and also running Docker Desktop, port 1337 may not be available on your system. To use port 3000 instead, set the environment variable `PORT` to 3000. ``` - port: env.int('PORT', 3000), +export PORT=3000 ``` Start strapi: diff --git a/app/.env.local.sample b/app/.env.local.sample index 7cfbb2f4..2088038d 100644 --- a/app/.env.local.sample +++ b/app/.env.local.sample @@ -19,3 +19,27 @@ NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= # for cookies signing COOKIE_SECRET_CURRENT= COOKIE_SECRET_PREVIOUS= + +### BEGIN: Environment variables for keycloak +NEXTAUTH_SECRET="some random string for signing" +NEXTAUTH_URL="base url of rc4community" +KEYCLOAK_ID='keycloak client id' +KEYCLOAK_SECRET='keycloak client secret' +KEYCLOAK_ISSUER='http://localhost:8080/realms/{Realm Name}' +### END: Environment variables for keycloak + +### BEGIN: Environment variables for RocketChat OAuth legacy +NEXTAUTH_SECRET="some random string for signing" +NEXTAUTH_URL="base url of rc4community" +ROCKETCHAT_CLIENT_ID="client id" +ROCKETCHAT_CLIENT_SECRET="client secret" +ROCKETCHAT_URL="base url of rocket chat server" +### END: Environment variables for RocketChat OAuth legacy + +NEXT_PUBLIC_ROCKET_CHAT_GREENROOM_RTMP="rtmp://bkk.contribute.live-video.net/app/{stream_key}" + +# STREAM-SOURCE-Environment + +NEXT_PUBLIC_IPINFO_TOKEN = "6ea41123456765" +NEXT_PUBLIC_SERVER_STREAM_LINK0 = "https://{some_url}/hls/stream.m3u8" +NEXT_PUBLIC_SERVER_STREAM_LINK1 = "https://{some_other_url}/hls/stream.m3u8" diff --git a/app/.gitignore b/app/.gitignore index 1437c53f..4250019e 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -32,3 +32,4 @@ yarn-error.log* # vercel .vercel + diff --git a/app/components/animation.js b/app/components/animation.js new file mode 100644 index 00000000..64ec3a76 --- /dev/null +++ b/app/components/animation.js @@ -0,0 +1,41 @@ +import React from 'react'; +import styles from '../styles/Mainstage.module.css'; + +function getRandomInt(max) { + return Math.floor(Math.random() * Math.floor(max)); +} + +const Animation = React.memo(() => { + console.log('animation rerendered'); + return ( + + {Array.apply(null, { length: 4 }).map(() => ( +

+ ❤ +

+ ))} + {Array.apply(null, { length: 4 }).map(() => ( +

+ 👍 +

+ ))} +
+ ); +}); + +export default Animation; diff --git a/app/components/auth/NoUserAvatar.js b/app/components/auth/NoUserAvatar.js index 57846510..466652c5 100644 --- a/app/components/auth/NoUserAvatar.js +++ b/app/components/auth/NoUserAvatar.js @@ -4,7 +4,7 @@ export function NoUserAvatar({size,name}){ const char = name ? name.charAt(0) : null; return (
+ + + + + + + ); +} + +export default MyApp; + +``` +3. Add `KeycloakAuthMenuButton` in menubar. +``` +... +import {KeycloakAuthMenuButton} from './auth/keycloak'; +... + +export default function Menubar(props) { + ... + + return ( + + + ... +
+ +
+
+
+ ); +} +``` +4. Use `useSession()` webhook to get user data. +``` +import { useSession } from "next-auth/react"; +... +... +export default Component(){ + const {data:session} = useSession(); + if(!session) + return
; + const user = session.user; + return
Hello ${user.name}
+} +``` +5. Sign out using custom sign out function `signOutKC()`. This will sign out user from keycloak as well. diff --git a/app/components/auth/keycloak/index.js b/app/components/auth/keycloak/index.js new file mode 100644 index 00000000..65f4822a --- /dev/null +++ b/app/components/auth/keycloak/index.js @@ -0,0 +1,7 @@ +import keycloakAuthMenuButtonModule from "./ui/KeycloakAuthMenuButton"; +import keycloakAuthUIModule from "./ui/KeycloakAuthUI"; +import signOutKCModule from './lib/signOutKC'; + +export const KeycloakAuthMenuButton = keycloakAuthMenuButtonModule; +export const KeycloakAuthUI = keycloakAuthUIModule; +export const signOutKC = signOutKCModule; diff --git a/app/components/auth/keycloak/lib/signOutKC.js b/app/components/auth/keycloak/lib/signOutKC.js new file mode 100644 index 00000000..ed7d22fd --- /dev/null +++ b/app/components/auth/keycloak/lib/signOutKC.js @@ -0,0 +1,30 @@ +import { BroadcastChannel } from "next-auth/client/_utils" +import { getCsrfToken } from "next-auth/react"; + +const broadcast = new BroadcastChannel(); + +export default async function signOutKC({ + callbackUrl = null +}){ + const csrfToken = await getCsrfToken(); + const fetchOptions = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + csrfToken, + callbackUrl: `/api/auth/signoutkc?token=${csrfToken}${callbackUrl?'&callbackUrl='+encodeURIComponent(callbackUrl):''}`, + json: true, + }), + } + const res = await fetch(`/api/auth/signout`, fetchOptions) + const data = await res.json(); + + broadcast.post({ event: "session", data: { trigger: "signout" } }) + + const url = data.url ?? callbackUrl + window.location.href = url + // If url contains a hash, the browser does not reload the page. We reload manually + if (url.includes("#")) window.location.reload(); +} diff --git a/app/components/auth/keycloak/styles/KeycloakAuthMenuButton.module.css b/app/components/auth/keycloak/styles/KeycloakAuthMenuButton.module.css new file mode 100644 index 00000000..f529c26f --- /dev/null +++ b/app/components/auth/keycloak/styles/KeycloakAuthMenuButton.module.css @@ -0,0 +1,54 @@ +.authDialogWrapper { + position: relative; +} +.authContainer { + display: block; + position: fixed; + right: 50%; + transform: translate(50%,0); + top: 62px; + width: 350px; + max-height: -webkit-calc(100vh - 62px - 100px); + max-height: calc(100vh - 62px - 100px); + overflow-y: auto; + overflow-x: hidden; + border-radius: 8px; + margin-left: 12px; + z-index: 991; + line-height: normal; + background: #fff; + border: 1px solid #ccc; + border-color: rgba(0,0,0,.2); + color: #000; + -webkit-box-shadow: 0 2px 10px rgb(0 0 0 / 20%); + box-shadow: 0 2px 10px rgb(0 0 0 / 20%); + -webkit-user-select: text; + user-select: text; +} +@media(min-width: 570px) { + .authContainer { + position: absolute; + right: 8px; + top: 62px; + width: 354px; + transform: translateX(0); + } +} +.avatar { + background: var(--bs-gray-300); + border-radius: 50%; + width: 42px; + height: 42px; + display: flex; + justify-content: center; + align-items: center; +} + +.avatarButton { + background: none; + border: none; +} + +.avatarButton:focus { + outline: none; +} diff --git a/app/components/auth/keycloak/styles/KeycloakAuthUI.module.css b/app/components/auth/keycloak/styles/KeycloakAuthUI.module.css new file mode 100644 index 00000000..7823d4d7 --- /dev/null +++ b/app/components/auth/keycloak/styles/KeycloakAuthUI.module.css @@ -0,0 +1,7 @@ +.authUIWrapper { + width: 100%; + max-width: 400px; + border: 1px solid #ddd; + box-shadow: 2px 2px 3px 3px #0000001D; + background: #FFF; +} diff --git a/app/components/auth/keycloak/ui/KeycloakAuthMenuButton.js b/app/components/auth/keycloak/ui/KeycloakAuthMenuButton.js new file mode 100644 index 00000000..d0f2d88e --- /dev/null +++ b/app/components/auth/keycloak/ui/KeycloakAuthMenuButton.js @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react"; +import KeycloakAuthUI from "./KeycloakAuthUI"; +import { NoUserAvatar } from "../../NoUserAvatar"; +import styles from "../styles/KeycloakAuthMenuButton.module.css"; +import { signIn, useSession } from "next-auth/react"; + +export default function KeycloakAuthMenuButton({}){ + const {data: session} = useSession(); + const user = session?.user; + const [isOpen,setOpen] = useState(false); + const dialogRef = useRef(); + const onAvatarButtonClick = () => { + if(session){ + setOpen(!isOpen); + } else { + signIn("keycloak",null,{prompt: "login"}); + } + } + useEffect(()=>{ + const clickListener = (e) => { + if(!e.target.closest('.'+styles.authDialogWrapper)){ + setOpen(false); + } + } + document.body.addEventListener('click',clickListener); + return () => document.body.removeEventListener('click',clickListener); + },[dialogRef.current]); + return ( +
+
+ +
+ { session && isOpen && +
+ } +
+ ) +} diff --git a/app/components/auth/keycloak/ui/KeycloakAuthUI.js b/app/components/auth/keycloak/ui/KeycloakAuthUI.js new file mode 100644 index 00000000..c49b1a9d --- /dev/null +++ b/app/components/auth/keycloak/ui/KeycloakAuthUI.js @@ -0,0 +1,50 @@ +import { useSession } from "next-auth/react"; +import { Button } from "react-bootstrap"; +import { NoUserAvatar } from "../../NoUserAvatar"; +import signOutKC from "../lib/signOutKC"; + +export default function KeycloakUserInfo(){ + const {data:session} = useSession(); + if(!session) + return
; + const user = session.user; + return ( + <> +
+
+ { + user.image ? + {user.name} + : + + } +
+
+ {user.name} +
+
+ {user.email} +
+
+ Manage profile +
+
+
+ +
+ + ) +} diff --git a/app/components/auth/rocketchat/README.md b/app/components/auth/rocketchat/README.md new file mode 100644 index 00000000..b43e2c97 --- /dev/null +++ b/app/components/auth/rocketchat/README.md @@ -0,0 +1,78 @@ +### Set up + +We have used `next-auth` npm package to configure rocketchat authentication. Read more about it [here](https://next-auth.js.org/) + +Before setting up rocket.chat oauth, an oauth app must be created in rocket.chat server. Then, add the callback url in the `redirect uri` list of that app. +The callback url is `base-rc4community-url/api/auth/callback/rocket.chat`. For example, if rc4community is running on `http://localhost:3000`, then the callback url should be `http://localhost:3000/api/auth/callback/rocket.chat`. + + +1. set up environment variables for rocketchat legacy oauth config in `.env.local` + +``` +### BEGIN: Environment variables for RocketChat OAuth legacy +NEXTAUTH_SECRET="some random string for signing" +NEXTAUTH_URL="base url of rc4community" +ROCKETCHAT_CLIENT_ID="client id" +ROCKETCHAT_CLIENT_SECRET="client secret" +ROCKETCHAT_URL="base url of rocket chat server" +### END: Environment variables for RocketChat OAuth legacy +``` + +2. Wrap the app with `SessionProvider` in _app.js in the following way +``` +import '/styles/globals.css'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import Layout from '../components/layout'; +import SSRProvider from 'react-bootstrap/SSRProvider'; +import {SessionProvider} from 'next-auth/react'; + +function MyApp({ Component, pageProps: {session, ...pageProps}}) { + return ( + + + + + + + + ); +} + +export default MyApp; + +``` +3. Add `RocketChatAuthMenuButton` in menubar. +``` +... +import {RocketChatAuthMenuButton} from './auth/rocketchat'; +... + +export default function Menubar(props) { + ... + + return ( + + + ... +
+ +
+
+
+ ); +} +``` +4. Use `useSession()` webhook to get user data. +``` +import { useSession } from "next-auth/react"; +... +... +export default Component(){ + const {data:session} = useSession(); + if(!session) + return
; + const user = session.user; + return
Hello ${user.name}
+} +``` +5. Sign out using custom sign out function `signOutKC()`. This will sign out user from rocketchat as well. diff --git a/app/components/auth/rocketchat/index.js b/app/components/auth/rocketchat/index.js new file mode 100644 index 00000000..dca1803f --- /dev/null +++ b/app/components/auth/rocketchat/index.js @@ -0,0 +1,5 @@ +import rocketChatAuthMenuButtonModule from "./ui/RocketChatAuthMenuButton"; +import rocketChatAuthUIModule from "./ui/RocketChatAuthUI"; + +export const RocketChatAuthMenuButton = rocketChatAuthMenuButtonModule; +export const RocketChatAuthUI = rocketChatAuthUIModule; diff --git a/app/components/auth/rocketchat/styles/RocketChatAuthMenuButton.module.css b/app/components/auth/rocketchat/styles/RocketChatAuthMenuButton.module.css new file mode 100644 index 00000000..f529c26f --- /dev/null +++ b/app/components/auth/rocketchat/styles/RocketChatAuthMenuButton.module.css @@ -0,0 +1,54 @@ +.authDialogWrapper { + position: relative; +} +.authContainer { + display: block; + position: fixed; + right: 50%; + transform: translate(50%,0); + top: 62px; + width: 350px; + max-height: -webkit-calc(100vh - 62px - 100px); + max-height: calc(100vh - 62px - 100px); + overflow-y: auto; + overflow-x: hidden; + border-radius: 8px; + margin-left: 12px; + z-index: 991; + line-height: normal; + background: #fff; + border: 1px solid #ccc; + border-color: rgba(0,0,0,.2); + color: #000; + -webkit-box-shadow: 0 2px 10px rgb(0 0 0 / 20%); + box-shadow: 0 2px 10px rgb(0 0 0 / 20%); + -webkit-user-select: text; + user-select: text; +} +@media(min-width: 570px) { + .authContainer { + position: absolute; + right: 8px; + top: 62px; + width: 354px; + transform: translateX(0); + } +} +.avatar { + background: var(--bs-gray-300); + border-radius: 50%; + width: 42px; + height: 42px; + display: flex; + justify-content: center; + align-items: center; +} + +.avatarButton { + background: none; + border: none; +} + +.avatarButton:focus { + outline: none; +} diff --git a/app/components/auth/rocketchat/styles/RocketChatAuthUI.module.css b/app/components/auth/rocketchat/styles/RocketChatAuthUI.module.css new file mode 100644 index 00000000..7823d4d7 --- /dev/null +++ b/app/components/auth/rocketchat/styles/RocketChatAuthUI.module.css @@ -0,0 +1,7 @@ +.authUIWrapper { + width: 100%; + max-width: 400px; + border: 1px solid #ddd; + box-shadow: 2px 2px 3px 3px #0000001D; + background: #FFF; +} diff --git a/app/components/auth/rocketchat/ui/RocketChatAuthMenuButton.js b/app/components/auth/rocketchat/ui/RocketChatAuthMenuButton.js new file mode 100644 index 00000000..9a81755a --- /dev/null +++ b/app/components/auth/rocketchat/ui/RocketChatAuthMenuButton.js @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react"; +import RocketChatAuthUI from "./RocketChatAuthUI"; +import { NoUserAvatar } from "../../NoUserAvatar"; +import styles from "../styles/RocketChatAuthMenuButton.module.css"; +import { signIn, useSession } from "next-auth/react"; + +export default function RocketChatAuthMenuButton({}){ + const {data: session} = useSession(); + const user = session?.user; + const [isOpen,setOpen] = useState(false); + const dialogRef = useRef(); + const onAvatarButtonClick = () => { + if(session){ + setOpen(!isOpen); + } else { + signIn("rocket.chat",null,{prompt: "login"}); + } + } + useEffect(()=>{ + const clickListener = (e) => { + if(!e.target.closest('.'+styles.authDialogWrapper)){ + setOpen(false); + } + } + document.body.addEventListener('click',clickListener); + return () => document.body.removeEventListener('click',clickListener); + },[dialogRef.current]); + return ( +
+
+ +
+ { session && isOpen && +
+ } +
+ ) +} diff --git a/app/components/auth/rocketchat/ui/RocketChatAuthUI.js b/app/components/auth/rocketchat/ui/RocketChatAuthUI.js new file mode 100644 index 00000000..8fbcb6b5 --- /dev/null +++ b/app/components/auth/rocketchat/ui/RocketChatAuthUI.js @@ -0,0 +1,44 @@ +import { signOut, useSession } from "next-auth/react"; +import { Button } from "react-bootstrap"; +import { NoUserAvatar } from "../../NoUserAvatar"; + +export default function RocketChatUserInfo(){ + const {data:session} = useSession(); + if(!session) + return
; + const user = session.user; + return ( + <> +
+
+ { + user.image ? + {user.name} + : + + } +
+
+ {user.name} +
+
+ {user.email} +
+
+
+ +
+ + ) +} diff --git a/app/components/clientForms/show.js b/app/components/clientForms/show.js new file mode 100644 index 00000000..672e883c --- /dev/null +++ b/app/components/clientForms/show.js @@ -0,0 +1,60 @@ +import Error from "next/error"; +import { Button, Card, Form, Spinner } from "react-bootstrap"; +import { getFormData } from "../../lib/formAPI"; +import styles from "../../styles/form.module.css"; + +function RCform({ formId, fw }) { + const { form, isLoading, isError } = getFormData(formId); + + if (isLoading) return ; + if (isError) return ; + + const handleSubmit = (e) => { + e.preventDefault(); + console.log("form submitted", e); + }; + + return ( + + {form.title} + +
+ {form.formQs.map((ele, i) => ( + + {ele.value} + {ele.type == "number" ? ( + <> + + + * Value must be in range {ele.min} - {ele.max} + + + ) : ( + + )} + + {ele.type == "number"} + + ))} + +
+
+
+ ); +} + +export default RCform; diff --git a/app/components/clientsideonly/jitsibroadcaster.js b/app/components/clientsideonly/jitsibroadcaster.js new file mode 100644 index 00000000..606fa4e9 --- /dev/null +++ b/app/components/clientsideonly/jitsibroadcaster.js @@ -0,0 +1,473 @@ +import dynamic from "next/dynamic"; +import React, { useEffect, useRef, useState } from "react"; +import { + Button, + ButtonGroup, + Dropdown, + DropdownButton, + OverlayTrigger, + Tooltip, +} from "react-bootstrap"; +import { BiMicrophone, BiMicrophoneOff } from "react-icons/bi"; +import { RiMic2Line } from "react-icons/ri"; +import { MdCameraswitch, MdHeadset } from "react-icons/md"; +import { AiFillEye, AiFillSetting } from "react-icons/ai"; +import { BiUserPin } from "react-icons/bi"; +import { HiViewGridAdd } from "react-icons/hi"; +import styles from "../../styles/Jitsi.module.css"; +import { FaRocketchat } from "react-icons/fa"; +import { FiUsers } from "react-icons/fi"; + +const JitsiMeeting = dynamic( + () => import("@jitsi/react-sdk").then((mod) => mod.JitsiMeeting), + { ssr: false } +); + +const rtmp = process.env.NEXT_PUBLIC_ROCKET_CHAT_GREENROOM_RTMP; + +const Jitsibroadcaster = ({ room, disName, rtmpSrc, handleChat }) => { + const apiRef = useRef(); + const [logItems, updateLog] = useState([]); + const [knockingParticipants, updateKnockingParticipants] = useState([]); + const [mute, setMute] = useState(true); + const [name, setName] = useState(null); + const dataArr = [ + { speaker: "A", hour: "10" }, + { speaker: "B", hour: "20" }, + { speaker: "C", hour: "30" }, + { speaker: "D", hour: "40" }, + { speaker: "Z", hour: "50" }, + ]; + + const handleDisplayName = async (hr) => { + const tar = dataArr.find((o) => o.hour === hr); + if (!tar || tar.speaker == name) { + return; + } + setName(tar.speaker); + await apiRef.current.executeCommand("displayName", tar.speaker); + }; + + useEffect(() => { + setInterval(() => { + const tada = new Date(); + handleDisplayName(tada.getHours().toString()); + }, 900000); + }, []); + + const printEventOutput = (payload) => { + updateLog((items) => [...items, JSON.stringify(payload)]); + }; + + const handleAudioStatusChange = (payload, feature) => { + if (payload.muted) { + updateLog((items) => [...items, `${feature} off`]); + } else { + updateLog((items) => [...items, `${feature} on`]); + } + }; + + const handleChatUpdates = (payload, ref) => { + if (payload.isOpen || !payload.unreadCount) { + return; + } + ref.current.executeCommand("toggleChat"); + updateLog((items) => [ + ...items, + `you have ${payload.unreadCount} unread messages`, + ]); + }; + + const handleKnockingParticipant = (payload) => { + updateLog((items) => [...items, JSON.stringify(payload)]); + updateKnockingParticipants((participants) => [ + ...participants, + payload?.participant, + ]); + }; + + const resolveKnockingParticipants = (ref, condition) => { + knockingParticipants.forEach((participant) => { + ref.current.executeCommand( + "answerKnockingParticipant", + participant?.id, + condition(participant) + ); + updateKnockingParticipants((participants) => + participants.filter((item) => item.id === participant.id) + ); + }); + }; + + const handleJitsiIFrameRef1 = (iframeRef) => { + iframeRef.style.border = "10px solid cadetblue"; + iframeRef.style.background = "cadetblue"; + iframeRef.style.height = "25em"; + iframeRef.style.width = "75%"; + }; + + const showDevices = async (ref) => { + const videoInputs = []; + // get all available video input + const devices = await ref.current.getAvailableDevices(); + + for (const [key, value] of Object.entries(devices)) { + if (key == "videoInput") { + value.forEach((vid) => { + videoInputs.push(vid.label); + }); + } + } + // log for debug + updateLog((items) => [...items, JSON.stringify(videoInputs)]); + + let nextDevice = ""; + let devs = await ref.current.getCurrentDevices(); + + for (const [key, value] of Object.entries(devs)) { + if (key == "videoInput") { + updateLog((items) => [...items, "found " + JSON.stringify(value)]); + let devLabel = value.label; + let idx = 0; + videoInputs.forEach((vid) => { + if (devLabel == vid) { + let cur = idx + 1; + if (cur >= videoInputs.length) { + nextDevice = videoInputs[0]; + } else { + nextDevice = videoInputs[cur]; + updateLog((items) => [...items, "next is " + nextDevice]); + } + } + idx++; + }); + } + } + updateLog((items) => [...items, "switching to " + nextDevice]); + + await ref.current.setVideoInputDevice(nextDevice); + }; + + const showAudioOutDevices = async (ref) => { + const audioOutputs = []; + // get all available audio output + const devices = await ref.current.getAvailableDevices(); + + for (const [key, value] of Object.entries(devices)) { + if (key == "audioOutput") { + value.forEach((vid) => { + audioOutputs.push(vid.label); + }); + } + } + // log for debug + updateLog((items) => [...items, JSON.stringify(audioOutputs)]); + + let nextDevice = ""; + let devs = await ref.current.getCurrentDevices(); + + for (const [key, value] of Object.entries(devs)) { + if (key == "audioOutput") { + updateLog((items) => [...items, "found " + JSON.stringify(value)]); + let devLabel = value.label; + let idx = 0; + audioOutputs.forEach((vid) => { + if (devLabel == vid) { + let cur = idx + 1; + if (cur >= audioOutputs.length) { + nextDevice = audioOutputs[0]; + } else { + nextDevice = audioOutputs[cur]; + updateLog((items) => [...items, "next is " + nextDevice]); + } + } + idx++; + }); + } + } + updateLog((items) => [...items, "switching to " + nextDevice]); + + await ref.current.setAudioOutputDevice(nextDevice); + }; + + const showAudioDevice = async (ref) => { + const audioInputs = []; + // get all available audio input + const devices = await ref.current.getAvailableDevices(); + + for (const [key, value] of Object.entries(devices)) { + if (key == "audioInput") { + value.forEach((vid) => { + audioInputs.push(vid.label); + }); + } + } + // log for debug + updateLog((items) => [...items, JSON.stringify(audioInputs)]); + + let nextDevice = ""; + let devs = await ref.current.getCurrentDevices(); + + for (const [key, value] of Object.entries(devs)) { + if (key == "audioInput") { + updateLog((items) => [...items, "found " + JSON.stringify(value)]); + let devLabel = value.label; + let idx = 0; + audioInputs.forEach((vid) => { + if (devLabel == vid) { + let cur = idx + 1; + if (cur >= audioInputs.length) { + nextDevice = audioInputs[0]; + } else { + nextDevice = audioInputs[cur]; + updateLog((items) => [...items, "next is " + nextDevice]); + } + } + idx++; + }); + } + } + updateLog((items) => [...items, "switching to " + nextDevice]); + await ref.current.setAudioInputDevice(nextDevice); + }; + + const handleApiReady = async (apiObj, ref) => { + ref.current = apiObj; + await ref.current.addEventListeners({ + // Listening to events from the external API + audioMuteStatusChanged: (payload) => + handleAudioStatusChange(payload, "audio"), + videoMuteStatusChanged: (payload) => + handleAudioStatusChange(payload, "video"), + raiseHandUpdated: printEventOutput, + tileViewChanged: printEventOutput, + chatUpdated: (payload) => handleChatUpdates(payload, ref), + knockingParticipant: handleKnockingParticipant, + }); + + await ref.current.executeCommand("toggleFilmStrip"); + }; + + // Multiple instances demo + const showUsers = async (ref, which) => { + try { + const pinfo = await ref.current.getParticipantsInfo(); + updateLog((items) => [ + ...items, + "participantes " + JSON.stringify(pinfo), + ]); + await ref.current.executeCommand("setTileView", false); + await ref.current.setLargeVideoParticipant(pinfo[which].participantId); + } catch (e) { + console.error("Participant not found!"); + return; + } + }; + + const makeTile = (ref) => { + ref.current.executeCommand("setTileView", true); + }; + + const renderStream = (key) => ( +
+ + + +
+ ); + + const toggleDevice = () => ( +
+ + + Microphone Device} + > + + + Camera Device} + > + + + Audio Device} + > + + + +
+ ); + + const toggleView = () => ( +
+ + + Tile View} + > + + + First User} + > + + + Second User} + > + + + +
+ ); + + const toolButton = () => ( +
+ + + + apiRef.current.executeCommand("hangup")} + > + Leave Meet + + apiRef.current.stopRecording("stream")} + > + End for everyone! + + + + +
+ ); + + const renderLog = () => + logItems.map((item, index) => ( +
+ {item} +
+ )); + + const renderSpinner = () => ( +
+ Loading.. +
+ ); + + return ( + <> + {rtmp ? renderStream(rtmp) : rtmpSrc && renderStream(rtmpSrc)} +
+ {toggleDevice()} + + handleApiReady(externalApi, apiRef)} + getIFrameRef={handleJitsiIFrameRef1} + configOverwrite={{ + startWithAudioMuted: true, + disableModeratorIndicator: true, + startScreenSharing: false, + enableEmailInStats: false, + toolbarButtons: [], + enableWelcomePage: false, + prejoinPageEnabled: false, + startWithVideoMuted: false, + liveStreamingEnabled: true, + disableSelfView: false, + disableSelfViewSettings: true, + disableShortcuts: true, + disable1On1Mode: true, + p2p: { + enabled: false, + }, + }} + interfaceConfigOverwrite={{ + DISABLE_JOIN_LEAVE_NOTIFICATIONS: true, + FILM_STRIP_MAX_HEIGHT: 0, + TILE_VIEW_MAX_COLUMNS: 0, + VIDEO_QUALITY_LABEL_DISABLED: true, + }} + userInfo={{ + displayName: disName, + }} + /> + {toggleView()} +
+ {toolButton()} +
{renderLog()}
+ + ); +}; + +export default Jitsibroadcaster; diff --git a/app/components/clientsideonly/videostreamer.js b/app/components/clientsideonly/videostreamer.js index b8a8a80f..216a3d07 100644 --- a/app/components/clientsideonly/videostreamer.js +++ b/app/components/clientsideonly/videostreamer.js @@ -1,13 +1,43 @@ -import { Col } from 'react-bootstrap'; -import styles from '../../styles/Videostreamer.module.css'; -import Script from 'next/script' -import Head from 'next/head' +import { Col, Toast, ToastContainer } from "react-bootstrap"; +import styles from "../../styles/Videostreamer.module.css"; +import Script from "next/script"; +import Head from "next/head"; +import { useEffect, useState } from "react"; export default function Videostreamer(props) { + const [ping, setPing] = useState(false); + + const pingStream = async () => { + const response = await fetch(props.src); + if (response.ok) { + setPing(true) + } + + return response; + }; + useEffect(() => { + setInterval(async () => { + pingStream() + .catch((e) => { + console.error("Stream error", e); + setPing(false); + return + }); + + }, 30000); + }, []); + + const handleToast = () => { + setPing(true); + }; + return ( <> - +