mess completely

This commit is contained in:
talksik
2022-05-12 15:24:36 -05:00
parent 4a5e259f7a
commit 9931c2a662
26 changed files with 32022 additions and 873 deletions
+108
View File
@@ -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>
);
}
+93
View File
@@ -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 />;
}
+78
View File
@@ -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);
}
+115
View File
@@ -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,
};
+58
View File
@@ -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" },
});
+3
View File
@@ -0,0 +1,3 @@
+62
View File
@@ -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;
+143
View File
@@ -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>
);
}
+566
View File
@@ -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>
);
}
+223
View File
@@ -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>
</>
);
}
+1
View File
@@ -0,0 +1 @@
const x = 'asdf';