| ... | @@ -12,12 +12,15 @@ import { | ... | @@ -12,12 +12,15 @@ import { |
| 12 | ChevronDownIcon, | 12 | ChevronDownIcon, |
| 13 | ChevronRightIcon, | 13 | ChevronRightIcon, |
| 14 | HammerIcon, | 14 | HammerIcon, |
| | 15 | ListFilterIcon, |
| 15 | TagIcon, | 16 | TagIcon, |
| 16 | UsersIcon, | 17 | UsersIcon, |
| 17 | } from "lucide-react"; | 18 | } from "lucide-react"; |
| | 19 | import * as Schema from "effect/Schema"; |
| 18 | import { useRef, useState, type ReactNode } from "react"; | 20 | import { useRef, useState, type ReactNode } from "react"; |
| 19 | | 21 | |
| 20 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 22 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| | 23 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 21 | import { cn } from "~/lib/utils"; | 24 | import { cn } from "~/lib/utils"; |
| 22 | import { useOpenLink } from "~/browser/useOpenLink"; | 25 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 23 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 26 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| ... | @@ -25,6 +28,14 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; | ... | @@ -25,6 +28,14 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 25 | import { Button } from "../ui/button"; | 28 | import { Button } from "../ui/button"; |
| 26 | import { PullRequestEditButton } from "./PullRequestEditButton"; | 29 | import { PullRequestEditButton } from "./PullRequestEditButton"; |
| 27 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | 30 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| | 31 | import { |
| | 32 | Menu, |
| | 33 | MenuCheckboxItem, |
| | 34 | MenuGroup, |
| | 35 | MenuGroupLabel, |
| | 36 | MenuPopup, |
| | 37 | MenuTrigger, |
| | 38 | } from "../ui/menu"; |
| 28 | import { toastManager } from "../ui/toast"; | 39 | import { toastManager } from "../ui/toast"; |
| 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 40 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 30 | import { | 41 | import { |
| ... | @@ -349,111 +360,21 @@ function Section({ | ... | @@ -349,111 +360,21 @@ function Section({ |
| 349 | ); | 360 | ); |
| 350 | } | 361 | } |
| 351 | | 362 | |
| 352 | function CommentGroup({ | | |
| 353 | label, | | |
| 354 | comments, | | |
| 355 | detail, | | |
| 356 | children, | | |
| 357 | onOpenChange, | | |
| 358 | }: { | | |
| 359 | label: string; | | |
| 360 | comments: readonly PullRequestComment[]; | | |
| 361 | detail: PullRequestDetailView; | | |
| 362 | children: ReactNode; | | |
| 363 | onOpenChange?: (open: boolean) => void; | | |
| 364 | }) { | | |
| 365 | const authors = [ | | |
| 366 | ...new Map( | | |
| 367 | comments.map((comment) => [reviewerKey(comment.author?.login ?? "ghost"), comment.author]), | | |
| 368 | ).values(), | | |
| 369 | ]; | | |
| 370 | const fileCount = new Set(comments.flatMap((comment) => (comment.path ? [comment.path] : []))) | | |
| 371 | .size; | | |
| 372 | const latest = comments.reduce<string | null>( | | |
| 373 | (date, comment) => (date === null || comment.createdAt > date ? comment.createdAt : date), | | |
| 374 | null, | | |
| 375 | ); | | |
| 376 | return ( | | |
| 377 | <div className="overflow-hidden rounded-lg border border-border bg-background"> | | |
| 378 | <Collapsible onOpenChange={onOpenChange}> | | |
| 379 | <div className="flex items-center gap-3 pl-3"> | | |
| 380 | <div className="flex shrink-0 -space-x-1.5"> | | |
| 381 | {authors.slice(0, 3).map((actor) => ( | | |
| 382 | // The ring separates the overlapping faces; it belongs to the stack, not the actor. | | |
| 383 | <span | | |
| 384 | key={actor?.login ?? "ghost"} | | |
| 385 | className="relative flex rounded-full ring-2 ring-background hover:z-10 focus-within:z-10" | | |
| 386 | > | | |
| 387 | <PullRequestActorLabel | | |
| 388 | actor={actor} | | |
| 389 | profileUrl={ | | |
| 390 | detail.provider === "github" && actor | | |
| 391 | ? new URL( | | |
| 392 | actor.isBot || actor.login.endsWith("[bot]") | | |
| 393 | ? `/apps/${encodeURIComponent(actor.login.replace(/\[bot\]$/, ""))}` | | |
| 394 | : `/${encodeURIComponent(actor.login)}`, | | |
| 395 | detail.url, | | |
| 396 | ).toString() | | |
| 397 | : null | | |
| 398 | } | | |
| 399 | variant="avatar" | | |
| 400 | /> | | |
| 401 | </span> | | |
| 402 | ))} | | |
| 403 | {authors.length > 3 ? ( | | |
| 404 | <span className="relative flex size-6 items-center justify-center rounded-full bg-muted text-3xs text-muted-foreground ring-2 ring-background"> | | |
| 405 | +{authors.length - 3} | | |
| 406 | </span> | | |
| 407 | ) : null} | | |
| 408 | </div> | | |
| 409 | <CollapsibleTrigger | | |
| 410 | aria-label={label} | | |
| 411 | className="group flex min-w-0 flex-1 items-center gap-3 rounded-md py-3 pr-3 text-left hover:bg-muted/30" | | |
| 412 | > | | |
| 413 | <span className="min-w-0 flex-1 space-y-1"> | | |
| 414 | <span className="block text-xs font-medium text-foreground/90">{label}</span> | | |
| 415 | <span className="flex flex-wrap gap-x-1.5 text-2xs text-muted-foreground"> | | |
| 416 | <span> | | |
| 417 | {authors.length} {authors.length === 1 ? "author" : "authors"} | | |
| 418 | </span> | | |
| 419 | {fileCount > 0 ? ( | | |
| 420 | <span> | | |
| 421 | · {fileCount} {fileCount === 1 ? "file" : "files"} | | |
| 422 | </span> | | |
| 423 | ) : null} | | |
| 424 | {latest ? ( | | |
| 425 | <span> | | |
| 426 | · Latest{" "} | | |
| 427 | <Tooltip> | | |
| 428 | <TooltipTrigger render={<time dateTime={latest} />}> | | |
| 429 | {formatRelativeTimeLabel(latest)} | | |
| 430 | </TooltipTrigger> | | |
| 431 | <TooltipPopup>{new Date(latest).toLocaleString()}</TooltipPopup> | | |
| 432 | </Tooltip> | | |
| 433 | </span> | | |
| 434 | ) : null} | | |
| 435 | </span> | | |
| 436 | </span> | | |
| 437 | <ChevronRightIcon | | |
| 438 | aria-hidden | | |
| 439 | className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" | | |
| 440 | /> | | |
| 441 | </CollapsibleTrigger> | | |
| 442 | </div> | | |
| 443 | <CollapsiblePanel keepMounted> | | |
| 444 | <div className="border-t border-border/60 px-3 pb-3">{children}</div> | | |
| 445 | </CollapsiblePanel> | | |
| 446 | </Collapsible> | | |
| 447 | </div> | | |
| 448 | ); | | |
| 449 | } | | |
| 450 | | | |
| 451 | /** | 363 | /** |
| 452 | * What a first render of the conversation carries. A pull request with two hundred comments is | 364 | * What a first render of the conversation carries. A pull request with two hundred comments is |
| 453 | * two hundred markdown documents, and the ones worth arriving for are the recent ones. | 365 | * two hundred markdown documents, and the ones worth arriving for are the recent ones. |
| 454 | */ | 366 | */ |
| 455 | const COMMENT_PAGE = 10; | 367 | const COMMENT_PAGE = 10; |
| 456 | | 368 | |
| | 369 | /** |
| | 370 | * Which kinds of comment the conversation shows. Kept per browser rather than per pull request: |
| | 371 | * whether a reader wants bot reports and finished threads in front of them is a habit, not a |
| | 372 | * property of one change. |
| | 373 | */ |
| | 374 | const COMMENT_FILTER_STORAGE_KEY = "t3code:pull-request-comment-filter:v1"; |
| | 375 | const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Schema.Boolean }); |
| | 376 | const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false }; |
| | 377 | |
| 457 | export function PullRequestSummaryTab({ | 378 | export function PullRequestSummaryTab({ |
| 458 | environmentId, | 379 | environmentId, |
| 459 | threadRef, | 380 | threadRef, |
| ... | @@ -487,10 +408,12 @@ export function PullRequestSummaryTab({ | ... | @@ -487,10 +408,12 @@ export function PullRequestSummaryTab({ |
| 487 | // Keyed by the pull request, so opening another one starts at the end of its conversation | 408 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 488 | // rather than wherever the last one had been read back to. | 409 | // rather than wherever the last one had been read back to. |
| 489 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); | 410 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 490 | const [openedBotGroup, setOpenedBotGroup] = useState<string | null>(null); | | |
| 491 | const [shownBots, setShownBots] = useState({ url: detail.url, count: COMMENT_PAGE }); | | |
| 492 | const shownBotComments = shownBots.url === detail.url ? shownBots.count : COMMENT_PAGE; | | |
| 493 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; | 411 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| | 412 | const [commentFilter, setCommentFilter] = useLocalStorage( |
| | 413 | COMMENT_FILTER_STORAGE_KEY, |
| | 414 | DEFAULT_COMMENT_FILTER, |
| | 415 | CommentFilterSchema, |
| | 416 | ); |
| 494 | // A comment that already lives on a review thread is that thread: the thread carries the line | 417 | // A comment that already lives on a review thread is that thread: the thread carries the line |
| 495 | // and side the bare comment has lost, and a resolved one is finished work nobody should be | 418 | // and side the bare comment has lost, and a resolved one is finished work nobody should be |
| 496 | // invited to fix again — the same call the whole-review hand-off makes. | 419 | // invited to fix again — the same call the whole-review hand-off makes. |
| ... | @@ -500,22 +423,24 @@ export function PullRequestSummaryTab({ | ... | @@ -500,22 +423,24 @@ export function PullRequestSummaryTab({ |
| 500 | ), | 423 | ), |
| 501 | ); | 424 | ); |
| 502 | | 425 | |
| 503 | const activeComments: PullRequestComment[] = []; | 426 | const isFinished = (comment: PullRequestComment) => |
| 504 | const finishedComments: PullRequestComment[] = []; | 427 | threadByCommentId.get(comment.id)?.isResolved === true || |
| 505 | const botComments: PullRequestComment[] = []; | 428 | pullRequestReviewOutcome(comment.reviewState) === "dismissed"; |
| 506 | for (const comment of detail.comments) { | 429 | const isBot = (comment: PullRequestComment) => |
| 507 | const finished = | 430 | comment.author?.isBot === true || comment.author?.login.endsWith("[bot]") === true; |
| 508 | threadByCommentId.get(comment.id)?.isResolved || | 431 | const filteredComments = detail.comments.filter( |
| 509 | pullRequestReviewOutcome(comment.reviewState) === "dismissed"; | 432 | (comment) => |
| 510 | const bot = comment.author?.isBot === true || comment.author?.login.endsWith("[bot]"); | 433 | (commentFilter.bots || !isBot(comment)) && (commentFilter.resolved || !isFinished(comment)), |
| 511 | (finished ? finishedComments : bot ? botComments : activeComments).push(comment); | 434 | ); |
| 512 | } | 435 | // The one count the filter shows. Per-kind counts would overlap, since a resolved bot comment |
| | 436 | // is both kinds and needs both shown. |
| | 437 | const filteredOutCount = detail.comments.length - filteredComments.length; |
| 513 | // Windowed by recency regardless of display order: expanding always reaches further back in | 438 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 514 | // time, whether the newest comment currently reads first or last. | 439 | // time, whether the newest comment currently reads first or last. |
| 515 | const recentComments = activeComments.slice(Math.max(0, activeComments.length - shownComments)); | 440 | const recentComments = filteredComments.slice( |
| 516 | const hiddenCommentCount = activeComments.length - recentComments.length; | 441 | Math.max(0, filteredComments.length - shownComments), |
| 517 | const recentBotComments = botComments.slice(Math.max(0, botComments.length - shownBotComments)); | 442 | ); |
| 518 | const hiddenBotCommentCount = botComments.length - recentBotComments.length; | 443 | const hiddenCommentCount = filteredComments.length - recentComments.length; |
| 519 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); | 444 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); |
| 520 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); | 445 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); |
| 521 | const showOldestCommentsButton = | 446 | const showOldestCommentsButton = |
| ... | @@ -638,6 +563,30 @@ export function PullRequestSummaryTab({ | ... | @@ -638,6 +563,30 @@ export function PullRequestSummaryTab({ |
| 638 | const renderComment = (comment: PullRequestComment) => { | 563 | const renderComment = (comment: PullRequestComment) => { |
| 639 | const thread = threadByCommentId.get(comment.id); | 564 | const thread = threadByCommentId.get(comment.id); |
| 640 | const body = visibleBody(comment.body); | 565 | const body = visibleBody(comment.body); |
| | 566 | if (isFinished(comment)) { |
| | 567 | return ( |
| | 568 | <CollapsedComment |
| | 569 | key={`${detail.url}:${comment.id}`} |
| | 570 | comment={comment} |
| | 571 | editing={commentEditing} |
| | 572 | detail={detail} |
| | 573 | thread={thread} |
| | 574 | label={thread?.isResolved ? "Resolved" : "Review dismissed"} |
| | 575 | body={body} |
| | 576 | reactionBar={ |
| | 577 | <PullRequestReactionBar |
| | 578 | className="ml-auto justify-end" |
| | 579 | reactions={comment.reactions ?? []} |
| | 580 | canReact={detail.capabilities.reactions === true} |
| | 581 | subjectId={comment.id} |
| | 582 | environmentId={environmentId} |
| | 583 | reference={reference} |
| | 584 | onRefresh={onRefresh} |
| | 585 | /> |
| | 586 | } |
| | 587 | /> |
| | 588 | ); |
| | 589 | } |
| 641 | const outcome = pullRequestReviewOutcome(comment.reviewState); | 590 | const outcome = pullRequestReviewOutcome(comment.reviewState); |
| 642 | // An approval is a verdict, not a finding: there is nothing in it to fix. | 591 | // An approval is a verdict, not a finding: there is nothing in it to fix. |
| 643 | const finding: PullRequestFinding | null = | 592 | const finding: PullRequestFinding | null = |
| ... | @@ -920,20 +869,51 @@ export function PullRequestSummaryTab({ | ... | @@ -920,20 +869,51 @@ export function PullRequestSummaryTab({ |
| 920 | <Section | 869 | <Section |
| 921 | title={`Comments (${detail.commentCount})`} | 870 | title={`Comments (${detail.commentCount})`} |
| 922 | actions={ | 871 | actions={ |
| 923 | <Button | 872 | <div className="flex shrink-0 items-center gap-1"> |
| 924 | size="xs" | 873 | <Menu> |
| 925 | variant="ghost-muted" | 874 | <MenuTrigger |
| 926 | className="shrink-0" | 875 | render={<Button size="xs" variant="ghost-muted" />} |
| 927 | aria-label={ | 876 | aria-label="Filter comments" |
| 928 | commentOrder === "newest" | 877 | > |
| 929 | ? "Show oldest comments first" | 878 | <ListFilterIcon aria-hidden className="size-3" /> |
| 930 | : "Show newest comments first" | 879 | {filteredOutCount > 0 ? `${filteredOutCount} hidden` : "Filter"} |
| 931 | } | 880 | </MenuTrigger> |
| 932 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} | 881 | <MenuPopup align="end"> |
| 933 | > | 882 | <MenuGroup> |
| 934 | <ArrowDownUpIcon aria-hidden className="size-3" /> | 883 | <MenuGroupLabel>Show</MenuGroupLabel> |
| 935 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} | 884 | <MenuCheckboxItem |
| 936 | </Button> | 885 | checked={commentFilter.bots} |
| | 886 | closeOnClick={false} |
| | 887 | onCheckedChange={(bots) => setCommentFilter((value) => ({ ...value, bots }))} |
| | 888 | > |
| | 889 | Bot comments |
| | 890 | </MenuCheckboxItem> |
| | 891 | <MenuCheckboxItem |
| | 892 | checked={commentFilter.resolved} |
| | 893 | closeOnClick={false} |
| | 894 | onCheckedChange={(resolved) => |
| | 895 | setCommentFilter((value) => ({ ...value, resolved })) |
| | 896 | } |
| | 897 | > |
| | 898 | Resolved or dismissed |
| | 899 | </MenuCheckboxItem> |
| | 900 | </MenuGroup> |
| | 901 | </MenuPopup> |
| | 902 | </Menu> |
| | 903 | <Button |
| | 904 | size="xs" |
| | 905 | variant="ghost-muted" |
| | 906 | aria-label={ |
| | 907 | commentOrder === "newest" |
| | 908 | ? "Show oldest comments first" |
| | 909 | : "Show newest comments first" |
| | 910 | } |
| | 911 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| | 912 | > |
| | 913 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| | 914 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| | 915 | </Button> |
| | 916 | </div> |
| 937 | } | 917 | } |
| 938 | > | 918 | > |
| 939 | {activityPending ? ( | 919 | {activityPending ? ( |
| ... | @@ -950,6 +930,12 @@ export function PullRequestSummaryTab({ | ... | @@ -950,6 +930,12 @@ export function PullRequestSummaryTab({ |
| 950 | ) : null} | 930 | ) : null} |
| 951 | {detail.comments.length === 0 ? ( | 931 | {detail.comments.length === 0 ? ( |
| 952 | <p className="py-2 text-xs text-muted-foreground">No comments yet.</p> | 932 | <p className="py-2 text-xs text-muted-foreground">No comments yet.</p> |
| | 933 | ) : filteredComments.length === 0 ? ( |
| | 934 | <p className="py-2 text-xs text-muted-foreground"> |
| | 935 | {filteredOutCount === 1 |
| | 936 | ? "The only comment is hidden by the filter." |
| | 937 | : `All ${filteredOutCount} comments are hidden by the filter.`} |
| | 938 | </p> |
| 953 | ) : ( | 939 | ) : ( |
| 954 | <div className="space-y-3"> | 940 | <div className="space-y-3"> |
| 955 | {commentOrder === "oldest" ? showOldestCommentsButton : null} | 941 | {commentOrder === "oldest" ? showOldestCommentsButton : null} |
| ... | @@ -965,87 +951,6 @@ export function PullRequestSummaryTab({ | ... | @@ -965,87 +951,6 @@ export function PullRequestSummaryTab({ |
| 965 | Show only {COMMENT_PAGE} recent comments | 951 | Show only {COMMENT_PAGE} recent comments |
| 966 | </Button> | 952 | </Button> |
| 967 | ) : null} | 953 | ) : null} |
| 968 | {botComments.length > 0 ? ( | | |
| 969 | <CommentGroup | | |
| 970 | key={`bots:${detail.url}`} | | |
| 971 | label={`${botComments.length} bot comment${botComments.length === 1 ? "" : "s"}`} | | |
| 972 | comments={botComments} | | |
| 973 | detail={detail} | | |
| 974 | onOpenChange={(open) => { | | |
| 975 | if (open) setOpenedBotGroup(detail.url); | | |
| 976 | }} | | |
| 977 | > | | |
| 978 | <div className="space-y-3 pt-2"> | | |
| 979 | {openedBotGroup === detail.url | | |
| 980 | ? orderPullRequestComments(recentBotComments, commentOrder).map( | | |
| 981 | renderComment, | | |
| 982 | ) | | |
| 983 | : null} | | |
| 984 | {hiddenBotCommentCount > 0 ? ( | | |
| 985 | <Button | | |
| 986 | size="sm" | | |
| 987 | variant="outline" | | |
| 988 | className="w-full" | | |
| 989 | onClick={() => | | |
| 990 | setShownBots({ | | |
| 991 | url: detail.url, | | |
| 992 | count: shownBotComments + COMMENT_PAGE, | | |
| 993 | }) | | |
| 994 | } | | |
| 995 | > | | |
| 996 | Show {Math.min(hiddenBotCommentCount, COMMENT_PAGE)} older bot comment | | |
| 997 | {hiddenBotCommentCount === 1 ? "" : "s"} ({hiddenBotCommentCount} hidden) | | |
| 998 | </Button> | | |
| 999 | ) : null} | | |
| 1000 | {shownBotComments > COMMENT_PAGE ? ( | | |
| 1001 | <Button | | |
| 1002 | size="sm" | | |
| 1003 | variant="ghost" | | |
| 1004 | className="w-full" | | |
| 1005 | onClick={() => setShownBots({ url: detail.url, count: COMMENT_PAGE })} | | |
| 1006 | > | | |
| 1007 | Show only {COMMENT_PAGE} recent bot comments | | |
| 1008 | </Button> | | |
| 1009 | ) : null} | | |
| 1010 | </div> | | |
| 1011 | </CommentGroup> | | |
| 1012 | ) : null} | | |
| 1013 | {finishedComments.length > 0 ? ( | | |
| 1014 | <CommentGroup | | |
| 1015 | key={detail.url} | | |
| 1016 | label={`${finishedComments.length} resolved or dismissed comment${finishedComments.length === 1 ? "" : "s"}`} | | |
| 1017 | comments={finishedComments} | | |
| 1018 | detail={detail} | | |
| 1019 | > | | |
| 1020 | <div className="space-y-2 pt-2"> | | |
| 1021 | {orderPullRequestComments(finishedComments, commentOrder).map((comment) => { | | |
| 1022 | const thread = threadByCommentId.get(comment.id); | | |
| 1023 | return ( | | |
| 1024 | <CollapsedComment | | |
| 1025 | key={comment.id} | | |
| 1026 | comment={comment} | | |
| 1027 | editing={commentEditing} | | |
| 1028 | detail={detail} | | |
| 1029 | thread={thread} | | |
| 1030 | label={thread?.isResolved ? "Resolved" : "Review dismissed"} | | |
| 1031 | body={visibleBody(comment.body)} | | |
| 1032 | reactionBar={ | | |
| 1033 | <PullRequestReactionBar | | |
| 1034 | className="ml-auto justify-end" | | |
| 1035 | reactions={comment.reactions ?? []} | | |
| 1036 | canReact={detail.capabilities.reactions === true} | | |
| 1037 | subjectId={comment.id} | | |
| 1038 | environmentId={environmentId} | | |
| 1039 | reference={reference} | | |
| 1040 | onRefresh={onRefresh} | | |
| 1041 | /> | | |
| 1042 | } | | |
| 1043 | /> | | |
| 1044 | ); | | |
| 1045 | })} | | |
| 1046 | </div> | | |
| 1047 | </CommentGroup> | | |
| 1048 | ) : null} | | |
| 1049 | </div> | 954 | </div> |
| 1050 | )} | 955 | )} |
| 1051 | </> | 956 | </> |