Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: performance and ux improvements

Hugo c96d8700 63af6493

+75 -139
+43 -75
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 1 1 import { useSignal, useComputed } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 - import { useLocation, useRoute } from "@exosphere/client/router"; 3 + import { useLocation, useRoute, spherePath } from "@exosphere/client/router"; 4 4 import { sphereState } from "@exosphere/client/sphere"; 5 - import { spherePath } from "@exosphere/client/router"; 6 5 import { useQuery } from "@exosphere/client/hooks"; 7 6 import * as ui from "@exosphere/client/ui.css"; 8 7 import * as frUi from "../ui.css.ts"; ··· 35 34 import { SortControls } from "../components/sort-controls.tsx"; 36 35 import { useSortParams } from "../hooks/use-sort-params.ts"; 37 36 import { CollapsibleSection } from "@exosphere/client/components/collapsible-section"; 38 - import { useEffect } from "preact/hooks"; 37 + import { useEffect, useRef } from "preact/hooks"; 39 38 import { formatDate } from "@exosphere/client/format"; 40 39 41 40 const fullDateOpts: Intl.DateTimeFormatOptions = { ··· 300 299 prefetchedComments ? { initialData: prefetchedComments } : undefined, 301 300 ); 302 301 303 - useEffect(() => { 304 - if (data) { 305 - comments.value = data.comments; 306 - } 307 - }, [data]); 302 + // Sync query data into comments signal synchronously to avoid a one-frame flash 303 + const prevData = useRef(data); 304 + if (data && data !== prevData.current) { 305 + prevData.current = data; 306 + comments.value = data.comments; 307 + } 308 308 309 309 const commentVotesQuery = useQuery( 310 310 () => (isAuthenticated ? getMyCommentVotes() : Promise.resolve(null)), ··· 340 340 } 341 341 }; 342 342 343 - const handleCommentVote = async (id: string) => { 344 - const prev = votedCommentIds.value; 345 - votedCommentIds.value = new Set([...prev, id]); 346 - comments.value = comments.value.map((c) => 347 - c.id === id ? { ...c, voteCount: c.voteCount + 1 } : c, 348 - ); 349 - 350 - try { 351 - await voteComment(id); 352 - } catch { 353 - votedCommentIds.value = prev; 354 - comments.value = comments.value.map((c) => 355 - c.id === id ? { ...c, voteCount: c.voteCount - 1 } : c, 356 - ); 357 - } 358 - }; 359 - 360 - const handleCommentUnvote = async (id: string) => { 343 + const toggleCommentVote = async (id: string) => { 344 + const wasVoted = votedCommentIds.value.has(id); 361 345 const prev = votedCommentIds.value; 362 346 const next = new Set(prev); 363 - next.delete(id); 347 + if (wasVoted) next.delete(id); 348 + else next.add(id); 364 349 votedCommentIds.value = next; 365 350 comments.value = comments.value.map((c) => 366 - c.id === id ? { ...c, voteCount: c.voteCount - 1 } : c, 351 + c.id === id ? { ...c, voteCount: c.voteCount + (wasVoted ? -1 : 1) } : c, 367 352 ); 368 353 369 354 try { 370 - await unvoteComment(id); 355 + await (wasVoted ? unvoteComment(id) : voteComment(id)); 371 356 } catch { 372 357 votedCommentIds.value = prev; 373 358 comments.value = comments.value.map((c) => 374 - c.id === id ? { ...c, voteCount: c.voteCount + 1 } : c, 359 + c.id === id ? { ...c, voteCount: c.voteCount + (wasVoted ? 1 : -1) } : c, 375 360 ); 376 361 } 377 362 }; 378 363 379 - const commentTitle = `Comments${comments.value.length > 0 ? ` (${comments.value.length})` : ""}`; 364 + const commentTitle = `Comments (${comments.value.length})`; 380 365 381 366 return ( 382 367 <CollapsibleSection title={commentTitle} defaultExpanded> ··· 399 384 hasVoted={votedCommentIds.value.has(comment.id)} 400 385 onDelete={handleDelete} 401 386 onUpdate={handleUpdate} 402 - onVote={handleCommentVote} 403 - onUnvote={handleCommentUnvote} 387 + onVote={toggleCommentVote} 388 + onUnvote={toggleCommentVote} 404 389 /> 405 390 ))} 406 391 </div> ··· 476 461 const { params } = useRoute(); 477 462 const { route } = useLocation(); 478 463 const number = parseInt(params.number, 10); 479 - const voteCountAdjust = useSignal(0); 480 464 const statusVersion = useSignal(0); 481 465 const prefetched = ssrPageData.peek()?.["feature-request"] as 482 466 | Awaited<ReturnType<typeof getFeatureRequest>> ··· 501 485 const currentDid = isAuthenticated ? auth.value.did : null; 502 486 const currentHandle = isAuthenticated ? auth.value.handle : null; 503 487 504 - const votedIds = useSignal<Set<string>>( 505 - new Set(prefetchedVotes?.votes), 506 - ); 488 + const votedIds = useSignal<Set<string>>(new Set(prefetchedVotes?.votes)); 507 489 const votesQuery = useQuery( 508 490 () => (isAuthenticated ? getMyVotes() : Promise.resolve(null)), 509 491 [isAuthenticated], 510 - prefetchedVotes ? { initialData: prefetchedVotes, cacheKey: "feature-request-votes" } : { cacheKey: "feature-request-votes" }, 492 + prefetchedVotes 493 + ? { initialData: prefetchedVotes, cacheKey: "feature-request-votes" } 494 + : { cacheKey: "feature-request-votes" }, 511 495 ); 512 496 useEffect(() => { 513 497 if (votesQuery.data) { ··· 515 499 } 516 500 }, [votesQuery.data]); 517 501 518 - useEffect(() => { 519 - voteCountAdjust.value = 0; 520 - }, [data]); 521 - 522 - const isAdminOrOwner = (() => { 502 + const isAdminOrOwner = useComputed(() => { 523 503 const sphereData = sphereState.value.data; 524 504 if (!isAuthenticated || !sphereData) return false; 525 505 const role = sphereData.role; 526 - if (role === "owner" || role === "admin") return true; 527 - return sphereData.sphere.ownerDid === auth.value.did; 528 - })(); 506 + return role === "owner" || role === "admin"; 507 + }); 529 508 530 509 const fr = data?.featureRequest; 531 510 ··· 549 528 } 550 529 }; 551 530 552 - const handleVote = async () => { 553 - if (!fr) return; 554 - const prev = votedIds.value; 555 - votedIds.value = new Set([...prev, fr.id]); 556 - voteCountAdjust.value++; 557 - 558 - try { 559 - await voteFeatureRequest(fr.id); 560 - } catch { 561 - votedIds.value = prev; 562 - voteCountAdjust.value--; 563 - } 564 - }; 565 - 566 - const handleUnvote = async () => { 531 + // votedIds signal update triggers the re-render that picks up the mutated voteCount 532 + const toggleVote = async () => { 567 533 if (!fr) return; 534 + const wasVoted = votedIds.value.has(fr.id); 568 535 const prev = votedIds.value; 569 536 const next = new Set(prev); 570 - next.delete(fr.id); 537 + if (wasVoted) next.delete(fr.id); 538 + else next.add(fr.id); 539 + fr.voteCount += wasVoted ? -1 : 1; 571 540 votedIds.value = next; 572 - voteCountAdjust.value--; 573 541 574 542 try { 575 - await unvoteFeatureRequest(fr.id); 543 + await (wasVoted ? unvoteFeatureRequest(fr.id) : voteFeatureRequest(fr.id)); 576 544 } catch { 545 + fr.voteCount += wasVoted ? 1 : -1; 577 546 votedIds.value = prev; 578 - voteCountAdjust.value++; 579 547 } 580 548 }; 581 549 ··· 616 584 <div class={frUi.detailContent}> 617 585 <div class={ui.stackLg}> 618 586 <RequestCard 619 - fr={{ ...fr, voteCount: fr.voteCount + voteCountAdjust.value }} 587 + fr={fr} 620 588 isAuthor={currentDid === fr.authorDid} 621 - isAdminOrOwner={isAdminOrOwner} 589 + isAdminOrOwner={isAdminOrOwner.value} 622 590 hasVoted={votedIds.value.has(fr.id)} 623 591 isAuthenticated={isAuthenticated} 624 592 isDetail 625 - onDelete={() => handleDelete()} 626 - onHide={() => handleHide()} 627 - onVote={() => handleVote()} 628 - onUnvote={() => handleUnvote()} 593 + onDelete={handleDelete} 594 + onHide={handleHide} 595 + onVote={toggleVote} 596 + onUnvote={toggleVote} 629 597 /> 630 598 631 - {isAdminOrOwner && ( 599 + {isAdminOrOwner.value && ( 632 600 <div class={ui.metaRow}> 633 601 <span class={ui.muted}>Status:</span> 634 602 <select ··· 664 632 requestId={fr.id} 665 633 status={fr.status} 666 634 isAuthenticated={isAuthenticated} 667 - isAdminOrOwner={isAdminOrOwner} 635 + isAdminOrOwner={isAdminOrOwner.value} 668 636 currentDid={currentDid} 669 637 currentHandle={currentHandle} 670 638 /> 671 639 672 640 <StatusHistory requestId={fr.id} version={statusVersion.value} /> 673 641 674 - {((currentDid === fr.authorDid || isAdminOrOwner) && fr.status === "requested") || 642 + {((currentDid === fr.authorDid || isAdminOrOwner.value) && fr.status === "requested") || 675 643 data.duplicateCount > 0 ? ( 676 644 <MergedRequestsSection 677 645 requestId={fr.id} 678 646 duplicateCount={data.duplicateCount} 679 647 canMerge={ 680 - (currentDid === fr.authorDid || isAdminOrOwner) && 648 + (currentDid === fr.authorDid || isAdminOrOwner.value) && 681 649 data.duplicateCount === 0 && 682 650 fr.status === "requested" 683 651 }
+32 -64
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 - import { useSignal } from "@preact/signals"; 1 + import { useComputed, useSignal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { sphereState } from "@exosphere/client/sphere"; 4 4 import { spherePath, useLocation } from "@exosphere/client/router"; ··· 135 135 136 136 type ActiveTab = "requests" | "done" | "not-planned"; 137 137 138 + const tabs = [ 139 + { tab: "requests", label: "Requests", path: "/infuse" }, 140 + { tab: "done", label: "Done", path: "/infuse/done" }, 141 + { tab: "not-planned", label: "Not planned", path: "/infuse/not-planned" }, 142 + ] as const; 143 + 138 144 function useActiveTab(): { activeTab: ActiveTab; statuses: string[] } { 139 145 const { path } = useLocation(); 140 146 if (path.endsWith("/done")) return { activeTab: "done", statuses: ["done"] }; ··· 171 177 const isAuthenticated = auth.value.authenticated; 172 178 const currentDid = isAuthenticated ? auth.value.did : null; 173 179 174 - const votedIds = useSignal<Set<string>>( 175 - new Set(prefetchedVotes?.votes), 176 - ); 180 + const votedIds = useSignal<Set<string>>(new Set(prefetchedVotes?.votes)); 177 181 // Fetch user's votes when authenticated (skips if SSR-prefetched) 178 182 const votesQuery = useQuery( 179 183 () => (isAuthenticated ? getMyVotes() : Promise.resolve(null)), 180 184 [isAuthenticated], 181 - prefetchedVotes ? { initialData: prefetchedVotes, cacheKey: "feature-request-votes" } : { cacheKey: "feature-request-votes" }, 185 + prefetchedVotes 186 + ? { initialData: prefetchedVotes, cacheKey: "feature-request-votes" } 187 + : { cacheKey: "feature-request-votes" }, 182 188 ); 183 189 useEffect(() => { 184 190 if (votesQuery.data) { ··· 186 192 } 187 193 }, [votesQuery.data]); 188 194 189 - const isAdminOrOwner = (() => { 195 + const isAdminOrOwner = useComputed(() => { 190 196 const sphereData = sphereState.value.data; 191 197 if (!isAuthenticated || !sphereData) return false; 192 198 const role = sphereData.role; 193 - if (role === "owner" || role === "admin") return true; 194 - return sphereData.sphere.ownerDid === auth.value.did; 195 - })(); 196 - 197 - const tabs = [ 198 - { tab: "requests", label: "Requests", path: "/infuse" }, 199 - { tab: "done", label: "Done", path: "/infuse/done" }, 200 - { tab: "not-planned", label: "Not planned", path: "/infuse/not-planned" }, 201 - ] as const; 199 + return role === "owner" || role === "admin"; 200 + }); 202 201 203 202 const onCreated = () => { 204 203 showForm.value = false; 205 204 refetch(); 206 205 }; 207 206 208 - const handleDelete = async (id: string) => { 207 + const handleAction = async (action: (id: string) => Promise<unknown>, id: string) => { 209 208 try { 210 - await deleteFeatureRequest(id); 209 + await action(id); 211 210 refetch(); 212 211 } catch (err) { 213 - console.error("Failed to delete feature request:", err); 212 + console.error("Action failed:", err); 214 213 } 215 214 }; 216 215 217 - const handleHide = async (id: string) => { 218 - try { 219 - await hideFeatureRequest(id); 220 - refetch(); 221 - } catch (err) { 222 - console.error("Failed to hide feature request:", err); 223 - } 224 - }; 225 - 226 - const handleVote = async (id: string) => { 227 - // Optimistic update 228 - const prev = votedIds.value; 229 - votedIds.value = new Set([...prev, id]); 230 - if (data) { 231 - const fr = data.featureRequests.find((r) => r.id === id); 232 - if (fr) fr.voteCount++; 233 - } 234 - 235 - try { 236 - await voteFeatureRequest(id); 237 - } catch { 238 - // Revert on failure 239 - votedIds.value = prev; 240 - if (data) { 241 - const fr = data.featureRequests.find((r) => r.id === id); 242 - if (fr) fr.voteCount--; 243 - } 244 - } 245 - }; 216 + const handleDelete = (id: string) => handleAction(deleteFeatureRequest, id); 217 + const handleHide = (id: string) => handleAction(hideFeatureRequest, id); 246 218 247 - const handleUnvote = async (id: string) => { 248 - // Optimistic update 219 + const toggleVote = async (id: string) => { 220 + const wasVoted = votedIds.value.has(id); 249 221 const prev = votedIds.value; 250 222 const next = new Set(prev); 251 - next.delete(id); 223 + if (wasVoted) next.delete(id); 224 + else next.add(id); 252 225 votedIds.value = next; 253 - if (data) { 254 - const fr = data.featureRequests.find((r) => r.id === id); 255 - if (fr) fr.voteCount--; 256 - } 226 + 227 + const fr = data?.featureRequests.find((r) => r.id === id); 228 + if (fr) fr.voteCount += wasVoted ? -1 : 1; 257 229 258 230 try { 259 - await unvoteFeatureRequest(id); 231 + await (wasVoted ? unvoteFeatureRequest(id) : voteFeatureRequest(id)); 260 232 } catch { 261 - // Revert on failure 262 233 votedIds.value = prev; 263 - if (data) { 264 - const fr = data.featureRequests.find((r) => r.id === id); 265 - if (fr) fr.voteCount++; 266 - } 234 + if (fr) fr.voteCount += wasVoted ? 1 : -1; 267 235 } 268 236 }; 269 237 ··· 313 281 {isAuthenticated && " Be the first to submit one."} 314 282 </p> 315 283 ) : ( 316 - <div class={ui.stackSm} style={pending ? { opacity: 0.6 } : undefined}> 284 + <div class={ui.stackSm}> 317 285 {data.featureRequests.map((fr) => ( 318 286 <RequestCard 319 287 key={fr.id} 320 288 fr={fr} 321 289 isAuthor={currentDid === fr.authorDid} 322 - isAdminOrOwner={isAdminOrOwner} 290 + isAdminOrOwner={isAdminOrOwner.value} 323 291 hasVoted={votedIds.value.has(fr.id)} 324 292 isAuthenticated={isAuthenticated} 325 293 onDelete={handleDelete} 326 294 onHide={handleHide} 327 - onVote={handleVote} 328 - onUnvote={handleUnvote} 295 + onVote={toggleVote} 296 + onUnvote={toggleVote} 329 297 /> 330 298 ))} 331 299 </div>