diff --git a/__test__/components/Graph/Force-Directed-Graph/ForceDirectedGraph.test.tsx b/__test__/components/Graph/Force-Directed-Graph/ForceDirectedGraph.test.tsx index a6e4fb12..9203a5c0 100644 --- a/__test__/components/Graph/Force-Directed-Graph/ForceDirectedGraph.test.tsx +++ b/__test__/components/Graph/Force-Directed-Graph/ForceDirectedGraph.test.tsx @@ -25,6 +25,12 @@ describe("ForceDirectedGraph", () => { let graph: Graph let constrainedNodeId: string + const mockFunc = jest.fn() + + beforeEach(() => { + mockFunc.mockClear() + }) + beforeEach(() => { graph = new Graph(["1", "2", "3"], ["1", "2"], ["2", "3"], [1, 2]) constrainedNodeId = "2" @@ -39,6 +45,7 @@ describe("ForceDirectedGraph", () => { , ) expect(component).toBeTruthy() @@ -49,6 +56,7 @@ describe("ForceDirectedGraph", () => { , ) @@ -111,6 +119,7 @@ describe("ForceDirectedGraph", () => { , ) @@ -136,35 +145,9 @@ describe("ForceDirectedGraph", () => { jest.advanceTimersByTime(500) }) - await waitFor(() => { - const modal = component.getByTestId("modal") - expect(modal).toBeTruthy() - expect(modal.props.visible).toBe(true) - }, {timeout: 10}) - + jest.useRealTimers() - - const modale_image = component.getByTestId("modal-profile-picture") - expect(modale_image).toBeTruthy() - - act (() => { - fireEvent(modale_image, "press") - }) - - const quitModal = component.getByTestId("modal-touchable") - expect(quitModal).toBeTruthy() - - act(() => { - fireEvent(quitModal, "press") - }) - - jest.useFakeTimers() - - await waitFor(() => { - expect(component.queryByTestId("modal")).toBeNull() - }, {timeout: 10}) - - jest.useRealTimers() + expect(mockFunc).toHaveBeenCalled() }) @@ -174,6 +157,7 @@ describe("ForceDirectedGraph", () => { , ) @@ -198,29 +182,14 @@ describe("ForceDirectedGraph", () => { , ) const node1 = component.getByTestId("node-1") expect(node1).toBeTruthy() - expect(component.queryByTestId("modal")).toBeNull() - - fireEvent(node1, "pressIn") - - jest.useFakeTimers() - act(() => { - jest.advanceTimersByTime(100) - }) - - act (() => { - fireEvent(node1, "pressOut") - }) - - await waitFor(() => { - expect(component.queryByTestId("modal")).toBeNull() - }, {timeout: 10}) - jest.useRealTimers() + expect(mockFunc).not.toHaveBeenCalled() }) it("pinching zooms the graph", async () => { @@ -229,6 +198,7 @@ describe("ForceDirectedGraph", () => { , ) diff --git a/__test__/screens/Contacts/ContactGraph/ContactGraph.test.tsx b/__test__/screens/Contacts/ContactGraph/ContactGraph.test.tsx index f3df14c3..ba0b176d 100644 --- a/__test__/screens/Contacts/ContactGraph/ContactGraph.test.tsx +++ b/__test__/screens/Contacts/ContactGraph/ContactGraph.test.tsx @@ -1,12 +1,111 @@ import React from "react" -import { render } from "@testing-library/react-native" +import { act, fireEvent, render } from "@testing-library/react-native" import ContactGraph from "../../../../screens/Contacts/ContactGraph/ContactGraph" describe("ContactGraph", () => { - it("renders correctly", () => { - const component = render() - expect(component).toBeTruthy() - }) + const mockFunc = jest.fn() + + beforeEach(() => { + mockFunc.mockClear() + }) + + it("renders the screen", () => { + const component = render( mockFunc} />) + expect(component).toBeTruthy() + }) + + it("search bar is reachable", () => { + const component = render( mockFunc} />) + expect(component).toBeTruthy() + + const searchBar = component.getByPlaceholderText("Search...") + expect(searchBar).toBeTruthy() + + act(() => { + fireEvent(searchBar, "press") + }) + + act(() => { + fireEvent.changeText(searchBar, "") + }) + + expect(searchBar.props.value).toBe("") + + act(() => { + fireEvent.changeText(searchBar, "0") + }) + + expect(searchBar.props.value).toBe("0") + + act (() => { + fireEvent(searchBar, "submitEditing") + }) + }) + + it("clicking a node opens a modal", () => { + const component = render( mockFunc} />) + expect(component).toBeTruthy() + + const node = component.getByTestId("node-0") + expect(node).toBeTruthy() + + act(() => { + fireEvent(node, "pressIn") + }) + + jest.useFakeTimers() + act(() => { + jest.advanceTimersByTime(50) + }) + + act (() => { + fireEvent(node, "pressOut") + jest.advanceTimersByTime(500) + }) + + + jest.useRealTimers() + + const modal = component.getByTestId("modal") + expect(modal).toBeTruthy() + }) + it("clicking outside the modal closes it", () => { + const component = render( mockFunc} />) + expect(component).toBeTruthy() + + const node = component.getByTestId("node-0") + expect(node).toBeTruthy() + + act(() => { + fireEvent(node, "pressIn") + }) + + jest.useFakeTimers() + act(() => { + jest.advanceTimersByTime(50) + }) + + act (() => { + fireEvent(node, "pressOut") + jest.advanceTimersByTime(500) + }) + + + jest.useRealTimers() + + const modal = component.getByTestId("modal") + expect(modal).toBeTruthy() + + const modalTouchable = component.getByTestId("modal-touchable") + expect(modalTouchable).toBeTruthy() + + act(() => { + fireEvent(modalTouchable, "press") + }) + + expect(component.queryByTestId("modal")).toBeNull() + }) + }) \ No newline at end of file diff --git a/__test__/screens/Contacts/ExploreScreen.test.tsx b/__test__/screens/Contacts/ExploreScreen.test.tsx index 9b46eaf3..d4b02ccc 100644 --- a/__test__/screens/Contacts/ExploreScreen.test.tsx +++ b/__test__/screens/Contacts/ExploreScreen.test.tsx @@ -1,5 +1,5 @@ import React from "react" -import { render, fireEvent } from "@testing-library/react-native" +import { render, fireEvent, act, waitFor } from "@testing-library/react-native" import ExploreScreen from "../../../screens/Contacts/ExploreScreen" import { SafeAreaProvider } from "react-native-safe-area-context" import { black, lightGray } from "../../../assets/colors/colors" @@ -38,6 +38,17 @@ jest.mock("react-native-safe-area-context", () => { } }) +jest.mock("react-native-gesture-handler", () => { + return { + State: { + END: 5, + }, + PanGestureHandler: 'View', + PinchGestureHandler: 'View', + GestureHandlerRootView: 'View', + } +}) + beforeAll(() => { global.alert = jest.fn() }) @@ -85,6 +96,61 @@ describe("ExploreScreen", () => { expect(mockNavigation.navigate).toHaveBeenCalledWith("ExternalProfile", {"uid": "1"}) }) + it ("navigates to profile screen when clicking on contact in graph view", async () => { + const { getByText, getByTestId, queryByTestId } = render( + + + + + + ) + fireEvent.press(getByText("Graph View")) + + const panHandler = getByTestId("pan-handler") + const node1 = getByTestId("node-1") + + expect(node1).toBeTruthy() + expect(panHandler).toBeTruthy() + expect(panHandler.props.enabled).toBe(true) + + act(() => { + fireEvent(node1, "pressIn") + }) + + jest.useFakeTimers() + act(() => { + jest.advanceTimersByTime(50) + }) + + act (() => { + fireEvent(node1, "pressOut") + jest.advanceTimersByTime(500) + }) + + await waitFor(() => { + const modal = getByTestId("modal") + expect(modal).toBeTruthy() + expect(modal.props.visible).toBe(true) + }, {timeout: 10}) + + jest.useRealTimers() + + const modale_image = getByTestId("modal-profile-picture") + expect(modale_image).toBeTruthy() + + act(() => { + fireEvent(modale_image, "press") + }) + + await waitFor(() => { + expect(queryByTestId("modal")).toBeNull() + }, {timeout: 10}) + + jest.useRealTimers() + + expect(mockNavigation.navigate).toHaveBeenCalledWith("ExternalProfile", {"uid": "1"}) + }) + }) diff --git a/components/Graph/ForceDirectedGraph/ExampleForceDirectedGraph.tsx b/components/Graph/ForceDirectedGraph/ExampleForceDirectedGraph.tsx deleted file mode 100644 index 0f9688d4..00000000 --- a/components/Graph/ForceDirectedGraph/ExampleForceDirectedGraph.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react" -import Graph from "../Graph" -import ForceDirectedGraph from "./ForceDirectedGraph" - -const ExampleForceDirectedGraph = () => { - const graph = new Graph( - ["0", "1", "2", "3", "4", "5", "6"], - ["0", "2", "3", "3", "3", "4", "5", "6", "6", "6"], - ["1", "1", "1", "2", "0", "1", "1", "1", "2", "0"], - [0.5, 0.2, 0.2, 0.5, 0.5, 0.5, 0.2, 0.5, 0.5, 0.5] - ) - const constrainedNodeId = "1" - return ( - - ) -} - -export default ExampleForceDirectedGraph diff --git a/components/Graph/ForceDirectedGraph/ForceDirectedGraph.tsx b/components/Graph/ForceDirectedGraph/ForceDirectedGraph.tsx index d0a4535a..65eb8d4e 100644 --- a/components/Graph/ForceDirectedGraph/ForceDirectedGraph.tsx +++ b/components/Graph/ForceDirectedGraph/ForceDirectedGraph.tsx @@ -2,10 +2,7 @@ import React, { useEffect, useRef, useState } from "react" import { ActivityIndicator, View, - Dimensions, - Modal, - Text, - TouchableWithoutFeedback, + Dimensions } from "react-native" import Svg, { Circle, G, Line, Text as SVGText, Image } from "react-native-svg" @@ -20,6 +17,8 @@ import { GestureHandlerRootView, } from "react-native-gesture-handler" +import { black, peach, transparent } from "../../../assets/colors/colors" + import profile_picture_0 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-0.png" import profile_picture_1 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-1.png" import profile_picture_2 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-2.png" @@ -43,10 +42,12 @@ const SHORT_PRESS_DURATION = 100 const NODE_HITBOX_SIZE = 20 // Hitbox size of the nodes const DEFAULT_NODE_SIZE = 10 // Default size of the nodes const DEFAULT_NODE_SIZE_INCREMENT = 2 // Increment in the size of the nodes +const NODE_HIGHLIGHT_RATIO = 1.3 +const NODE_TEXT_OFFSET = 1.5 -const DEFAULT_LINK_COLOR = "black" // Default color of the links +const DEFAULT_LINK_COLOR = black // Default color of the links const DEFAULT_CLICKED_NODE_ID = "" - +const INITIAL_SCALE = 1 // Initial scale of the graph const MAX_ITERATIONS = 1000 // Maximum number of iterations for the used algorithn const WIDTH = Dimensions.get("window").width // Width of the screen @@ -69,7 +70,9 @@ const TOTAL_FRAMES = FPS * (ANIMATION_DURATION / 1000) // Total number of frames const ForceDirectedGraph: React.FC<{ graph: Graph constrainedNodeId: string -}> = ({ graph, constrainedNodeId }) => { + onModalPress: (uid: string) => void +}> = ({ graph, constrainedNodeId, onModalPress}) => { + // States to store the nodes, links, sizes and loading status const [nodes, setNodes] = useState([]) const [links, setLinks] = useState([]) @@ -81,13 +84,11 @@ const ForceDirectedGraph: React.FC<{ const [clickedNodeID, setClickedNodeID] = useState( DEFAULT_CLICKED_NODE_ID, ) // Node ID of clicked node - const [scale, setScale] = useState(1) - const [lastScale, setLastScale] = useState(1) // Add state to keep track of last scale + const [scale, setScale] = useState(INITIAL_SCALE) + const [lastScale, setLastScale] = useState(INITIAL_SCALE) // Add state to keep track of last scale const [gestureEnabled, setGestureEnabled] = useState(true) // Add state to keep track of animation start - const [modalVisible, setModalVisible] = useState(false) - const pressStartRef = useRef(0) const panRef = useRef(null) @@ -96,28 +97,35 @@ const ForceDirectedGraph: React.FC<{ // Use effect to update the graph useEffect(() => { // Get the initial links, nodes and sizes + const initialLinks = graph.getLinks() - const initialNodes = graph.getNodes() - const initialSizes = setNodesSizes([...initialLinks]) + const initialSizes = setNodesSizes(initialLinks) // Set the links, nodes and sizes - setLinks([...initialLinks]) - setNodes( - fruchtermanReingold( - [...initialNodes], - initialLinks, - constrainedNodeId, - WIDTH, - HEIGHT, - MAX_ITERATIONS, - ), - ) + setLinks(initialLinks) + if (graph.getInitialized() === false) { + setNodes( + fruchtermanReingold( + graph.getNodes(), + initialLinks, + constrainedNodeId, + WIDTH, + HEIGHT, + MAX_ITERATIONS, + ), + ) + graph.setInitialized(true) + } + else { + setNodes(graph.getNodes()) + } setSizes(initialSizes) setLoad(true) + }, [graph, constrainedNodeId]) // If the graph is not loaded, display an activity indicator if (!load) { - return + return } // Handle Dragging @@ -142,7 +150,6 @@ const ForceDirectedGraph: React.FC<{ y: coordY(node), })), ) - setClickedNodeID(DEFAULT_CLICKED_NODE_ID) setTotalOffset({ x: 0, y: 0 }) } @@ -180,7 +187,6 @@ const ForceDirectedGraph: React.FC<{ const nodeZoomIn = (clickedNode: Node) => { setGestureEnabled(false) // Set animation started to true - setModalVisible(true) let currentFrame = 0 @@ -301,6 +307,13 @@ const ForceDirectedGraph: React.FC<{ const CIRCLES = nodes.map((node) => ( + { handlePressIn(() => { pressStartRef.current = Date.now() @@ -327,6 +340,8 @@ const ForceDirectedGraph: React.FC<{ } onPressOut={() =>{ handlePressOut(() => { + onModalPress(node.id) + setClickedNodeID(DEFAULT_CLICKED_NODE_ID) nodeZoomIn(node) }) } @@ -339,7 +354,7 @@ const ForceDirectedGraph: React.FC<{ y={ coordY(node) + (sizes.get(node.id) ?? DEFAULT_NODE_SIZE) + - DEFAULT_NODE_SIZE + NODE_TEXT_OFFSET * DEFAULT_NODE_SIZE } // Position below the circle adjust 10 as needed textAnchor="middle" // Center the text under the circle > @@ -350,48 +365,6 @@ const ForceDirectedGraph: React.FC<{ return ( - - { - setModalVisible(false) - setClickedNodeID(DEFAULT_CLICKED_NODE_ID) - setGestureEnabled(true) - }} - testID="modal-touchable" - > - - - - - { - console.warn("Pressed") - }} - testID="modal-profile-picture" - /> - - - Node ID: {clickedNodeID} - - - - - - - { return link.source !== id && link.target !== id }) + this.setInitialized(false) } /** @@ -160,6 +179,14 @@ export default class Graph { this.links = this.links.filter((link: Link): boolean => { return link.source !== source || link.target !== target }) + this.setInitialized(false) + } + + + getNodeById(id: string): Node | undefined { + return this.nodes.find((node: Node): boolean => { + return node.id === id + }) } } diff --git a/components/Graph/NodeModal/NodeModal.tsx b/components/Graph/NodeModal/NodeModal.tsx new file mode 100644 index 00000000..48d4f806 --- /dev/null +++ b/components/Graph/NodeModal/NodeModal.tsx @@ -0,0 +1,78 @@ +import React from "react" +import { Modal, TouchableWithoutFeedback, View, Text } from "react-native" +import Svg, { Image } from "react-native-svg" +import { Node } from "../Graph" + +import styles from "./styles" + +import profile_picture_0 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-0.png" +import profile_picture_1 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-1.png" +import profile_picture_2 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-2.png" +import profile_picture_3 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-3.png" +import profile_picture_4 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-4.png" +import profile_picture_5 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-5.png" +import profile_picture_6 from "../../../assets/graph-template-profile-pictures/graph-template-profile-picture-6.png" + +const PROFILE_PICTURES = [ + profile_picture_0, + profile_picture_1, + profile_picture_2, + profile_picture_3, + profile_picture_4, + profile_picture_5, + profile_picture_6, +] + +const NodeModal: React.FC<{ + node: Node + visible: boolean + onPressOut: () => void + onContactPress: (uid: string) => void +}> = ({ node, visible, onPressOut, onContactPress }) => { + + + return ( + + { + onPressOut() + }} + testID="modal-touchable" + > + + + + + { + onPressOut() + onContactPress(node.id) + }} + testID="modal-profile-picture" + /> + + + Node ID: {node.id} + + + + + + + ) +} + +export default NodeModal \ No newline at end of file diff --git a/components/Graph/NodeModal/styles.ts b/components/Graph/NodeModal/styles.ts new file mode 100644 index 00000000..74e1fd7e --- /dev/null +++ b/components/Graph/NodeModal/styles.ts @@ -0,0 +1,54 @@ +import { StyleSheet } from "react-native" +import { black, lightPeach, peach } from "../../../assets/colors/colors" +import { globalStyles } from "../../../assets/global/globalStyles" + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + flex: 1, + justifyContent: "center", + }, + modalContainer: { + alignItems: "center", + flex: 1, + justifyContent: "flex-end", + }, + modalProfileName: { + fontFamily: globalStyles.text.fontFamily, + fontSize: 20, + left: 20, + position: "absolute", + top: 120, + }, + modalProfilePicture: { + alignItems: "center", + height: 80, + justifyContent: "center", + left: 20, + position: "absolute", + top: 20, + width: 80, + }, + modalView: { + alignItems: "center", + backgroundColor: peach, + borderColor: lightPeach, + borderRadius: 20, + borderWidth: 3, + elevation: 5, + height: "40%", + justifyContent: "center", + margin: 20, + padding: 35, + shadowColor: black, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + width: "90%", + }, +}) + +export default styles diff --git a/components/Graph/graphAlgorithms/FruchtermanReingold.tsx b/components/Graph/graphAlgorithms/FruchtermanReingold.tsx index 819c7905..9eed539b 100644 --- a/components/Graph/graphAlgorithms/FruchtermanReingold.tsx +++ b/components/Graph/graphAlgorithms/FruchtermanReingold.tsx @@ -20,14 +20,14 @@ export const fruchtermanReingold = ( iterations: number ) => { // Maximum distance between nodes - const k = Math.sqrt((width * height) / nodes.length) + const k = Math.sqrt((width * height) / nodes.length)/2 // Randomly initialize node positions initializePositions(nodes, width, height) // Initialize temperature and cooling rate let temperature = width / 10 - const cooling = temperature / (iterations + 1) + const cooling = temperature / (iterations) // Perform iterations for (let i = 0; i < iterations; i++) { @@ -155,7 +155,7 @@ const updatePositions = ( const distance = distanceBetween(0, 0, node.dx, node.dy) // Skip if the node is too close - if (distance != 0) { + if (distance > 0) { // Calculate ratio of displacement, limited to temperature, to distance const ratio = Math.min(distance, temperature) / distance @@ -163,9 +163,6 @@ const updatePositions = ( node.x += node.dx * ratio node.y += node.dy * ratio - // Keep node within the graph - node.x = Math.min(width, Math.max(0, node.x)) - node.y = Math.min(height, Math.max(0, node.y)) } } } diff --git a/screens/Contacts/ContactGraph/ContactGraph.tsx b/screens/Contacts/ContactGraph/ContactGraph.tsx index 62803ca9..dd8ebdd2 100644 --- a/screens/Contacts/ContactGraph/ContactGraph.tsx +++ b/screens/Contacts/ContactGraph/ContactGraph.tsx @@ -1,12 +1,88 @@ -import { View, Text } from "react-native" +import { View, TextInput, TouchableWithoutFeedback, Keyboard } from "react-native" import { styles } from "./styles" +import Graph, { Node } from "../../../components/Graph/Graph" -const ContactGraph = () => { +import ForceDirectedGraph from "../../../components/Graph/ForceDirectedGraph/ForceDirectedGraph" +import { useState } from "react" + +import NodeModal from "../../../components/Graph/NodeModal/NodeModal" + +import { graph, constrainedNodeId } from "./mockGraph" +interface ContactGraphProps{ + onContactPress: (uid : string) => void +} + +const ContactGraph = ({onContactPress} : ContactGraphProps) => { + + const [searchText, setSearchText] = useState("") + + const [clickedNode, setClickedNode] = useState(graph.getNodes()[0]) + + const [modalVisible, setModalVisible] = useState(false) + + const onModalPress = (uid: string) => { + const node = graph.getNodeById(uid) + if (node) { + setClickedNode(node) + setModalVisible(true) + } + } + + const onPressOut = () => { + setModalVisible(false) + } return ( + + Keyboard.dismiss()}> - Graph view + { + setSearchText(text) + handleSearch(text, graph) + } + } + onSubmitEditing={() => handleQuery(onContactPress)} + /> + + + + + + + ) } export default ContactGraph + +function handleSearch(text: string, graph: Graph): void { + if (text === "") { + for (const node of graph.getNodes()) { + node.selected = false + } + } + else { + for (const node of graph.getNodes()) { + if (node.id.includes(text) || text.includes(node.id)) { + node.selected = true + } + else { + node.selected = false + } + } + } +} + +function handleQuery(callback: (uid: string) => void): void { + for (const node of graph.getNodes()) { + if (node.selected) { + callback(node.id) + return + } + } +} + diff --git a/screens/Contacts/ContactGraph/mockGraph.tsx b/screens/Contacts/ContactGraph/mockGraph.tsx new file mode 100644 index 00000000..f5417368 --- /dev/null +++ b/screens/Contacts/ContactGraph/mockGraph.tsx @@ -0,0 +1,10 @@ +import Graph from "../../../components/Graph/Graph" + +export const graph = new Graph( + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], + ["0", "2", "3", "4", "5", "6", "7", "8", "9", "10"], + ["1", "1", "1", "1", "1", "1", "2", "2", "3", "3"], + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + ) + +export const constrainedNodeId = "1" \ No newline at end of file diff --git a/screens/Contacts/ContactGraph/styles.ts b/screens/Contacts/ContactGraph/styles.ts index 53d7637c..a4ade20b 100644 --- a/screens/Contacts/ContactGraph/styles.ts +++ b/screens/Contacts/ContactGraph/styles.ts @@ -1,11 +1,29 @@ import { StyleSheet } from "react-native" +import { lightGray, lightPeach, peach } from "../../../assets/colors/colors" export const styles = StyleSheet.create({ container: { - alignItems: "center", flex: 1, - justifyContent: "center", }, - -}) + graphContainer: { + backgroundColor: lightPeach, // Add background color + borderColor: peach, // Add border color + borderRadius: 10, // Round the corners + borderWidth: 3, // Add border width + flex: 1, + flexWrap: "wrap", + marginBottom: 10, + overflow: 'hidden', // Hide overflow content + width: "100%", + }, + searchBar: { + borderColor: lightGray, + borderRadius: 40, + borderWidth: 1, + height: 60, + margin: 10, + padding: 20, + }, + }) + \ No newline at end of file diff --git a/screens/Contacts/ExploreScreen.tsx b/screens/Contacts/ExploreScreen.tsx index 21c8af6e..1687b7ee 100644 --- a/screens/Contacts/ExploreScreen.tsx +++ b/screens/Contacts/ExploreScreen.tsx @@ -33,7 +33,9 @@ const ExploreScreen = ({navigation} : ContactListScreenProps ) => { } {selectedTab === "Graph View" && - + navigation.navigate("ExternalProfile", {uid: uid})} + /> }