mess completely
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import Channels, { STORE_ITEMS } from "../../electron/constants";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRecoilState, useSetRecoilState } from "recoil";
|
||||
|
||||
import { $jwtToken } from "../../controller/recoil";
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
import Logo from "../../components/Logo";
|
||||
import { useLogin } from "../../controller/index";
|
||||
|
||||
export default function Login() {
|
||||
const { mutateAsync } = useLogin();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const setJwtToken = useSetRecoilState($jwtToken);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.once(
|
||||
Channels.GOOGLE_AUTH_TOKENS,
|
||||
async (tokens: {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token: string;
|
||||
}) => {
|
||||
console.log("got tokens", tokens);
|
||||
|
||||
setIsLoading(true);
|
||||
// todo: implement refresh token procedure in api layer by sending refresh_token and such
|
||||
|
||||
const loginResponse = await mutateAsync({
|
||||
accessToken: tokens.access_token,
|
||||
idToken: tokens.id_token,
|
||||
});
|
||||
|
||||
const { jwtToken, userDetails } = loginResponse;
|
||||
setJwtToken(jwtToken);
|
||||
}
|
||||
);
|
||||
|
||||
// todo: figure out how to clean up with the preload api
|
||||
// return () => {
|
||||
// window.electronAPI.removeAllListeners(Channels.GOOGLE_AUTH_TOKENS);
|
||||
// };
|
||||
}, []);
|
||||
|
||||
const continueAuth = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// send to main process
|
||||
window.electronAPI.auth.initiateLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col space-y-5 justify-center items-center
|
||||
h-screen w-screen
|
||||
bg-zinc-700"
|
||||
>
|
||||
<Logo className="scale-50" />
|
||||
|
||||
{/* ! TESTING PURPOSES */}
|
||||
<div className={"text-white flex flex-col gap-5"}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setJwtToken(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2MjczYTNjZGVmYzc3MDNhZDg1NWYzMjYiLCJnb29nbGVVc2VySWQiOiIxMTM0NzA3ODY2OTAzNTMxMDkwODYiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2ptMjlTZDFXbm04TnZaYlhrb3N2ZjZTb0lENmtCUDVPSFJMVklPQlE9czk2LWMiLCJlbWFpbCI6InBhdGVsLmFyanVuNTBAZ21haWwuY29tIiwibmFtZSI6IkFyanVuIFBhdGVsIiwiaWF0IjoxNjUxOTY2MDg2fQ.bGK4DaUyuHCIRFhX5g3xQxI5SVMKR7hmte1UpmTZaVc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Personal Account
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setJwtToken(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2MjczYTZmYmVmYzc3MDNhZDg1NWYzMjciLCJnb29nbGVVc2VySWQiOiIxMTE2NzEzNTI4MDkwMTg3NjM4MjYiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dUUmFNaVVUZkNaX1ZOM2M3SFJyYUlZUmpmN1BNSUZjVThJdlZiPXM5Ni1jIiwiZW1haWwiOiJhcmp1bnBhdGVsQGJlcmtlbGV5LmVkdSIsIm5hbWUiOiJBcmp1biBQYXRlbCIsImlhdCI6MTY1MTk2NjA5OH0.53TbuXaHDTivEpqVr-TP5A1vVIFznT3q_HoVxOReZKc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Berkeley Email
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setJwtToken(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2MjczZTAxNTM1OTg3YWZhMGZjOTA5NzYiLCJnb29nbGVVc2VySWQiOiIxMTQyMTgxMjM0Mzk1OTA4OTU4MjAiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL29ndy9BRGVhNEk0RUhmY08ycVc1blEtelJXdzdRdTVOMWVQdnU5cHkyQzFPbUVxbj1zNjQtYy1tbyIsImVtYWlsIjoidXNlbmlydmFuYUBnbWFpbC5jb20iLCJuYW1lIjoiTmlydmFuYSBTdXBwb3J0IiwiaWF0IjoxNjUxOTY3MDA2fQ.9AgmMW2LYv4QNqibvopiKAaV0GWNbChujWiY6t0OZeQ"
|
||||
)
|
||||
}
|
||||
>
|
||||
Nirvana Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="text-white">Attempting to log you in</span>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={continueAuth}
|
||||
className=" text-md text-zinc-200 py-2 px-5 border border-gray-200 transition-all hover:bg-gray-200 hover:text-teal-500 rounded flex flex-row items-center space-x-2"
|
||||
>
|
||||
<FcGoogle className="text-lg" />
|
||||
<span>Continue with Google</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useAuthCheck, useServerCheck } from '../../controller';
|
||||
|
||||
import { $jwtToken } from '../../controller/recoil';
|
||||
import Login from '../../pages/Login';
|
||||
import NirvanaApi from '../../controller/nirvanaApi';
|
||||
import { STORE_ITEMS } from '../../electron/constants';
|
||||
import SkeletonLoader from '../loading/skeleton';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
export default function ProtectedRoute({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [jwtToken, setJwtToken] = useRecoilState($jwtToken);
|
||||
|
||||
const {
|
||||
isError: serverFailure,
|
||||
isLoading: serverLoading,
|
||||
isSuccess: isServerHealthy,
|
||||
error: serverStatusError,
|
||||
isFetching: serverFetching,
|
||||
} = useServerCheck();
|
||||
|
||||
const { isLoading, isError, isFetching, isSuccess, refetch } = useAuthCheck(
|
||||
!serverLoading
|
||||
);
|
||||
|
||||
// ! REMOVING FOR TESTING MULTIPLE CLIENTS
|
||||
// useEffect(() => {
|
||||
// // on load of this, if we already have jwt tokens in store,
|
||||
// // then try using them with auth check
|
||||
// window.electronAPI.store
|
||||
// .get(STORE_ITEMS.AUTH_SESSION_JWT)
|
||||
// .then((jwtToken: string) => {
|
||||
// if (jwtToken) {
|
||||
// NirvanaApi._jwtToken = jwtToken;
|
||||
// setJwtToken(jwtToken);
|
||||
// refetch();
|
||||
|
||||
// console.log("retrieved jwtToken from storage", jwtToken);
|
||||
// } else {
|
||||
// console.log("no jwt token in store");
|
||||
// }
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('change in jwt token', jwtToken);
|
||||
|
||||
if (jwtToken) {
|
||||
window.electronAPI.store.set(STORE_ITEMS.AUTH_SESSION_JWT, jwtToken);
|
||||
setJwtToken(jwtToken);
|
||||
} else {
|
||||
window.electronAPI.store.set(STORE_ITEMS.AUTH_SESSION_JWT, null);
|
||||
}
|
||||
|
||||
NirvanaApi._jwtToken = jwtToken;
|
||||
|
||||
refetch();
|
||||
}, [jwtToken]);
|
||||
|
||||
// first time loading
|
||||
if (serverLoading)
|
||||
return (
|
||||
<span className='text-md text-gray-400 flex items-center justify-center flex-1 text-center p-5 h-screen'>
|
||||
Sorry...this is our bad. Our servers are loading. We are trying our best
|
||||
to back up and running! :) <br /> Please contact me for urgent concerns:
|
||||
arjunpatel@berkeley.edu
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='container h-screen w-screen flex flex-col justify-center mx-10'>
|
||||
<SkeletonLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
// if we can successfully get user details, we are good to continue
|
||||
// basic auth...any additional auth should be done at lower levels
|
||||
if (isSuccess) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <Login />;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import NirvanaApi, { ApiCalls } from "./nirvanaApi";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
|
||||
import { $jwtToken } from "./recoil";
|
||||
import { queryClient } from "../pages/nirvanaApp";
|
||||
import { useRecoilValue } from "recoil";
|
||||
|
||||
// ====== QUERIES
|
||||
|
||||
/**
|
||||
* ensure that the server is up
|
||||
*/
|
||||
export function useServerCheck() {
|
||||
return useQuery("SERVER_CHECK", ApiCalls.serverCheck, {
|
||||
retry: true,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthCheck(enabled: boolean = true) {
|
||||
const jwtToken = useRecoilValue($jwtToken);
|
||||
|
||||
return useQuery("AUTH_CHECK", ApiCalls.authCheck, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
refetchInterval: 10000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
return useMutation("LOGIN", ApiCalls.login);
|
||||
}
|
||||
|
||||
export function useGetUserDetails() {
|
||||
return useQuery("USER_DETAILS", ApiCalls.getUserDetails, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onError: (err) => {
|
||||
console.log(err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserSearch(searchQuery: string) {
|
||||
return useQuery("USER_SEARCH", () => ApiCalls.userSearch(searchQuery), {
|
||||
enabled: searchQuery ? true : false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** query responsible solely for getting lines and the intersection with sockets happens elsewhere */
|
||||
export function useUserLines() {
|
||||
// todo: base/source of truth for getting all of the lines for the user
|
||||
// merge with sockets + audio clip data + master data + convomember data
|
||||
|
||||
return useQuery("USER_LINES", ApiCalls.getUserLines, {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
// =========== MUTATIONS
|
||||
export function useGetDmByUserId() {
|
||||
return useMutation(ApiCalls.getDmByUserId);
|
||||
}
|
||||
|
||||
export function useCreateLine() {
|
||||
return useMutation(ApiCalls.createLine, {
|
||||
onSuccess: (res, req) => {
|
||||
queryClient.invalidateQueries(["USER_LINES"]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { $desktopMode, $jwtToken, $selectedLineId } from "./recoil";
|
||||
import {
|
||||
ConnectToLineRequest,
|
||||
ServerRequestChannels,
|
||||
ServerResponseChannels,
|
||||
SomeoneConnectedResponse,
|
||||
SomeoneTunedResponse,
|
||||
SomeoneUntunedFromLineResponse,
|
||||
StartBroadcastingRequest,
|
||||
StopBroadcastingRequest,
|
||||
TuneToLineRequest,
|
||||
UntuneFromLineRequest,
|
||||
UserStartedBroadcastingResponse,
|
||||
UserStoppedBroadcastingResponse,
|
||||
} from "@nirvana/core/sockets/channels";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Socket, io } from "socket.io-client";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRecoilValue, useSetRecoilState } from "recoil";
|
||||
|
||||
import { LineMemberState } from "@nirvana/core/models/line.model";
|
||||
import MasterLineData from "@nirvana/core/models/masterLineData.model";
|
||||
import { User } from "@nirvana/core/models";
|
||||
import { queryClient } from "../pages/nirvanaApp";
|
||||
import toast from "react-hot-toast";
|
||||
import { useUserLines } from "./index";
|
||||
|
||||
let $ws: Socket;
|
||||
|
||||
function useSocketHandler(linesData: MasterLineData[]) {
|
||||
const jwtToken = useRecoilValue($jwtToken);
|
||||
|
||||
const [linesMap, setLinesMap] = useState<LineIdToMasterLine>({});
|
||||
const setDesktopMode = useSetRecoilState($desktopMode);
|
||||
|
||||
/**
|
||||
* handle ws connection
|
||||
*/
|
||||
|
||||
// ! POTENTIAL DIAGNOSIS with socket reconnections on electron idle causing
|
||||
// no more event listeners being fired off: potentially because the listeners were for an older manager or instance
|
||||
// so when the client reconnects, with another instance, the old listeners are lost
|
||||
// https://stackoverflow.com/questions/34984980/socket-io-not-receiving-emit-data-after-reconnect
|
||||
// https://socket.io/docs/v4/client-options/#reconnection
|
||||
|
||||
useEffect(() => {
|
||||
$ws = io("http://localhost:5000", {
|
||||
query: { token: jwtToken },
|
||||
transports: ["websocket"],
|
||||
upgrade: false,
|
||||
forceNew: true,
|
||||
reconnection: false, // ! TESTING THE PROBLEM WITH RECONNECTION CLIENT LISTENERS NOT ACTIVATING
|
||||
});
|
||||
|
||||
$ws.on("connect", () => {
|
||||
console.log(
|
||||
"SOCKETS | CLIENT CONNECTED in socket handler, setting up client side listeners for requests"
|
||||
);
|
||||
toast.success("you are connected");
|
||||
|
||||
/**
|
||||
* initiate listeners
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO: P0 : notified that someone else added me to a line they created
|
||||
*/
|
||||
// 1. get the new line from axios or just from the ws itself
|
||||
// 2. add it to the lines map with all of the right data
|
||||
// 3. make sure to connect to it as if it's inbox material
|
||||
|
||||
// when me or anyone just initially connects to line
|
||||
$ws.on(
|
||||
ServerResponseChannels.SOMEONE_CONNECTED_TO_LINE,
|
||||
(res: SomeoneConnectedResponse) => {
|
||||
console.log(
|
||||
`connected to line...here are all of the updated in the conected line ${res.lineId}...this isn't reliable considering it's not updated later`,
|
||||
res.allConnectedIntoUserIds
|
||||
);
|
||||
|
||||
setLinesMap((prevLinesMap) => {
|
||||
const newMap = { ...prevLinesMap };
|
||||
|
||||
if (newMap[res.lineId])
|
||||
newMap[res.lineId].connectedMemberIds = [
|
||||
...(newMap[res.lineId]?.connectedMemberIds ?? []),
|
||||
res.userId,
|
||||
];
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// someone tuning in, including perhaps me | either toggled in or just temporary
|
||||
$ws.on(
|
||||
ServerResponseChannels.SOMEONE_TUNED_INTO_LINE,
|
||||
(res: SomeoneTunedResponse) => {
|
||||
console.log(
|
||||
`here are all of updated users in the tuned in room`,
|
||||
res.allTunedIntoUserIds
|
||||
);
|
||||
|
||||
// TODO: if toggled in, make sure to update the current line member in the lines map so that
|
||||
// we can know to untune if user selects another line
|
||||
|
||||
// below, we are setting the list of tuned in folks based on fresh list from the server
|
||||
// better than just adding and removing? i think so, but have to handle not interrupting existing peer connections as this changes
|
||||
setLinesMap((prevLinesMap) => {
|
||||
const newMap = { ...prevLinesMap };
|
||||
|
||||
if (newMap[res.lineId]) {
|
||||
newMap[res.lineId].tunedInMemberIds = res.allTunedIntoUserIds;
|
||||
|
||||
// if user is me, make sure to show me my updated line member association
|
||||
if (
|
||||
newMap[res.lineId].currentUserMember?.userId.toString() ===
|
||||
res.userId
|
||||
) {
|
||||
newMap[res.lineId].currentUserMember.lastVisitDate = new Date();
|
||||
newMap[res.lineId].currentUserMember.state = res.toggledIn
|
||||
? LineMemberState.TUNED
|
||||
: LineMemberState.INBOX;
|
||||
}
|
||||
|
||||
// TODO: update the relevant lineMember (based on which userId is given): state and last visit date if current user is joining
|
||||
// and not just if it's the current user toggling in
|
||||
// right now, we just want user to know number of folks tuned and no need to expose who is toggle tuned...that lineMember can be stale
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
$ws.on(
|
||||
ServerResponseChannels.SOMEONE_UNTUNED_FROM_LINE,
|
||||
(res: SomeoneUntunedFromLineResponse) => {
|
||||
console.log(
|
||||
`here are all of updated users in the tuned in room`,
|
||||
res.allTunedIntoUserIds
|
||||
);
|
||||
|
||||
// TODO: if toggled in, make sure to update the current line member in the lines map so that
|
||||
// we can know to untune if user selects another line
|
||||
|
||||
setLinesMap((prevLinesMap) => {
|
||||
const newMap = { ...prevLinesMap };
|
||||
|
||||
// TODO: update the relevant lineMember (based on which userId is given): state and last visit date if current user is joining
|
||||
|
||||
if (newMap[res.lineId])
|
||||
newMap[res.lineId].tunedInMemberIds = res.allTunedIntoUserIds;
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
$ws.on(
|
||||
ServerResponseChannels.SOMEONE_STARTED_BROADCASTING,
|
||||
(res: UserStartedBroadcastingResponse) => {
|
||||
console.log("someone is starting to broadcast");
|
||||
|
||||
setLinesMap((prevLinesMap) => {
|
||||
const newMap = { ...prevLinesMap };
|
||||
|
||||
if (newMap[res.lineId])
|
||||
newMap[res.lineId].currentBroadcastersUserIds = [
|
||||
...(newMap[res.lineId].currentBroadcastersUserIds ?? []),
|
||||
res.userId,
|
||||
];
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
$ws.on(
|
||||
ServerResponseChannels.SOMEONE_STOPPED_BROADCASTING,
|
||||
(res: UserStoppedBroadcastingResponse) => {
|
||||
setLinesMap((prevLinesMap) => {
|
||||
const newMap = { ...prevLinesMap };
|
||||
|
||||
if (newMap[res.lineId]?.currentBroadcastersUserIds) {
|
||||
newMap[res.lineId].currentBroadcastersUserIds = newMap[
|
||||
res.lineId
|
||||
].currentBroadcastersUserIds.filter(
|
||||
(broadcasterUserId) => broadcasterUserId !== res.userId
|
||||
);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// on disconnections, just switch it to flow state?
|
||||
// what if there was another problem?
|
||||
$ws.io.on("close", () => {
|
||||
console.error(
|
||||
"SOCKET | there was a problem with your app...connection closed likely due to idling or manual disconnect"
|
||||
);
|
||||
// todo: figure out the right thing based on the situation whether it's a problem or user unplugs
|
||||
// toast(
|
||||
// "Disconnected due to idling or some other issue. Please reconnect or refresh to fix the problem."
|
||||
// );
|
||||
|
||||
toast("unplugging");
|
||||
setDesktopMode("flowState");
|
||||
});
|
||||
|
||||
// client-side errors
|
||||
$ws.on("connect_error", (err) => {
|
||||
console.error(`SOCKETS | ${err.message}`); // prints the message associated with the error
|
||||
toast.error("sorry...this is our bad...please refresh with cmd + r");
|
||||
|
||||
// force refetch of server status as well as these generally go hand in hand
|
||||
|
||||
// this should overall remount this component currently which is what we want for new data
|
||||
queryClient.invalidateQueries("SERVER_CHECK");
|
||||
});
|
||||
|
||||
// on unmounting this component, we want to disconnect
|
||||
return () => {
|
||||
$ws.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** handle initial data coming in and creating the initial line map
|
||||
* and doing initial connections/tune ins ?could trigger this later?
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (linesData?.length > 0) {
|
||||
setLinesMap((prevMappings) => {
|
||||
// go through the lines from the persistent store
|
||||
|
||||
// get all of the id's and map assign to the main object
|
||||
|
||||
// ?prolly have no previous at this point...but I'm okay with override since this
|
||||
// ?useeffect is triggered on the refetching of the persistent store so we
|
||||
// ?are prolly looking to do a full app refresh and connection refresh
|
||||
const newMap = { ...prevMappings };
|
||||
|
||||
linesData.forEach((masterLine) => {
|
||||
const lineId = masterLine.lineDetails._id.toString();
|
||||
newMap[lineId] = masterLine;
|
||||
|
||||
handleConnectToLine(lineId);
|
||||
|
||||
// tune into lines that I should be
|
||||
if (masterLine.currentUserMember.state === LineMemberState.TUNED) {
|
||||
// don't need to turn toggle on, it's already on
|
||||
// ! CHANGE PARADIGM TO NOT INCLUDING TOGGLING IN REQUESTS WITH THE SAME AS TUNING IN
|
||||
// right now, writing just because of later logic
|
||||
handleTuneToLine(lineId, true);
|
||||
}
|
||||
});
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [linesData, setLinesMap]);
|
||||
|
||||
/** handlers for emitting events to server */
|
||||
const handleConnectToLine = useCallback(
|
||||
(lineId: string) => {
|
||||
$ws.emit(
|
||||
ServerRequestChannels.CONNECT_TO_LINE,
|
||||
new ConnectToLineRequest(lineId)
|
||||
);
|
||||
},
|
||||
[$ws]
|
||||
);
|
||||
|
||||
/**
|
||||
* toggle into a specific line
|
||||
* @param temporary: denotes whether we are just listening in or want to persist "toggling" it on so that it shows up in overlay
|
||||
* TODO: have loading state for this particular part of context value
|
||||
*/
|
||||
const handleTuneToLine = useCallback(
|
||||
(lineId: string, turnToggleOn: boolean = false) => {
|
||||
// they already are in the socket room for updates including media connections and disconnections
|
||||
// but set the flag so that the line row can know whether or not to start the webrtc process
|
||||
// and know when to get out or disconnect from the webrtc when the flag turns off
|
||||
|
||||
$ws.emit(
|
||||
ServerRequestChannels.TUNE_INTO_LINE,
|
||||
new TuneToLineRequest(lineId, turnToggleOn)
|
||||
);
|
||||
},
|
||||
[$ws]
|
||||
);
|
||||
|
||||
const handleUnTuneToLine = useCallback(
|
||||
(lineId: string) => {
|
||||
// they already are in the socket room for updates including media connections and disconnections
|
||||
// but set the flag so that the line row can know whether or not to start the webrtc process
|
||||
// and know when to get out or disconnect from the webrtc when the flag turns off
|
||||
|
||||
$ws.emit(
|
||||
ServerRequestChannels.UNTUNE_FROM_LINE,
|
||||
new UntuneFromLineRequest(lineId)
|
||||
);
|
||||
},
|
||||
[$ws]
|
||||
);
|
||||
|
||||
/**
|
||||
* This is when the user wants to tell everyone that they are streaming/broadcasting/buzzing to
|
||||
* a specific line, whether they are toggle tuned or temporarily tuned in
|
||||
*
|
||||
* Note: They could be using push to talk or toggle broadcast
|
||||
*
|
||||
* Note: this is handling the data transmission as is the responsibility of this overall context provider
|
||||
* and not necessarily the aspect of enabling and disabling streaming as webrtc is reliable enough for that
|
||||
*
|
||||
* @param lineId the line that the current user is talking into
|
||||
*/
|
||||
const handleStartBroadcast = useCallback(
|
||||
(lineId: string) => {
|
||||
// emit telling people
|
||||
$ws.emit(
|
||||
ServerRequestChannels.BROADCAST_TO_LINE,
|
||||
new StartBroadcastingRequest(lineId)
|
||||
);
|
||||
},
|
||||
[$ws]
|
||||
);
|
||||
|
||||
const handleStopBroadcast = useCallback(
|
||||
(lineId: string) => {
|
||||
// emit telling people
|
||||
$ws.emit(
|
||||
ServerRequestChannels.STOP_BROADCAST_TO_LINE,
|
||||
new StopBroadcastingRequest(lineId)
|
||||
);
|
||||
|
||||
// ?handle recording here as well? or record the incoming stream instead?
|
||||
// ?could just take in the audiochunks here and all, but not sure yet
|
||||
},
|
||||
[$ws]
|
||||
);
|
||||
|
||||
/**
|
||||
* get more audio blocks for a certain line
|
||||
*/
|
||||
const handleFetchMoreAudioBlocks = useCallback(
|
||||
(lineId: string) => {
|
||||
// simple axios fetch for this specific block
|
||||
// update the specific line in lines map
|
||||
// set the state to trigger the re-renders in the tree
|
||||
},
|
||||
[$ws]
|
||||
);
|
||||
|
||||
/**
|
||||
* TODO: handle telling overall current client that we are finally rtc connected for a certain line
|
||||
*/
|
||||
|
||||
// TODO: move all of these emitters to just having child views doing the work here
|
||||
return {
|
||||
linesMap,
|
||||
handleConnectToLine,
|
||||
handleTuneToLine,
|
||||
handleStartBroadcast,
|
||||
handleStopBroadcast,
|
||||
handleFetchMoreAudioBlocks,
|
||||
handleUnTuneToLine,
|
||||
};
|
||||
}
|
||||
|
||||
type LineIdToMasterLine = {
|
||||
[lineId: string]: MasterLineData;
|
||||
};
|
||||
interface ILineDataContext {
|
||||
// represents the modified and up to date lines data with availability from session
|
||||
linesMap: LineIdToMasterLine;
|
||||
relevantUsers: User[];
|
||||
|
||||
// in case some components need this like in the peer handling
|
||||
$ws: Socket;
|
||||
}
|
||||
|
||||
const LineDataContext = React.createContext<
|
||||
Partial<ILineDataContext & ReturnType<typeof useSocketHandler>>
|
||||
>({
|
||||
linesMap: {},
|
||||
relevantUsers: [],
|
||||
$ws: undefined,
|
||||
});
|
||||
|
||||
export function LineDataProvider({ children }) {
|
||||
// TODO: have internal loading to prevent showing even router and all if something is not ready
|
||||
// or have it within each property within the context value
|
||||
|
||||
// persistent store of lines
|
||||
// ?just do simple synchronous axios/fetch in useEffect and manage isLoading ourselves?
|
||||
const { data: basicUserLinesData } = useUserLines();
|
||||
const desktopMode = useRecoilValue($desktopMode);
|
||||
|
||||
// ! passing in the same data of the query passes reference so changes that happen in socketHandler impact react query cache
|
||||
const { linesMap, ...handlers } = useSocketHandler(
|
||||
basicUserLinesData?.data?.masterLines
|
||||
);
|
||||
|
||||
// TODO: not complete...not sure of priority of this
|
||||
// if we go into flow state
|
||||
// emit flow state message to all of my rooms
|
||||
// persist it in my user object
|
||||
// and then disconnect
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (desktopMode === "flowState" && $ws) {
|
||||
console.log(
|
||||
"SOCKETS | telling all of my connected rooms that I am unplugging"
|
||||
);
|
||||
|
||||
$ws.emit(ServerRequestChannels.GOING_INTO_FLOW_STATE);
|
||||
}
|
||||
};
|
||||
}, [desktopMode, $ws]);
|
||||
|
||||
// avoid children rendering if they don't have the ws to make individual calls with
|
||||
if (!$ws) {
|
||||
return <span>attempting to connect you for the here and now...</span>;
|
||||
}
|
||||
|
||||
const value: ILineDataContext & ReturnType<typeof useSocketHandler> = {
|
||||
linesMap, // TODO send the updated map instead of this array
|
||||
relevantUsers: [],
|
||||
$ws,
|
||||
...handlers,
|
||||
};
|
||||
|
||||
return (
|
||||
<LineDataContext.Provider value={value}>
|
||||
{children}
|
||||
</LineDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLineDataProvider() {
|
||||
return useContext(LineDataContext);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Method } from 'axios';
|
||||
|
||||
import CreateLineRequest from '@nirvana/core/requests/createLine.request';
|
||||
import GetUserLinesResponse from '@nirvana/core/responses/getUserLines.response';
|
||||
import { Line } from '@nirvana/core/models/line.model';
|
||||
import LoginResponse from '@nirvana/core/responses/login.response';
|
||||
import NirvanaResponse from '@nirvana/core/responses/nirvanaResponse';
|
||||
import UserDetailsResponse from '@nirvana/core/responses/userDetails.response';
|
||||
import UserSearchResponse from '@nirvana/core/responses/userSearch.response';
|
||||
|
||||
// export const localHost = process.env.REACT_APP_API_DOMAIN;
|
||||
|
||||
export const localHost = 'http://localhost:5000/api';
|
||||
|
||||
export default class NirvanaApi {
|
||||
// auth token from google that our backend will use
|
||||
static _jwtToken?: string;
|
||||
|
||||
static async fetch<T>(
|
||||
url: string,
|
||||
method: Method,
|
||||
privateRoute = false,
|
||||
body: object = null
|
||||
) {
|
||||
// use the auth token if it's a private route
|
||||
// error if no auth token and it's a private route
|
||||
// throw error and show message on anything that is an error from the backend
|
||||
|
||||
const fullUrl = localHost + url;
|
||||
|
||||
let res;
|
||||
if (privateRoute && !this._jwtToken) throw Error('No jwt token available!');
|
||||
|
||||
if (privateRoute && this._jwtToken) {
|
||||
res = await fetch(fullUrl, {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: this._jwtToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
} else {
|
||||
res = await fetch(fullUrl);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) throw new Error('You are not authorized here');
|
||||
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function serverCheck(): Promise<void> {
|
||||
return await NirvanaApi.fetch(`/status`, 'GET', false);
|
||||
}
|
||||
|
||||
async function login(reqLoginTokens: {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
}): Promise<LoginResponse> {
|
||||
return await NirvanaApi.fetch(
|
||||
`/user/login?access_token=${reqLoginTokens.accessToken}&id_token=${reqLoginTokens.idToken}`,
|
||||
'GET',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
async function authCheck(): Promise<void> {
|
||||
return await NirvanaApi.fetch(`/user/authcheck`, 'GET', true);
|
||||
}
|
||||
|
||||
async function getUserDetails(): Promise<UserDetailsResponse> {
|
||||
return await NirvanaApi.fetch(`/user`, 'GET', true);
|
||||
}
|
||||
|
||||
async function userSearch(searchQuery: string): Promise<UserSearchResponse> {
|
||||
return await NirvanaApi.fetch(
|
||||
`/search/users?query=${searchQuery}`,
|
||||
'GET',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
async function getUserLines(): Promise<NirvanaResponse<GetUserLinesResponse>> {
|
||||
return await NirvanaApi.fetch<NirvanaResponse<GetUserLinesResponse>>(
|
||||
`/lines`,
|
||||
'GET',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
async function getDmByUserId(otherUserId: string): Promise<Line> {
|
||||
return await NirvanaApi.fetch(`/lines/dm/${otherUserId}`, 'GET', true);
|
||||
}
|
||||
|
||||
async function createLine(
|
||||
request: CreateLineRequest
|
||||
): Promise<NirvanaResponse<Line>> {
|
||||
return await NirvanaApi.fetch(`/lines`, 'POST', true, request);
|
||||
}
|
||||
|
||||
export const ApiCalls = {
|
||||
serverCheck,
|
||||
login,
|
||||
authCheck,
|
||||
getUserDetails,
|
||||
userSearch,
|
||||
getUserLines,
|
||||
getDmByUserId,
|
||||
createLine,
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { atom } from "recoil";
|
||||
|
||||
export const $searchQuery = atom<string>({
|
||||
key: "SEARCH_QUERY",
|
||||
default: "",
|
||||
});
|
||||
|
||||
export const $jwtToken = atom<string>({
|
||||
key: "JWT_TOKEN",
|
||||
default: null,
|
||||
});
|
||||
|
||||
// conversation id
|
||||
export const $selectedConversation = atom<string>({
|
||||
key: "SELECTED_CONVERSATION",
|
||||
default: null,
|
||||
});
|
||||
|
||||
// new convo page trigger
|
||||
export const $newConvoPage = atom<boolean>({
|
||||
key: "NEW_CONVO_PAGE",
|
||||
default: false,
|
||||
});
|
||||
|
||||
// number of active lines
|
||||
export const $numberActiveLines = atom<number>({
|
||||
key: "NUMBER_ACTIVE_LINES",
|
||||
default: 0,
|
||||
});
|
||||
|
||||
// max number of active streams
|
||||
export const $maxNumberActiveStreams = atom<number>({
|
||||
key: "MAX_NUMBER_ACTIVE_STREAMS",
|
||||
default: 0,
|
||||
});
|
||||
|
||||
// ============
|
||||
|
||||
type DesktopMode = "flowState" | "overlayOnly" | "terminal";
|
||||
|
||||
export const $desktopMode = atom<DesktopMode>({
|
||||
key: "DESKTOP_MODE",
|
||||
default: "terminal",
|
||||
});
|
||||
|
||||
export const $selectedLineId = atom<string>({
|
||||
key: "SELECTED_LINE_ID",
|
||||
default: null,
|
||||
});
|
||||
|
||||
interface MediaSettings {
|
||||
mode: "audio" | "video" | "screen";
|
||||
isMuted: boolean;
|
||||
}
|
||||
export const $mediaSettings = atom<MediaSettings>({
|
||||
key: "MEDIA_SETTINGS",
|
||||
default: { isMuted: false, mode: "audio" },
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { ReactQueryDevtools } from 'react-query/devtools';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { configure } from 'react-hotkeys';
|
||||
import testConnection from '@nirvana/core';
|
||||
import NirvanaRouter from './router';
|
||||
import ProtectedRoute from '../components/ProtectedRoute';
|
||||
|
||||
testConnection();
|
||||
|
||||
configure({
|
||||
// TODO: put back to default from docs: https://github.com/greena13/react-hotkeys#Configuration
|
||||
|
||||
/**
|
||||
* The HTML tags that React HotKeys should ignore key events from. This only works
|
||||
* if you are using the default ignoreEventsCondition function.
|
||||
* @type {String[]}
|
||||
*/
|
||||
ignoreTags: [],
|
||||
|
||||
/**
|
||||
* The function used to determine whether a key event should be ignored by React
|
||||
* Hotkeys. By default, keyboard events originating elements with a tag name in
|
||||
* ignoreTags, or a isContentEditable property of true, are ignored.
|
||||
*
|
||||
* @type {Function<KeyboardEvent>}
|
||||
*/
|
||||
// ignoreEventsCondition: function,
|
||||
});
|
||||
|
||||
// Create a client
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
function NirvanaApp() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<ProtectedRoute>
|
||||
<NirvanaRouter />
|
||||
</ProtectedRoute>
|
||||
|
||||
<ReactQueryDevtools initialIsOpen position='bottom-left' />
|
||||
</RecoilRoot>
|
||||
|
||||
<Toaster
|
||||
position='bottom-right'
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '10px',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default NirvanaApp;
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
$desktopMode,
|
||||
$maxNumberActiveStreams,
|
||||
$numberActiveLines,
|
||||
$selectedLineId,
|
||||
} from '../../controller/recoil';
|
||||
import Channels, {
|
||||
DEFAULT_APP_PRESET,
|
||||
Dimensions,
|
||||
} from '../../electron/constants';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import FullMottoLogo from '../../../../components/logo/fullMotto';
|
||||
import { LineDataProvider } from '../../controller/lineDataProvider';
|
||||
import { LogoType } from '@nirvana/components/logo/full';
|
||||
import NirvanaHeader from '../../components/header/index';
|
||||
import NirvanaTerminal from '../terminal.tsx';
|
||||
|
||||
export default function NirvanaRouter() {
|
||||
const [selectedLineId, setSelectedLineId] = useRecoilState($selectedLineId);
|
||||
const [desktopMode, setDesktopMode] = useRecoilState($desktopMode);
|
||||
const numberOfOverlayColumns = useRecoilValue($numberActiveLines);
|
||||
const numberOfOverlayRows = useRecoilValue($maxNumberActiveStreams);
|
||||
|
||||
// handle all window resizing logic
|
||||
useEffect(() => {
|
||||
// add dimensions if it's not overlay only mode
|
||||
let finalDimensions: Dimensions = { height: 0, width: 0 };
|
||||
let finalPosition: 'center' | 'topRight';
|
||||
|
||||
const setAlwaysOnTop = desktopMode === 'overlayOnly';
|
||||
|
||||
// go hunting for which dimensions to have
|
||||
if (desktopMode === 'terminal' || desktopMode == 'flowState') {
|
||||
finalDimensions = DEFAULT_APP_PRESET;
|
||||
}
|
||||
|
||||
if (desktopMode === 'overlayOnly') {
|
||||
finalDimensions = {
|
||||
height: 68 + numberOfOverlayRows * 200,
|
||||
width: 360 + 360 * numberOfOverlayColumns,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
'setting new dimensions',
|
||||
desktopMode,
|
||||
numberOfOverlayColumns,
|
||||
numberOfOverlayRows,
|
||||
finalDimensions,
|
||||
setAlwaysOnTop
|
||||
);
|
||||
|
||||
// send the final dimensions to main process
|
||||
window.electronAPI.window.resizeWindow({
|
||||
setAlwaysOnTop,
|
||||
dimensions: {
|
||||
height: finalDimensions.height,
|
||||
width: finalDimensions.width,
|
||||
},
|
||||
setPosition: finalPosition,
|
||||
addDimensions: false,
|
||||
});
|
||||
}, [desktopMode, numberOfOverlayColumns, numberOfOverlayRows]);
|
||||
|
||||
// on window blur, put app in overlay only mode
|
||||
// get rid of selected line from terminal if we had any...
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.on(Channels.ON_WINDOW_BLUR, () => {
|
||||
console.log(
|
||||
'window blurring now, should be always on top and then ill tell main process to change dimensions'
|
||||
);
|
||||
// TODO: testing mode... uncomment both instructions below
|
||||
// setDesktopMode("overlayOnly");
|
||||
|
||||
// todo: make sure that if I am toggle broadcasted into a line, then don't deselect selected line
|
||||
// the overlay should be showing selected line if I am broadcasting toggled into it as well as of course all other toggle tuned ones
|
||||
// setSelectedLineId(null);
|
||||
});
|
||||
}, [setDesktopMode, setSelectedLineId]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col flex-1'>
|
||||
<NirvanaHeader onHeaderFocus={() => setDesktopMode('terminal')} />
|
||||
|
||||
{desktopMode === 'flowState' && <FlowState />}
|
||||
|
||||
{/* remount nirvana terminal */}
|
||||
{(desktopMode === 'terminal' || desktopMode === 'overlayOnly') && (
|
||||
<LineDataProvider>
|
||||
<NirvanaTerminal overlayOnly={desktopMode === 'overlayOnly'} />
|
||||
</LineDataProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Quote = {
|
||||
content: string;
|
||||
author: string;
|
||||
length: number;
|
||||
dateAdded: Date;
|
||||
_id: string;
|
||||
tags: string[];
|
||||
};
|
||||
function FlowState() {
|
||||
const [quote, setQuote] = useState<Quote>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://api.quotable.io/random')
|
||||
.then((res) => res.json())
|
||||
.then((data: Quote) => {
|
||||
console.warn(data);
|
||||
|
||||
setQuote(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
// do nothing, don't bother user, just don't show the quote
|
||||
console.warn(error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col flex-1 justify-center items-center relative'>
|
||||
{/* <img src="https://source.unsplash.com/random/?nature" /> */}
|
||||
<FullMottoLogo
|
||||
type={LogoType.small}
|
||||
className={'absolute bottom-2 mx-auto'}
|
||||
/>
|
||||
|
||||
{quote && (
|
||||
<span className='flex flex-col justify-center items-center max-w-screen-sm'>
|
||||
<span className='text-xl text-gray-800 font-semibold text-center'>
|
||||
"{quote.content}"
|
||||
</span>
|
||||
<span className='tex-md italic text-gray-400'>{quote.author}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { $desktopMode, $selectedLineId } from "../../controller/recoil";
|
||||
import { Avatar, Skeleton, Tooltip } from "antd";
|
||||
import { FiActivity, FiHeadphones, FiSettings, FiSun } from "react-icons/fi";
|
||||
import { GlobalHotKeys, KeyMap } from "react-hotkeys";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useGetUserDetails, useUserLines } from "../../controller/index";
|
||||
import { useRecoilState, useSetRecoilState } from "recoil";
|
||||
|
||||
import { FaPlus } from "react-icons/fa";
|
||||
import LineIcon from "../../components/lines/lineIcon/index";
|
||||
import { LineMemberState } from "@nirvana/core/models/line.model";
|
||||
import LineRow from "../../components/lines/lineRow.tsx/index";
|
||||
import MasterLineData from "@nirvana/core/models/masterLineData.model";
|
||||
import NewLineModal from "./newLine";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLineDataProvider } from "../../controller/lineDataProvider";
|
||||
|
||||
/**
|
||||
* Socket Provider
|
||||
* Line Data Provider
|
||||
*/
|
||||
export default function NirvanaTerminal({
|
||||
overlayOnly,
|
||||
}: {
|
||||
overlayOnly: boolean;
|
||||
}) {
|
||||
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
|
||||
|
||||
const { data: userDetails } = useGetUserDetails();
|
||||
const [selectedLineId, setSelectedLineId] = useRecoilState($selectedLineId);
|
||||
const [desktopMode, setDesktopMode] = useRecoilState($desktopMode);
|
||||
|
||||
// simply using this query for specific data on loading
|
||||
// todo: add these properties in context provider value although more work down the line for control
|
||||
const { isLoading: isLoadingInitialLines } = useUserLines();
|
||||
const { linesMap, handleTuneToLine, handleUnTuneToLine } =
|
||||
useLineDataProvider();
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("change/update in lines map");
|
||||
// console.log(linesMap);
|
||||
}, [linesMap]);
|
||||
|
||||
// ! remounting when toggle tuning and untoggle tuning a line
|
||||
// because it changes the list that it's in...solution 1: put everything in one list and use one sort to handle toggle tuned items to be clean
|
||||
// the change in the object or the property which is specifically currentUserMember of the masterLineData sent in to line row
|
||||
// causes a re-render but not a unmount and remount
|
||||
|
||||
const allLines: MasterLineData[] = useMemo(() => {
|
||||
const masterLines: MasterLineData[] = Object.values(linesMap);
|
||||
|
||||
// TODO: sort based on the audio blocks and currentMember lastActiveDate
|
||||
|
||||
return masterLines.filter(
|
||||
(masterLine) =>
|
||||
masterLine.currentUserMember.state === LineMemberState.INBOX
|
||||
);
|
||||
}, [linesMap]);
|
||||
|
||||
const toggleTunedLines = useMemo(() => {
|
||||
const masterLines: MasterLineData[] = Object.values(linesMap);
|
||||
|
||||
return masterLines.filter(
|
||||
(masterLine) =>
|
||||
masterLine.currentUserMember.state === LineMemberState.TUNED
|
||||
);
|
||||
}, [linesMap]);
|
||||
|
||||
const selectedLine: MasterLineData | undefined = useMemo(() => {
|
||||
if (!selectedLineId) return undefined;
|
||||
|
||||
// find the line from the data provider
|
||||
if (linesMap[selectedLineId]) {
|
||||
// console.log("looking for selected Line in map for details section");
|
||||
|
||||
const foundSelectedLine = linesMap[selectedLineId];
|
||||
|
||||
// on mount of this, we want to temporarily tune into the line if we are not already tuned in...which would happen if we toggle tuned in
|
||||
if (
|
||||
!foundSelectedLine.tunedInMemberIds?.includes(
|
||||
userDetails?.user?._id.toString()
|
||||
)
|
||||
) {
|
||||
handleTuneToLine(selectedLineId, false);
|
||||
}
|
||||
|
||||
return { ...foundSelectedLine };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [selectedLineId, linesMap]);
|
||||
|
||||
// todo: sort/order based on activity and activity date and currently broadcasting/live
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
console.log("deselecting line");
|
||||
|
||||
setSelectedLineId((prevSelectedLineId) => {
|
||||
// ! only want to untune if it's a temporarily tuned line
|
||||
if (selectedLine?.currentUserMember?.state === LineMemberState.INBOX)
|
||||
handleUnTuneToLine(prevSelectedLineId);
|
||||
|
||||
return null;
|
||||
});
|
||||
}, [setSelectedLineId, selectedLine]);
|
||||
|
||||
/** show user line details on click of one line */
|
||||
const handleSelectLine = useCallback(
|
||||
(newLineIdToSelect: string) => {
|
||||
if (newLineIdToSelect !== selectedLine?.lineDetails._id.toString()) {
|
||||
setSelectedLineId((prevSelectedLineId) => {
|
||||
// untune myself from the previously selected if it was a temporary one/inbox
|
||||
// todo: p3: consolidate this logic as it's used in escape but different scenarios sort of so p3
|
||||
if (
|
||||
prevSelectedLineId &&
|
||||
selectedLine.currentUserMember?.state === LineMemberState.INBOX
|
||||
)
|
||||
handleUnTuneToLine(prevSelectedLineId);
|
||||
|
||||
return newLineIdToSelect;
|
||||
});
|
||||
}
|
||||
},
|
||||
[setSelectedLineId, selectedLine]
|
||||
);
|
||||
|
||||
const handleToggleTuneToLine = useCallback(
|
||||
(lineId: string, turnToggleOn: boolean) => {
|
||||
// inhibit if they are trying to turn on and already have 3 toggle tuned
|
||||
if (toggleTunedLines?.length >= 3 && turnToggleOn) {
|
||||
toast.error("You cannot toggle more than 3 lines!");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleTuneToLine(lineId, turnToggleOn);
|
||||
},
|
||||
[toggleTunedLines, handleTuneToLine]
|
||||
);
|
||||
|
||||
const handleStartBroadcast = useCallback(
|
||||
(lineId: string) => () => {
|
||||
console.log(`starting broadcast for ${lineId}!!!`);
|
||||
|
||||
// todo: enable stream in this tuned in channel
|
||||
|
||||
// if (lineId) handleUserBroadcast(lineId, true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleStopBroadcast = useCallback(
|
||||
(lineId: string) => () => {
|
||||
console.log(`stopping broadcast for ${lineId}!!!`);
|
||||
|
||||
// todo: disable stream in this tuned in channel
|
||||
|
||||
// if (lineId) handleUserBroadcast(lineId, false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const keyMap: KeyMap = useMemo(
|
||||
() => ({
|
||||
DESELECT_LINE: "esc",
|
||||
START_BROADCAST: {
|
||||
sequence: "`",
|
||||
action: "keydown",
|
||||
},
|
||||
STOP_BROADCAST: {
|
||||
sequence: "`",
|
||||
action: "keyup",
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
DESELECT_LINE: handleEscape,
|
||||
START_BROADCAST: handleStartBroadcast(selectedLineId),
|
||||
STOP_BROADCAST: handleStopBroadcast(selectedLineId),
|
||||
}),
|
||||
[selectedLineId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
||||
|
||||
<div className="flex flex-row flex-1">
|
||||
<div className="flex flex-col bg-white w-[400px] relative group">
|
||||
{/* modal for creating new line */}
|
||||
<NewLineModal
|
||||
open={isModalVisible}
|
||||
handleClose={() => setIsModalVisible(false)}
|
||||
/>
|
||||
|
||||
{/* tuned in lines block */}
|
||||
<div className="bg-gray-100 flex flex-col shadow-lg">
|
||||
{/* tuned in header + general controls */}
|
||||
<div className="flex flex-row items-center py-3 px-4 pb-0">
|
||||
<span className="flex flex-row gap-2 items-center justify-start text-gray-400 animate-pulse">
|
||||
<FiActivity className="text-sm" />
|
||||
|
||||
<h2 className="text-inherit text-sm">Tuned In</h2>
|
||||
|
||||
<p className="text-slate-300 text-xs">{`${
|
||||
toggleTunedLines?.length || 0
|
||||
}/3`}</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* list of toggle tuned lines */}
|
||||
<div className="flex flex-col mt-2">
|
||||
{toggleTunedLines.map((masterLineData) => (
|
||||
<LineRow
|
||||
key={`terminalListLines-${masterLineData.lineDetails._id.toString()}`}
|
||||
masterLineData={masterLineData}
|
||||
handleSelectLine={handleSelectLine}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!(allLines.length > 0) && !(toggleTunedLines.length > 0) && (
|
||||
<span className="text-gray-300 text-sm my-5 text-center">
|
||||
You have no lines! <br /> Create one to connect to your team
|
||||
instantly.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* rest of the lines */}
|
||||
<div className={"flex flex-col"}>
|
||||
{isLoadingInitialLines ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
allLines.map((masterLineData) => (
|
||||
<LineRow
|
||||
key={`terminalListLines-${masterLineData.lineDetails._id.toString()}`}
|
||||
masterLineData={masterLineData}
|
||||
handleSelectLine={handleSelectLine}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setIsModalVisible(true)}
|
||||
className="absolute bottom-3 right-3 z-10 scale-0
|
||||
group-hover:scale-100 ease-in-out hover:transition group-hover:transition delay-100 duration-200"
|
||||
>
|
||||
<button
|
||||
className="flex flex-row gap-2 items-center justify-evenly
|
||||
shadow-xl bg-gray-800 p-2 text-white text-xs"
|
||||
>
|
||||
<FaPlus />
|
||||
<span>New line</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLine ? (
|
||||
<LineDetailsTerminal
|
||||
handleToggleTuneToLine={handleToggleTuneToLine}
|
||||
selectedLine={selectedLine}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-col flex-1 justify-center items-center bg-gray-100
|
||||
border-l border-l-gray-200"
|
||||
>
|
||||
<span className="text-xl text-gray-800">
|
||||
{`Hi ${userDetails?.user?.givenName}!`}
|
||||
</span>
|
||||
<span className="text-md text-gray-400">You're all set!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LineDetailsTerminal({
|
||||
selectedLine,
|
||||
handleToggleTuneToLine,
|
||||
}: {
|
||||
selectedLine: MasterLineData;
|
||||
handleToggleTuneToLine: (lineId: string, turnToggleOn: boolean) => void;
|
||||
}) {
|
||||
const { data: userDetails } = useGetUserDetails();
|
||||
|
||||
console.log("selected line", selectedLine);
|
||||
|
||||
const isUserToggleTuned = useMemo(
|
||||
() => selectedLine?.currentUserMember?.state === LineMemberState.TUNED,
|
||||
[selectedLine]
|
||||
);
|
||||
|
||||
// seeing if I am in the list of broadcasters
|
||||
// the source of truth from the socket connections telling me if my clicking actually made a round trip
|
||||
const isUserBroadcasting = useMemo(
|
||||
() =>
|
||||
selectedLine?.currentBroadcastersUserIds?.includes(
|
||||
userDetails?.user._id.toString()
|
||||
),
|
||||
[userDetails, selectedLine]
|
||||
);
|
||||
|
||||
// showing all tuned in members...they may not hear me since they might be doing something else
|
||||
// but feeling of presentness
|
||||
const tunedProfiles = useMemo(() => {
|
||||
const pictureSources: { name: string; pictureSrc: string }[] = [];
|
||||
|
||||
selectedLine.tunedInMemberIds?.forEach((tunedInMemberUserId) => {
|
||||
// don't want to see my own picture
|
||||
// TODO: don't show myself!?
|
||||
if (tunedInMemberUserId === userDetails.user._id.toString()) {
|
||||
pictureSources.push();
|
||||
|
||||
pictureSources.push({
|
||||
name: userDetails.user?.givenName,
|
||||
pictureSrc: userDetails.user?.picture,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const otherUserObject = selectedLine.otherUserObjects?.find(
|
||||
(userObj) => userObj._id.toString() === tunedInMemberUserId
|
||||
);
|
||||
if (otherUserObject?.picture)
|
||||
pictureSources.push({
|
||||
name: otherUserObject.givenName,
|
||||
pictureSrc: otherUserObject.picture,
|
||||
});
|
||||
});
|
||||
|
||||
return pictureSources;
|
||||
}, [selectedLine, userDetails]);
|
||||
|
||||
// pics for the line icons
|
||||
const profilePictures = useMemo(() => {
|
||||
const pictureSources: string[] = [];
|
||||
|
||||
// ?don't add in my image as that's useless contextually?
|
||||
// if (userData?.user?.picture) pictureSources.push(userData.user.picture);
|
||||
|
||||
selectedLine.otherUserObjects?.forEach((otherUser) => {
|
||||
if (otherUser.picture) pictureSources.push(otherUser.picture);
|
||||
});
|
||||
|
||||
return pictureSources;
|
||||
}, [selectedLine, userDetails]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col flex-1 bg-gray-100
|
||||
border-l border-l-gray-200 relative"
|
||||
>
|
||||
{/* line details */}
|
||||
<div
|
||||
className="p-4
|
||||
flex flex-row items-center gap-2 justify-end border-b-gray-200 border-b"
|
||||
>
|
||||
{profilePictures && (
|
||||
<LineIcon grayscale={false} sourceImages={profilePictures} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start mr-auto group">
|
||||
<span className="flex flex-row gap-2 items-center">
|
||||
<h2 className={`text-lg text-slate-800 font-semibold`}>
|
||||
{selectedLine.lineDetails.name ||
|
||||
selectedLine.otherUserObjects[0].givenName}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
className={`p-1 hidden group-hover:flex justify-center items-center hover:bg-gray-300
|
||||
transition-all hover:scale-105`}
|
||||
>
|
||||
<FiSettings className="text-gray-400 text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span className="flex flex-row gap-2 items-center">
|
||||
<span className="text-gray-300 text-xs">{`${
|
||||
selectedLine.otherMembers?.length + 1 ?? 0
|
||||
} members`}</span>
|
||||
<span className="h-1 w-1 bg-gray-800 rounded-full"></span>
|
||||
<span className="text-teal-500 text-xs">{`${
|
||||
selectedLine.tunedInMemberIds?.length ?? 0
|
||||
} on the line`}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* TODO: move to on hover of line row */}
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={`${
|
||||
isUserToggleTuned ? "click to untoggle" : "click to stay tuned in"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className={`p-2 flex justify-center items-center shadow-lg
|
||||
hover:scale-105 transition-all animate-pulse ${
|
||||
isUserToggleTuned ? "bg-gray-800 text-white" : "text-black"
|
||||
}`}
|
||||
onClick={() =>
|
||||
isUserToggleTuned
|
||||
? handleToggleTuneToLine(
|
||||
selectedLine.lineDetails._id.toString(),
|
||||
false
|
||||
)
|
||||
: handleToggleTuneToLine(
|
||||
selectedLine.lineDetails._id.toString(),
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
<FiActivity className="text-md" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* line timeline */}
|
||||
<div className="flex flex-col items-center gap-2 my-2 mx-auto max-w-lg w-full">
|
||||
<span
|
||||
className={"text-gray-300 text-sm cursor-pointer hover:underline"}
|
||||
>
|
||||
load more
|
||||
</span>
|
||||
|
||||
<span className={"text-gray-300 text-sm"}>yesterday</span>
|
||||
|
||||
<div className={"rounded border border-gray-200 flex flex-col w-full"}>
|
||||
{selectedLine.otherUserObjects.map((otherUser) => (
|
||||
<div
|
||||
className="flex flex-row p-2 gap-2 items-center bg-transparent
|
||||
border-b border-b-gray-200 last:border-b-0"
|
||||
>
|
||||
<Avatar
|
||||
key={`linehistory-${1}`}
|
||||
src={otherUser.picture}
|
||||
shape="square"
|
||||
size={"default"}
|
||||
// grayscale if not playing?
|
||||
className={`${true && "grayscale"}`}
|
||||
/>
|
||||
<span className="text-gray-500">{otherUser.givenName}</span>
|
||||
|
||||
<span className="ml-auto text-xs text-gray-300">{`${
|
||||
Math.floor(Math.random() * 10) + 1
|
||||
}:${Math.floor(Math.random() * 100) + 10}pm |`}</span>
|
||||
|
||||
<span className="text-gray-400 text-md">{`${
|
||||
Math.floor(Math.random() * 60) + 1
|
||||
} seconds`}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="flex flex-row p-2 gap-2 items-center bg-transparent
|
||||
border-b border-b-gray-200 last:border-b-0"
|
||||
>
|
||||
<Avatar
|
||||
key={`linehistory-${1}`}
|
||||
src={userDetails.user.picture}
|
||||
shape="square"
|
||||
size={"default"}
|
||||
// grayscale if not playing?
|
||||
className={`${true && "grayscale"}`}
|
||||
/>
|
||||
<span className="text-gray-500">{"Arjun Patel"}</span>
|
||||
|
||||
<span className="ml-auto text-xs text-gray-300">{`${
|
||||
Math.floor(Math.random() * 10) + 1
|
||||
}:${Math.floor(Math.random() * 100) + 10}pm |`}</span>
|
||||
|
||||
<span className="text-gray-400 text-md">{`${
|
||||
Math.floor(Math.random() * 60) + 1
|
||||
} seconds`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={"text-gray-300 text-sm"}>today</span>
|
||||
|
||||
<div
|
||||
className={
|
||||
"rounded border border-gray-400 flex flex-col w-full shadow-xl"
|
||||
}
|
||||
>
|
||||
{selectedLine.otherUserObjects.map((otherUser) => (
|
||||
// TODO: show the shadow if it's unheard
|
||||
<div
|
||||
className="flex flex-row p-2 gap-2 items-center bg-transparent
|
||||
border-b border-b-gray-400 last:border-b-0"
|
||||
>
|
||||
<Avatar
|
||||
key={`linehistory-${1}`}
|
||||
src={otherUser.picture}
|
||||
shape="square"
|
||||
size={"default"}
|
||||
// grayscale if not playing?
|
||||
className={`${false && "grayscale"}`}
|
||||
/>
|
||||
<span className="text-gray-600 font-semibold">
|
||||
{otherUser.name}
|
||||
</span>
|
||||
|
||||
<span className="ml-auto text-xs text-gray-400">{`${
|
||||
Math.floor(Math.random() * 10) + 1
|
||||
}:${Math.floor(Math.random() * 100) + 10}pm |`}</span>
|
||||
|
||||
<span className="text-gray-500 text-md">{`${
|
||||
Math.floor(Math.random() * 60) + 1
|
||||
} seconds`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* live broadcasters */}
|
||||
<span
|
||||
className={"flex flex-row gap-2 items-center text-teal-500 text-sm"}
|
||||
>
|
||||
<FiSun className="animate-ping" />
|
||||
<span>right now</span>
|
||||
</span>
|
||||
<div
|
||||
className={
|
||||
"rounded border border-teal-500 flex flex-col w-full shadow-2xl"
|
||||
}
|
||||
>
|
||||
{selectedLine.otherUserObjects.map((otherUser) => (
|
||||
<div className="flex flex-row p-2 gap-2 items-center bg-transparent">
|
||||
<Avatar
|
||||
key={`linehistory-${1}`}
|
||||
src={otherUser.picture}
|
||||
shape="square"
|
||||
size={"large"}
|
||||
// grayscale if not playing?
|
||||
className={`shadow-lg`}
|
||||
/>
|
||||
<span className="text-gray-600 font-semibold">
|
||||
{otherUser.name}
|
||||
</span>
|
||||
|
||||
<Tooltip title={"audio only"}>
|
||||
<FiHeadphones className="text-gray-600 text-md ml-auto" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`p-3 absolute right-3 bottom-3 flex justify-center items-center shadow-2xl
|
||||
hover:scale-105 transition-all ${
|
||||
isUserBroadcasting
|
||||
? "bg-teal-800 text-white"
|
||||
: "text-teal-800 border-teal-800 border"
|
||||
}`}
|
||||
>
|
||||
<FiSun className="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Avatar, Modal } from "antd";
|
||||
import { HotKeys, KeyMap } from "react-hotkeys";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCreateLine, useUserSearch } from "../../../controller/index";
|
||||
|
||||
import BasicUserRow from "../../../components/User/basicUserDetailsRow";
|
||||
import { FiSearch } from "react-icons/fi";
|
||||
import { User } from "@nirvana/core/models";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default function NewLineModal({
|
||||
open,
|
||||
handleClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
handleClose: () => void;
|
||||
}) {
|
||||
const [selectedPeople, setSelectedPeople] = useState<User[]>([]);
|
||||
|
||||
const [peopleSearchValue, setPeopleSearchValue] = useState<string>("");
|
||||
|
||||
// the actual state that goes to query...the debounced one so to speak
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const { refetch, data: searchRes } = useUserSearch(searchQuery);
|
||||
|
||||
const { mutateAsync, isLoading } = useCreateLine();
|
||||
|
||||
const [lineName, setLineName] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) refetch();
|
||||
}, [searchQuery]);
|
||||
|
||||
const onSearch = useCallback(() => {
|
||||
console.log("enter key pressed");
|
||||
|
||||
if (peopleSearchValue) {
|
||||
console.log("searching for people in database");
|
||||
setSearchQuery(peopleSearchValue);
|
||||
}
|
||||
}, [setSearchQuery, peopleSearchValue]);
|
||||
|
||||
const selectUser = useCallback(
|
||||
(userToAdd: User) => {
|
||||
setSelectedPeople((prevSelectedUsers) => {
|
||||
// if user is not already in the selected user
|
||||
const foundUser = prevSelectedUsers.find(
|
||||
(currentUser) =>
|
||||
currentUser._id.toString() === userToAdd._id.toString()
|
||||
);
|
||||
if (!foundUser) {
|
||||
return [...prevSelectedUsers, userToAdd];
|
||||
}
|
||||
|
||||
toast.error("you already selected this person below!");
|
||||
return prevSelectedUsers;
|
||||
});
|
||||
},
|
||||
[setSelectedPeople]
|
||||
);
|
||||
|
||||
const unSelectUser = useCallback(
|
||||
(userIdToRemove: string) => {
|
||||
setSelectedPeople((prevSelectedUsers) => {
|
||||
return prevSelectedUsers.filter(
|
||||
(prevUser) => prevUser._id.toString() !== userIdToRemove
|
||||
);
|
||||
});
|
||||
},
|
||||
[setSelectedPeople]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// TODO: prevent creating one-on-one line if already exists with x person?
|
||||
// ensure that we don't have a one on one chat already with x person if it's one person selected
|
||||
|
||||
// upon success,
|
||||
// make sure that the list of lines updates for this client and others so that it shows this new line
|
||||
// select the line so that it shows up in the line details for this client
|
||||
|
||||
console.log("trying to create line now!");
|
||||
|
||||
try {
|
||||
if (!selectedPeople?.length) {
|
||||
toast.error("you must select at least one person");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMemberIds = selectedPeople.map((selectedPerson) =>
|
||||
selectedPerson._id.toString()
|
||||
);
|
||||
|
||||
const res = await mutateAsync({
|
||||
lineName,
|
||||
otherMemberIds: selectedMemberIds,
|
||||
});
|
||||
|
||||
toast.success("created line!");
|
||||
|
||||
// handle close once the new line is created
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
console.error(error);
|
||||
} finally {
|
||||
console.log("done");
|
||||
}
|
||||
}, [lineName, selectedPeople]);
|
||||
|
||||
const handleCancel = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const keyMap: KeyMap = {
|
||||
HANDLE_SEARCH: "enter",
|
||||
};
|
||||
const handlers = {
|
||||
HANDLE_SEARCH: onSearch,
|
||||
};
|
||||
|
||||
if (isLoading) return <span>one second while we make magic</span>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="Create a Line"
|
||||
visible={open}
|
||||
onCancel={handleCancel}
|
||||
footer={
|
||||
<div className="flex flex-row text-white">
|
||||
<button
|
||||
className="flex-1 bg-gray-500 pb-3 pt-2 text-left pl-2"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 bg-teal-500 pb-3 pt-2 text-left pl-2"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
className={"flex flex-col gap-5"}
|
||||
>
|
||||
<HotKeys handlers={handlers} keyMap={keyMap} allowChanges={true}>
|
||||
<div className="flex flex-col items-start gap-2 mb-5">
|
||||
<p className="text-gray-300 text-sm">People</p>
|
||||
|
||||
<span className="flex flex-row gap-1 w-full items-center border border-gray-200 p-2 shadow">
|
||||
<FiSearch className="text-gray-300" />
|
||||
|
||||
<input
|
||||
className="placeholder:text-gray-300 outline-none placeholder:text-sm border-0 flex-1"
|
||||
value={peopleSearchValue}
|
||||
onChange={(e) => setPeopleSearchValue(e.target.value)}
|
||||
placeholder="search by name or email"
|
||||
/>
|
||||
|
||||
<span className="text-xs text-gray-200 ml-auto">
|
||||
enter to search
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* search results */}
|
||||
{searchRes?.users?.length > 0 && (
|
||||
<div className="flex flex-col border border-gray-200 shadow-md max-h-[500px] w-full overflow-y-auto">
|
||||
{searchRes?.users?.map((searchedUser) => (
|
||||
<BasicUserRow
|
||||
key={`searchResUser-${searchedUser.googleId}`}
|
||||
user={searchedUser}
|
||||
rightJsx={
|
||||
<button onClick={() => selectUser(searchedUser)}>
|
||||
Add
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* selected people */}
|
||||
{selectedPeople?.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-5">
|
||||
<p className="text-gray-300 text-sm">Selected People</p>
|
||||
|
||||
<div className="flex flex-col w-full">
|
||||
{selectedPeople.map((selectedUser) => (
|
||||
<BasicUserRow
|
||||
key={`selectedUser-${selectedUser.googleId}`}
|
||||
user={selectedUser}
|
||||
rightJsx={
|
||||
<button
|
||||
onClick={() =>
|
||||
unSelectUser(selectedUser._id.toString())
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-gray-300 text-sm">Line Name (optional)</p>
|
||||
<input
|
||||
value={lineName}
|
||||
onChange={(e) => setLineName(e.target.value)}
|
||||
className="placeholder:text-gray-300 outline-none placeholder:text-sm flex-1
|
||||
border p-2 border-gray-200"
|
||||
placeholder={"ex. Engineering, Sprint 7, Follow up on present..."}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
const x = 'asdf';
|
||||
Reference in New Issue
Block a user