From 84558cd0c722de5e1bf26dfc7b64ac8c8baae99c Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 28 Jan 2026 20:01:54 -0800 Subject: [PATCH 1/4] include margin logo if uri is from margin --- src/webapp/components/MarginLogo.tsx | 22 ++++++++++++ .../cards/components/urlCard/UrlCard.tsx | 3 +- .../urlCardContent/LinkCardContent.tsx | 34 +++++++++++-------- .../urlCardContent/UrlCardContent.tsx | 7 ++-- .../CardsContainerContent.tsx | 1 + .../collectionCard/CollectionCard.tsx | 11 ++++-- .../CollectionContainer.tsx | 7 +++- .../CollectionContainerContent.tsx | 1 + .../feeds/components/feedItem/FeedItem.tsx | 1 + .../components/recentCards/RecentCards.tsx | 1 + .../notificationItem/NotificationItem.tsx | 1 + .../profileContainer/ProfileContainer.tsx | 1 + src/webapp/lib/utils/margin.ts | 9 +++++ 13 files changed, 77 insertions(+), 22 deletions(-) create mode 100644 src/webapp/components/MarginLogo.tsx create mode 100644 src/webapp/lib/utils/margin.ts diff --git a/src/webapp/components/MarginLogo.tsx b/src/webapp/components/MarginLogo.tsx new file mode 100644 index 000000000..70cbe078d --- /dev/null +++ b/src/webapp/components/MarginLogo.tsx @@ -0,0 +1,22 @@ +import { Box } from '@mantine/core'; + +interface Props { + size?: number; +} + +export default function MarginLogo({ size = 16 }: Props) { + return ( + + + + + ); +} diff --git a/src/webapp/features/cards/components/urlCard/UrlCard.tsx b/src/webapp/features/cards/components/urlCard/UrlCard.tsx index 24dd49287..72dd7c4ae 100644 --- a/src/webapp/features/cards/components/urlCard/UrlCard.tsx +++ b/src/webapp/features/cards/components/urlCard/UrlCard.tsx @@ -14,6 +14,7 @@ import UrlCardDebugView from '../UrlCardDebugView/UrlCardDebugView'; interface Props { id: string; url: string; + uri?: string; cardContent: UrlCard['cardContent']; note?: UrlCard['note']; currentCollection?: Collection; @@ -65,7 +66,7 @@ export default function UrlCard(props: Props) { onAuxClick={handleAuxClick} > - + {settings.tinkerMode && ( - - e.stopPropagation()} - component={Link} - href={props.cardContent.url} - target="_blank" - c={'gray'} - lineClamp={1} - w={'fit-content'} - fz={'sm'} - > - {domain} - - + + + e.stopPropagation()} + component={Link} + href={props.cardContent.url} + target="_blank" + c={'gray'} + lineClamp={1} + w={'fit-content'} + fz={'sm'} + > + {domain} + + + {isMarginUri(props.uri) && } + {props.cardContent.title && ( {props.cardContent.title} diff --git a/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx b/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx index 545eacad1..9dbad383d 100644 --- a/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx +++ b/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx @@ -14,6 +14,7 @@ import { useUserSettings } from '@/features/settings/lib/queries/useUserSettings interface Props { url: string; + uri?: string; cardContent: UrlCard['cardContent']; } @@ -31,13 +32,13 @@ export default function UrlCardContent(props: Props) { ) { return ( } + fallback={} > }> + } /> @@ -59,5 +60,5 @@ export default function UrlCardContent(props: Props) { return ; } - return ; + return ; } diff --git a/src/webapp/features/cards/containers/cardsContainerContent/CardsContainerContent.tsx b/src/webapp/features/cards/containers/cardsContainerContent/CardsContainerContent.tsx index 1ac9ddcf1..725135521 100644 --- a/src/webapp/features/cards/containers/cardsContainerContent/CardsContainerContent.tsx +++ b/src/webapp/features/cards/containers/cardsContainerContent/CardsContainerContent.tsx @@ -89,6 +89,7 @@ export default function CardsContainerContent(props: Props) { - - {collection.name} - + + + {collection.name} + + {isMarginUri(collection.uri) && } + {props.showAuthor && ( Collection - {firstPage.name} + + {firstPage.name} + {isMarginUri(firstPage.uri) && } + {firstPage.description && ( {firstPage.description} diff --git a/src/webapp/features/collections/containers/collectionContainerContent/CollectionContainerContent.tsx b/src/webapp/features/collections/containers/collectionContainerContent/CollectionContainerContent.tsx index 21b372493..341656941 100644 --- a/src/webapp/features/collections/containers/collectionContainerContent/CollectionContainerContent.tsx +++ b/src/webapp/features/collections/containers/collectionContainerContent/CollectionContainerContent.tsx @@ -74,6 +74,7 @@ export default function CollectionContainerContent(props: Props) { Date: Wed, 28 Jan 2026 20:02:45 -0800 Subject: [PATCH 2/4] make margin logo clickable to margin link --- src/webapp/components/MarginLogo.tsx | 30 +++++++++++++++++-- .../cards/components/urlCard/UrlCard.tsx | 7 ++++- .../urlCardContent/LinkCardContent.tsx | 6 ++-- .../urlCardContent/UrlCardContent.tsx | 23 ++++++++++++-- .../collectionCard/CollectionCard.tsx | 7 +++-- .../CollectionContainer.tsx | 7 +++-- src/webapp/lib/utils/margin.ts | 25 ++++++++++++++++ 7 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/webapp/components/MarginLogo.tsx b/src/webapp/components/MarginLogo.tsx index 70cbe078d..510d7df27 100644 --- a/src/webapp/components/MarginLogo.tsx +++ b/src/webapp/components/MarginLogo.tsx @@ -1,11 +1,17 @@ -import { Box } from '@mantine/core'; +import { Anchor, Box, Tooltip } from '@mantine/core'; +import { MouseEvent } from 'react'; interface Props { size?: number; + marginUrl?: string | null; } -export default function MarginLogo({ size = 16 }: Props) { - return ( +export default function MarginLogo({ size = 16, marginUrl }: Props) { + const handleClick = (e: MouseEvent) => { + e.stopPropagation(); + }; + + const logo = ( ); + + if (!marginUrl) { + return logo; + } + + return ( + + + {logo} + + + ); } diff --git a/src/webapp/features/cards/components/urlCard/UrlCard.tsx b/src/webapp/features/cards/components/urlCard/UrlCard.tsx index 72dd7c4ae..912b84d74 100644 --- a/src/webapp/features/cards/components/urlCard/UrlCard.tsx +++ b/src/webapp/features/cards/components/urlCard/UrlCard.tsx @@ -66,7 +66,12 @@ export default function UrlCard(props: Props) { onAuxClick={handleAuxClick} > - + {settings.tinkerMode && ( @@ -45,7 +47,7 @@ export default function LinkCardContent(props: Props) { {domain} - {isMarginUri(props.uri) && } + {isMarginUri(props.uri) && } {props.cardContent.title && ( diff --git a/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx b/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx index 9dbad383d..2eedee41b 100644 --- a/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx +++ b/src/webapp/features/cards/components/urlCardContent/UrlCardContent.tsx @@ -16,6 +16,7 @@ interface Props { url: string; uri?: string; cardContent: UrlCard['cardContent']; + authorHandle?: string; } export default function UrlCardContent(props: Props) { @@ -32,13 +33,23 @@ export default function UrlCardContent(props: Props) { ) { return ( } + fallback={ + + } > }> + } /> @@ -60,5 +71,11 @@ export default function UrlCardContent(props: Props) { return ; } - return ; + return ( + + ); } diff --git a/src/webapp/features/collections/components/collectionCard/CollectionCard.tsx b/src/webapp/features/collections/components/collectionCard/CollectionCard.tsx index b981294eb..13d65b8f5 100644 --- a/src/webapp/features/collections/components/collectionCard/CollectionCard.tsx +++ b/src/webapp/features/collections/components/collectionCard/CollectionCard.tsx @@ -13,7 +13,7 @@ import { useUserSettings } from '@/features/settings/lib/queries/useUserSettings import CollectionCardDebugView from '../collectionCardDebugView/CollectionCardDebugView'; import { useRouter } from 'next/navigation'; import { MouseEvent } from 'react'; -import { isMarginUri } from '@/lib/utils/margin'; +import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; import MarginLogo from '@/components/MarginLogo'; interface Props { @@ -30,6 +30,7 @@ export default function CollectionCard(props: Props) { time === 'just now' ? `Updated ${time}` : `Updated ${time} ago`; const { settings } = useUserSettings(); const router = useRouter(); + const marginUrl = getMarginUrl(collection.uri, collection.author.handle); const handleNavigateToCollection = (e: MouseEvent) => { e.stopPropagation(); @@ -70,7 +71,9 @@ export default function CollectionCard(props: Props) { {collection.name} - {isMarginUri(collection.uri) && } + {isMarginUri(collection.uri) && ( + + )} {props.showAuthor && ( 0; const isAuthor = user?.handle === firstPage?.author.handle; + const marginUrl = getMarginUrl(firstPage?.uri, firstPage?.author.handle); // Create share URL for Bluesky intent const currentUrl = typeof window !== 'undefined' ? window.location.href : ''; @@ -79,7 +80,9 @@ export default function CollectionContainer(props: Props) { {firstPage.name} - {isMarginUri(firstPage.uri) && } + {isMarginUri(firstPage.uri) && ( + + )} {firstPage.description && ( diff --git a/src/webapp/lib/utils/margin.ts b/src/webapp/lib/utils/margin.ts index 64c14e2c8..6616dacf9 100644 --- a/src/webapp/lib/utils/margin.ts +++ b/src/webapp/lib/utils/margin.ts @@ -7,3 +7,28 @@ export function isMarginUri(uri?: string): boolean { if (!uri) return false; return uri.includes('/at.margin.'); } + +/** + * Extract Margin URL from an AT Protocol URI + * @param uri - The AT Protocol URI (e.g., "at://did:plc:xyz/at.margin.bookmark/3mdjtvntgej2v") + * @param handle - The user's handle (e.g., "alice.bsky.social") + * @returns The Margin URL or null if not a valid Margin URI + * @example + * getMarginUrl("at://did:plc:xyz/at.margin.bookmark/3mdjtvntgej2v", "alice.bsky.social") + * // returns "https://margin.at/alice.bsky.social/bookmark/3mdjtvntgej2v" + */ +export function getMarginUrl(uri?: string, handle?: string): string | null { + if (!uri || !handle || !isMarginUri(uri)) return null; + + // URI format: at://did:plc:xyz/at.margin.{collection|bookmark}/{rkey} + const parts = uri.split('/'); + if (parts.length < 4) return null; + + const collection = parts[parts.length - 2]; // "at.margin.bookmark" or "at.margin.collection" + const rkey = parts[parts.length - 1]; // "3mdjtvntgej2v" + + // Extract the type from collection name + const type = collection.replace('at.margin.', ''); // "bookmark" or "collection" + + return `https://margin.at/${handle}/${type}/${rkey}`; +} From 9917e31ed38b91e1b390b0b055ae9e9d8b73ab35 Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 28 Jan 2026 21:10:21 -0800 Subject: [PATCH 3/4] show margin logo in more places --- src/webapp/components/CollectionSelector.tsx | 124 +++++++++++------- .../addCardDrawer/AddCardDrawer.tsx | 59 +++++---- .../urlCardContent/LinkCardContent.tsx | 4 +- .../CollectionSelectorItem.tsx | 23 +++- .../CollectionSelectorItemList.tsx | 8 ++ .../features/collections/types/index.ts | 4 + 6 files changed, 143 insertions(+), 79 deletions(-) diff --git a/src/webapp/components/CollectionSelector.tsx b/src/webapp/components/CollectionSelector.tsx index 387c23696..611897ce3 100644 --- a/src/webapp/components/CollectionSelector.tsx +++ b/src/webapp/components/CollectionSelector.tsx @@ -13,6 +13,8 @@ import { import { ApiClient, Collection } from '@/api-client'; import { useCollectionSearch } from '@/hooks/useCollectionSearch'; import { CreateCollectionModal } from './CreateCollectionModal'; +import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; +import MarginLogo from '@/components/MarginLogo'; interface LocalCollection extends Collection { authorId: string; // Extended for local component use @@ -105,11 +107,22 @@ export function CollectionSelector({ {existingCollections.length !== 1 && 's'}: - {existingCollections.map((collection) => ( - - {collection.name} - - ))} + {existingCollections.map((collection) => { + const marginUrl = getMarginUrl( + collection.uri, + collection.author?.handle, + ); + return ( + + + {collection.name} + {isMarginUri(collection.uri) && ( + + )} + + + ); + })} )} @@ -163,52 +176,61 @@ export function CollectionSelector({ )} - {availableCollections.map((collection, index) => ( - handleCollectionToggle(collection.id)} - > - - - - - {collection.name} - - - {collection.cardCount} cards - - - {collection.description && ( - - {collection.description} - - )} - - handleCollectionToggle(collection.id)} - disabled={disabled} - onClick={(e) => e.stopPropagation()} - size="sm" - /> - - - ))} + {availableCollections.map((collection, index) => { + const marginUrl = getMarginUrl( + collection.uri, + collection.author?.handle, + ); + return ( + handleCollectionToggle(collection.id)} + > + + + + + {collection.name} + + {isMarginUri(collection.uri) && ( + + )} + + {collection.cardCount} cards + + + {collection.description && ( + + {collection.description} + + )} + + handleCollectionToggle(collection.id)} + disabled={disabled} + onClick={(e) => e.stopPropagation()} + size="sm" + /> + + + ); + })} ) : searchText.trim() ? ( diff --git a/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx b/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx index 3c112c68e..bc29a0538 100644 --- a/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx +++ b/src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx @@ -21,6 +21,8 @@ import { IoMdLink } from 'react-icons/io'; import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; import { track } from '@vercel/analytics'; import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; +import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; +import MarginLogo from '@/components/MarginLogo'; interface Props { isOpen: boolean; @@ -152,29 +154,40 @@ export default function AddCardDrawer(props: Props) { : 'Manage/View all'} - {myCollections.map((col) => ( - - ))} + {myCollections.map((col) => { + const marginUrl = getMarginUrl( + col.uri, + col.author?.handle, + ); + return ( + + ); + })} diff --git a/src/webapp/features/cards/components/urlCardContent/LinkCardContent.tsx b/src/webapp/features/cards/components/urlCardContent/LinkCardContent.tsx index 1995566c6..093d88d34 100644 --- a/src/webapp/features/cards/components/urlCardContent/LinkCardContent.tsx +++ b/src/webapp/features/cards/components/urlCardContent/LinkCardContent.tsx @@ -47,7 +47,9 @@ export default function LinkCardContent(props: Props) { {domain} - {isMarginUri(props.uri) && } + {isMarginUri(props.uri) && ( + + )} {props.cardContent.title && ( diff --git a/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx b/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx index f67e28983..61ffd5a8c 100644 --- a/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx +++ b/src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx @@ -6,6 +6,8 @@ import { Tooltip, } from '@mantine/core'; import classes from './CollectionSelectorItem.module.css'; +import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; +import MarginLogo from '@/components/MarginLogo'; interface Props { value: string; @@ -14,9 +16,13 @@ interface Props { cardCount: number; onChange: (checked: boolean, item: SelectableCollectionItem) => void; disabled?: boolean; + uri?: string; + authorHandle?: string; } export default function CollectionSelectorItem(props: Props) { + const marginUrl = getMarginUrl(props.uri, props.authorHandle); + return ( - - {props.name} {'·'} {props.cardCount}{' '} - {props.cardCount === 1 ? 'card' : 'cards'} - + + + {props.name} {'·'} {props.cardCount}{' '} + {props.cardCount === 1 ? 'card' : 'cards'} + + {isMarginUri(props.uri) && ( + + )} + props.onChange(checked, c)} disabled={isDisabled} + uri={c.uri} + authorHandle={c.author?.handle} /> ); })} @@ -51,6 +57,8 @@ export default function CollectionSelectorItemList(props: Props) { value={c.id} checked={!!props.selectedCollections.find((col) => col.id === c.id)} onChange={(checked) => props.onChange(checked, c)} + uri={c.uri} + authorHandle={c.author?.handle} /> ))} diff --git a/src/webapp/features/collections/types/index.ts b/src/webapp/features/collections/types/index.ts index 65ae958a6..e76c48dcc 100644 --- a/src/webapp/features/collections/types/index.ts +++ b/src/webapp/features/collections/types/index.ts @@ -2,4 +2,8 @@ interface SelectableCollectionItem { id: string; name: string; cardCount: number; + uri?: string; + author?: { + handle: string; + }; } From 413771da64b7ecff28b5bf991b05681231ef916d Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Thu, 29 Jan 2026 11:01:08 -0800 Subject: [PATCH 4/4] include source filtering on feed page --- src/webapp/api-client/clients/FeedClient.ts | 2 + .../components/feedControls/FeedControls.tsx | 80 ++++++++++++++++++- .../gemsFeedContainer/GemsFeedContainer.tsx | 5 +- .../myFeedContainer/MyFeedContainer.tsx | 5 +- src/webapp/features/feeds/lib/dal.ts | 5 +- src/webapp/features/feeds/lib/feedKeys.ts | 16 ++-- .../feeds/lib/queries/useGemsFeed.tsx | 6 +- .../feeds/lib/queries/useGlobalFeed.tsx | 12 ++- 8 files changed, 112 insertions(+), 19 deletions(-) diff --git a/src/webapp/api-client/clients/FeedClient.ts b/src/webapp/api-client/clients/FeedClient.ts index 865d2c358..ac02b01e0 100644 --- a/src/webapp/api-client/clients/FeedClient.ts +++ b/src/webapp/api-client/clients/FeedClient.ts @@ -15,6 +15,7 @@ export class FeedClient extends BaseClient { if (params?.beforeActivityId) searchParams.set('beforeActivityId', params.beforeActivityId); if (params?.urlType) searchParams.set('urlType', params.urlType); + if (params?.source) searchParams.set('source', params.source); const queryString = searchParams.toString(); const endpoint = queryString @@ -31,6 +32,7 @@ export class FeedClient extends BaseClient { if (params?.page) searchParams.set('page', params.page.toString()); if (params?.limit) searchParams.set('limit', params.limit.toString()); if (params?.urlType) searchParams.set('urlType', params.urlType); + if (params?.source) searchParams.set('source', params.source); const queryString = searchParams.toString(); const endpoint = queryString diff --git a/src/webapp/features/feeds/components/feedControls/FeedControls.tsx b/src/webapp/features/feeds/components/feedControls/FeedControls.tsx index 09592dae3..b5efffac6 100644 --- a/src/webapp/features/feeds/components/feedControls/FeedControls.tsx +++ b/src/webapp/features/feeds/components/feedControls/FeedControls.tsx @@ -8,9 +8,11 @@ import { Group, } from '@mantine/core'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { BiCollection } from 'react-icons/bi'; import FeedFilters from '../feedFilters/FeedFilters'; +import { ActivitySource } from '@semble/types'; +import { useOptimistic, useTransition } from 'react'; const options = [ { value: 'explore', label: 'Latest', href: '/explore' }, @@ -21,19 +23,56 @@ const options = [ }, ]; +const sourceOptions = [ + { value: null, label: 'All Sources' }, + { value: ActivitySource.SEMBLE, label: 'Semble' }, + { value: ActivitySource.MARGIN, label: 'Margin' }, +]; + export default function FeedControls() { const pathname = usePathname(); const router = useRouter(); + const searchParams = useSearchParams(); const segment = pathname.split('/')[2]; const currentValue = segment || 'explore'; const isGemsFeed = currentValue === 'gems-of-2025'; + const sourceFromUrl = searchParams.get('source') as ActivitySource | null; + + const [optimisticSource, setOptimisticSource] = + useOptimistic(sourceFromUrl); + + const [, startTransition] = useTransition(); + const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption(), }); + const sourceCombobox = useCombobox({ + onDropdownClose: () => sourceCombobox.resetSelectedOption(), + }); + const selected = options.find((o) => o.value === currentValue); + const selectedSource = + sourceOptions.find((o) => o.value === optimisticSource) || sourceOptions[0]; + + const handleSourceClick = (source: ActivitySource | null) => { + startTransition(() => { + setOptimisticSource(source); + + const params = new URLSearchParams(searchParams.toString()); + if (source) { + params.set('source', source); + } else { + params.delete('source'); + } + + router.push(`?${params.toString()}`, { scroll: false }); + }); + + sourceCombobox.closeDropdown(); + }; return ( @@ -75,6 +114,45 @@ export default function FeedControls() { + + { + const option = sourceOptions.find( + (o) => String(o.value) === value, + ); + if (option) { + handleSourceClick(option.value); + } + }} + width={150} + > + + + + + + + {sourceOptions.map((option) => ( + + {option.label} + + ))} + + + + {isGemsFeed && (