1import {useContext, useEffect, useRef, useState, useCallback} from "react";
2import {createPortal} from "react-dom";
3import {ConfigContext} from "@/common/contexts/Config";
4import {SpeedtestContext} from "@/common/contexts/Speedtests";
5import Speedtest from "../Speedtest";
6import {getIconBySpeed} from "@/common/utils/TestUtil";
7import "./styles.sass";
8import {t} from "i18next";
9import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
10import {faArrowUp} from "@fortawesome/free-solid-svg-icons";
11
12const TestArea = () => {
13 const config = useContext(ConfigContext)[0];
14 const {speedtests, loadMoreTests, loading, hasMore} = useContext(SpeedtestContext);
15 const [stickyDate, setStickyDate] = useState(null);
16 const [showStickyDate, setShowStickyDate] = useState(false);
17 const [showBackToTop, setShowBackToTop] = useState(false);
18 const [initialLoadComplete, setInitialLoadComplete] = useState(false);
19 const containerRef = useRef();
20 const lastElementRef = useRef();
21
22 useEffect(() => {
23 if (!loading && !initialLoadComplete) {
24 setInitialLoadComplete(true);
25 }
26 }, [loading, initialLoadComplete]);
27
28 useEffect(() => {
29 if (speedtests.length > 0) {
30 const initialDate = getDateFromTest(speedtests[0]);
31 setStickyDate(initialDate);
32 }
33 }, [speedtests]);
34
35 const getDateFromTest = (test) => {
36 const date = new Date(Date.parse(test.created));
37 return date.toLocaleDateString("default", {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'});
38 };
39
40 const handleScroll = useCallback(() => {
41 const scrollTop = Math.max(window.scrollY, document.documentElement.scrollTop, document.body.scrollTop);
42
43 const shouldShow = scrollTop > 50;
44 setShowStickyDate(shouldShow);
45
46 const shouldShowBackToTop = scrollTop > 300;
47 setShowBackToTop(shouldShowBackToTop);
48
49 const windowHeight = window.innerHeight;
50 const documentHeight = Math.max(document.body.scrollHeight, document.body.offsetHeight,
51 document.documentElement.clientHeight, document.documentElement.scrollHeight,
52 document.documentElement.offsetHeight);
53
54 const nearBottom = scrollTop + windowHeight >= documentHeight - 500;
55 const atBottom = scrollTop + windowHeight >= documentHeight - 50;
56
57 if ((nearBottom || atBottom) && hasMore && !loading && speedtests.length > 0) {
58 loadMoreTests();
59 }
60
61 if (shouldShow && speedtests.length > 0) {
62 const testElements = document.querySelectorAll('.speedtest');
63 if (testElements.length > 0) {
64 for (let i = 0; i < testElements.length; i++) {
65 const element = testElements[i];
66 const elementRect = element.getBoundingClientRect();
67
68 if (elementRect.top <= 200 && elementRect.bottom > 50) {
69 if (speedtests[i]) {
70 const newDate = getDateFromTest(speedtests[i]);
71 setStickyDate(newDate);
72 }
73 break;
74 }
75 }
76 }
77 }
78 }, [speedtests, stickyDate, hasMore, loading, loadMoreTests]);
79
80 const scrollToTop = useCallback(() => {
81 window.scrollTo({top: 0, behavior: 'smooth'});
82 document.documentElement.scrollTo({top: 0, behavior: 'smooth'});
83 document.body.scrollTo({top: 0, behavior: 'smooth'});
84 }, []);
85
86 useEffect(() => {
87 let ticking = false;
88
89 const throttledScrollHandler = () => {
90 if (!ticking) {
91 requestAnimationFrame(() => {
92 handleScroll();
93 ticking = false;
94 });
95 ticking = true;
96 }
97 };
98
99 window.addEventListener('scroll', throttledScrollHandler, {passive: true});
100 document.body.addEventListener('scroll', throttledScrollHandler, {passive: true});
101
102 setTimeout(handleScroll, 100);
103
104 return () => {
105 window.removeEventListener('scroll', throttledScrollHandler);
106 document.body.removeEventListener('scroll', throttledScrollHandler);
107 };
108 }, [handleScroll]);
109
110 useEffect(() => {
111 if (!lastElementRef.current || !hasMore || loading) return;
112
113 const observer = new IntersectionObserver((entries) => {
114 if (entries[0].isIntersecting && hasMore && !loading) loadMoreTests();
115 }, {threshold: 0.01, rootMargin: '200px 0px'});
116
117 observer.observe(lastElementRef.current);
118
119 return () => {
120 observer.disconnect();
121 };
122 }, [hasMore, loading, loadMoreTests, speedtests.length]);
123
124 if (Object.entries(config).length === 0) return (<></>);
125
126 if (!initialLoadComplete || (speedtests.length === 0 && loading)) return <></>;
127
128 if (speedtests.length === 0 && initialLoadComplete)
129 return <h2 className="error-text">{t("test.not_available")}</h2>;
130
131 return (
132 <>
133 {showStickyDate && stickyDate && createPortal(
134 <div className="floating-date-indicator">
135 <span>{stickyDate}</span>
136 </div>, document.body)}
137
138 {showBackToTop && createPortal(
139 <button className="back-to-top-button" onClick={scrollToTop} aria-label={t("common.back_to_top")}>
140 <FontAwesomeIcon icon={faArrowUp}/>
141 </button>, document.body)}
142
143 <div className="speedtest-area" ref={containerRef}>
144 {speedtests.map((test, index) => {
145 const date = new Date(Date.parse(test.created));
146 const isLast = index === speedtests.length - 1;
147
148 return (
149 <Speedtest
150 key={test.id}
151 ref={isLast ? lastElementRef : null}
152 time={date}
153 ping={test.ping}
154 pingLevel={getIconBySpeed(test.ping, config.ping, false)}
155 jitter={test.jitter}
156 down={test.download}
157 downLevel={getIconBySpeed(test.download, config.download, true)}
158 up={test.upload}
159 upLevel={getIconBySpeed(test.upload, config.upload, true)}
160 error={test.error}
161 url={test.url}
162 type={test.type}
163 duration={test.time}
164 amount={test.amount}
165 resultId={test.resultId}
166 id={test.id}
167 />
168 );
169 })}
170
171 {loading && (
172 <div className="loading-more">
173 <p>{t("test.loading_more")}</p>
174 </div>
175 )}
176
177 {!hasMore && speedtests.length > 0 && (
178 <div className="end-of-list">
179 <p>{t("test.no_more_tests")}</p>
180 </div>
181 )}
182 </div>
183 </>
184 );
185}
186
187export default TestArea;