BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: add help section settings panel

* quote/repost menu

+301 -42
+2 -1
src/components/feeds/FeedComposer.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 3 - import { buildPublicPostUrl, getDisplayName, getPostText } from "$/lib/feeds"; 3 + import { buildPublicPostUrl, getDisplayName, getPostFacets, getPostText } from "$/lib/feeds"; 4 4 import type { PostView } from "$/lib/types"; 5 5 import { createMemo, For, Show } from "solid-js"; 6 6 import { Motion, Presence } from "solid-motionone"; ··· 355 355 <QuotedPostPreview 356 356 author={post().author} 357 357 class="mt-4 rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 358 + facets={getPostFacets(post())} 358 359 href={buildPublicPostUrl(post())} 359 360 text={getPostText(post()) || "Quoted post"} 360 361 truncate
+65 -9
src/components/feeds/PostCard.tsx
··· 107 107 108 108 type PostActionButtonProps = { 109 109 active?: boolean; 110 + ariaExpanded?: boolean; 111 + ariaHasPopup?: "menu"; 110 112 ariaLabel?: string; 111 113 busy?: boolean; 112 114 icon: string; ··· 119 121 function PostActionButton(props: PostActionButtonProps) { 120 122 return ( 121 123 <button 124 + aria-expanded={props.ariaExpanded} 125 + aria-haspopup={props.ariaHasPopup} 122 126 aria-label={props.ariaLabel ?? props.label} 123 127 class="inline-flex min-w-0 items-center gap-1.5 rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-primary disabled:cursor-wait disabled:opacity-70 max-[520px]:px-2.5" 124 128 classList={{ "text-primary": !!props.active }} ··· 171 175 onOpen: (element: HTMLButtonElement) => void; 172 176 triggerRef: (element: HTMLButtonElement) => void; 173 177 }; 178 + repostMenuOpen: boolean; 174 179 showThreadAction: boolean; 175 180 state: PostActionStatus; 176 181 }; 177 182 178 183 function PostActions(props: PostActionsProps) { 179 184 const [status, menu, actions, visibility] = splitProps(props, ["state"], ["menu"], ["handlers"], [ 185 + "repostMenuOpen", 180 186 "showThreadAction", 181 187 ]); 182 188 ··· 198 204 onClick={actions.handlers.onReply} /> 199 205 <PostActionButton 200 206 active={status.state.isReposted} 207 + ariaExpanded={visibility.repostMenuOpen} 208 + ariaHasPopup="menu" 201 209 ariaLabel="Repost" 202 210 busy={status.state.repostPending} 203 211 icon="i-ri-repeat-2-line" ··· 396 404 397 405 const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 398 406 const [menuOpen, setMenuOpen] = createSignal(false); 407 + const [repostMenuAnchor, setRepostMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 408 + const [repostMenuOpen, setRepostMenuOpen] = createSignal(false); 399 409 const [reportOpen, setReportOpen] = createSignal(false); 400 410 const [reportTarget, setReportTarget] = createSignal<ReportTarget | null>(null); 401 411 let menuTriggerRef: HTMLButtonElement | undefined; 412 + let repostMenuTriggerRef: HTMLButtonElement | undefined; 402 413 403 414 const menuItems = createMemo<ContextMenuItem[]>(() => { 404 415 const items: ContextMenuItem[] = []; ··· 486 497 return items; 487 498 }); 488 499 489 - function closeMenu() { 500 + const repostMenuItems = createMemo<ContextMenuItem[]>(() => { 501 + const items: ContextMenuItem[] = []; 502 + 503 + if (interactions.onRepost) { 504 + items.push({ 505 + icon: isReposted() ? "i-ri-repeat-2-fill" : "i-ri-repeat-2-line", 506 + label: isReposted() ? "Undo repost" : "Repost", 507 + onSelect: interactions.onRepost, 508 + }); 509 + } 510 + 511 + if (interactions.onQuote) { 512 + items.push({ icon: "i-ri-chat-quote-line", label: "Quote post", onSelect: interactions.onQuote }); 513 + } 514 + 515 + return items; 516 + }); 517 + 518 + function closeContextMenu() { 490 519 setMenuOpen(false); 491 520 setMenuAnchor(null); 492 521 } 493 522 494 - function openMenuFromTrigger(element: HTMLButtonElement) { 523 + function closeRepostMenu() { 524 + setRepostMenuOpen(false); 525 + setRepostMenuAnchor(null); 526 + } 527 + 528 + function openContextMenuFromTrigger(element: HTMLButtonElement) { 529 + closeRepostMenu(); 495 530 setMenuAnchor({ kind: "element", rect: element.getBoundingClientRect() }); 496 531 setMenuOpen(true); 497 532 } 498 533 499 - function openMenuFromPointer(event: MouseEvent) { 534 + function openContextMenuFromPointer(event: MouseEvent) { 500 535 event.preventDefault(); 536 + closeRepostMenu(); 501 537 setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); 502 538 setMenuOpen(true); 539 + } 540 + 541 + function openRepostMenuFromTrigger(element: HTMLButtonElement) { 542 + if (repostMenuItems().length === 0) { 543 + return; 544 + } 545 + 546 + closeContextMenu(); 547 + repostMenuTriggerRef = element; 548 + setRepostMenuAnchor({ kind: "element", rect: element.getBoundingClientRect() }); 549 + setRepostMenuOpen(true); 503 550 } 504 551 505 552 async function submitReport(input: { reasonType: ModerationReasonType; reason: string }) { ··· 545 592 return; 546 593 } 547 594 548 - openMenuFromPointer(event); 595 + openContextMenuFromPointer(event); 549 596 }}> 550 597 <Show when={reasonLabel()}> 551 598 <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-primary"> ··· 622 669 }, 623 670 onReply: () => interactions.onReply?.(), 624 671 onRepost: (event) => { 625 - if (event.shiftKey && interactions.onOpenEngagement) { 626 - interactions.onOpenEngagement("reposts"); 672 + if (event.shiftKey) { 673 + interactions.onRepost?.(); 627 674 return; 628 675 } 629 676 630 - interactions.onRepost?.(); 677 + openRepostMenuFromTrigger(event.currentTarget as HTMLButtonElement); 631 678 }, 632 679 }} 633 680 menu={{ 634 681 open: menuOpen(), 635 - onOpen: openMenuFromTrigger, 682 + onOpen: openContextMenuFromTrigger, 636 683 triggerRef: (element) => { 637 684 menuTriggerRef = element; 638 685 }, 639 686 }} 687 + repostMenuOpen={repostMenuOpen()} 640 688 showThreadAction={showThreadAction()} 641 689 state={{ 642 690 bookmarkPending: !!actionFlags.bookmarkPending, ··· 662 710 label="Post actions" 663 711 open={menuOpen()} 664 712 returnFocusTo={menuTriggerRef} 665 - onClose={closeMenu} /> 713 + onClose={closeContextMenu} /> 714 + 715 + <ContextMenu 716 + anchor={repostMenuAnchor()} 717 + items={repostMenuItems()} 718 + label="Repost actions" 719 + open={repostMenuOpen()} 720 + returnFocusTo={repostMenuTriggerRef} 721 + onClose={closeRepostMenu} /> 666 722 667 723 <ReportDialog 668 724 open={reportOpen()}
+26
src/components/feeds/tests/FeedComposer.test.tsx
··· 21 21 }; 22 22 23 23 describe("FeedComposer", () => { 24 + it("renders quote preview with rich-text facets when quote target has facets", () => { 25 + render(() => ( 26 + <FeedComposer 27 + {...BASE_PROPS} 28 + state={{ 29 + ...BASE_PROPS.state, 30 + quoteTarget: { 31 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 32 + cid: "cid-quote", 33 + indexedAt: "2026-04-03T12:00:00.000Z", 34 + record: { 35 + createdAt: "2026-04-03T12:00:00.000Z", 36 + facets: [{ 37 + features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 38 + index: { byteEnd: 25, byteStart: 6 }, 39 + }], 40 + text: "Visit https://example.com", 41 + }, 42 + uri: "at://did:plc:bob/app.bsky.feed.post/abc123", 43 + }, 44 + }} /> 45 + )); 46 + 47 + expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 48 + }); 49 + 24 50 it("renders a contained scroll region for typeahead suggestions", () => { 25 51 render(() => <FeedComposer {...BASE_PROPS} state={{ ...BASE_PROPS.state, suggestions, text: "@ha" }} />); 26 52
+23 -4
src/components/feeds/tests/PostCard.test.tsx
··· 142 142 expect(screen.getByRole("menuitem", { name: "Copy post link" })).toBeInTheDocument(); 143 143 }); 144 144 145 - it("uses shift-click on like/repost/quote to open engagement lists without toggling actions", () => { 145 + it("uses shift-click on like and quote to open engagement lists, but shift-click repost toggles repost", () => { 146 146 const onLike = vi.fn(); 147 147 const onQuote = vi.fn(); 148 148 const onRepost = vi.fn(); ··· 161 161 fireEvent.click(screen.getByRole("button", { name: "Quote" }), { shiftKey: true }); 162 162 163 163 expect(onOpenEngagement).toHaveBeenNthCalledWith(1, "likes"); 164 - expect(onOpenEngagement).toHaveBeenNthCalledWith(2, "reposts"); 165 - expect(onOpenEngagement).toHaveBeenNthCalledWith(3, "quotes"); 164 + expect(onOpenEngagement).toHaveBeenNthCalledWith(2, "quotes"); 166 165 expect(onLike).not.toHaveBeenCalled(); 167 - expect(onRepost).not.toHaveBeenCalled(); 168 166 expect(onQuote).not.toHaveBeenCalled(); 167 + expect(onRepost).toHaveBeenCalledTimes(1); 168 + }); 169 + 170 + it("opens a repost action menu from the repost button and supports repost/quote actions", () => { 171 + const onRepost = vi.fn(); 172 + const onQuote = vi.fn(); 173 + 174 + render(() => <PostCard post={createPost()} onQuote={onQuote} onRepost={onRepost} />); 175 + 176 + fireEvent.click(screen.getByRole("button", { name: "Repost" })); 177 + 178 + expect(screen.getByRole("menu", { name: "Repost actions" })).toBeInTheDocument(); 179 + expect(screen.getByRole("menuitem", { name: "Repost" })).toBeInTheDocument(); 180 + expect(screen.getByRole("menuitem", { name: "Quote post" })).toBeInTheDocument(); 181 + 182 + fireEvent.click(screen.getByRole("menuitem", { name: "Quote post" })); 183 + expect(onQuote).toHaveBeenCalledTimes(1); 184 + 185 + fireEvent.click(screen.getByRole("button", { name: "Repost" })); 186 + fireEvent.click(screen.getByRole("menuitem", { name: "Repost" })); 187 + expect(onRepost).toHaveBeenCalledTimes(1); 169 188 }); 170 189 171 190 it("hides Thread action when no known thread context exists", () => {
+2 -8
src/components/rail/AppRailButton.tsx
··· 45 45 class={"relative flex h-11 shrink-0 items-center rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition-[width,padding,transform,background-color,color] duration-200 ease-out motion-reduce:transition-none hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface"} 46 46 activeClass="bg-surface-container text-primary" 47 47 inactiveClass="" 48 - classList={{ 49 - "w-[2.75rem] justify-center gap-0": !!props.compact, 50 - "gap-2.5 px-3": !props.compact, 51 - }} 48 + classList={{ "w-[2.75rem] justify-center gap-0": !!props.compact, "gap-2.5 px-3": !props.compact }} 52 49 aria-label={props.label} 53 50 title={props.label}> 54 51 <RailButtonContent {...props} /> ··· 61 58 <button 62 59 type="button" 63 60 class={"relative flex h-11 shrink-0 items-center rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition-[width,padding,transform,background-color,color] duration-200 ease-out motion-reduce:transition-none hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface"} 64 - classList={{ 65 - "w-[2.75rem] justify-center gap-0": !!props.compact, 66 - "gap-2.5 px-3": !props.compact, 67 - }} 61 + classList={{ "w-[2.75rem] justify-center gap-0": !!props.compact, "gap-2.5 px-3": !props.compact }} 68 62 aria-label={props.label} 69 63 title={props.label} 70 64 onClick={() => props.onClick()}>
+42 -7
src/components/saved/SavedPostsPanel.tsx
··· 1 + import { PostCard } from "$/components/feeds/PostCard"; 1 2 import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; 2 - import { LocalPostResultsList, LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList"; 3 + import { LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList"; 3 4 import { SearchEmptyState } from "$/components/search/SearchEmptyState"; 4 5 import { SearchQueryInput } from "$/components/search/SearchQueryInput"; 5 6 import { Icon } from "$/components/shared/Icon"; ··· 9 10 import type { LocalPostResult, SavedPostSource, SyncStatus } from "$/lib/api/types/search"; 10 11 import { formatRelativeTime } from "$/lib/feeds"; 11 12 import { subscribeBookmarkChanged } from "$/lib/post-events"; 13 + import type { PostView } from "$/lib/types"; 12 14 import { normalizeError } from "$/lib/utils/text"; 13 15 import * as logger from "@tauri-apps/plugin-log"; 14 16 import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"; ··· 406 408 activeTab={activeTab()} 407 409 browsingState={activeTabState()} 408 410 onOpenThread={(uri) => void postNavigation.openPost(uri)} 409 - query={trimmedQuery()} 410 411 searching={isSearching()} 411 412 searchingState={activeSearchState()} 412 413 onLoadMore={() => void (isSearching() ··· 525 526 browsingState: TabState; 526 527 onOpenThread: (uri: string) => void; 527 528 onLoadMore: () => void; 528 - query: string; 529 529 searching: boolean; 530 530 searchingState: SearchTabState; 531 531 }, ··· 538 538 browsingState={props.browsingState} 539 539 onOpenThread={props.onOpenThread} 540 540 onLoadMore={props.onLoadMore} 541 - query={props.query} 542 541 searching={props.searching} 543 542 searchingState={props.searchingState} 544 543 source={props.activeTab} /> ··· 548 547 browsingState={props.browsingState} 549 548 onOpenThread={props.onOpenThread} 550 549 onLoadMore={props.onLoadMore} 551 - query={props.query} 552 550 searching={props.searching} 553 551 searchingState={props.searchingState} 554 552 source={props.activeTab} /> ··· 563 561 browsingState: TabState; 564 562 onOpenThread: (uri: string) => void; 565 563 onLoadMore: () => void; 566 - query: string; 567 564 searching: boolean; 568 565 searchingState: SearchTabState; 569 566 source: TabKey; ··· 609 606 </Match> 610 607 <Match when={activeState().items.length > 0}> 611 608 <div class="grid gap-3"> 612 - <LocalPostResultsList onOpenThread={props.onOpenThread} query={props.query} results={activeState().items} /> 609 + <SavedPostsResultsList onOpenThread={props.onOpenThread} results={activeState().items} /> 613 610 <LoadMoreButton 614 611 next={activeState().nextOffset} 615 612 onLoadMore={props.onLoadMore} ··· 620 617 </Motion.div> 621 618 ); 622 619 } 620 + 621 + function SavedPostsResultsList(props: { onOpenThread: (uri: string) => void; results: LocalPostResult[] }) { 622 + return ( 623 + <Motion.div 624 + class="grid gap-2" 625 + initial={{ opacity: 0 }} 626 + animate={{ opacity: 1 }} 627 + exit={{ opacity: 0 }} 628 + transition={{ duration: 0.15 }}> 629 + <For each={props.results}> 630 + {(result, index) => ( 631 + <Motion.div 632 + initial={{ opacity: 0, y: -6 }} 633 + animate={{ opacity: 1, y: 0 }} 634 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }}> 635 + <PostCard 636 + post={toSavedPost(result)} 637 + showActions={false} 638 + onOpenThread={() => props.onOpenThread(result.uri)} /> 639 + </Motion.div> 640 + )} 641 + </For> 642 + </Motion.div> 643 + ); 644 + } 645 + 646 + function toSavedPost(result: LocalPostResult): PostView { 647 + const handle = result.authorHandle?.trim() || result.authorDid; 648 + const createdAt = result.createdAt ?? ""; 649 + 650 + return { 651 + author: { did: result.authorDid, handle, displayName: handle }, 652 + cid: result.cid, 653 + indexedAt: createdAt, 654 + record: { createdAt, text: result.text ?? "" }, 655 + uri: result.uri, 656 + }; 657 + }
+2 -2
src/components/saved/tests/SavedPostsPanel.test.tsx
··· 160 160 await Promise.resolve(); 161 161 162 162 expect(listSavedPostsMock).toHaveBeenNthCalledWith(2, "bookmark", 50, 0, "rust"); 163 - expect(screen.getByText((_, element) => element?.textContent === "rust archive")).toBeInTheDocument(); 163 + await waitFor(() => expect(screen.getAllByText("rust archive").length).toBeGreaterThan(0)); 164 164 165 165 fireEvent.click(screen.getByRole("button", { name: /liked/i })); 166 166 await Promise.resolve(); 167 167 await Promise.resolve(); 168 168 169 169 expect(listSavedPostsMock).toHaveBeenNthCalledWith(3, "like", 50, 0, "rust"); 170 - expect(screen.getByText((_, element) => element?.textContent === "rust like")).toBeInTheDocument(); 170 + await waitFor(() => expect(screen.getAllByText("rust like").length).toBeGreaterThan(0)); 171 171 }, 5000); 172 172 173 173 it("patches mounted bookmark results when a bookmark is removed elsewhere", async () => {
+98
src/components/settings/SettingsHelp.tsx
··· 1 + import { For } from "solid-js"; 2 + import { SettingsCard } from "./SettingsCard"; 3 + 4 + type ShortcutEntry = { action: string; keys: string }; 5 + 6 + type ShortcutGroup = { entries: ShortcutEntry[]; title: string }; 7 + 8 + const KEYBOARD_GROUPS: ShortcutGroup[] = [{ 9 + title: "Global", 10 + entries: [{ action: "Open settings (outside text inputs)", keys: "," }, { 11 + action: "Open composer from anywhere", 12 + keys: "Ctrl+Shift+N", 13 + }], 14 + }, { 15 + title: "Feed & Composer", 16 + entries: [ 17 + { action: "Switch pinned feeds", keys: "1-9" }, 18 + { action: "Move focused post", keys: "j / k" }, 19 + { action: "Like focused post", keys: "l" }, 20 + { action: "Reply to focused post", keys: "r" }, 21 + { action: "Repost focused post", keys: "t" }, 22 + { action: "Open focused thread", keys: "o / Enter" }, 23 + { action: "Open composer", keys: "n" }, 24 + { action: "Save draft (composer open)", keys: "Ctrl/Cmd+S" }, 25 + { action: "Open drafts list", keys: "Ctrl/Cmd+D" }, 26 + ], 27 + }, { 28 + title: "Search", 29 + entries: [{ action: "Focus search input", keys: "/ or Ctrl/Cmd+F" }, { 30 + action: "Cycle post search modes", 31 + keys: "Tab", 32 + }, { action: "Clear query / close profile suggestions", keys: "Escape" }], 33 + }, { 34 + title: "Deck & Diagnostics", 35 + entries: [ 36 + { action: "Add deck column", keys: "Ctrl/Cmd+Shift+N" }, 37 + { action: "Close last deck column", keys: "Ctrl/Cmd+Shift+W" }, 38 + { action: "Switch diagnostics tabs", keys: "1-5" }, 39 + { action: "Close diagnostics view", keys: "Escape" }, 40 + ], 41 + }, { 42 + title: "Explorer", 43 + entries: [{ action: "Focus explorer input", keys: "Ctrl/Cmd+L" }, { 44 + action: "Navigate up one level", 45 + keys: "Backspace", 46 + }, { action: "Back / forward", keys: "Ctrl/Cmd+[ or Ctrl/Cmd+]" }], 47 + }, { 48 + title: "Messaging & Overlays", 49 + entries: [{ action: "Send message", keys: "Enter" }, { 50 + action: "Insert newline in message composer", 51 + keys: "Shift+Enter", 52 + }, { action: "Close thread drawer, image gallery, and menus", keys: "Escape" }], 53 + }]; 54 + 55 + const CLICK_GROUPS: ShortcutGroup[] = [{ 56 + title: "Core Click Combos", 57 + entries: [ 58 + { action: "Open repost menu (choose repost or quote)", keys: "Click Repost" }, 59 + { action: "Toggle repost directly", keys: "Shift+Click Repost" }, 60 + { action: "Open likes engagement list", keys: "Shift+Click Like" }, 61 + { action: "Open quotes engagement list", keys: "Shift+Click Quote" }, 62 + { action: "Open post actions menu", keys: "Right-click post" }, 63 + { action: "Open thread", keys: "Click post body" }, 64 + ], 65 + }]; 66 + 67 + export function SettingsHelp() { 68 + return ( 69 + <SettingsCard icon="info" title="Help"> 70 + <div class="grid gap-5"> 71 + <For each={KEYBOARD_GROUPS}>{(group) => <ShortcutGroupBlock group={group} />}</For> 72 + <For each={CLICK_GROUPS}>{(group) => <ShortcutGroupBlock group={group} />}</For> 73 + </div> 74 + </SettingsCard> 75 + ); 76 + } 77 + 78 + function ShortcutGroupBlock(props: { group: ShortcutGroup }) { 79 + return ( 80 + <section class="grid gap-2"> 81 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.group.title}</p> 82 + <div class="grid gap-2"> 83 + <For each={props.group.entries}>{(entry) => <ShortcutLine action={entry.action} keys={entry.keys} />}</For> 84 + </div> 85 + </section> 86 + ); 87 + } 88 + 89 + function ShortcutLine(props: ShortcutEntry) { 90 + return ( 91 + <div class="tone-muted flex items-center justify-between gap-3 rounded-2xl px-3 py-2.5 shadow-(--inset-shadow)"> 92 + <span class="text-sm text-on-surface">{props.action}</span> 93 + <kbd class="ui-input-strong rounded-full px-2 py-1 text-[0.68rem] uppercase tracking-[0.08em] text-primary"> 94 + {props.keys} 95 + </kbd> 96 + </div> 97 + ); 98 + }
+2
src/components/settings/SettingsPanel.tsx
··· 23 23 import { SettingsDangerZone } from "./SettingsDangerZone"; 24 24 import { SettingsData } from "./SettingsData"; 25 25 import { SettingsDownloads } from "./SettingsDownloads"; 26 + import { SettingsHelp } from "./SettingsHelp"; 26 27 import { SettingsLogs } from "./SettingsLogs"; 27 28 import { SettingsModeration } from "./SettingsModeration"; 28 29 import { NotificationsControl } from "./SettingsNotification"; ··· 305 306 logs={panel.logs} 306 307 loadLogs={loadLogs} 307 308 expand={(expanded) => setPanel("logsExpanded", expanded)} /> 309 + <SettingsHelp /> 308 310 <SettingsAbout /> 309 311 </Motion.div> 310 312 </Presence>
+3
src/components/settings/tests/SettingsPanel.test.tsx
··· 173 173 expect(await screen.findByText("Downloads")).toBeInTheDocument(); 174 174 expect(await screen.findByText("Danger Zone")).toBeInTheDocument(); 175 175 expect(await screen.findByText("Logs")).toBeInTheDocument(); 176 + expect(await screen.findByText("Help")).toBeInTheDocument(); 176 177 expect(await screen.findByText("About")).toBeInTheDocument(); 178 + expect(await screen.findByText("Open repost menu (choose repost or quote)")).toBeInTheDocument(); 179 + expect(await screen.findByText("Shift+Click Repost")).toBeInTheDocument(); 177 180 expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(1); 178 181 }); 179 182
+36 -11
src/components/shared/QuotedPostPreview.tsx
··· 1 + import { PostRichText } from "$/components/shared/PostRichText"; 1 2 import { getDisplayName } from "$/lib/feeds"; 2 - import type { ProfileViewBasic } from "$/lib/types"; 3 + import type { ProfileViewBasic, RichTextFacet } from "$/lib/types"; 3 4 import { formatHandle } from "$/lib/utils/text"; 4 5 import { createMemo, Show } from "solid-js"; 5 6 6 - function QuotedText(props: { text: string; truncated: boolean }) { 7 + function QuotedText(props: { facets?: RichTextFacet[] | null; text: string; truncated: boolean }) { 8 + const hasFacets = createMemo(() => Array.isArray(props.facets) && props.facets.length > 0); 9 + 7 10 return ( 8 11 <Show 9 - when={props.truncated} 12 + when={hasFacets()} 10 13 fallback={ 11 - <p class="mt-2 whitespace-pre-wrap wrap-break-word text-sm leading-[1.55] text-on-secondary-container"> 12 - {props.text} 13 - </p> 14 + <Show 15 + when={props.truncated} 16 + fallback={ 17 + <p class="mt-2 whitespace-pre-wrap wrap-break-word text-sm leading-[1.55] text-on-secondary-container"> 18 + {props.text} 19 + </p> 20 + }> 21 + <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{props.text}</p> 22 + </Show> 14 23 }> 15 - <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{props.text}</p> 24 + <div class="mt-2" classList={{ "line-clamp-4": props.truncated }}> 25 + <PostRichText 26 + class="m-0 text-sm leading-[1.55] text-on-secondary-container [&_p]:text-sm [&_p]:leading-[1.55] [&_p]:text-on-secondary-container" 27 + facets={props.facets} 28 + text={props.text} /> 29 + </div> 16 30 </Show> 17 31 ); 18 32 } ··· 20 34 type QuotedPostPreviewProps = { 21 35 author: ProfileViewBasic | null; 22 36 class?: string; 37 + facets?: RichTextFacet[] | null; 23 38 href?: string | null; 24 39 onOpenPost?: () => void; 25 40 text?: unknown; ··· 48 63 rel={openInNewTab() ? "noreferrer" : undefined} 49 64 target={openInNewTab() ? "_blank" : undefined} 50 65 onClick={(event) => event.stopPropagation()}> 51 - <QuotedPreviewContent author={props.author} preview={preview()} truncated={truncated()} /> 66 + <QuotedPreviewContent 67 + author={props.author} 68 + facets={props.facets} 69 + preview={preview()} 70 + truncated={truncated()} /> 52 71 </a> 53 72 )} 54 73 </Show> ··· 60 79 event.stopPropagation(); 61 80 props.onOpenPost?.(); 62 81 }}> 63 - <QuotedPreviewContent author={props.author} preview={preview()} truncated={truncated()} /> 82 + <QuotedPreviewContent 83 + author={props.author} 84 + facets={props.facets} 85 + preview={preview()} 86 + truncated={truncated()} /> 64 87 </button> 65 88 </Show> 66 89 </div> 67 90 ); 68 91 } 69 92 70 - function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview: string; truncated: boolean }) { 93 + function QuotedPreviewContent( 94 + props: { author: ProfileViewBasic | null; facets?: RichTextFacet[] | null; preview: string; truncated: boolean }, 95 + ) { 71 96 return ( 72 97 <> 73 98 <Show when={props.author}> ··· 83 108 <Show 84 109 when={props.preview} 85 110 fallback={<p class="mt-2 text-sm leading-[1.55] text-on-surface-variant">Quoted post</p>}> 86 - {(text) => <QuotedText text={text()} truncated={props.truncated} />} 111 + {(text) => <QuotedText facets={props.facets} text={text()} truncated={props.truncated} />} 87 112 </Show> 88 113 </> 89 114 );