From 6c3fdbb3e756e9dcd2726bd278bc990c2d786765 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:45:05 +0300 Subject: [PATCH 01/11] feat(squads): redesign the squad page and add Verified Company Squad features Replaces the legacy squad page for every squad with the designer's profile-style direction (storybook squad-page, 1. Direction), with no feature flag, and deletes the legacy components. - Header with cover, round logo, stats and one action row; right column that becomes an About tab below laptop and stays in the DOM for SEO; composer, pinned posts, infinite feed, role-specific empty states, private wall, View as a visitor and the share card. - Members, Pending posts and a Manage area for staff. Legacy /edit, /analytics and /moderate URLs redirect permanently. - Paid surfaces, each rendered only when the squad's feature is on: verified badge and card, Links widget (dofollow) and website, products. - Ad-free squads get no ads or brand sponsorships on their feed, search results and posts (page, modal, focus and reader). - Spotlight can be scoped to a squad, with scoped type-ahead suggestions. - Feed.tsx compared against the squads directory feed name, so the squad feed ad cadence never applied. Spec: docs/verified-company-squad.md. Requires dailydotdev/daily-api#4314. --- docs/verified-company-squad.md | 157 ++++ packages/shared/src/components/Feed.spec.tsx | 4 +- packages/shared/src/components/Feed.tsx | 2 +- .../brand/adFreeSponsorships.spec.tsx | 129 ++++ .../components/cards/squad/SquadGrid.spec.tsx | 43 +- .../src/components/comments/AdAsComment.tsx | 12 +- .../src/components/errors/SquadLoading.tsx | 130 ---- .../components/layout/DefaultSquadHeader.tsx | 29 - .../src/components/modals/BasePostModal.tsx | 5 +- .../components/modals/SquadMemberModal.tsx | 179 ----- .../shared/src/components/modals/common.tsx | 19 - .../src/components/modals/common/types.ts | 3 - .../modals/squads/PrivilegedMembersModal.tsx | 31 - .../modals/squads/TopMembersModal.tsx | 25 - .../src/components/post/PostEngagements.tsx | 4 +- .../components/post/PostSidebarAdWidget.tsx | 11 +- .../src/components/post/PostWidgets.tsx | 1 + .../src/components/post/SquadPostWidgets.tsx | 1 + .../post/collection/CollectionPostWidgets.tsx | 1 + .../post/focus/PostDiscussionPanel.tsx | 4 +- .../components/post/focus/PostFocusCard.tsx | 6 +- .../post/read/PhoneTopAdStrip.spec.tsx | 27 +- .../components/post/read/PhoneTopAdStrip.tsx | 11 +- .../components/post/read/useReadAdSlots.ts | 27 +- .../components/post/reader/EngagementRail.tsx | 1 + .../components/spotlight/ScopeBreadcrumbs.tsx | 45 +- .../components/spotlight/ScopeFilterPill.tsx | 80 ++- .../components/spotlight/Spotlight.spec.tsx | 265 +++++++ .../src/components/spotlight/Spotlight.tsx | 257 ++++++- .../components/spotlight/SpotlightContext.tsx | 119 ++++ .../components/spotlight/commands/search.ts | 49 +- .../spotlight/commands/sourcePosts.ts | 89 +++ .../shared/src/components/spotlight/types.ts | 8 + .../shared/src/components/squads/Details.tsx | 157 +--- .../squads/Members/PrivilegedMemberItem.tsx | 41 -- .../src/components/squads/SharePostBar.tsx | 163 ----- .../components/squads/SquadActionButton.tsx | 5 +- .../components/squads/SquadFeedHeading.tsx | 85 --- .../components/squads/SquadHeaderBar.spec.tsx | 148 ---- .../src/components/squads/SquadHeaderBar.tsx | 299 -------- .../squads/SquadMemberShortList.tsx | 26 +- .../src/components/squads/SquadPageHeader.tsx | 312 -------- .../src/components/squads/SquadTabs.tsx | 64 -- .../squads/common/SquadPrivacyState.tsx | 48 -- .../components/squads/common/SquadStat.tsx | 31 - .../moderation/SquadModerationEmptyScreen.tsx | 2 +- .../squads/moderation/SquadModerationItem.tsx | 26 +- .../squads/moderation/SquadModerationList.tsx | 6 +- .../moderation/useSourceModerationItem.ts | 9 +- .../squads/settings/SquadDangerZone.tsx | 30 +- .../SquadModerationSettingsSection.tsx | 149 ++-- .../components/squads/stack/SquadStack.tsx | 24 +- .../src/contexts/EngagementAdsContext.tsx | 15 +- .../src/features/squads/SquadPageContext.tsx | 81 +++ .../features/squads/components/SquadHome.tsx | 165 +++++ .../squads/components/SquadMembersList.tsx | 197 ++++++ .../squads/components/SquadPageLayout.tsx | 86 +++ .../squads/components/SquadPageSkeleton.tsx | 26 + .../squads/components/SquadPrivateWall.tsx | 31 + .../squads/components/SquadSubPageHeader.tsx | 60 ++ .../squads/components/SquadSubPages.tsx | 91 +++ .../squads/components/VerifiedSquad.tsx | 72 ++ .../squads/components/feed/SquadComposer.tsx | 163 +++++ .../squads/components/feed/SquadEmptyFeed.tsx | 32 + .../components/feed/SquadPinnedPosts.tsx | 148 ++++ .../components/feed/SquadSearchHeader.tsx | 59 ++ .../squads/components/header/SquadActions.tsx | 206 ++++++ .../components/header/SquadOptionsMenu.tsx | 289 ++++++++ .../components/header/SquadProfileHeader.tsx | 251 +++++++ .../manage/SquadManageAnalytics.tsx | 123 ++++ .../components/manage/SquadManageDetails.tsx | 204 ++++++ .../manage/SquadManageIntegrations.tsx | 79 +++ .../components/manage/SquadManageLayout.tsx | 279 ++++++++ .../components/manage/SquadManageLinks.tsx | 201 ++++++ .../components/manage/SquadManagePage.tsx | 135 ++++ .../components/manage/SquadManagePosting.tsx | 58 ++ .../components/manage/SquadManageProducts.tsx | 209 ++++++ .../components/products/SquadProductForm.tsx | 349 +++++++++ .../components/products/SquadProductLogo.tsx | 20 + .../components/products/SquadProductsList.tsx | 104 +++ .../products/SquadProductsShelf.tsx | 90 +++ .../widgets/SquadAnalyticsWidget.tsx | 101 +++ .../components/widgets/SquadLinksWidget.tsx | 68 ++ .../components/widgets/SquadPreview.tsx | 78 ++ .../components/widgets/SquadShareWidget.tsx | 152 ++++ .../components/widgets/SquadTeamWidget.tsx | 79 +++ .../squads/components/widgets/SquadWidget.tsx | 37 + .../components/widgets/SquadWidgets.tsx | 30 + .../src/features/squads/hooks/useEditSquad.ts | 67 ++ .../squads/hooks/useSquadAnalytics.ts | 85 +++ .../squads/hooks/useSquadProductMutations.ts | 94 +++ .../features/squads/hooks/useSquadProducts.ts | 26 + .../squads/hooks/useUpdateSquadLinks.ts | 28 + .../src/features/squads/lib/features.ts | 18 + .../shared/src/features/squads/lib/limits.ts | 8 + .../src/features/squads/lib/links.spec.ts | 28 + .../shared/src/features/squads/lib/links.ts | 82 +++ .../shared/src/features/squads/lib/manage.tsx | 123 ++++ .../shared/src/features/squads/lib/routes.ts | 48 ++ .../src/features/squads/lib/spotlight.ts | 11 + .../src/features/squads/lib/viewer.spec.ts | 88 +++ .../shared/src/features/squads/lib/viewer.ts | 87 +++ packages/shared/src/graphql/fragments.ts | 6 + packages/shared/src/graphql/search.ts | 84 ++- packages/shared/src/graphql/sources.ts | 12 + packages/shared/src/graphql/squadProducts.ts | 178 +++++ packages/shared/src/graphql/squads.ts | 87 ++- .../src/hooks/search/useSearchProvider.ts | 4 + .../search/useSearchProviderSuggestions.ts | 3 + .../shared/src/hooks/useSquadNavigation.ts | 6 +- packages/shared/src/lib/ads.ts | 10 + packages/shared/src/lib/log.ts | 3 + packages/shared/src/styles/custom.ts | 11 + .../webapp/__tests__/GatedPagesSeo.spec.ts | 12 +- packages/webapp/__tests__/PostPage.tsx | 91 +++ packages/webapp/__tests__/ReadArticlePage.tsx | 30 + packages/webapp/__tests__/SquadFeedPage.tsx | 669 ------------------ .../__tests__/SquadLegacyRedirects.spec.ts | 36 + packages/webapp/__tests__/SquadPage.tsx | 558 +++++++++++++++ .../webapp/components/squads/SquadRoute.tsx | 107 +++ packages/webapp/pages/articles/[id].tsx | 29 +- packages/webapp/pages/posts/[id]/index.tsx | 36 +- .../pages/squads/[handle]/analytics.tsx | 274 +------ .../webapp/pages/squads/[handle]/edit.tsx | 140 +--- .../webapp/pages/squads/[handle]/index.tsx | 378 ++-------- .../squads/[handle]/manage/[section].tsx | 49 ++ .../pages/squads/[handle]/manage/index.tsx | 28 + .../[handle]/manage/products/[productId].tsx | 51 ++ .../squads/[handle]/manage/products/new.tsx | 29 + .../webapp/pages/squads/[handle]/members.tsx | 28 + .../webapp/pages/squads/[handle]/moderate.tsx | 29 +- .../webapp/pages/squads/[handle]/pending.tsx | 57 ++ .../pages/squads/[handle]/products/index.tsx | 28 + packages/webapp/pages/squads/moderate.tsx | 72 +- 134 files changed, 8212 insertions(+), 3483 deletions(-) create mode 100644 docs/verified-company-squad.md create mode 100644 packages/shared/src/components/brand/adFreeSponsorships.spec.tsx delete mode 100644 packages/shared/src/components/errors/SquadLoading.tsx delete mode 100644 packages/shared/src/components/layout/DefaultSquadHeader.tsx delete mode 100644 packages/shared/src/components/modals/SquadMemberModal.tsx delete mode 100644 packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx delete mode 100644 packages/shared/src/components/modals/squads/TopMembersModal.tsx create mode 100644 packages/shared/src/components/spotlight/Spotlight.spec.tsx create mode 100644 packages/shared/src/components/spotlight/commands/sourcePosts.ts delete mode 100644 packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx delete mode 100644 packages/shared/src/components/squads/SharePostBar.tsx delete mode 100644 packages/shared/src/components/squads/SquadFeedHeading.tsx delete mode 100644 packages/shared/src/components/squads/SquadHeaderBar.spec.tsx delete mode 100644 packages/shared/src/components/squads/SquadHeaderBar.tsx delete mode 100644 packages/shared/src/components/squads/SquadPageHeader.tsx delete mode 100644 packages/shared/src/components/squads/SquadTabs.tsx delete mode 100644 packages/shared/src/components/squads/common/SquadPrivacyState.tsx delete mode 100644 packages/shared/src/components/squads/common/SquadStat.tsx create mode 100644 packages/shared/src/features/squads/SquadPageContext.tsx create mode 100644 packages/shared/src/features/squads/components/SquadHome.tsx create mode 100644 packages/shared/src/features/squads/components/SquadMembersList.tsx create mode 100644 packages/shared/src/features/squads/components/SquadPageLayout.tsx create mode 100644 packages/shared/src/features/squads/components/SquadPageSkeleton.tsx create mode 100644 packages/shared/src/features/squads/components/SquadPrivateWall.tsx create mode 100644 packages/shared/src/features/squads/components/SquadSubPageHeader.tsx create mode 100644 packages/shared/src/features/squads/components/SquadSubPages.tsx create mode 100644 packages/shared/src/features/squads/components/VerifiedSquad.tsx create mode 100644 packages/shared/src/features/squads/components/feed/SquadComposer.tsx create mode 100644 packages/shared/src/features/squads/components/feed/SquadEmptyFeed.tsx create mode 100644 packages/shared/src/features/squads/components/feed/SquadPinnedPosts.tsx create mode 100644 packages/shared/src/features/squads/components/feed/SquadSearchHeader.tsx create mode 100644 packages/shared/src/features/squads/components/header/SquadActions.tsx create mode 100644 packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx create mode 100644 packages/shared/src/features/squads/components/header/SquadProfileHeader.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManageAnalytics.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManageDetails.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManageIntegrations.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManageLayout.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManageLinks.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManagePage.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManagePosting.tsx create mode 100644 packages/shared/src/features/squads/components/manage/SquadManageProducts.tsx create mode 100644 packages/shared/src/features/squads/components/products/SquadProductForm.tsx create mode 100644 packages/shared/src/features/squads/components/products/SquadProductLogo.tsx create mode 100644 packages/shared/src/features/squads/components/products/SquadProductsList.tsx create mode 100644 packages/shared/src/features/squads/components/products/SquadProductsShelf.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadAnalyticsWidget.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadLinksWidget.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadPreview.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadShareWidget.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadTeamWidget.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadWidget.tsx create mode 100644 packages/shared/src/features/squads/components/widgets/SquadWidgets.tsx create mode 100644 packages/shared/src/features/squads/hooks/useEditSquad.ts create mode 100644 packages/shared/src/features/squads/hooks/useSquadAnalytics.ts create mode 100644 packages/shared/src/features/squads/hooks/useSquadProductMutations.ts create mode 100644 packages/shared/src/features/squads/hooks/useSquadProducts.ts create mode 100644 packages/shared/src/features/squads/hooks/useUpdateSquadLinks.ts create mode 100644 packages/shared/src/features/squads/lib/features.ts create mode 100644 packages/shared/src/features/squads/lib/limits.ts create mode 100644 packages/shared/src/features/squads/lib/links.spec.ts create mode 100644 packages/shared/src/features/squads/lib/links.ts create mode 100644 packages/shared/src/features/squads/lib/manage.tsx create mode 100644 packages/shared/src/features/squads/lib/routes.ts create mode 100644 packages/shared/src/features/squads/lib/spotlight.ts create mode 100644 packages/shared/src/features/squads/lib/viewer.spec.ts create mode 100644 packages/shared/src/features/squads/lib/viewer.ts create mode 100644 packages/shared/src/graphql/squadProducts.ts delete mode 100644 packages/webapp/__tests__/SquadFeedPage.tsx create mode 100644 packages/webapp/__tests__/SquadLegacyRedirects.spec.ts create mode 100644 packages/webapp/__tests__/SquadPage.tsx create mode 100644 packages/webapp/components/squads/SquadRoute.tsx create mode 100644 packages/webapp/pages/squads/[handle]/manage/[section].tsx create mode 100644 packages/webapp/pages/squads/[handle]/manage/index.tsx create mode 100644 packages/webapp/pages/squads/[handle]/manage/products/[productId].tsx create mode 100644 packages/webapp/pages/squads/[handle]/manage/products/new.tsx create mode 100644 packages/webapp/pages/squads/[handle]/members.tsx create mode 100644 packages/webapp/pages/squads/[handle]/pending.tsx create mode 100644 packages/webapp/pages/squads/[handle]/products/index.tsx diff --git a/docs/verified-company-squad.md b/docs/verified-company-squad.md new file mode 100644 index 00000000000..04d3e8ad482 --- /dev/null +++ b/docs/verified-company-squad.md @@ -0,0 +1,157 @@ +# Verified Company Squad and the squad page redesign + +The reference for this project. Validate every PR in `apps` and `daily-api` against it, and update it when a decision changes. + +Design reference: the Storybook mock-ups in `packages/storybook/stories/squad-page` (commit `1ef074325`), stories "Squad Page / 1. Direction" and "Squad Page / 2. Use cases". The `archive/` folder and "4. Research" are superseded. The mock is a designer implementation: it is the source of truth for look and behavior, not for architecture, data or copy that this doc overrides. + +## Decisions + +- The product is called **Verified Company Squad**. Replace every "Verified Squad page" string from the mock. +- **No feature flag.** The redesign replaces the legacy squad page for every squad, and the legacy components are deleted. +- Paid features are driven by a **per-squad feature map**, set by sales operations through a private API endpoint that Smith calls. There is no self-serve purchase, no billing and no expiry. +- **Free squads show nothing of the paid features**: no placeholders, no locked states, no upsell, no Manage entries. +- **Dofollow applies only to the Links widget** (and the website in the header meta line, which is the same data). Post content, comments and product links stay `nofollow`. +- The **content feed** (a company RSS posting into its squad) is managed on the backend only: ops add the RSS to the squad through the existing Yggdrasil ingestion (a `SourceFeed` on the squad itself, not `linkedSourceIds`). No UI, no API surface, no feature key. The mock's Content feed pages, menu items and integration row are ignored. +- **Products** live on the tools catalog (`DatasetTool`): a squad's products are the tools whose `officialSourceId` is that squad. No separate product entity. +- The squad **feed is infinite scroll**. +- **Rules** ship in iteration 2. **FAQ** is dropped. +- The verified badge appears **only on the squad page** in this project. Directory cards and post headers are unchanged. +- The verified badge card is copied **as designed**, frosted glass included. + +## Paid vs free + +| Feature | Free (every squad) | Paid feature key | +|---|---|---| +| New layout: cover, round logo, meta line, stats, action row, options menu | yes | | +| Right column below laptop as an About tab | yes | | +| Composer, pinned posts, infinite feed, polls | yes | | +| Squad scoped search in Spotlight with type-ahead suggestions, results page | yes | | +| Manage area (details, members, moderation, posting and invitations, analytics, integrations, danger zone) | yes | | +| View as a visitor, share card | yes | | +| Team, Stack & Tools, Analytics widgets | yes | | +| Empty states, private squad wall, blocked state, production parity fixes | yes | | +| Verified badge next to the name and the Verified Company Squad card | | `verified` | +| No ads on the squad feed, the squad's search results, and the page or modal of any post in the squad | | `adFree` | +| Links widget and website in the meta line, dofollow; Manage > Links | | `links` | +| Products shelf, Products page, Add or edit product, Manage > Products | | `products` | + +## Data model and API contract (daily-api) + +The API ships first and is additive only, so it can deploy before `apps` merges. `apps` must not merge before the API is live in production. + +### Feature map + +- A dedicated jsonb column on `source` (not inside `flags`, which crons and CDC write counters into). Missing keys mean off. +- Keys today: `verified`, `adFree`, `links`, `products`. Tiers can come later as named presets that resolve to the same map; the column shape does not change. +- GraphQL: `Source.features: SourceFeatures!` with `verified: Boolean!`, `adFree: Boolean!`, `links: Boolean!`, `products: Boolean!`. Always present, all `false` for sources without a map. +- Enforcement is server side: a paid field reads as empty and its mutations reject while its feature is off. The stored data is kept, so switching a feature back on restores it. + +### Private endpoint for Smith + +- `GET /p/squad-features/:idOrHandle` returns `{ id, handle, name, features }`. +- `PATCH /p/squad-features/:idOrHandle` with a partial map (for example `{ "adFree": true }`) merges and returns the full result. +- Service authentication only (same guard as `/p/company-verification`), squads only, unknown keys rejected (strict schema). +- Smith learns the endpoint through a skill in `smith-brain`, next to `company-verification-review`. + +### Links (`links` feature) + +- Squad fields: `website` (single URL) and `links` (ordered list of URLs). The client derives the icon and label from the domain. +- GraphQL: `Source.website: String`, `Source.links: [String!]!`, both empty while the feature is off. +- Mutation `updateSquadLinks(sourceId: ID!, website: String, links: [String!]!): Source!`: admins only (`Edit` permission), feature on, `http(s)` URLs only. +- The client renders these links with `rel="noopener"` and no `nofollow`. + +### Products (`products` feature) + +- A squad's products are `DatasetTool` rows with `officialSourceId` equal to the squad, ordered by a new position column. +- New `DatasetTool` columns: `tagline`, product `links` (ordered URLs), position. +- The logo upload is stored in `faviconUrl` with an owner value in `faviconSource`, replacing the auto-fetched favicon everywhere the tool appears. +- Pricing (`free`, `freemium`, `paid`, `open-source`, shown as Free, Freemium, Paid, Open source) and description are written as tool facts (`pricingModel`, `description`) with an owner-corrected status, which the enrichment cron never overwrites. An empty value deletes the fact. +- Category is the tool's curated category, read only. Tools without one show no category chip. The mock's category dropdown is dropped. +- GraphQL: `Source.products: [DatasetTool!]!` (ordered, empty while the feature is off). New `DatasetTool` fields: `tagline`, `description`, `pricingModel`, `links: [String!]!`; the logo is the existing `faviconUrl`. +- Mutations, admins only and feature on (`FORBIDDEN` otherwise): + - `addSquadProduct(sourceId: ID!, input: AddSquadProductInput!, logo: Upload): DatasetTool!` finds or creates the catalog tool by `name` and sets `officialSourceId`; `CONFLICT` when the tool is official elsewhere. + - `updateSquadProduct(id: ID!, input: UpdateSquadProductInput!, logo: Upload): DatasetTool!` (no name). + - `removeSquadProduct(id: ID!): EmptyResponse!` + - `reorderSquadProducts(sourceId: ID!, ids: [ID!]!): [DatasetTool!]!` +- Rules: + - A tool that is already official for another source cannot be added. + - The tool name cannot be edited after creation (the `/tools/` URL derives from it); ops can rename. + - Removing a product clears `officialSourceId`; the tool stays in the catalog. + - A product belongs to one squad. +- The tool page shows the official squad regardless of the squad's features; ops clean up manually when needed. +- Ops manage everything about products through private endpoints under `/p/squad-features/:idOrHandle/products` (list, add with URL and logo from URL, edit including the tool URL and name, delete, reorder), without being squad admins and regardless of the `products` feature. Squad admins cannot change the tool URL or name. +- Known limitations: unrelated products with the same name collapse into one catalog tool; a tool can be both claimed by a company (work-email claim) and official for a squad, and ops resolve disputes. +- No import from Product Hunt, G2, Trustpilot or GitHub, and no ratings. + +### Limits + +Named constants in the API (`src/common/schema/squadFeatures.ts`), mirrored by one constants file in `apps` that the forms and helper copy use: 10 squad links, 20 products, 5 links per product, tagline 120 characters, description 500 characters, 500 characters per URL, `http(s)` only. + +### Search + +- `searchPostSuggestions(query: String!, source: ID)`: the new optional `source` argument. When given it applies the same permission check as `searchSourcePosts`, filters to that source, and includes private squad posts the viewer may read. +- `searchSourcePosts` (results) already exists. + +### Indexing + +- Squads with the `verified` feature are exempt from the minimum-members `noindex` rule. They stay `noindex` when private, inactive or flagged by vordr. Without this, the page-level `nofollow` that mirrors `noindex` would cancel the dofollow links. + +### Content feed (backend only) + +- Verify that articles ingested from a `SourceFeed` on a squad land correctly: attributed to the squad, not held by moderation, sensible member notifications. Fix gaps in the API; no GraphQL surface. + +## Frontend (apps) + +### Routes and surfaces + +- `/squads/[handle]`: the new page. Posts and About tabs below laptop. +- Squad search results stay on `/squads/[handle]?q=`. +- New pages: Products, Members, and the author's Pending posts, reachable from the page. +- A Manage area for staff replaces `/squads/[handle]/edit`, `/squads/[handle]/analytics` and the members modal. Every legacy URL redirects to its new home, since notifications and emails link to them. +- Manage sections: Details, Products (paid), Links (paid), Members, Moderation, Posting and invitations, Analytics, Integrations (Slack only), Danger zone. No Rules, FAQ or Content feed. + +### Ads + +- One predicate, `isSourceAdFree` in `lib/ads.ts`, gates every ad placement: the squad feed (`disableAds`), squad search results, and on the post page (page, modal, focus and reader views) the sidebar ad, the ad shown as a comment, the in-article ad slots, the phone top strip, the focus card ad, and brand sponsorships (sponsored tool mentions, sponsored tags, brand highlights). Organic tags and tool mentions stay. +- The homepage feed post fragment gets no new fields for this project; `source.features` rides on post page and squad queries only. + +### SEO + +- The right column, including the Links widget, is server-rendered. +- Below laptop the About tab content stays in the DOM (hidden with CSS, not unmounted), because Google indexes the mobile render. + +### Differences from the mock + +- Copy: "Verified Company Squad". +- Private squads mark the squad as not public (the mock only walls content and still shows Join and Boost). +- The feed is infinite, pinned-posts collapse and the own-pending-posts strip are wired. +- No mock data ships: no CodeRabbit copy, placeholder stats, ratings, company size or location. +- The tagline slot renders the squad description. +- Content feed, Rules, FAQ, Releases wording and the product category dropdown are not built. + +## Testing + +- E2E: one Playwright smoke test, only if a verified squad can be seeded in the test environment: badge visible, Links without `nofollow`, no ad requests; a free squad shows none of these. +- API integration: feature map resolution; paid fields read empty and mutations reject while off; the private endpoint's auth and validation; product rules; scoped suggestions against private squads and blocked members; the indexing exemption. +- Frontend integration (RTL): the mock's viewer matrix (anonymous, visitor, member, moderator, admin, blocked, private) as the squad page test table; every ad placement renders nothing for an ad-free source; a free squad renders no paid widget or Manage entry, including for admins. +- Unit: posting permission rules and client feature helpers only. + +## Iteration 2 and later + +- Rules (API and UI). +- Badge on directory cards and post headers. +- Product import and ratings. +- Tiers as presets over the feature map. + +## Validation checklist + +- [ ] No "Verified Squad page" string anywhere. +- [ ] No feature flag introduced; legacy squad components and their tests deleted. +- [ ] A free squad (admin view included) shows no badge, card, Links, website, products or their Manage entries. +- [ ] A squad with `adFree` shows no ads on its feed, its search results or any of its posts, in page and modal. +- [ ] Links widget anchors have no `nofollow`; post, comment and product links still do. +- [ ] Links are in the server HTML on phone and desktop. +- [ ] Verified squads under the member threshold are indexable. +- [ ] Feature changes via `/p/squad-features` take effect without a deploy. +- [ ] Legacy squad URLs redirect. +- [ ] API deployed to production before `apps` merges. diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 3a5529688e7..7f127e793b9 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -2451,7 +2451,7 @@ describe('Feed ad cadence with highlight cards', () => { renderWithHighlightLayout({ posts: [buildPost('p0')], highlightEnabled: false, - feedName: OtherFeedPage.Squad, + feedName: OtherFeedPage.Squads, }); await waitFor(() => { @@ -2534,7 +2534,7 @@ describe('Feed ad cadence with highlight cards', () => { buildPost('p3'), ], highlightEnabled: false, - feedName: OtherFeedPage.Squad, + feedName: OtherFeedPage.Squads, }); expect( diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index c865b954934..8b5245f34e0 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -245,7 +245,7 @@ export default function Feed({ const { openNewTab, loadedSettings } = useContext(SettingsContext); const { isListMode, shouldUseListFeedLayout } = useFeedLayout(); const numCards = currentSettings.numCards.eco; - const isSquadFeed = feedName === OtherFeedPage.Squad; + const isSquadFeed = feedName === OtherFeedPage.Squads; const trackedFeedFinish = useRef(false); const isMyFeed = feedName === SharedFeedPage.MyFeed; const showAcquisitionForm = diff --git a/packages/shared/src/components/brand/adFreeSponsorships.spec.tsx b/packages/shared/src/components/brand/adFreeSponsorships.spec.tsx new file mode 100644 index 00000000000..fef5130842b --- /dev/null +++ b/packages/shared/src/components/brand/adFreeSponsorships.spec.tsx @@ -0,0 +1,129 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { defaultQueryClientTestingConfig } from '../../../__tests__/helpers/tanstack-query'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import defaultPost from '../../../__tests__/fixture/post'; +import type { Post } from '../../graphql/posts'; +import { UserVote } from '../../graphql/posts'; +import type { EngagementCreative } from '../../lib/engagementAds'; +import { EngagementAdsProvider } from '../../contexts/EngagementAdsContext'; +import { ActivePostContextProvider } from '../../contexts/ActivePostContext'; +import { PostTagList } from '../post/tags/PostTagList'; +import { PostActions } from '../post/PostActions'; +import ShowMoreContent from '../cards/common/ShowMoreContent'; +import { MentionedToolsWidget } from './MentionedToolsWidget'; + +jest.mock('../../features/profile/hooks/useUserStack', () => ({ + useUserStack: jest.fn(() => ({ + stackItems: [], + add: jest.fn(), + remove: jest.fn(), + })), +})); + +const creative: EngagementCreative = { + gen_id: 'c1', + promoted_name: 'Copilot', + promoted_body: 'AI pair programming', + promoted_cta: 'Try free', + promoted_url: 'https://example.com', + promoted_logo_img: { + dark: 'https://example.com/logo-dark.png', + light: 'https://example.com/logo-light.png', + }, + promoted_icon_img: { + dark: 'https://example.com/icon-dark.png', + light: 'https://example.com/icon-light.png', + }, + promoted_gradient_start: { dark: '#000', light: '#fff' }, + promoted_gradient_end: { dark: '#111', light: '#eee' }, + tools: ['VSCode'], + keywords: ['Copilot'], + tags: ['ai'], + placements: [], +}; + +const regularPost: Post = { + ...defaultPost, + tags: ['ai', 'react'], + userState: { vote: UserVote.None }, +}; + +const adFreePost: Post = { + ...regularPost, + source: { + ...defaultPost.source, + features: { verified: true, adFree: true, links: false, products: false }, + } as Post['source'], +}; + +const wrap = (post: Post, ui: ReactElement) => ( + + + {ui} + + +); + +const cases = [ + ['shown for a regular source', regularPost, true], + ['hidden for an ad-free squad', adFreePost, false], +] as const; + +describe('brand sponsorships on a post', () => { + it.each(cases)( + 'sponsored tag branding is %s, the plain tags stay', + (_, post, isShown) => { + render(wrap(post, )); + + const list = screen.getByRole('list', { name: 'Post tags' }); + expect(list).toHaveTextContent('react'); + expect(list).toHaveTextContent('ai'); + expect(screen.queryAllByText(/sponsored by Copilot/).length > 0).toBe( + isShown, + ); + }, + ); + + it.each(cases)( + 'the sponsored keyword highlight in the summary is %s', + (_, post, isShown) => { + render( + wrap(post, ), + ); + + expect(screen.getByTestId('tldr-container')).toHaveTextContent( + 'Try Copilot for AI coding', + ); + expect(!!screen.queryByText('Copilot', { selector: 'span' })).toBe( + isShown, + ); + }, + ); + + it.each(cases)('the sponsored tools widget is %s', (_, post, isShown) => { + render(wrap(post, )); + + expect(!!screen.queryByText('Sponsored tools')).toBe(isShown); + }); + + it.each(cases)('the branded upvote animation is %s', (_, post, isShown) => { + const { rerender, container } = render( + wrap(post, ), + ); + const upvoted = { ...post, userState: { vote: UserVote.Up } }; + rerender( + wrap(upvoted, ), + ); + + // The particle canvas has no role; it mounts only for a branded upvote. + // eslint-disable-next-line testing-library/no-container + expect(!!container.querySelector('canvas')).toBe(isShown); + }); +}); diff --git a/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx b/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx index c264617ffb5..5067fed5921 100644 --- a/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx +++ b/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx @@ -7,19 +7,13 @@ import { AuthContextProvider } from '../../../contexts/AuthContext'; import loggedUser from '../../../../__tests__/fixture/loggedUser'; import { generateMembersList, - generateMembersResult, generateTestAdmin, generateTestSquad, } from '../../../../__tests__/fixture/squads'; import { SquadGrid } from './SquadGrid'; import { LazyModalElement } from '../../modals/LazyModalElement'; -import type { MockedGraphQLResponse } from '../../../../__tests__/helpers/graphql'; import { mockGraphQL } from '../../../../__tests__/helpers/graphql'; -import type { SquadEdgesData } from '../../../graphql/squads'; -import { - SQUAD_JOIN_MUTATION, - SQUAD_MEMBERS_QUERY, -} from '../../../graphql/squads'; +import { SQUAD_JOIN_MUTATION } from '../../../graphql/squads'; import { waitForNock } from '../../../../__tests__/helpers/utilities'; import { cloudinarySquadsDirectoryCardBannerDefault } from '../../../lib/image'; import { ActionType, COMPLETE_ACTION_MUTATION } from '../../../graphql/actions'; @@ -34,29 +28,6 @@ const members = generateMembersList(); const admin = generateTestAdmin(); admin.source.members!.edges = members; admin.source.membersCount = members.length; -const defaultSquad = generateTestSquad(); - -const createSourceMembersMock = ( - result = generateMembersResult(), - variables: Record = { id: defaultSquad.id, first: 5 }, -): MockedGraphQLResponse => ({ - request: { query: SQUAD_MEMBERS_QUERY, variables }, - result: { data: result }, -}); - -const openedMembersModal = async () => { - const result = generateMembersResult(members); - mockGraphQL( - createSourceMembersMock(result, { id: defaultSquad.id, role: null }), - ); - const membersCount = result.sourceMembers.edges.length; - const trigger = await screen.findByLabelText( - `View ${membersCount} squad members`, - ); - trigger.click(); - await screen.findByText('Squad members'); - return members; -}; beforeEach(async () => { nock.cleanAll(); @@ -121,10 +92,16 @@ it('should render the component and member short list when members are provided' ); }); -it('should show the admin on top of the list', async () => { +it('should link the member short list to the members page', async () => { renderComponent(); - const membersList = await openedMembersModal(); - expect(membersList[0].node.user.name).toEqual('Eliz Kılıç'); + const list = await screen.findByLabelText( + `View ${admin.source.membersCount} squad members`, + ); + + expect(list).toHaveAttribute( + 'href', + expect.stringMatching(/squads\/test\/members$/), + ); }); it('should render the component with a view squad button', async () => { diff --git a/packages/shared/src/components/comments/AdAsComment.tsx b/packages/shared/src/components/comments/AdAsComment.tsx index 9d8fe79b921..6154a0eb084 100644 --- a/packages/shared/src/components/comments/AdAsComment.tsx +++ b/packages/shared/src/components/comments/AdAsComment.tsx @@ -13,7 +13,8 @@ import { AdPixel } from '../cards/ad/common/AdPixel'; import { AdViewability } from '../cards/ad/common/AdViewability'; import type { ViewabilityData } from '../../features/monetization/viewability'; import { viewabilityLogExtra } from '../../features/monetization/viewability'; -import { AdActions, AdPlacement } from '../../lib/ads'; +import { AdActions, AdPlacement, isSourceAdFree } from '../../lib/ads'; +import type { Post } from '../../graphql/posts'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { RemoveAd } from '../cards/ad/common/RemoveAd'; import { AdRefresh } from '../cards/ad/common/AdRefresh'; @@ -27,11 +28,17 @@ import { AdvertiseLink } from '../cards/ad/common/AdvertiseLink'; interface AdAsCommentProps { postId: string; + /** The post's source; an ad-free squad gets no ad and no ad request. */ + source: Post['source']; } -export const AdAsComment = ({ postId }: AdAsCommentProps): ReactElement => { +export const AdAsComment = ({ + postId, + source: postSource, +}: AdAsCommentProps): ReactElement => { const { logEvent } = useLogContext(); const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); + const isAdFree = isSourceAdFree(postSource); const { data: ad, @@ -41,6 +48,7 @@ export const AdAsComment = ({ postId }: AdAsCommentProps): ReactElement => { } = useAdQuery({ placement: AdPlacement.PostComment, queryKey: generateQueryKey(RequestKey.Ads, user, postId), + enabled: !isAdFree, staleTime: StaleTime.OneHour, }); diff --git a/packages/shared/src/components/errors/SquadLoading.tsx b/packages/shared/src/components/errors/SquadLoading.tsx deleted file mode 100644 index 3acf83aa33b..00000000000 --- a/packages/shared/src/components/errors/SquadLoading.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import type { HTMLAttributes, ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import classed from '../../lib/classed'; -import { FlexCol, FlexRow } from '../utilities'; -import type { Squad } from '../../graphql/sources'; - -const PlaceholderElement = classed('div', 'bg-accent-pepper-subtlest'); -const RectangleElement = classed(PlaceholderElement, 'rounded-12'); -const Container = classed('div', 'flex flex-row'); -const ColumnContainer = classed( - Container, - 'flex-col items-center laptopL:items-start', -); - -const TitleDescription = ({ className }: HTMLAttributes) => ( - - - - -); - -const Actions = ({ className }: HTMLAttributes) => ( - - - - - - - -); - -const MemberCard = ({ - className, - showBadge = true, -}: HTMLAttributes & { showBadge?: boolean }) => ( - - - - - {showBadge && } - - -); - -const MemberRow = ({ - titleWidthClassName, - className, - showOverflow = false, - showBadge = true, -}: HTMLAttributes & { - titleWidthClassName: string; - showOverflow?: boolean; - showBadge?: boolean; -}) => ( - - - - - - - {showOverflow && } - - -); - -function SquadLoading({ - squad, - sidebarRendered, -}: { - squad?: Pick; - sidebarRendered: boolean; -}): ReactElement { - return ( -
-
- - - {squad?.description && } -
- -
- - - - - - - - {squad?.public && ( - - )} - -
- - -
-
- - - -
-
-
-
- ); -} - -export default SquadLoading; diff --git a/packages/shared/src/components/layout/DefaultSquadHeader.tsx b/packages/shared/src/components/layout/DefaultSquadHeader.tsx deleted file mode 100644 index 1a76cd02be5..00000000000 --- a/packages/shared/src/components/layout/DefaultSquadHeader.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { PageHeader, PageHeaderTitle } from './common'; -import { Button, ButtonVariant } from '../buttons/Button'; -import { ArrowIcon } from '../icons'; -import { TypographyType } from '../typography/Typography'; - -interface DefaultSquadHeaderProps { - onClick?: () => void; - className?: string; -} - -export function DefaultSquadHeader({ - onClick, - className, -}: DefaultSquadHeaderProps): ReactElement { - return ( - - - ); -}; - -export function SquadMemberModal({ - squad, - ...props -}: SquadMemberModalProps): ReactElement { - const { user: loggedUser } = useAuthContext(); - const [roleFilter, setRoleFilter] = useState(null); - const [query, setQuery] = useState(''); - const [handleSearchDebounce] = useDebounceFn( - (value: string) => setQuery(value), - defaultSearchDebounceMs, - ); - const { - members, - membersQueryResult: queryResult, - onUnblock, - onDemoteSelf, - onUpdateRole, - membersQueryKey, - } = useSquadActions({ - squad, - query: query?.trim?.()?.length ? query : undefined, - membersQueryParams: { role: roleFilter }, - membersQueryEnabled: true, - }); - - useUsersContentPreferenceMutationSubscription({ - queryKey: membersQueryKey, - queryProp: 'sourceMembers', - }); - - const hasPermission = - isSystemModerator(loggedUser) || - verifyPermission(squad, SourcePermissions.ViewBlockedMembers); - - const onTabClick = (tab: SquadMemberTab) => { - switch (tab) { - case SquadMemberTab.Moderators: - setRoleFilter(SourceMemberRole.Moderator); - break; - case SquadMemberTab.BlockedMembers: - setRoleFilter(SourceMemberRole.Blocked); - break; - default: - setRoleFilter(null); - } - }; - - const filteredTabs = useMemo(() => { - if (hasPermission) { - return Object.values(SquadMemberTab); - } - - return Object.values(SquadMemberTab).filter( - (tab) => tab !== SquadMemberTab.BlockedMembers, - ); - }, [hasPermission]); - - return ( - } - scrollingProps={{ - isFetchingNextPage: queryResult.isFetchingNextPage, - canFetchMore: checkFetchMore(queryResult), - fetchNextPage: queryResult.fetchNextPage, - }} - userListProps={ - hasPermission && { - afterContent: (user, index) => ( - - onUnblock({ sourceId: squad.id, memberId: user.id }) - } - /> - ), - emptyPlaceholder: - roleFilter === SourceMemberRole.Blocked ? ( - - ) : ( - - No{' '} - {roleFilter === SourceMemberRole.Moderator - ? 'moderator' - : 'member'}{' '} - found - - ), - isLoading: queryResult.isPending, - initialItem: - roleFilter === SourceMemberRole.Blocked || - query?.length ? undefined : ( - - ), - } - } - users={members?.map(({ user, role }) => { - return { - ...user, - role, - }; - })} - onSearch={handleSearchDebounce} - origin={Origin.SquadMembersList} - /> - ); -} - -export default SquadMemberModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 05e2b0477af..267295ef46a 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -6,9 +6,6 @@ export type CloseModalFunc = ( e: React.MouseEvent | React.KeyboardEvent | React.FormEvent, ) => void; -const SquadMemberModal = dynamic( - () => import(/* webpackChunkName: "squadMemberModal" */ './SquadMemberModal'), -); const UpvotedPopupModal = dynamic( () => import(/* webpackChunkName: "upvotedPopupModal" */ './UpvotedPopupModal'), @@ -125,19 +122,6 @@ const ShareModal = dynamic( () => import(/* webpackChunkName: "shareModal" */ './ShareModal'), ); -const PrivilegedMemberModal = dynamic( - () => - import( - /* webpackChunkName: "privilegedMembersModal" */ './squads/PrivilegedMembersModal' - ), -); -const TopMembersModal = dynamic( - () => - import( - /* webpackChunkName: "topMembersModal" */ './squads/TopMembersModal' - ), -); - const BookmarkReminderModal = dynamic( () => import( @@ -531,7 +515,6 @@ const PostImpressionsModal = dynamic( ); export const modals = { - [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, [LazyModal.RepostsPopup]: RepostsModal, [LazyModal.SquadTour]: SquadTourModal, @@ -551,8 +534,6 @@ export const modals = { [LazyModal.ReputationPrivileges]: ReputationPrivilegesModal, [LazyModal.MarketingCta]: MarketingCtaModal, [LazyModal.Share]: ShareModal, - [LazyModal.PrivilegedMembers]: PrivilegedMemberModal, - [LazyModal.TopMembers]: TopMembersModal, [LazyModal.BookmarkReminder]: BookmarkReminderModal, [LazyModal.RecoverStreak]: StreakRecoverModal, [LazyModal.StreakFreezePurchase]: StreakFreezePurchaseModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 1b73e6bad5a..759530861aa 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -25,7 +25,6 @@ export enum ModalSize { } export enum LazyModal { - SquadMember = 'squadMember', SquadTour = 'squadTour', UpvotedPopup = 'upvotedPopup', RepostsPopup = 'repostsPopup', @@ -48,8 +47,6 @@ export enum LazyModal { ReputationPrivileges = 'reputationPrivileges', MarketingCta = 'marketingCta', Share = 'share', - PrivilegedMembers = 'privilegedMembers', - TopMembers = 'topMembers', BookmarkReminder = 'bookmarkReminder', SlackIntegration = 'slackIntegration', SlackShare = 'slackShare', diff --git a/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx b/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx deleted file mode 100644 index cfa36854b45..00000000000 --- a/packages/shared/src/components/modals/squads/PrivilegedMembersModal.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import type { ModalProps } from '../common/Modal'; -import type { Source } from '../../../graphql/sources'; -import { SquadUsersModal } from './SquadUsersModal'; - -export interface PrivilegedMembersModalProps - extends Omit { - source: Pick; -} - -function PrivilegedMembersModal({ - source, - ...props -}: PrivilegedMembersModalProps): ReactElement { - return ( - - squad?.privilegedMembers?.map(({ user, role }) => ({ - ...user, - role, - })) ?? [] - } - /> - ); -} - -export default PrivilegedMembersModal; diff --git a/packages/shared/src/components/modals/squads/TopMembersModal.tsx b/packages/shared/src/components/modals/squads/TopMembersModal.tsx deleted file mode 100644 index 13f73aa99db..00000000000 --- a/packages/shared/src/components/modals/squads/TopMembersModal.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import type { ModalProps } from '../common/Modal'; -import type { Source } from '../../../graphql/sources'; -import { SquadUsersModal } from './SquadUsersModal'; - -export interface TopMembersModalProps extends Omit { - source: Pick; -} - -function TopMembersModal({ - source, - ...props -}: TopMembersModalProps): ReactElement { - return ( - squad?.topMembers ?? []} - /> - ); -} - -export default TopMembersModal; diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index dbbab862e59..e0781668bad 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -183,7 +183,9 @@ function PostEngagements({ composer — the one place on the page where someone has already decided to engage. */} - {!isPlus && !hideInternalAd && } + {!isPlus && !hideInternalAd && ( + + )} , )} diff --git a/packages/shared/src/components/post/SquadPostWidgets.tsx b/packages/shared/src/components/post/SquadPostWidgets.tsx index 875fa223fca..f06fa9b2e24 100644 --- a/packages/shared/src/components/post/SquadPostWidgets.tsx +++ b/packages/shared/src/components/post/SquadPostWidgets.tsx @@ -66,6 +66,7 @@ export function SquadPostWidgets({ )} diff --git a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx index 10d2291c47d..0a36c83d372 100644 --- a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx +++ b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx @@ -30,6 +30,7 @@ export const CollectionPostWidgets = ({ /> diff --git a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx index 1a54d0293eb..44fe72c191e 100644 --- a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx @@ -185,7 +185,9 @@ export const PostDiscussionPanel = ({ />
- {!isPlus && !renderInterleaved && } + {!isPlus && !renderInterleaved && ( + + )} {showSortHeader && commentsCount > 0 && ( // A text link (not a button) so it aligns flush-left with the comments // below it; `mb-2` adds breathing room before the first comment. diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 408ca19a3ef..e0246304646 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -841,7 +841,11 @@ const PostFocusCardRaw = ({ )} {!ads?.withoutDirectSold && ( - + )} ({ ...(jest.requireActual('../../GrowthBookProvider') as Record< @@ -33,6 +33,7 @@ jest.mock('../../../features/monetization/prebid', () => ({ })); const mockSlotMaps = jest.requireMock('./slots') as { + READ_AD_SLOTS: AdSlots; ORGANIC_AD_SLOTS: AdSlots; }; const mockUseFeature = jest.mocked(useFeature); @@ -77,6 +78,30 @@ describe('PhoneTopAdStrip', () => { ).toBe(''); }); + it.each(['organic', 'read'] as const)( + 'renders nothing on the %s surface for a post in an ad-free squad', + (surface) => { + mockSlotMaps.READ_AD_SLOTS = { + [READ_SLOT.topLeaderboardPhone]: { sizes: [[320, 50]] }, + }; + const post = { + source: { + features: { + verified: true, + adFree: true, + links: false, + products: false, + }, + }, + }; + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }, + ); + it('pins the phone unit and publishes its height for the chrome below it', () => { const { unmount } = render(); diff --git a/packages/shared/src/components/post/read/PhoneTopAdStrip.tsx b/packages/shared/src/components/post/read/PhoneTopAdStrip.tsx index f2145829875..4386261952a 100644 --- a/packages/shared/src/components/post/read/PhoneTopAdStrip.tsx +++ b/packages/shared/src/components/post/read/PhoneTopAdStrip.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef } from 'react'; import type { ReadAdSurface } from './ReadAdSlot'; import { ReadAdFormat, ReadAdSlot } from './ReadAdSlot'; import { ORGANIC_SLOT, READ_SLOT } from './slots'; +import type { AdSlotsPost } from './useReadAdSlots'; import { useOrganicAdSlots, useReadAdSlots } from './useReadAdSlots'; import { hasLiveAdSlots } from '../../../features/monetization/kueez'; @@ -21,6 +22,11 @@ const PHONE_SLOT: Record = { export interface PhoneTopAdStripProps { surface: ReadAdSurface; + /** + * The page's post. The strip renders in the layout banner, outside the + * active post context, so the page hands it over for the ad-free check. + */ + post?: AdSlotsPost; } /** @@ -33,9 +39,10 @@ export interface PhoneTopAdStripProps { */ export function PhoneTopAdStrip({ surface, + post, }: PhoneTopAdStripProps): ReactElement | null { - const readSlots = useReadAdSlots(); - const organicSlots = useOrganicAdSlots(); + const readSlots = useReadAdSlots(post); + const organicSlots = useOrganicAdSlots(post); const isActive = hasLiveAdSlots( surface === 'organic' ? organicSlots : readSlots, ); diff --git a/packages/shared/src/components/post/read/useReadAdSlots.ts b/packages/shared/src/components/post/read/useReadAdSlots.ts index 7f08b9978e9..9d60ef606e4 100644 --- a/packages/shared/src/components/post/read/useReadAdSlots.ts +++ b/packages/shared/src/components/post/read/useReadAdSlots.ts @@ -1,6 +1,9 @@ import { useContext } from 'react'; import { featureReadAds } from '../../../lib/featureManagement'; import AuthContext from '../../../contexts/AuthContext'; +import { useActivePostContext } from '../../../contexts/ActivePostContext'; +import type { Source } from '../../../graphql/sources'; +import { isSourceAdFree } from '../../../lib/ads'; import { isDevelopment } from '../../../lib/constants'; import { useFeature } from '../../GrowthBookProvider'; import type { AdSlots } from '../../../features/monetization/kueez'; @@ -20,6 +23,20 @@ const useIsAnonymous = (): boolean => { return !!auth?.isAuthReady && !auth?.user; }; +/** + * A post in an ad-free squad carries no slot at all. The post comes from the + * caller where it has one (the page itself, the layout banner), otherwise from + * the active post every slot below the page renders under. + */ +export interface AdSlotsPost { + source?: Pick | null; +} + +const useIsAdFreePost = (post?: AdSlotsPost): boolean => { + const { activePost } = useActivePostContext(); + return isSourceAdFree((post ?? activePost)?.source); +}; + /** * The /read template's slots. Anonymous visitors only: the page exists for * paid-acquisition traffic, and ad-free is part of what Plus members pay for. @@ -27,15 +44,16 @@ const useIsAnonymous = (): boolean => { * ramp. Development builds get the dashed density placeholders instead of * live auctions, hence the empty map there. */ -export const useReadAdSlots = (): AdSlots => { +export const useReadAdSlots = (post?: AdSlotsPost): AdSlots => { const isAnonymous = useIsAnonymous(); const enabled = useFeature(featureReadAds); + const isAdFree = useIsAdFreePost(post); if (isDevelopment) { return NO_SLOTS; } - return enabled && isAnonymous ? READ_AD_SLOTS : NO_SLOTS; + return enabled && isAnonymous && !isAdFree ? READ_AD_SLOTS : NO_SLOTS; }; /** @@ -43,8 +61,9 @@ export const useReadAdSlots = (): AdSlots => { * won: anonymous visitors only, so any logged-in user (member or Plus) never * sees programmatic ads on their post pages. Both post layouts carry them. */ -export const useOrganicAdSlots = (): AdSlots => { +export const useOrganicAdSlots = (post?: AdSlotsPost): AdSlots => { const isAnonymous = useIsAnonymous(); + const isAdFree = useIsAdFreePost(post); - return isAnonymous ? ORGANIC_AD_SLOTS : NO_SLOTS; + return isAnonymous && !isAdFree ? ORGANIC_AD_SLOTS : NO_SLOTS; }; diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index b52093b552a..79bf125796b 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -335,6 +335,7 @@ export function EngagementRail({ diff --git a/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx b/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx index 22b82988c6f..e89a4aff2df 100644 --- a/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx +++ b/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx @@ -11,13 +11,37 @@ import { import type { IconProps } from '../Icon'; import { IconSize } from '../Icon'; import { isAppleDevice } from '../../lib/func'; +import { fallbackImages } from '../../lib/config'; +import type { SpotlightSource } from './types'; import { scopeMeta, scopeOrder, SpotlightScope } from './types'; interface ScopeBreadcrumbsProps { scope: SpotlightScope; onSelect: (scope: SpotlightScope) => void; + /** The squad the session was opened for, offered as the first chip. */ + source?: SpotlightSource | null; + onSelectSource?: () => void; } +export const SourceAvatar = ({ + source, +}: { + source: Pick; +}): ReactElement => ( + +); + +const chipClassName = classNames( + 'flex h-8 shrink-0 items-center gap-1.5 rounded-10 border px-3 transition-all typo-callout', + 'border-border-subtlest-tertiary bg-surface-float text-text-tertiary', + 'hover:-translate-y-px hover:border-border-subtlest-secondary hover:bg-surface-hover hover:text-text-primary hover:shadow-2', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-1', +); + const altLabel = isAppleDevice() ? '⌥' : 'Alt'; export const scopeIcons: Record< @@ -47,6 +71,8 @@ export const scopeIcons: Record< export const ScopeBreadcrumbs = ({ scope, onSelect, + source, + onSelectSource, }: ScopeBreadcrumbsProps): ReactElement | null => { if (scope !== SpotlightScope.All) { return null; @@ -58,6 +84,18 @@ export const ScopeBreadcrumbs = ({ aria-label="Filter results by type" className="flex items-center gap-2 overflow-x-auto px-4 pb-2 pt-3" > + {source && onSelectSource && ( + + )} {scopeOrder.map((s) => { const meta = scopeMeta[s]; const Icon = scopeIcons[s]; @@ -69,12 +107,7 @@ export const ScopeBreadcrumbs = ({ aria-keyshortcuts={`Alt+${meta.shortcutIndex}`} onClick={() => onSelect(s)} title={`${meta.triggerLabel} (${altLabel}+${meta.shortcutIndex})`} - className={classNames( - 'flex h-8 shrink-0 items-center gap-1.5 rounded-10 border px-3 transition-all typo-callout', - 'border-border-subtlest-tertiary bg-surface-float text-text-tertiary', - 'hover:-translate-y-px hover:border-border-subtlest-secondary hover:bg-surface-hover hover:text-text-primary hover:shadow-2', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-1', - )} + className={chipClassName} > {meta.label} diff --git a/packages/shared/src/components/spotlight/ScopeFilterPill.tsx b/packages/shared/src/components/spotlight/ScopeFilterPill.tsx index f74cc1be29c..8559f5f915c 100644 --- a/packages/shared/src/components/spotlight/ScopeFilterPill.tsx +++ b/packages/shared/src/components/spotlight/ScopeFilterPill.tsx @@ -1,11 +1,11 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { IconSize } from '../Icon'; import { isAppleDevice } from '../../lib/func'; -import type { SpotlightScope } from './types'; +import type { SpotlightScope, SpotlightSource } from './types'; import { scopeMeta } from './types'; -import { scopeIcons } from './ScopeBreadcrumbs'; +import { scopeIcons, SourceAvatar } from './ScopeBreadcrumbs'; interface ScopeFilterPillProps { scope: Exclude; @@ -14,6 +14,37 @@ interface ScopeFilterPillProps { const backspaceLabel = isAppleDevice() ? '⌫' : 'Backspace'; +const FilterPill = ({ + label, + leading, + ariaLabel, + testId, + onRemove, +}: { + label: string; + leading: ReactNode; + ariaLabel: string; + testId: string; + onRemove: () => void; +}): ReactElement => ( + +); + /** * Slack/Apple-Tahoe-style search token shown inside the input field at * the start of the query. Replaces the old "active scope chip with X @@ -33,23 +64,34 @@ export const ScopeFilterPill = ({ const Icon = scopeIcons[scope]; return ( - + } + ariaLabel={`Filtering by ${meta.label}. Click or press ${backspaceLabel} to remove.`} + testId="scope-filter-pill" + onRemove={onRemove} + /> ); }; +/** + * The squad a Spotlight session is narrowed to, as the same in-field token. + * Removing it widens the search to all of daily.dev. + */ +export const SourceFilterPill = ({ + source, + onRemove, +}: { + source: SpotlightSource; + onRemove: () => void; +}): ReactElement => ( + } + ariaLabel={`Searching in ${source.name}. Click or press ${backspaceLabel} to search all of daily.dev.`} + testId="source-filter-pill" + onRemove={onRemove} + /> +); + export default ScopeFilterPill; diff --git a/packages/shared/src/components/spotlight/Spotlight.spec.tsx b/packages/shared/src/components/spotlight/Spotlight.spec.tsx new file mode 100644 index 00000000000..4886509930a --- /dev/null +++ b/packages/shared/src/components/spotlight/Spotlight.spec.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import nock from 'nock'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../__tests__/helpers/graphql'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import { + SEARCH_POST_SUGGESTIONS, + SOURCE_SPOTLIGHT_POSTS_LIMIT, + SOURCE_SPOTLIGHT_POSTS_QUERY, +} from '../../graphql/search'; +import { supportedTypesForPrivateSources } from '../../graphql/feed'; +import { SPOTLIGHT_ACTIONS_QUERY } from '../../graphql/spotlight'; +import { feature } from '../../lib/featureManagement'; +import { Spotlight } from './Spotlight'; +import { + SpotlightProvider, + useSpotlight, + useSpotlightPageSource, +} from './SpotlightContext'; +import { SOURCE_POST_SUGGESTIONS_LIMIT } from './commands/search'; +import type { SpotlightSource } from './types'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +const push = jest.fn(); + +const squad: SpotlightSource = { + id: 'squad-id', + handle: 'rabbits', + name: 'Rabbit Hole', + image: 'https://daily.dev/squad.png', +}; + +const mockSpotlightActions = (): void => + mockGraphQL({ + request: { query: SPOTLIGHT_ACTIONS_QUERY }, + result: { data: { spotlightActions: [] } }, + }); + +const mockSquadPosts = (): void => + mockGraphQL({ + request: { + query: SOURCE_SPOTLIGHT_POSTS_QUERY, + variables: { + source: squad.id, + first: SOURCE_SPOTLIGHT_POSTS_LIMIT, + supportedTypes: supportedTypesForPrivateSources, + }, + }, + result: { + data: { + page: { + edges: [ + { + node: { + id: 'pinned', + title: 'Read this first', + pinnedAt: '2026-09-01T00:00:00.000Z', + createdAt: '2026-09-01T00:00:00.000Z', + author: { name: 'Ido' }, + }, + }, + { + node: { + id: 'latest', + title: 'Release notes', + pinnedAt: null, + createdAt: '2026-09-20T00:00:00.000Z', + author: { name: 'Ido' }, + }, + }, + ], + }, + }, + }, + }); + +const mockSuggestions = ( + query: string, + hits: { id: string; title: string }[], +): void => + mockGraphQL({ + request: { + query: SEARCH_POST_SUGGESTIONS, + variables: { + query, + version: feature.searchVersion.defaultValue, + limit: SOURCE_POST_SUGGESTIONS_LIMIT, + source: squad.id, + }, + }, + result: { data: { searchPostSuggestions: { hits } } }, + }); + +const Harness = ({ pageSource }: { pageSource?: SpotlightSource }) => { + const { isOpen, close, openWithSource } = useSpotlight(); + useSpotlightPageSource(pageSource); + + return ( + <> + + + + ); +}; + +const renderSpotlight = (pageSource?: SpotlightSource) => + render( + + + + + , + ); + +const getInput = () => screen.getByRole('combobox'); + +beforeAll(() => { + // jsdom has no scrollIntoView; cmdk calls it when the arrow keys move. + Element.prototype.scrollIntoView = jest.fn(); +}); + +beforeEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + jest.mocked(useRouter).mockReturnValue({ + push, + pathname: '/squads/[handle]', + query: {}, + } as unknown as NextRouter); + mockSpotlightActions(); + mockSquadPosts(); +}); + +describe('Spotlight scoped to a squad', () => { + it('opens with the squad as a filter pill and lists its pinned and latest posts', async () => { + renderSpotlight(); + fireEvent.click(screen.getByText('Search this squad')); + + expect(screen.getByTestId('source-filter-pill')).toHaveTextContent( + squad.name, + ); + expect(getInput()).toHaveAttribute( + 'placeholder', + `Search ${squad.name} posts…`, + ); + expect( + await screen.findByRole('group', { name: `Pinned in ${squad.name}` }), + ).toHaveTextContent('Read this first'); + expect( + screen.getByRole('group', { name: `Latest in ${squad.name}` }), + ).toHaveTextContent('Release notes'); + }); + + it('widens to all of daily.dev on Backspace in an empty field, and narrows back', async () => { + renderSpotlight(); + fireEvent.click(screen.getByText('Search this squad')); + await screen.findByText('Read this first'); + + fireEvent.keyDown(getInput(), { key: 'Backspace' }); + + expect(screen.queryByTestId('source-filter-pill')).not.toBeInTheDocument(); + expect(getInput()).toHaveAttribute( + 'placeholder', + 'Search posts, squads, people, tags, or actions…', + ); + + fireEvent.click(screen.getByTestId('scope-chip-source')); + + expect(screen.getByTestId('source-filter-pill')).toBeInTheDocument(); + }); + + it('widens when the pill is clicked', async () => { + renderSpotlight(); + fireEvent.click(screen.getByText('Search this squad')); + + fireEvent.click(screen.getByTestId('source-filter-pill')); + + expect(screen.queryByTestId('source-filter-pill')).not.toBeInTheDocument(); + expect(await screen.findByText(`Search in ${squad.name}`)).toBeVisible(); + }); + + it('suggests posts from the squad only and sends Enter to its results page', async () => { + renderSpotlight(); + fireEvent.click(screen.getByText('Search this squad')); + // Matches only a request that carries the squad as `source`. + mockSuggestions('review', [{ id: 'p1', title: 'Code review tips' }]); + + fireEvent.change(getInput(), { target: { value: 'review' } }); + + const group = await screen.findByRole('group', { + name: `Posts in ${squad.name}`, + }); + expect(group).toHaveTextContent('Code review tips'); + expect( + screen.getByText(`See all results in ${squad.name}`), + ).toBeInTheDocument(); + + fireEvent.keyDown(getInput(), { key: 'Enter' }); + + await waitFor(() => + expect(push).toHaveBeenCalledWith(`/squads/${squad.handle}?q=review`), + ); + }); + + it('opens a suggestion picked with the arrow keys', async () => { + renderSpotlight(); + fireEvent.click(screen.getByText('Search this squad')); + mockSuggestions('review', [{ id: 'p1', title: 'Code review tips' }]); + + fireEvent.change(getInput(), { target: { value: 'review' } }); + await screen.findByText('Code review tips'); + fireEvent.keyDown(getInput(), { key: 'ArrowDown' }); + fireEvent.keyDown(getInput(), { key: 'ArrowUp' }); + fireEvent.keyDown(getInput(), { key: 'Enter' }); + + await waitFor(() => expect(push).toHaveBeenCalledWith('/posts/p1')); + }); + + it('offers all of daily.dev when nothing in the squad matches', async () => { + renderSpotlight(); + fireEvent.click(screen.getByText('Search this squad')); + mockSuggestions('nothing', []); + + fireEvent.change(getInput(), { target: { value: 'nothing' } }); + + expect( + await screen.findByText(`No posts in ${squad.name} match “nothing”`), + ).toBeInTheDocument(); + fireEvent.click(screen.getByText('Search all of daily.dev')); + + expect(screen.queryByTestId('source-filter-pill')).not.toBeInTheDocument(); + expect(getInput()).toHaveValue('nothing'); + }); + + it('opens scoped to the page squad from the keyboard shortcut', async () => { + renderSpotlight(squad); + + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + + expect(await screen.findByTestId('source-filter-pill')).toHaveTextContent( + squad.name, + ); + }); + + it('keeps the shortcut global without a page squad', async () => { + renderSpotlight(); + + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + + expect(await screen.findByRole('combobox')).toHaveAttribute( + 'placeholder', + 'Search posts, squads, people, tags, or actions…', + ); + expect(screen.queryByTestId('source-filter-pill')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/spotlight/Spotlight.tsx b/packages/shared/src/components/spotlight/Spotlight.tsx index 82698cb8392..a9eb4c6bc44 100644 --- a/packages/shared/src/components/spotlight/Spotlight.tsx +++ b/packages/shared/src/components/spotlight/Spotlight.tsx @@ -36,13 +36,18 @@ import { SpotlightGroup, type SpotlightResultsImpressionDetails, SpotlightScope, + type SpotlightSource, } from './types'; import { useSpotlight } from './SpotlightContext'; import { useRecentCommands } from './useRecentCommands'; import { useSpotlightCommands } from './useSpotlightCommands'; -import { useSpotlightSearchCommands } from './commands/search'; +import { + buildSourceSeeAllCommand, + useSpotlightSearchCommands, +} from './commands/search'; +import { useSpotlightSourcePosts } from './commands/sourcePosts'; import { ScopeBreadcrumbs } from './ScopeBreadcrumbs'; -import { ScopeFilterPill } from './ScopeFilterPill'; +import { ScopeFilterPill, SourceFilterPill } from './ScopeFilterPill'; import { useQuickKeyDispatch } from './useQuickKeyDispatch'; import { spotlightCommandFilter, @@ -409,6 +414,24 @@ interface SpotlightDialogProps { onCloseLog?: (details: SpotlightCloseDetails) => void; } +const getPlaceholder = ( + scope: SpotlightScope, + source: SpotlightSource | null, +): string => { + if (source) { + return `Search ${source.name} posts…`; + } + return scope === SpotlightScope.All + ? 'Search posts, squads, people, tags, or actions…' + : scopeMeta[scope].placeholder; +}; + +const renderSkeletonGroup = (heading: string) => ( + + + +); + const Hint = ({ label, combo }: { label: string; combo: string }) => ( @@ -452,23 +475,38 @@ export const Spotlight = ({ pushScope, popScope, clearScope, + source, + isSourceScoped, + scopeToSource, + clearSourceScope, } = spotlight; + const scopedSource = isSourceScoped ? source : null; const trimmedQuery = query.trim(); const isFiltering = trimmedQuery.length > 0; - const isSearching = isOpen && trimmedQuery.length >= minSearchQueryLength; + const isQueryLongEnough = trimmedQuery.length >= minSearchQueryLength; + const isSearching = isOpen && isQueryLongEnough; const { value: searchVersion } = useConditionalFeature({ feature: feature.searchVersion, shouldEvaluate: isSearching, }); const searchId = useSearchId( - isSearching ? [trimmedQuery, searchVersion, scope].join('|') : '', + isSearching + ? [trimmedQuery, searchVersion, scope, scopedSource?.id].join('|') + : '', ); const search = useSpotlightSearchCommands({ router, query, scope, searchId, + source: scopedSource, + }); + const sourcePosts = useSpotlightSourcePosts({ + router, + source: scopedSource, + enabled: isOpen, }); + const showSourcePosts = !!scopedSource && !isQueryLongEnough; const hasSearchResults = search.users.length > 0 || search.sources.length > 0 || @@ -476,6 +514,9 @@ export const Spotlight = ({ search.posts.length > 0; const openedAtRef = useRef(0); + // In a squad scope, typing then Enter runs the full search in the squad + // unless the arrow keys picked a suggestion first. + const hasMovedSelectionRef = useRef(false); const ranCommandRef = useRef(false); const closeLoggedRef = useRef(false); const loggedImpressionRef = useRef(null); @@ -570,7 +611,8 @@ export const Spotlight = ({ useQuickKeyDispatch({ query, setQuery, - commands, + // A squad scope searches posts, so two letters and a space stay text. + commands: scopedSource ? [] : commands, scope, onDispatch: runCommand, }); @@ -596,7 +638,7 @@ export const Spotlight = ({ handleClose(); return; } - spotlight.open(); + spotlight.openFromShortcut(); onOpenViaShortcut?.(); }; window.addEventListener('keydown', handleKeydown); @@ -672,6 +714,7 @@ export const Spotlight = ({ // - cmdk preserves selection on the old Action row, so arrow keys feel // "stuck at the bottom" while the visible top is the entity hits. useEffect(() => { + hasMovedSelectionRef.current = false; if (!listRef.current) { return; } @@ -682,11 +725,14 @@ export const Spotlight = ({ }, [ query, scope, + scopedSource, search.isLoading, search.users.length, search.sources.length, search.tags.length, search.posts.length, + sourcePosts.pinned.length, + sourcePosts.latest.length, ]); const commandById = useMemo(() => { @@ -805,17 +851,41 @@ export const Spotlight = ({ if (!trimmedQuery) { return; } - const fallthrough = search.fallthrough[0]; + const fallthrough = scopedSource + ? buildSourceSeeAllCommand(scopedSource, trimmedQuery, router) + : search.fallthrough[0]; if (fallthrough) { handleSelect(fallthrough, { fallthrough: true }); } - }, [trimmedQuery, search.fallthrough, handleSelect]); + }, [trimmedQuery, scopedSource, router, search.fallthrough, handleSelect]); + + const sourceScopeCommand = useMemo(() => { + if (!source) { + return null; + } + return { + id: 'search.scope-source', + title: trimmedQuery + ? `Search “${trimmedQuery}” in ${source.name}` + : `Search in ${source.name}`, + icon: SearchIcon, + group: SpotlightGroup.Search, + meta: { kind: 'source', image: source.image }, + perform: () => { + scopeToSource(); + return { keepOpen: true }; + }, + }; + }, [source, trimmedQuery, scopeToSource]); /** * Groups visible right now (skip Suggested when filtering, etc.). Cmd+1..9 * and Tab navigate between these in display order. */ const visibleGroups = useMemo(() => { + if (scopedSource) { + return []; + } if (isFiltering) { const filterGroups: SpotlightGroup[] = []; groupOrder.forEach((group) => { @@ -851,7 +921,13 @@ export const Spotlight = ({ } }); return out; - }, [isFiltering, grouped, suggested.length, recentCommands.length]); + }, [ + scopedSource, + isFiltering, + grouped, + suggested.length, + recentCommands.length, + ]); const scopeSearch = useMemo(() => { if (scope === SpotlightScope.Posts) { @@ -891,6 +967,28 @@ export const Spotlight = ({ onSelect: handleSelect, }; + const listProps = { + className: classNames( + 'motion-safe:animate-spotlight-list-fade overflow-y-auto overflow-x-hidden pb-1 [overflow-anchor:none] [&_*]:[overflow-anchor:none]', + firstHeadingNoTopPaddingClass, + isMobile ? 'flex-1' : 'max-h-[min(40rem,60vh)]', + ), + ref: (node: HTMLDivElement | null) => { + listRef.current = node; + if (!node) { + setResultCount(null); + return; + } + const items = node.querySelectorAll('[data-command-id]'); + setResultCount(items.length); + }, + }; + + const onClearSourceScope = () => { + clearSourceScope(); + inputRef.current?.focus(); + }; + const paletteBody = ( <>

@@ -978,6 +1076,12 @@ export const Spotlight = ({ className="text-text-tertiary transition-colors group-focus-within/spotlight:text-text-primary" aria-hidden /> + {scopedSource && ( + + )} {scope !== SpotlightScope.All && ( )} @@ -985,15 +1089,20 @@ export const Spotlight = ({ ref={inputRef} value={query} onValueChange={setQuery} - placeholder={ - scope === SpotlightScope.All - ? 'Search posts, squads, people, tags, or actions…' - : scopeMeta[scope].placeholder - } + placeholder={getPlaceholder(scope, scopedSource)} autoFocus className="h-full flex-1 bg-transparent text-text-primary outline-none typo-body placeholder:text-text-tertiary" aria-labelledby="spotlight-title" onKeyDown={(event) => { + if ( + event.key === 'Backspace' && + query.length === 0 && + scopedSource + ) { + event.preventDefault(); + clearSourceScope(); + return; + } if ( event.key === 'Backspace' && query.length === 0 && @@ -1003,10 +1112,18 @@ export const Spotlight = ({ popScope(); return; } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + hasMovedSelectionRef.current = true; + } + const runsSourceSearch = + !!scopedSource && + isQueryLongEnough && + (!hasMovedSelectionRef.current || + search.posts.length === 0); if ( event.key === 'Enter' && isFiltering && - resultCount === 0 + (resultCount === 0 || runsSourceSearch) ) { event.preventDefault(); handleFallthroughEnter(); @@ -1041,8 +1158,13 @@ export const Spotlight = ({ )} - {scope === SpotlightScope.All && ( - + {scope === SpotlightScope.All && !scopedSource && ( + )} )} @@ -1055,24 +1177,90 @@ export const Spotlight = ({ /> )} - {!pendingCommand && ( - + {showSourcePosts && + sourcePosts.isLoading && + renderSkeletonGroup(`Latest in ${scopedSource.name}`)} + {showSourcePosts && sourcePosts.pinned.length > 0 && ( + + {renderRows({ + ...commonRowProps, + commands: sourcePosts.pinned, + })} + )} - ref={(node) => { - listRef.current = node; - if (!node) { - setResultCount(null); - return; - } - const items = node.querySelectorAll('[data-command-id]'); - setResultCount(items.length); - }} - > + {showSourcePosts && sourcePosts.latest.length > 0 && ( + + {renderRows({ + ...commonRowProps, + commands: sourcePosts.latest, + })} + + )} + {!showSourcePosts && + search.postsLoading && + search.posts.length === 0 && + renderSkeletonGroup(`Posts in ${scopedSource.name}`)} + {!showSourcePosts && search.posts.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: search.posts })} + + )} + {!search.isLoading && !sourcePosts.isLoading && ( + + +

+ {trimmedQuery + ? `No posts in ${scopedSource.name} match “${trimmedQuery}”` + : `No posts in ${scopedSource.name} yet.`} +

+ {trimmedQuery && ( + + )} +
+ )} +
+ )} + + {!pendingCommand && !scopedSource && ( + + {scope === SpotlightScope.All && sourceScopeCommand && ( + + {renderRows({ + ...commonRowProps, + commands: [sourceScopeCommand], + })} + + )} + {scope !== SpotlightScope.All && scope !== SpotlightScope.Actions && scopeSearch.isLoading && @@ -1305,6 +1493,7 @@ export const Spotlight = ({ + {scopedSource && } )} diff --git a/packages/shared/src/components/spotlight/SpotlightContext.tsx b/packages/shared/src/components/spotlight/SpotlightContext.tsx index 09e09dc1179..f4259bb8150 100644 --- a/packages/shared/src/components/spotlight/SpotlightContext.tsx +++ b/packages/shared/src/components/spotlight/SpotlightContext.tsx @@ -5,6 +5,7 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -17,6 +18,7 @@ import { type SpotlightAction, } from '../../graphql/spotlight'; import { SpotlightScope } from './types'; +import type { SpotlightSource } from './types'; import { registerSpotlightShortcutBlocker } from './shortcuts'; type SpotlightActionsResponse = { spotlightActions: SpotlightAction[] }; @@ -39,6 +41,15 @@ const spotlightActionsQueryOptions = { const platformId = isExtension ? 'extension' : 'webapp'; +export interface SpotlightSourceOptions { + /** Prefills the field, e.g. with the query of the squad's results page. */ + query?: string; +} + +interface PageSource extends SpotlightSourceOptions { + source: SpotlightSource; +} + export interface SpotlightContextValue { isOpen: boolean; query: string; @@ -65,6 +76,28 @@ export interface SpotlightContextValue { popScope: () => void; /** Reset the stack to `All`. */ clearScope: () => void; + /** + * The squad this session was opened for. It stays set after the pill is + * removed, so the palette can offer to narrow back to it. + */ + source: SpotlightSource | null; + /** True while results are narrowed to `source` (the pill is showing). */ + isSourceScoped: boolean; + /** Open the modal narrowed to one squad's posts. */ + openWithSource: ( + source: SpotlightSource, + options?: SpotlightSourceOptions, + ) => void; + /** Narrow back to `source` after widening. */ + scopeToSource: () => void; + /** Remove the pill and search all of daily.dev, keeping the query. */ + clearSourceScope: () => void; + /** + * Opens the way the keyboard shortcut does: narrowed to the page's squad + * when one is registered through `useSpotlightPageSource`. + */ + openFromShortcut: () => void; + registerPageSource: (entry: PageSource) => () => void; /** * Warm the action catalog before the modal opens. Call it from hover/focus * on anything that opens Spotlight so the list is there on click; the query @@ -91,6 +124,9 @@ export const SpotlightProvider = ({ const [query, setQueryState] = useState(''); const [pendingConfirmId, setPendingConfirmId] = useState(null); const [pages, setPages] = useState([]); + const [source, setSource] = useState(null); + const [isSourceScoped, setIsSourceScoped] = useState(false); + const pageSourceRef = useRef(null); const queryClient = useQueryClient(); const { isLoggedIn } = useAuthContext(); @@ -132,6 +168,8 @@ export const SpotlightProvider = ({ setQueryState(''); setPendingConfirmId(null); setPages([]); + setSource(null); + setIsSourceScoped(false); }, []); const toggle = useCallback(() => { @@ -140,6 +178,8 @@ export const SpotlightProvider = ({ setQueryState(''); setPendingConfirmId(null); setPages([]); + setSource(null); + setIsSourceScoped(false); } return !prev; }); @@ -162,10 +202,53 @@ export const SpotlightProvider = ({ setPages(next === SpotlightScope.All ? [] : [next]); setQueryState(''); setPendingConfirmId(null); + setSource(null); + setIsSourceScoped(false); + setIsOpen(true); + }, []); + + const openWithSource = useCallback( + (next: SpotlightSource, options?: SpotlightSourceOptions) => { + setPages([]); + setQueryState(options?.query ?? ''); + setPendingConfirmId(null); + setSource(next); + setIsSourceScoped(true); + setIsOpen(true); + }, + [], + ); + + const scopeToSource = useCallback(() => { + setPages([]); + setIsSourceScoped(true); + }, []); + + const clearSourceScope = useCallback(() => { + setIsSourceScoped(false); + }, []); + + const openFromShortcut = useCallback(() => { + const pageSource = pageSourceRef.current; + if (pageSource) { + openWithSource(pageSource.source, { query: pageSource.query }); + return; + } setIsOpen(true); + }, [openWithSource]); + + const registerPageSource = useCallback((entry: PageSource) => { + pageSourceRef.current = entry; + return () => { + if (pageSourceRef.current === entry) { + pageSourceRef.current = null; + } + }; }, []); const pushScope = useCallback((next: SpotlightScope) => { + // A type filter replaces the squad pill: the two never stack. + setIsSourceScoped(false); // Keep the active query when narrowing scope. The chip is a filter, not // a fresh search — clearing the query here would empty the entity lists // (search hooks return nothing without a query) and the user would see @@ -208,6 +291,13 @@ export const SpotlightProvider = ({ pushScope, popScope, clearScope, + source, + isSourceScoped, + openWithSource, + scopeToSource, + clearSourceScope, + openFromShortcut, + registerPageSource, prefetch, actions, isActionsLoading, @@ -228,6 +318,13 @@ export const SpotlightProvider = ({ pushScope, popScope, clearScope, + source, + isSourceScoped, + openWithSource, + scopeToSource, + clearSourceScope, + openFromShortcut, + registerPageSource, prefetch, actions, isActionsLoading, @@ -258,3 +355,25 @@ export const useDisableSpotlightShortcut = (enabled = true): void => { return registerSpotlightShortcutBlocker(); }, [enabled]); }; + +/** + * Lets a page make its squad the default scope of the keyboard shortcut, so + * ⌘K on a squad page searches that squad (the pill widens it back to all of + * daily.dev). Pass the page's current search query to prefill the field. + */ +export const useSpotlightPageSource = ( + source?: SpotlightSource | null, + options?: SpotlightSourceOptions, +): void => { + const { registerPageSource } = useSpotlight(); + const { id, handle, name, image } = source ?? {}; + const query = options?.query; + + useEffect(() => { + if (!id || !handle || !name) { + return undefined; + } + + return registerPageSource({ source: { id, handle, name, image }, query }); + }, [registerPageSource, id, handle, name, image, query]); +}; diff --git a/packages/shared/src/components/spotlight/commands/search.ts b/packages/shared/src/components/spotlight/commands/search.ts index ab85756b593..fc7c5ae2dfd 100644 --- a/packages/shared/src/components/spotlight/commands/search.ts +++ b/packages/shared/src/components/spotlight/commands/search.ts @@ -14,6 +14,7 @@ import { SpotlightGroup, SpotlightScope, type SpotlightCommand, + type SpotlightSource, } from '../types'; interface SearchCommandsContext { @@ -22,8 +23,22 @@ interface SearchCommandsContext { scope?: SpotlightScope; /** Correlation id for the current palette query, minted by Spotlight. */ searchId?: string; + /** + * Narrows the search to one squad's posts. Every other provider stays idle + * and "see all" lands on the squad's own results page. + */ + source?: SpotlightSource | null; } +/** A squad scope shows more post hits than the mixed palette's three. */ +export const SOURCE_POST_SUGGESTIONS_LIMIT = 6; + +const getSourceSearchUrl = ( + source: Pick, + query: string, +): string => + `${webappUrl}squads/${source.handle}?q=${encodeURIComponent(query)}`; + export interface SpotlightSearchCommands { /** True while any provider relevant to the active scope is still loading. */ isLoading: boolean; @@ -165,6 +180,21 @@ const buildSeeAllCommand = ( }, }); +export const buildSourceSeeAllCommand = ( + source: SpotlightSource, + query: string, + router: SearchCommandsContext['router'], +): SpotlightCommand => ({ + id: 'search.see-all.source', + title: `See all results in ${source.name}`, + icon: OpenLinkIcon, + group: SpotlightGroup.Search, + meta: { kind: 'see-all', scope: SpotlightScope.Posts }, + perform: () => { + router.push(getSourceSearchUrl(source, query)); + }, +}); + /** * Always-on rows that act as the no-results escape hatch and explicit * search-provider entry points. Visible whenever there's a query. @@ -206,14 +236,17 @@ const scopeProviders: Record = { [SpotlightScope.Tags]: [SearchProviderEnum.Tags], }; +const sourceProviders = [SearchProviderEnum.Posts]; + export const useSpotlightSearchCommands = ({ router, query, scope = SpotlightScope.All, searchId, + source, }: SearchCommandsContext): SpotlightSearchCommands => { const trimmed = query.trim(); - const providers = scopeProviders[scope]; + const providers = source ? sourceProviders : scopeProviders[scope]; const { suggestions: postHits, isLoading: postsLoading } = useSearchProviderSuggestions({ @@ -221,7 +254,9 @@ export const useSpotlightSearchCommands = ({ query: trimmed, enabled: providers.includes(SearchProviderEnum.Posts), searchId, - scope, + scope: source ? 'source' : scope, + source: source?.id, + limit: source ? SOURCE_POST_SUGGESTIONS_LIMIT : undefined, }); const { suggestions: tagHits, isLoading: tagsLoading } = useSearchProviderSuggestions({ @@ -284,10 +319,15 @@ export const useSpotlightSearchCommands = ({ ); const isUsersLoading = loadingFor(SearchProviderEnum.Users, usersLoading); + const postsWithSeeAll = + source && posts.length > 0 + ? [...posts, buildSourceSeeAllCommand(source, trimmed, router)] + : withSeeAll(SpotlightScope.Posts, posts); + return { isLoading: isPostsLoading || isTagsLoading || isSourcesLoading || isUsersLoading, - posts: withSeeAll(SpotlightScope.Posts, posts), + posts: postsWithSeeAll, postsLoading: isPostsLoading, tags: withSeeAll(SpotlightScope.Tags, tags), tagsLoading: isTagsLoading, @@ -295,7 +335,7 @@ export const useSpotlightSearchCommands = ({ sourcesLoading: isSourcesLoading, users: withSeeAll(SpotlightScope.People, users), usersLoading: isUsersLoading, - fallthrough: buildFallthrough(trimmed, router), + fallthrough: source ? [] : buildFallthrough(trimmed, router), counts: { posts: posts.length, tags: tags.length, @@ -306,6 +346,7 @@ export const useSpotlightSearchCommands = ({ }, [ trimmed, router, + source, providers, postHits, postsLoading, diff --git a/packages/shared/src/components/spotlight/commands/sourcePosts.ts b/packages/shared/src/components/spotlight/commands/sourcePosts.ts new file mode 100644 index 00000000000..1452983039a --- /dev/null +++ b/packages/shared/src/components/spotlight/commands/sourcePosts.ts @@ -0,0 +1,89 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { SearchIcon } from '../../icons'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import type { SourceSpotlightPost } from '../../../graphql/search'; +import { sourceSpotlightPostsQueryOptions } from '../../../graphql/search'; +import { getPostTitle } from '../../../graphql/posts'; +import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; +import { webappUrl } from '../../../lib/constants'; +import { + SpotlightGroup, + type SpotlightCommand, + type SpotlightSource, +} from '../types'; + +const LATEST_POSTS_LIMIT = 5; + +interface UseSpotlightSourcePostsProps { + router: Pick; + source: SpotlightSource | null; + enabled: boolean; +} + +export interface SpotlightSourcePosts { + pinned: SpotlightCommand[]; + latest: SpotlightCommand[]; + isLoading: boolean; +} + +const buildSourcePostCommand = ( + post: SourceSpotlightPost, + source: SpotlightSource, + router: UseSpotlightSourcePostsProps['router'], +): SpotlightCommand => { + const date = post.createdAt + ? formatDate({ value: post.createdAt, type: TimeFormatType.Post }) + : ''; + + return { + id: `search.source-post.${post.id}`, + title: getPostTitle(post) ?? '', + icon: SearchIcon, + group: SpotlightGroup.Search, + meta: { + kind: 'post', + sourceImage: post.image || post.sharedPost?.image || source.image, + sourceName: [post.author?.name, date].filter(Boolean).join(' · '), + }, + perform: () => { + router.push(`${webappUrl}posts/${post.id}`); + }, + }; +}; + +/** + * What a squad scoped Spotlight lists before anything is typed: the squad's + * pinned posts, then its latest ones. + */ +export const useSpotlightSourcePosts = ({ + router, + source, + enabled, +}: UseSpotlightSourcePostsProps): SpotlightSourcePosts => { + const { user } = useAuthContext(); + const isEnabled = enabled && !!source; + const { data, isPending } = useQuery({ + ...sourceSpotlightPostsQueryOptions({ user, sourceId: source?.id ?? '' }), + enabled: isEnabled, + }); + + return useMemo(() => { + if (!source || !data) { + return { pinned: [], latest: [], isLoading: isEnabled && isPending }; + } + + const toCommand = (post: SourceSpotlightPost) => + buildSourcePostCommand(post, source, router); + + return { + pinned: data.filter((post) => !!post.pinnedAt).map(toCommand), + latest: data + .filter((post) => !post.pinnedAt) + .slice(0, LATEST_POSTS_LIMIT) + .map(toCommand), + isLoading: false, + }; + }, [source, data, router, isEnabled, isPending]); +}; diff --git a/packages/shared/src/components/spotlight/types.ts b/packages/shared/src/components/spotlight/types.ts index 933aea1ee20..489b6b170f2 100644 --- a/packages/shared/src/components/spotlight/types.ts +++ b/packages/shared/src/components/spotlight/types.ts @@ -127,6 +127,14 @@ export enum SpotlightScope { Tags = 'tags', } +/** The squad (or source) a Spotlight session can be narrowed to. */ +export interface SpotlightSource { + id: string; + handle: string; + name: string; + image?: string; +} + export interface ScopeMetaEntry { label: string; /** Used for tooltips and ARIA labels on the trigger icon buttons. */ diff --git a/packages/shared/src/components/squads/Details.tsx b/packages/shared/src/components/squads/Details.tsx index 383e61e2cc4..d628488d557 100644 --- a/packages/shared/src/components/squads/Details.tsx +++ b/packages/shared/src/components/squads/Details.tsx @@ -1,48 +1,31 @@ -import type { ChangeEvent, FormEvent, ReactElement, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import type { FormEvent, ReactElement, ReactNode } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; import type { ClientError } from 'graphql-request'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/router'; -import { - Button, - ButtonColor, - ButtonSize, - ButtonVariant, -} from '../buttons/Button'; +import { ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button'; import { TextField } from '../fields/TextField'; -import { ArrowIcon, AtIcon, CameraIcon, SlackIcon, SquadIcon } from '../icons'; +import { ArrowIcon, AtIcon, SlackIcon, SquadIcon } from '../icons'; import Textarea from '../fields/Textarea'; -import ImageInput from '../fields/ImageInput'; -import { cloudinarySquadsImageFallback } from '../../lib/image'; import { formToJson } from '../../lib/form'; import type { SquadForm } from '../../graphql/squads'; import { checkExistingHandle } from '../../graphql/squads'; import { capitalize } from '../../lib/strings'; -import { IconSize } from '../Icon'; import { FormWrapper } from '../fields/form'; import { SquadPrivacySection } from './settings/SquadPrivacySection'; +import type { SquadPostingGate } from './settings/SquadModerationSettingsSection'; import { + postingGateToInput, SquadModerationSettingsSection, - SquadPostingGate, } from './settings/SquadModerationSettingsSection'; import { SquadSettingsSection } from './settings'; -import { SquadStats } from './common/SquadStat'; -import { SquadPrivacyState } from './common/SquadPrivacyState'; -import { SquadDangerZone } from './settings/SquadDangerZone'; import type { Squad } from '../../graphql/sources'; import { useViewSize, ViewSize } from '../../hooks'; import { useSlackChannelsQuery } from '../../hooks/integrations/slack/useSlackChannelsQuery'; import { Dropdown } from '../fields/Dropdown'; import { Typography, TypographyType } from '../typography/Typography'; -import { ACCEPTED_TYPES, acceptedTypesList } from '../../graphql/posts'; -import { useFileInput } from '../../hooks/utils/useFileInput'; - -const squadImageId = 'squad_image_file'; -const squadHeaderId = 'squad_header_file'; -// The three posting gates are exclusive in the UI but two independent fields on -// the API, so the radio value is expanded back into both here. type SquadFormFields = Omit< SquadForm, 'moderationRequired' | 'postingMinReputation' @@ -51,25 +34,6 @@ type SquadFormFields = Omit< postingMinReputation?: string; }; -const postingGateToInput = ( - gate: SquadPostingGate | undefined, - minReputation: string | undefined, -): Pick => { - if (gate === SquadPostingGate.Reputation) { - const parsed = parseInt(minReputation, 10); - - return { - moderationRequired: false, - postingMinReputation: Number.isNaN(parsed) ? null : parsed, - }; - } - - return { - moderationRequired: gate === SquadPostingGate.Moderation, - postingMinReputation: null, - }; -}; - interface SquadDetailsProps { onSubmit: ( e: FormEvent, @@ -79,7 +43,6 @@ interface SquadDetailsProps { onRequestClose?: () => void; children?: ReactNode; isLoading?: boolean; - squad?: Squad; initialData?: Partial; integrationId?: string; } @@ -88,37 +51,27 @@ export function SquadDetails({ onSubmit, children, isLoading, - squad, initialData = {}, integrationId, }: SquadDetailsProps): ReactElement { - const createMode = !squad; const { name, handle, description, - image, category, - flags, - headerImage, memberPostingRole: initialMemberPostingRole, memberInviteRole: initialMemberInviteRole, moderationRequired: initialModerationRequired, postingMinReputation: initialPostingMinReputation, - } = squad ?? { ...initialData }; + } = initialData; const [activeHandle, setActiveHandle] = useState(handle); - const [imageChanged, setImageChanged] = useState(false); - const [headerChanged, setHeaderChanged] = useState(false); const [handleHint, setHandleHint] = useState(null); const [canSubmit, setCanSubmit] = useState(!!name && !!activeHandle); const [categoryHint, setCategoryHint] = useState(''); - const [isDescriptionOpen, setDescriptionOpen] = useState(!createMode); + const [isDescriptionOpen, setDescriptionOpen] = useState(false); const [selectedChannel, setSelectedChannel] = useState(null); const router = useRouter(); const isMobile = useViewSize(ViewSize.MobileL); - const headerImageRef = useRef(); - const imageFileRef = useRef(null); - const headerFileRef = useRef(null); const { channels, @@ -127,7 +80,7 @@ export function SquadDetails({ isFetchingNextPage: isFetchingNextChannelPage, } = useSlackChannelsQuery({ integrationId, - queryOptions: { enabled: createMode }, + queryOptions: { enabled: true }, selectedChannelId: selectedChannel, }); @@ -175,18 +128,6 @@ export function SquadDetails({ ...postingGateToInput(postingGate, postingMinReputation), }; - if (imageChanged) { - data.file = imageFileRef.current ?? undefined; - } - - if (headerChanged) { - data.header = headerFileRef.current ?? undefined; - } - - if (!createMode) { - return onSubmit(e, data, channels?.[selectedChannelIndex]?.id); - } - const handleExists = await onValidateHandle(formJson.handle); setHandleHint(handleExists ? 'The handle already exists' : null); @@ -212,40 +153,27 @@ export function SquadDetails({ setCanSubmit(!!formJson.name); }; - const [headerImageBase64, setHeaderImageBase64] = useState(headerImage); - const { onFileChange } = useFileInput({ - acceptedTypes: acceptedTypesList, - limitMb: 2, - onChange(base64, file) { - setHeaderImageBase64(base64); - setHeaderChanged(true); - headerFileRef.current = file; - }, - }); - return ( , - onClick: () => - router.push(createMode ? '/squads' : `/squads/${handle}`), + onClick: () => router.push('/squads'), }} rightButtonProps={{ disabled: !canSubmit || isLoading, variant: ButtonVariant.Primary, - color: createMode ? ButtonColor.Cabbage : undefined, + color: ButtonColor.Cabbage, loading: isLoading, }} > @@ -256,65 +184,9 @@ export function SquadDetails({ onBlur={handleChange} id="squad-form" > - {!createMode && ( -
-
- } - alwaysShowHover={!imageChanged} - onChange={(_, file) => { - setImageChanged(true); - imageFileRef.current = file ?? null; - }} - size="medium" - /> - - { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - onFileChange(file); - }} - /> -
- - -
- )} setCategoryHint(''), [])} /> @@ -405,7 +277,6 @@ export function SquadDetails({ initialModerationRequired={initialModerationRequired} initialPostingMinReputation={initialPostingMinReputation} /> - {!createMode && }
); diff --git a/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx b/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx deleted file mode 100644 index c049f111981..00000000000 --- a/packages/shared/src/components/squads/Members/PrivilegedMemberItem.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; -import type { SourceMember, SourceMemberRole } from '../../../graphql/sources'; -import { SourceMemberRole as SourceMemberRoleEnum } from '../../../graphql/sources'; -import { ProfileLink } from '../../profile/ProfileLink'; -import { ProfileTooltip } from '../../profile/ProfileTooltip'; -import UserBadge from '../../UserBadge'; - -interface PrivilegedMemberItemProps { - user: SourceMember['user']; - badge?: string; - role?: SourceMemberRole; -} - -export function PrivilegedMemberItem({ - user, - badge, - role = SourceMemberRoleEnum.Member, -}: PrivilegedMemberItemProps): ReactElement { - return ( - - - -
- - {user.name} - - {badge && ( - - {badge} - - )} -
-
-
- ); -} diff --git a/packages/shared/src/components/squads/SharePostBar.tsx b/packages/shared/src/components/squads/SharePostBar.tsx deleted file mode 100644 index 499e57c5ae3..00000000000 --- a/packages/shared/src/components/squads/SharePostBar.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import type { FormEvent, ReactElement } from 'react'; -import React, { useRef, useState } from 'react'; -import classNames from 'classnames'; -import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; -import { Button, ButtonColor, ButtonVariant } from '../buttons/Button'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { LockIcon } from '../icons'; -import { Card } from '../cards/common/Card'; -import { IconSize } from '../Icon'; -import { usePostToSquad, useViewSize, ViewSize } from '../../hooks'; -import { ClickableText } from '../buttons/ClickableText'; -import { useLazyModal } from '../../hooks/useLazyModal'; -import { LazyModal } from '../modals/common/types'; -import type { Squad } from '../../graphql/sources'; -import type { ExternalLinkPreview } from '../../graphql/posts'; -import { Divider } from '../utilities'; - -export interface SharePostBarProps { - className?: string; - disabled?: boolean; - disabledText?: string; - squad: Squad; -} - -function SharePostBar({ - className, - disabled = false, - disabledText = 'Only admins and moderators can post', - squad, -}: SharePostBarProps): ReactElement { - const inputRef = useRef(null); - const { user } = useAuthContext(); - const { openModal } = useLazyModal(); - const [url, setUrl] = useState(''); - const isMobile = useViewSize(ViewSize.MobileL); - const [urlFocused, toggleUrlFocus] = useState(false); - - const shouldRenderReadingHistory = !urlFocused && url.length === 0; - - const clearInput = () => { - if (inputRef.current) { - inputRef.current.value = ''; - } - setUrl(''); - }; - - const onOpenCreatePost = (preview: ExternalLinkPreview, link?: string) => { - openModal({ - type: LazyModal.SmartComposer, - props: { - initialUrl: link, - initialSquadHandle: squad.handle, - preview: { ...preview, url: link }, - onAfterClose: clearInput, - }, - }); - }; - - const onOpenHistory = () => - openModal({ - type: LazyModal.ReadingHistory, - props: { - onArticleSelected: ({ post }) => onOpenCreatePost(post, post.permalink), - keepOpenAfterSelecting: true, - }, - }); - - const { getLinkPreview, isLoadingPreview } = usePostToSquad({ - onExternalLinkSuccess: onOpenCreatePost, - }); - - const onSubmit = async (e: FormEvent) => { - e.preventDefault(); - await getLinkPreview(url); - }; - - if (disabled) { - return ( - - -

{disabledText}

-
- ); - } - - return ( -
- -
- {user && ( - - )} -
- setUrl(e.currentTarget.value)} - value={url} - onBlur={() => toggleUrlFocus(false)} - onFocus={() => toggleUrlFocus(true)} - /> - {shouldRenderReadingHistory && ( - - reading history - - )} -
-
- -
- {isMobile && ( - - )} - - - ); -} - -export default SharePostBar; diff --git a/packages/shared/src/components/squads/SquadActionButton.tsx b/packages/shared/src/components/squads/SquadActionButton.tsx index 0dd7c38f7c6..10943ae23e5 100644 --- a/packages/shared/src/components/squads/SquadActionButton.tsx +++ b/packages/shared/src/components/squads/SquadActionButton.tsx @@ -302,12 +302,15 @@ export const SquadActionButton = ({ return null; } + const label = isCurrentMember ? leave : join; + return ( - {isCurrentMember ? leave : join} + {label} ); diff --git a/packages/shared/src/components/squads/SquadFeedHeading.tsx b/packages/shared/src/components/squads/SquadFeedHeading.tsx deleted file mode 100644 index bdd1c3b1020..00000000000 --- a/packages/shared/src/components/squads/SquadFeedHeading.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { useContext, useMemo } from 'react'; -import { Button, ButtonVariant } from '../buttons/Button'; -import { PinIcon } from '../icons'; -import type { Squad } from '../../graphql/sources'; -import { ActiveFeedContext } from '../../contexts'; -import { useSquadActions } from '../../hooks'; - -interface SquadFeedHeadingProps { - squad: Squad; - /** Optional search field rendered at the start of the heading row. */ - searchChildren?: ReactNode; -} - -function SquadFeedHeading({ - squad, - searchChildren, -}: SquadFeedHeadingProps): ReactElement { - const { items } = useContext(ActiveFeedContext); - const { collapseSquadPinnedPosts, expandSquadPinnedPosts } = useSquadActions({ - squad, - membersQueryEnabled: false, - }); - const collapsePinnedPosts = squad?.currentMember?.flags?.collapsePinnedPosts; - const isSquadMember = !!squad.currentMember; - - const onClick = async () => { - const togglePinnedPosts = collapsePinnedPosts - ? expandSquadPinnedPosts - : collapseSquadPinnedPosts; - - if (!togglePinnedPosts || !squad.id) { - throw new Error( - 'SquadFeedHeading: pinned posts toggle requires a squad id and mutation', - ); - } - - return togglePinnedPosts(squad.id); - }; - - const pinnedPostsCount = useMemo( - () => - items.reduce((acc, item) => { - if (item.type === 'post' && !!item.post.pinnedAt) { - return acc + 1; - } - return acc; - }, 0), - [items], - ); - - return ( -
- {/* Mirrors the bookmarks CustomFeedHeader idiom: the search field fills - the row and the action buttons sit directly after it, so there is no - dead gap between the two. */} - {searchChildren && ( -
- {searchChildren} -
- )} - {isSquadMember && ( - - - - )} -
- ); -} - -export default SquadFeedHeading; diff --git a/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx b/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx deleted file mode 100644 index 39d6276ad97..00000000000 --- a/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { QueryClient } from '@tanstack/react-query'; -import type { RenderResult } from '@testing-library/react'; -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { SquadHeaderBar } from './SquadHeaderBar'; - -import { - generateMembersList, - generateTestSquad, -} from '../../../__tests__/fixture/squads'; -import { TestBootProvider } from '../../../__tests__/helpers/boot'; -import { SourcePermissions } from '../../graphql/sources'; - -jest.mock('../../hooks/integrations/useSourceIntegrationQuery', () => ({ - useSourceIntegrationQuery: jest.fn(() => ({ - data: null, - isPending: false, - })), -})); - -const client = new QueryClient(); -const mock = { - squad: generateTestSquad(), - members: generateMembersList().map((member) => member.node), -} as const; - -const renderComponent = (options?: { - props?: Partial; -}): RenderResult => { - const { props = {} } = options ?? {}; - - return render( - - - , - ); -}; - -describe('Member list', () => { - it('should render the squad header bar with the correct number of members', async () => { - renderComponent(); - const { membersCount } = mock.squad; - const countEl = await screen.findByLabelText( - `View ${membersCount} squad members`, - ); - expect(countEl).toHaveTextContent(membersCount.toString()); - }); -}); - -describe('Moderation button', () => { - it('should not render button with pending posts moderation is disabled', async () => { - const squad = generateTestSquad({ - moderationRequired: false, - moderationPostCount: 10, - }); - renderComponent({ props: { squad } }); - const buttonEl = screen.queryByTestId('squad-moderation-button'); - expect(buttonEl).not.toBeInTheDocument(); - }); - - it('should not render button with pending posts if none', async () => { - const squad = generateTestSquad({ - moderationRequired: true, - moderationPostCount: 0, - }); - renderComponent({ props: { squad } }); - const buttonEl = screen.queryByTestId('squad-moderation-button'); - expect(buttonEl).not.toBeInTheDocument(); - }); - - it('should render button with pending posts if any', async () => { - const squad = generateTestSquad({ - moderationRequired: true, - moderationPostCount: 1, - }); - renderComponent({ props: { squad } }); - await screen.findByText('1 Pending post'); - }); -}); - -describe('Analytics button', () => { - it('should render analytics button when user has ViewAnalytics permission', () => { - const squad = generateTestSquad({ - currentMember: { - ...mock.squad.currentMember!, - permissions: [SourcePermissions.ViewAnalytics], - }, - }); - renderComponent({ props: { squad } }); - - const analyticsLink = screen.getByRole('link', { - name: 'Squad analytics', - }); - - expect(analyticsLink).toHaveAttribute( - 'href', - `/squads/${squad.handle}/analytics`, - ); - }); - - it('should not render analytics button when user does not have ViewAnalytics permission', () => { - const squad = generateTestSquad({ - currentMember: { - ...mock.squad.currentMember!, - permissions: [SourcePermissions.Post], - }, - }); - renderComponent({ props: { squad } }); - - expect( - screen.queryByRole('link', { name: 'Squad analytics' }), - ).not.toBeInTheDocument(); - }); -}); - -describe('Link controls', () => { - it('should show the invitation link and the copy link to a member who can invite', () => { - const squad = generateTestSquad({ - currentMember: { - ...mock.squad.currentMember!, - permissions: [SourcePermissions.Invite], - }, - }); - renderComponent({ props: { squad } }); - - expect( - screen.getByRole('button', { name: 'Invitation link' }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Copy link' }), - ).toBeInTheDocument(); - }); - - it('should show only the copy link to a non-member', () => { - const squad = generateTestSquad({ currentMember: undefined }); - renderComponent({ props: { squad } }); - - expect( - screen.getByRole('button', { name: 'Copy link' }), - ).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Invitation link' }), - ).not.toBeInTheDocument(); - }); -}); diff --git a/packages/shared/src/components/squads/SquadHeaderBar.tsx b/packages/shared/src/components/squads/SquadHeaderBar.tsx deleted file mode 100644 index e508e884138..00000000000 --- a/packages/shared/src/components/squads/SquadHeaderBar.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import classNames from 'classnames'; -import type { HTMLAttributes, ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import type { AllowedTags, ButtonProps } from '../buttons/Button'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import SquadHeaderMenu from './SquadHeaderMenu'; -import type { SquadMemberShortListProps } from './SquadMemberShortList'; -import SquadMemberShortList from './SquadMemberShortList'; -import { useSquadInvitation } from '../../hooks/useSquadInvitation'; -import { LogEvent, Origin } from '../../lib/log'; -import { verifyPermission } from '../../graphql/squads'; -import { SourcePermissions } from '../../graphql/sources'; -import { SquadActionButton } from './SquadActionButton'; -import { - AddUserIcon, - AnalyticsIcon, - BellIcon, - SlackIcon, - TimerIcon, -} from '../icons'; -import { useLazyModal } from '../../hooks/useLazyModal'; -import { LazyModal } from '../modals/common/types'; -import { useSourceIntegrationQuery } from '../../hooks/integrations/useSourceIntegrationQuery'; -import { UserIntegrationType } from '../../graphql/integrations'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { ProfileImageSize } from '../ProfilePicture'; -import { useGetSquadAwardAdmin } from '../../hooks/useCoresFeature'; -import { AwardButton } from '../award/AwardButton'; -import type { LoggedUser } from '../../lib/user'; -import { Tooltip } from '../tooltip/Tooltip'; -import { BoostSourceButton } from '../../features/boost/BoostSourceButton'; -import { CopyLinkButton } from '../share/CopyLinkButton'; -import { ReferralCampaignKey } from '../../lib/referral'; - -type SquadBarButtonProps = Pick< - Partial>, - 'onClick' | 'disabled' | 'icon' -> & - Pick; - -const SquadSlackButton = ({ - squad, - ...props -}: SquadBarButtonProps) => { - const { user } = useAuthContext(); - const { data: sourceIntegration, isPending } = useSourceIntegrationQuery({ - sourceId: squad.id, - userIntegrationType: UserIntegrationType.Slack, - }); - - const slackButtonLabel = useMemo(() => { - if (!verifyPermission(squad, SourcePermissions.ConnectSlack)) { - return null; - } - - if (isPending && !sourceIntegration) { - return null; - } - - if (!sourceIntegration) { - return 'Connect to Slack'; - } - - if (sourceIntegration?.userIntegration.userId === user?.id) { - return 'Manage'; - } - - return null; - }, [sourceIntegration, user, squad, isPending]); - - if (!slackButtonLabel) { - return null; - } - - return ( - - ); -}; - -const SquadAwardButton = ({ - squad, -}: Pick) => { - const { user } = useAuthContext(); - const eligibleAdmin = useGetSquadAwardAdmin({ - sendingUser: user, - squad, - }); - const canAwardSquad = !!eligibleAdmin; - - if (!canAwardSquad || !squad.id) { - return null; - } - return ( - - ); -}; - -const SquadInviteButton = ({ - squad, - ...props -}: SquadBarButtonProps) => { - return ( - - ); -}; - -const SquadUserNotifications = ({ - squad, - ...props -}: SquadBarButtonProps<'button'>) => { - return ( - - - ); -}; - -export function SquadHeaderBar({ - squad, - members, - className, - ...props -}: SquadMemberShortListProps & HTMLAttributes): ReactElement { - const { copying, logAndCopyLink } = useSquadInvitation({ - squad, - origin: Origin.SquadPage, - }); - const { openModal, modal } = useLazyModal(); - const isMember = !!squad.currentMember; - const userCanJoin = squad.public && !isMember; - const canInvite = - !userCanJoin && verifyPermission(squad, SourcePermissions.Invite); - const showPendingCount = !!( - squad.moderationRequired && squad.moderationPostCount - ); - - return ( -
- {userCanJoin && ( - - )} - - {canInvite && ( - { - logAndCopyLink(); - }} - disabled={copying} - /> - )} - - {squad.public && ( - - )} - {showPendingCount && } - { - openModal({ - type: LazyModal.SlackIntegration, - props: { - source: squad, - trackStart: true, - }, - }); - }} - /> - {isMember && ( - - } - onClick={() => { - openModal({ - type: LazyModal.SquadNotifications, - props: { squad }, - }); - }} - squad={squad} - /> - )} - ({ - event_name: LogEvent.ShareSource, - target_id: squad.id, - }), - }} - /> - - -
- ); -} diff --git a/packages/shared/src/components/squads/SquadMemberShortList.tsx b/packages/shared/src/components/squads/SquadMemberShortList.tsx index f3beffa7c61..fe78c6fed93 100644 --- a/packages/shared/src/components/squads/SquadMemberShortList.tsx +++ b/packages/shared/src/components/squads/SquadMemberShortList.tsx @@ -1,9 +1,8 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; import React from 'react'; +import { useRouter } from 'next/router'; import type { BasicSourceMember, Squad } from '../../graphql/sources'; -import { useLazyModal } from '../../hooks/useLazyModal'; -import { LazyModal } from '../modals/common/types'; import { ProfileImageSize, ProfilePicture, @@ -12,6 +11,7 @@ import { import useSidebarRendered from '../../hooks/useSidebarRendered'; import { largeNumberFormat } from '../../lib'; import { Tooltip } from '../tooltip/Tooltip'; +import { getSquadMembersUrl } from '../../features/squads/lib/routes'; export interface SquadMemberShortListProps { squad: Squad; @@ -26,27 +26,23 @@ function SquadMemberShortList({ className, size = ProfileImageSize.Medium, }: SquadMemberShortListProps): ReactElement { + const router = useRouter(); const { sidebarRendered } = useSidebarRendered(); - const { openModal } = useLazyModal(); - const openMemberListModal = () => - openModal({ - type: LazyModal.SquadMember, - props: { - squad, - placeholderAmount: squad?.membersCount, - }, - }); + const membersUrl = getSquadMembersUrl(squad.handle); return ( - + ); } diff --git a/packages/shared/src/components/squads/SquadPageHeader.tsx b/packages/shared/src/components/squads/SquadPageHeader.tsx deleted file mode 100644 index f1d4c49b480..00000000000 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useCallback } from 'react'; -import classNames from 'classnames'; -import type { BasicSourceMember, Squad } from '../../graphql/sources'; -import { SourceMemberRole, SourcePermissions } from '../../graphql/sources'; -import { SquadHeaderBar } from './SquadHeaderBar'; -import { SquadImage } from './SquadImage'; -import { FlexCentered, FlexCol, getRoleName } from '../utilities'; -import SharePostBar from './SharePostBar'; -import { verifyPermission } from '../../graphql/squads'; -import { Button, ButtonColor, ButtonVariant } from '../buttons/Button'; -import classed from '../../lib/classed'; -import ConditionalWrapper from '../ConditionalWrapper'; -import { link } from '../../lib/links'; -import { Separator } from '../cards/common/common'; -import { PrivilegedMemberItem } from './Members/PrivilegedMemberItem'; -import { formatMonthYearOnly } from '../../lib/dateFormat'; -import { - MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP, - MAX_VISIBLE_PRIVILEGED_MEMBERS_MOBILE, -} from '../../lib/config'; -import { useViewSize, ViewSize } from '../../hooks'; -import { useLazyModal } from '../../hooks/useLazyModal'; -import { LazyModal } from '../modals/common/types'; -import { SquadStat } from './common/SquadStat'; -import { SquadPrivacyState } from './common/SquadPrivacyState'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../typography/Typography'; -import { ClickableText } from '../buttons/ClickableText'; -import { SquadStack } from './stack/SquadStack'; - -interface SquadPageHeaderProps { - squad: Squad; - members: BasicSourceMember[]; - shouldUseListMode: boolean; - /** - * v2: hide the in-card `` when the unified PageHeader at - * the top of the floating card already hosts the action bar. Keeps the - * squad identity card focused on identity + sharing. - */ - hideHeaderBar?: boolean; -} - -const MAX_WIDTH = 'laptopL:max-w-[38.5rem]'; -const Divider = classed('span', 'flex flex-1 h-px bg-border-subtlest-tertiary'); - -const getPostingDisabledText = ( - squad: Squad, - isSquadMember: boolean, -): string => { - if (!isSquadMember) { - return 'Join the Squad to create new posts'; - } - - if ( - typeof squad.postingMinReputation === 'number' && - squad.currentMember?.role === SourceMemberRole.Member - ) { - return `You need ${squad.postingMinReputation} reputation points to post`; - } - - return 'Only admins and moderators can post'; -}; - -export function SquadPageHeader({ - squad, - members, - shouldUseListMode, - hideHeaderBar = false, -}: SquadPageHeaderProps): ReactElement { - const { openModal } = useLazyModal(); - const allowedToPost = verifyPermission(squad, SourcePermissions.Post); - const { category } = squad; - const squadId = squad.id ?? ''; - const isSquadMember = !!squad.currentMember; - - const createdAt = squad.createdAt - ? formatMonthYearOnly(squad.createdAt) - : null; - const privilegedLength = squad.privilegedMembers?.length || 0; - const topMembers = squad.topMembers ?? []; - const topMembersLength = topMembers.length; - const isMobile = useViewSize(ViewSize.MobileL); - const listMax = isMobile - ? MAX_VISIBLE_PRIVILEGED_MEMBERS_MOBILE - : MAX_VISIBLE_PRIVILEGED_MEMBERS_LAPTOP; - const onNewPostClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - openModal({ - type: LazyModal.SmartComposer, - props: { initialSquadHandle: squad.handle }, - }); - }, - [openModal, squad.handle], - ); - - return ( - -
- - - - {squad.name} - -
- - @{squad.handle} - - {createdAt && ( - <> - - Created {createdAt} - - )} - {!!category && ( - <> - - - {category.title} - - - )} -
-
- - ( -
{component}
- )} - > - - - - {squad.flags?.totalAwards ? ( - { - openModal({ - type: LazyModal.ListAwards, - props: { - queryProps: { - id: squadId, - type: 'SQUAD', - }, - }, - }); - }} - > - - - ) : undefined} -
-
-
-
- {squad.description && ( - - {squad.description} - - )} - {!hideHeaderBar && ( - - )} - - Moderated by - -
- {squad.privilegedMembers?.slice(0, listMax).map((member) => ( - - ))} - {privilegedLength > listMax && ( - - )} -
- {topMembers.length > 0 && ( - <> - - Top members - -
- {topMembers.slice(0, listMax).map((member) => ( - - ))} - {topMembersLength > listMax && ( - - )} -
- - )} -
- -
-
- ( - <> - - {children} - - - or - - - - - )} - > - - -
-
- ); -} diff --git a/packages/shared/src/components/squads/SquadTabs.tsx b/packages/shared/src/components/squads/SquadTabs.tsx deleted file mode 100644 index ca8c74544d6..00000000000 --- a/packages/shared/src/components/squads/SquadTabs.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { TabContainer, Tab } from '../tabs/TabContainer'; -import { webappUrl } from '../../lib/constants'; -import { SourcePermissions } from '../../graphql/sources'; -import { verifyPermission } from '../../graphql/squads'; -import { useSquad } from '../../hooks'; -import { useSquadPendingPosts } from '../../hooks/squads/useSquadPendingPosts'; - -export enum SquadTab { - Settings = 'Settings', - PendingPosts = 'Pending posts', -} - -interface SquadTabsProps { - active: SquadTab; - handle: string; -} - -export function SquadTabs({ active, handle }: SquadTabsProps): ReactElement { - const { squad } = useSquad({ - handle, - }); - const { count } = useSquadPendingPosts({ - squadId: squad?.id, - }); - const isModerator = squad - ? verifyPermission(squad, SourcePermissions.ModeratePost) - : false; - const squadLink = `${webappUrl}squads/${handle}`; - const pendingTabLabel = count - ? `${SquadTab.PendingPosts} (${count})` - : SquadTab.PendingPosts; - - const links = [ - ...(isModerator - ? [ - { - label: SquadTab.Settings, - url: `${squadLink}/edit`, - }, - ] - : []), - { - label: pendingTabLabel, - url: `${webappUrl}squads/moderate?handle=${handle}`, - }, - ]; - - const controlledActive = - active === SquadTab.PendingPosts ? pendingTabLabel : active; - - return ( - - {links.map(({ label, url }) => ( - - ))} - - ); -} diff --git a/packages/shared/src/components/squads/common/SquadPrivacyState.tsx b/packages/shared/src/components/squads/common/SquadPrivacyState.tsx deleted file mode 100644 index 83fae55aa13..00000000000 --- a/packages/shared/src/components/squads/common/SquadPrivacyState.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { EarthIcon, LockIcon, SourceIcon, SparkleIcon } from '../../icons'; - -interface SquadPrivacyStateProps { - isPublic: boolean; - isFeatured: boolean; -} - -export function SquadPrivacyState({ - isPublic, - isFeatured, -}: SquadPrivacyStateProps): ReactElement { - const props = (() => { - if (isFeatured) { - return { icon: , copy: 'Featured' }; - } - - if (isPublic) { - return { icon: , copy: 'Public' }; - } - - return { icon: , copy: 'Private' }; - })(); - - return ( - - ); -} diff --git a/packages/shared/src/components/squads/common/SquadStat.tsx b/packages/shared/src/components/squads/common/SquadStat.tsx deleted file mode 100644 index 5a1744d41fa..00000000000 --- a/packages/shared/src/components/squads/common/SquadStat.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { largeNumberFormat } from '../../../lib'; -import type { Squad } from '../../../graphql/sources'; - -interface SquadStatProps { - count: number; - label: string; -} - -export function SquadStat({ count, label }: SquadStatProps): ReactElement { - return ( - - - {largeNumberFormat(count)} - - {label} - - ); -} - -export const SquadStats = ({ flags }: Pick): ReactElement => ( -
- - - - {flags?.totalAwards ? ( - - ) : undefined} -
-); diff --git a/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx b/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx index f140668b348..5d59d5684f0 100644 --- a/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx +++ b/packages/shared/src/components/squads/moderation/SquadModerationEmptyScreen.tsx @@ -40,7 +40,7 @@ export const EmptyModerationList = ({ isModerator: boolean; isFetched: boolean; }): ReactElement => { - if (!isFetched || !isModerator) { + if (!isFetched) { return (
diff --git a/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx b/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx index 37613b73464..5c6e80b0886 100644 --- a/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx +++ b/packages/shared/src/components/squads/moderation/SquadModerationItem.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { useSearchParams } from 'next/navigation'; import { Typography, TypographyColor, @@ -41,14 +40,12 @@ const SquadModerationPreview = (props: SquadModerationItemProps) => { const SpamWarnings = { [WarningReason.MultipleSquadPost]: 'Shared in multiple Squads - Spam alert', [WarningReason.DuplicatedInSameSquad]: - 'Duplicate post - the same post has been previously shared in the Squad"', + 'Duplicate post - the same post has been previously shared in the Squad', }; export function SquadModerationItem( props: SquadModerationItemProps, ): ReactElement { - const searchParams = useSearchParams(); - const handle = searchParams?.get('handle'); const { context, modal, user } = useSourceModerationItem(props); const { data, squad, onApprove, onReject, isPending } = props; const { rejectionReason, createdBy, createdAt, status, source } = data; @@ -58,8 +55,9 @@ export function SquadModerationItem( const post = data.sharedPost || data.post; const { title } = useTruncatedSummary( - data?.title || data.sharedPost?.title || data.post?.title, + data?.title || data.sharedPost?.title || data.post?.title || '', ); + const squadName = (squad ?? source)?.name; return (
@@ -72,7 +70,7 @@ export function SquadModerationItem( onClick={modal.open} type="button" /> - {!handle && ( + {!squad && source && (
)}
- + {createdBy && ( + + )}
- {createdBy.name} + {createdBy?.name}
@@ -136,7 +136,7 @@ export function SquadModerationItem( {status === SourcePostModerationStatus.Rejected && !user.isModerator && ( - Your post in {squad.name} was not approved for the following reason: + Your post in {squadName} was not approved for the following reason:{' '} {rejectionReason}. Please review the feedback and consider making changes before resubmitting. diff --git a/packages/shared/src/components/squads/moderation/SquadModerationList.tsx b/packages/shared/src/components/squads/moderation/SquadModerationList.tsx index f1eec170225..db8e8110212 100644 --- a/packages/shared/src/components/squads/moderation/SquadModerationList.tsx +++ b/packages/shared/src/components/squads/moderation/SquadModerationList.tsx @@ -12,7 +12,7 @@ import { EmptyModerationList } from './SquadModerationEmptyScreen'; import InfiniteScrolling from '../../containers/InfiniteScrolling'; interface SquadModerationListProps { - squad: Squad; + squad?: Squad; isModerator: boolean; } @@ -73,8 +73,8 @@ export function SquadModerationList({ squad={squad} data={item} isPending={isPending} - onReject={() => moderate.onReject(item.id, item.source.id)} - onApprove={() => moderate.onApprove([item.id], item.source.id)} + onReject={() => moderate.onReject(item.id, item.source?.id)} + onApprove={() => moderate.onApprove([item.id], item.source?.id)} /> ))} diff --git a/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts b/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts index 5d1474eee80..1e47c4108a3 100644 --- a/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts +++ b/packages/shared/src/components/squads/moderation/useSourceModerationItem.ts @@ -1,4 +1,3 @@ -import { useSearchParams } from 'next/navigation'; import type { SourcePostModeration } from '../../../graphql/squads'; import { verifyPermission } from '../../../graphql/squads'; import { useLazyModal } from '../../../hooks/useLazyModal'; @@ -12,7 +11,8 @@ export interface SquadModerationItemProps { onApprove: () => Promise; onReject: () => void; isPending: boolean; - squad: Squad; + /** Unset on the queue of every squad the viewer moderates. */ + squad?: Squad; } interface UseSourceModerationItem { @@ -34,13 +34,10 @@ export const useSourceModerationItem = ({ onReject, }: SquadModerationItemProps): UseSourceModerationItem => { const squad = data.source as Squad; - const searchParams = useSearchParams(); const { openModal, closeModal } = useLazyModal(); - const isModerator = - verifyPermission(squad, SourcePermissions.ModeratePost) || - !searchParams?.get('handle'); + const isModerator = verifyPermission(squad, SourcePermissions.ModeratePost); const { onDelete } = useSourceModerationList(); diff --git a/packages/shared/src/components/squads/settings/SquadDangerZone.tsx b/packages/shared/src/components/squads/settings/SquadDangerZone.tsx index 0e4e6481e34..2a6a34168bf 100644 --- a/packages/shared/src/components/squads/settings/SquadDangerZone.tsx +++ b/packages/shared/src/components/squads/settings/SquadDangerZone.tsx @@ -2,7 +2,6 @@ import type { ReactElement } from 'react'; import React from 'react'; import { useRouter } from 'next/router'; import { DangerZone } from '../../widgets/DangerZone'; -import { SquadSettingsSection } from './SquadSettingsSection'; import { useDeleteSquad } from '../../../hooks/useDeleteSquad'; import type { Squad } from '../../../graphql/sources'; import { anchorDefaultRel } from '../../../lib/strings'; @@ -35,21 +34,18 @@ export function SquadDangerZone({ squad }: SquadDangerZoneProps): ReactElement { }); return ( - - } - buttonDisabled={isPending} - buttonLoading={isPending} - /> - + } + buttonDisabled={isPending} + buttonLoading={isPending} + /> ); } diff --git a/packages/shared/src/components/squads/settings/SquadModerationSettingsSection.tsx b/packages/shared/src/components/squads/settings/SquadModerationSettingsSection.tsx index a75f27f64cc..6a5cc74ca6f 100644 --- a/packages/shared/src/components/squads/settings/SquadModerationSettingsSection.tsx +++ b/packages/shared/src/components/squads/settings/SquadModerationSettingsSection.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { SquadSettingsSection } from './SquadSettingsSection'; import { Radio } from '../../fields/Radio'; import { SourceMemberRole } from '../../../graphql/sources'; +import type { SquadForm } from '../../../graphql/squads'; import { TextField } from '../../fields/TextField'; import { WidgetCard } from '../../widgets/WidgetCard'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -20,11 +21,34 @@ export enum SquadPostingGate { export const DEFAULT_POSTING_MIN_REPUTATION = 250; +// The three posting gates are exclusive in the UI but two independent fields +// on the API, so the radio value is expanded back into both here. +export const postingGateToInput = ( + gate: SquadPostingGate | undefined, + minReputation: string | undefined, +): Pick => { + if (gate === SquadPostingGate.Reputation) { + const parsed = parseInt(minReputation ?? '', 10); + + return { + moderationRequired: false, + postingMinReputation: Number.isNaN(parsed) ? null : parsed, + }; + } + + return { + moderationRequired: gate === SquadPostingGate.Moderation, + postingMinReputation: null, + }; +}; + interface SquadModerationSettingsSectionProps { initialMemberPostingRole?: SourceMemberRole; initialMemberInviteRole?: SourceMemberRole; initialModerationRequired?: boolean; initialPostingMinReputation?: number | null; + /** Without the card, for a page that already frames the section. */ + isBare?: boolean; } const memberRoleOptions = [ @@ -95,6 +119,7 @@ export function SquadModerationSettingsSection({ initialMemberPostingRole, initialModerationRequired, initialPostingMinReputation, + isBare = false, }: SquadModerationSettingsSectionProps): ReactElement { const [memberPostingRole, setMemberPostingRole] = useState( initialMemberPostingRole || SourceMemberRole.Moderator, @@ -118,68 +143,72 @@ export function SquadModerationSettingsSection({ } }; - return ( - -
- + + + + + - - - - - - setPostingGate(value as SquadPostingGate)} - /> - - - {postingGate === SquadPostingGate.Reputation && ( - + setPostingGate(value as SquadPostingGate)} /> - )} - - - setMemberInviteRole(value as SourceMemberRole)} + + + {postingGate === SquadPostingGate.Reputation && ( + - -
-
+ )} + + + setMemberInviteRole(value as SourceMemberRole)} + /> + +
); + + if (isBare) { + return sections; + } + + return {sections}; } diff --git a/packages/shared/src/components/squads/stack/SquadStack.tsx b/packages/shared/src/components/squads/stack/SquadStack.tsx index 964e74a7b09..abbf8750de5 100644 --- a/packages/shared/src/components/squads/stack/SquadStack.tsx +++ b/packages/shared/src/components/squads/stack/SquadStack.tsx @@ -4,8 +4,9 @@ import type { Squad } from '../../../graphql/sources'; import { useSourceStack } from '../../../hooks/source/useSourceStack'; import { Typography, - TypographyType, TypographyColor, + TypographyTag, + TypographyType, } from '../../typography/Typography'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { PlusIcon } from '../../icons'; @@ -120,19 +121,20 @@ export function SquadStack({ squad }: SquadStackProps): ReactElement | null { } return ( -
+
Stack & Tools - {canEdit && canAddMore && ( + {canEdit && canAddMore && hasItems && (
) : ( canEdit && ( -
+
- Share your squad's stack & tools + Share your Squad's stack & tools
+
); } diff --git a/packages/shared/src/contexts/EngagementAdsContext.tsx b/packages/shared/src/contexts/EngagementAdsContext.tsx index 5eed4c37538..d1029591898 100644 --- a/packages/shared/src/contexts/EngagementAdsContext.tsx +++ b/packages/shared/src/contexts/EngagementAdsContext.tsx @@ -15,6 +15,8 @@ import { import { useIsLightTheme } from '../hooks/utils/useThemedAsset'; import { useAuthContext } from './AuthContext'; import { isProduction } from '../lib/constants'; +import { isSourceAdFree } from '../lib/ads'; +import { useActivePostContext } from './ActivePostContext'; interface EngagementAdsContextValue { /** All creatives from boot, theme-resolved */ @@ -42,8 +44,17 @@ const defaultValue: EngagementAdsContextValue = { const EngagementAdsContext = createContext(defaultValue); -export const useEngagementAdsContext = (): EngagementAdsContextValue => - useContext(EngagementAdsContext); +/** + * Brand sponsorships (sponsored tags, keyword highlights, the branded upvote, + * sponsored tools) are ads too: under a post from an ad-free source every + * lookup comes back empty, so each surface falls back to its organic render. + */ +export const useEngagementAdsContext = (): EngagementAdsContextValue => { + const value = useContext(EngagementAdsContext); + const { activePost } = useActivePostContext(); + + return isSourceAdFree(activePost?.source) ? defaultValue : value; +}; interface EngagementAdsProviderProps { children: ReactNode; diff --git a/packages/shared/src/features/squads/SquadPageContext.tsx b/packages/shared/src/features/squads/SquadPageContext.tsx new file mode 100644 index 00000000000..99e398a640d --- /dev/null +++ b/packages/shared/src/features/squads/SquadPageContext.tsx @@ -0,0 +1,81 @@ +import { createContextProvider } from '@kickass-coderz/react'; +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import type { Squad } from '../../graphql/sources'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent } from '../../lib/log'; +import type { SquadViewer } from './lib/viewer'; +import { getSquadViewer, isStaffViewer } from './lib/viewer'; + +interface SquadPageContextProps { + squad: Squad; + /** + * False while the page renders the server's anonymous copy of the squad, + * before the viewer's own copy arrives. Viewer specific controls wait. + */ + isViewerReady: boolean; + children?: ReactNode; +} + +export interface SquadPageContextValue { + /** The squad as the page renders it: without the membership in preview. */ + squad: Squad; + viewer: SquadViewer; + /** The viewer's own role, which preview does not change. */ + ownViewer: SquadViewer; + isViewerReady: boolean; + /** Staff may look at the page as a logged in visitor who has not joined. */ + canPreview: boolean; + isPreviewing: boolean; + togglePreview: () => void; +} + +const [SquadPageContextProvider, useSquadPageContext] = createContextProvider( + ({ squad, isViewerReady }: SquadPageContextProps): SquadPageContextValue => { + const { isLoggedIn } = useAuthContext(); + const { logEvent } = useLogContext(); + const [isPreviewing, setIsPreviewing] = useState(false); + const ownViewer = getSquadViewer(squad, isLoggedIn); + const canPreview = isViewerReady && isStaffViewer(ownViewer); + const isPreviewActive = canPreview && isPreviewing; + + const togglePreview = useCallback(() => { + logEvent({ + event_name: LogEvent.ToggleSquadPreview, + target_id: squad.id, + extra: JSON.stringify({ preview: !isPreviewing }), + }); + setIsPreviewing(!isPreviewing); + }, [isPreviewing, logEvent, squad.id]); + + const pageSquad = useMemo( + () => (isPreviewActive ? { ...squad, currentMember: undefined } : squad), + [isPreviewActive, squad], + ); + + return useMemo( + () => ({ + squad: pageSquad, + viewer: getSquadViewer(pageSquad, isLoggedIn), + ownViewer, + isViewerReady, + canPreview, + isPreviewing: isPreviewActive, + togglePreview, + }), + [ + pageSquad, + isLoggedIn, + ownViewer, + isViewerReady, + canPreview, + isPreviewActive, + togglePreview, + ], + ); + }, + { errorMessage: 'SquadPageContext is missing its provider' }, +); + +export { SquadPageContextProvider, useSquadPageContext }; diff --git a/packages/shared/src/features/squads/components/SquadHome.tsx b/packages/shared/src/features/squads/components/SquadHome.tsx new file mode 100644 index 00000000000..bc708f8d796 --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadHome.tsx @@ -0,0 +1,165 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import dynamic from 'next/dynamic'; +import Feed from '../../../components/Feed'; +import type { FeedProps } from '../../../components/Feed'; +import { + SEARCH_SOURCE_POSTS_QUERY, + SOURCE_FEED_QUERY, + supportedTypesForPrivateSources, +} from '../../../graphql/feed'; +import { FeedLayoutProvider } from '../../../contexts/FeedContext'; +import { useSearchContextProvider } from '../../../contexts/search/SearchContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useFeedLayout } from '../../../hooks/useFeedLayout'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { useSearchId } from '../../../hooks/search/useSearchId'; +import { feature } from '../../../lib/featureManagement'; +import { OtherFeedPage } from '../../../lib/query'; +import { useSquadPageContext } from '../SquadPageContext'; +import { isSourceAdFree } from '../../../lib/ads'; +import { useSpotlightPageSource } from '../../../components/spotlight/SpotlightContext'; +import { getSquadSpotlightSource } from '../lib/spotlight'; +import { SquadPageLayout } from './SquadPageLayout'; +import { SquadProfileHeader } from './header/SquadProfileHeader'; +import { SquadComposer } from './feed/SquadComposer'; +import { SquadPinnedPosts } from './feed/SquadPinnedPosts'; +import { SquadEmptyFeed } from './feed/SquadEmptyFeed'; +import { SquadSearchHeader } from './feed/SquadSearchHeader'; +import { SquadProductsShelf } from './products/SquadProductsShelf'; + +const SearchEmptyScreen = dynamic( + () => + import( + /* webpackChunkName: "searchEmptyScreen" */ '../../../components/SearchEmptyScreen' + ), +); + +// Two cards a row at most: the feed shares the page with the right column. +const MAX_FEED_COLUMNS = 2; + +interface SquadHomeProps { + searchQuery: string; + onClearSearch: () => void; +} + +export const SquadHome = ({ + searchQuery, + onClearSearch, +}: SquadHomeProps): ReactElement => { + const { squad } = useSquadPageContext(); + const { user } = useAuthContext(); + const { shouldUseListFeedLayout } = useFeedLayout(); + const { postTypesFilter } = useSearchContextProvider(); + const isSearching = searchQuery.length > 0; + const isAdFree = isSourceAdFree(squad); + useSpotlightPageSource(getSquadSpotlightSource(squad), { + query: searchQuery || undefined, + }); + const { value: searchVersion } = useConditionalFeature({ + feature: feature.searchVersion, + shouldEvaluate: isSearching, + }); + const searchId = useSearchId( + isSearching + ? [squad.id, searchQuery, searchVersion, postTypesFilter.join(',')].join( + '|', + ) + : '', + ); + + const feedProps = useMemo>(() => { + if (isSearching) { + return { + feedName: OtherFeedPage.SearchSquad, + feedQueryKey: [ + 'searchSourcePosts', + user?.id ?? 'anonymous', + squad.id, + searchQuery, + postTypesFilter, + ], + query: SEARCH_SOURCE_POSTS_QUERY, + variables: { + source: squad.id, + query: searchQuery, + supportedTypes: supportedTypesForPrivateSources, + postTypes: postTypesFilter, + version: searchVersion, + }, + searchId, + searchVersion, + emptyScreen: , + }; + } + + const variables = { + source: squad.id, + ranking: 'TIME', + supportedTypes: supportedTypesForPrivateSources, + }; + + return { + feedName: OtherFeedPage.Squads, + feedQueryKey: [ + 'sourceFeed', + user?.id ?? 'anonymous', + Object.values(variables), + ], + query: SOURCE_FEED_QUERY, + variables, + emptyScreen: , + excludePinnedPosts: true, + }; + }, [ + isSearching, + postTypesFilter, + searchId, + searchQuery, + searchVersion, + squad.id, + user?.id, + ]); + + const feed = ( + + + + ); + + if (isSearching) { + return ( + + } + > + {feed} + + ); + } + + return ( + } hasAboutTab> +
+ +
+ + +
+
+ {feed} +
+ ); +}; diff --git a/packages/shared/src/features/squads/components/SquadMembersList.tsx b/packages/shared/src/features/squads/components/SquadMembersList.tsx new file mode 100644 index 00000000000..22b9b7642b6 --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadMembersList.tsx @@ -0,0 +1,197 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import type { Squad } from '../../../graphql/sources'; +import { SourceMemberRole, SourcePermissions } from '../../../graphql/sources'; +import { verifyPermission } from '../../../graphql/squads'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { isSystemModerator } from '../../../lib/user'; +import { Origin } from '../../../lib/log'; +import { defaultSearchDebounceMs } from '../../../lib/func'; +import { largeNumberFormat } from '../../../lib/numberFormat'; +import { useSquadActions } from '../../../hooks/squads/useSquadActions'; +import { useSquadInvitation } from '../../../hooks/useSquadInvitation'; +import useDebounceFn from '../../../hooks/useDebounceFn'; +import { useUsersContentPreferenceMutationSubscription } from '../../../hooks/contentPreference/useUsersContentPreferenceMutationSubscription'; +import UserList from '../../../components/profile/UserList'; +import { checkFetchMore } from '../../../components/containers/InfiniteScrolling'; +import { SearchField } from '../../../components/fields/SearchField'; +import { ButtonSize } from '../../../components/buttons/Button'; +import { AddUserIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '../../../components/squads/layout/SquadDirectoryNavbar'; +import { BlockedMembersPlaceholder } from '../../../components/squads/Members/BlockedMembersPlaceholder'; +import SquadMemberItemOptionsButton from '../../../components/squads/SquadMemberItemOptionsButton'; +import { getSquadId } from '../lib/features'; + +enum MembersTab { + Members = 'Members', + Moderators = 'Moderators', + Blocked = 'Blocked', +} + +const tabRole: Record = { + [MembersTab.Members]: undefined, + [MembersTab.Moderators]: SourceMemberRole.Moderator, + [MembersTab.Blocked]: SourceMemberRole.Blocked, +}; + +const InviteRow = ({ squad }: { squad: Squad }): ReactElement | null => { + const { copying, logAndCopyLink } = useSquadInvitation({ + squad, + origin: Origin.SquadMembersList, + }); + + if (!verifyPermission(squad, SourcePermissions.Invite)) { + return null; + } + + return ( + + ); +}; + +interface SquadMembersListProps { + squad: Squad; +} + +// The members modal as a page: three tabs (Blocked for those who may see +// it), search, the invitation link first, and the role menu for staff. +export const SquadMembersList = ({ + squad, +}: SquadMembersListProps): ReactElement => { + const { user: loggedUser } = useAuthContext(); + const [tab, setTab] = useState(MembersTab.Members); + const [query, setQuery] = useState(''); + const [onSearch] = useDebounceFn( + (value) => setQuery(value ?? ''), + defaultSearchDebounceMs, + ); + const role = tabRole[tab]; + const { + members = [], + membersQueryResult, + membersQueryKey, + onUnblock, + onDemoteSelf, + onUpdateRole, + } = useSquadActions({ + squad, + query: query.trim() || undefined, + membersQueryParams: { role }, + membersQueryEnabled: true, + }); + + useUsersContentPreferenceMutationSubscription({ + queryKey: membersQueryKey, + queryProp: 'sourceMembers', + }); + + const canSeeBlocked = + isSystemModerator(loggedUser) || + verifyPermission(squad, SourcePermissions.ViewBlockedMembers); + const tabs = Object.values(MembersTab).filter( + (item) => canSeeBlocked || item !== MembersTab.Blocked, + ); + + return ( +
+
+ + {tabs.map((item) => ( + setTab(item)} + /> + ))} + + + {tab === MembersTab.Members && ( + + + {largeNumberFormat(squad.membersCount) ?? 0} + {' '} + members + + )} +
+ ({ + ...user, + role: memberRole, + }))} + placeholderAmount={Math.min(squad.membersCount, 5)} + isLoading={membersQueryResult?.isPending} + scrollingProps={{ + isFetchingNextPage: !!membersQueryResult?.isFetchingNextPage, + canFetchMore: + !!membersQueryResult && checkFetchMore(membersQueryResult), + fetchNextPage: () => + membersQueryResult?.fetchNextPage() ?? Promise.resolve(), + }} + initialItem={ + tab !== MembersTab.Blocked && !query ? ( + + ) : undefined + } + emptyPlaceholder={ + tab === MembersTab.Blocked ? ( + + ) : ( +

+ No {tab === MembersTab.Moderators ? 'moderator' : 'member'} found +

+ ) + } + afterContent={ + canSeeBlocked + ? (user, index) => ( + + onUnblock?.({ + sourceId: getSquadId(squad), + memberId: user.id, + }) + } + /> + ) + : undefined + } + userInfoProps={{ + origin: Origin.SquadMembersList, + showFollow: true, + showSubscribe: false, + }} + /> +
+ ); +}; diff --git a/packages/shared/src/features/squads/components/SquadPageLayout.tsx b/packages/shared/src/features/squads/components/SquadPageLayout.tsx new file mode 100644 index 00000000000..3407181643e --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadPageLayout.tsx @@ -0,0 +1,86 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { ButtonSize } from '../../../components/buttons/Button'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '../../../components/squads/layout/SquadDirectoryNavbar'; +import { SquadPreviewNotice } from './widgets/SquadPreview'; +import { SquadWidgets } from './widgets/SquadWidgets'; + +enum SquadPageTab { + Posts = 'Posts', + About = 'About', +} + +interface SquadPageLayoutProps { + /** The card's top: the profile header on the page, a title bar elsewhere. */ + header: ReactNode; + children: ReactNode; + /** + * Below laptop the right column moves behind an About tab beside Posts. + * It stays in the DOM either way, since crawlers read the phone render. + */ + hasAboutTab?: boolean; +} + +// One right column for every width: from laptop it sits beside the card, +// below laptop the card's wrapper dissolves (`contents`) so the column can +// be ordered between the tabs and the posts. +export const SquadPageLayout = ({ + header, + children, + hasAboutTab = false, +}: SquadPageLayoutProps): ReactElement => { + const [tab, setTab] = useState(SquadPageTab.Posts); + const isAbout = hasAboutTab && tab === SquadPageTab.About; + + return ( +
+
+
+ +
+ {header} +
+
+ {hasAboutTab && ( +
+ + {Object.values(SquadPageTab).map((item) => ( + setTab(item)} + /> + ))} + +
+ )} +
+ {children} +
+
+ +
+ ); +}; diff --git a/packages/shared/src/features/squads/components/SquadPageSkeleton.tsx b/packages/shared/src/features/squads/components/SquadPageSkeleton.tsx new file mode 100644 index 00000000000..35d44179f34 --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadPageSkeleton.tsx @@ -0,0 +1,26 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ElementPlaceholder } from '../../../components/ElementPlaceholder'; + +// The page's own frame, empty, so nothing moves when the squad arrives. +export const SquadPageSkeleton = (): ReactElement => ( +
+
+ +
+ + + + +
+
+
+ + +
+
+); diff --git a/packages/shared/src/features/squads/components/SquadPrivateWall.tsx b/packages/shared/src/features/squads/components/SquadPrivateWall.tsx new file mode 100644 index 00000000000..efbe6e7e567 --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadPrivateWall.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import Unauthorized from '../../../components/errors/Unauthorized'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { AuthTriggers } from '../../../lib/auth'; + +// A private squad has no request to join: the invitation link is the only +// door, so the wall only offers a way back, and logging in when logged out. +export const SquadPrivateWall = (): ReactElement => { + const { isLoggedIn, showLogin } = useAuthContext(); + + return ( + + {!isLoggedIn && ( + + )} + + ); +}; diff --git a/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx b/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx new file mode 100644 index 00000000000..de59ba43fd2 --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadSubPageHeader.tsx @@ -0,0 +1,60 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import Link from '../../../components/utilities/Link'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { MoveToIcon } from '../../../components/icons'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; + +interface SquadSubPageHeaderProps { + title: string; + backUrl: string; + backLabel: string; + action?: ReactNode; + className?: string; +} + +// Back, the page's title and its one action, the way a profile sub-page +// (Add experience) is laid out. +export const SquadSubPageHeader = ({ + title, + backUrl, + backLabel, + action, + className, +}: SquadSubPageHeaderProps): ReactElement => ( +
+ +
+); diff --git a/packages/shared/src/features/squads/components/SquadSubPages.tsx b/packages/shared/src/features/squads/components/SquadSubPages.tsx new file mode 100644 index 00000000000..391be87bac8 --- /dev/null +++ b/packages/shared/src/features/squads/components/SquadSubPages.tsx @@ -0,0 +1,91 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { PlusIcon } from '../../../components/icons'; +import Link from '../../../components/utilities/Link'; +import { SourcePermissions } from '../../../graphql/sources'; +import { verifyPermission } from '../../../graphql/squads'; +import { SquadModerationList } from '../../../components/squads/moderation/SquadModerationList'; +import { useSquadPageContext } from '../SquadPageContext'; +import { getSquadProductFormUrl, getSquadUrl } from '../lib/routes'; +import { SQUAD_PRODUCTS_MAX } from '../lib/limits'; +import { useSquadProducts } from '../hooks/useSquadProducts'; +import { SquadPageLayout } from './SquadPageLayout'; +import { SquadSubPageHeader } from './SquadSubPageHeader'; +import { SquadMembersList } from './SquadMembersList'; +import { SquadProductsList } from './products/SquadProductsList'; + +const useBackToSquad = () => { + const { squad } = useSquadPageContext(); + + return { + backUrl: getSquadUrl(squad.handle), + backLabel: `Back to ${squad.name}`, + }; +}; + +export const SquadMembersPage = (): ReactElement => { + const { squad } = useSquadPageContext(); + + return ( + } + > + + + ); +}; + +export const SquadProductsPage = (): ReactElement => { + const { squad } = useSquadPageContext(); + const { products } = useSquadProducts(squad); + const canAdd = + verifyPermission(squad, SourcePermissions.Edit) && + products.length < SQUAD_PRODUCTS_MAX; + const addUrl = getSquadProductFormUrl(squad.handle); + + return ( + + + + ) + } + /> + } + > + + + ); +}; + +export const SquadPendingPostsPage = (): ReactElement => { + const { squad } = useSquadPageContext(); + + return ( + + } + > + + + ); +}; diff --git a/packages/shared/src/features/squads/components/VerifiedSquad.tsx b/packages/shared/src/features/squads/components/VerifiedSquad.tsx new file mode 100644 index 00000000000..6f05d969106 --- /dev/null +++ b/packages/shared/src/features/squads/components/VerifiedSquad.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + verifiedSquadCardBg, + verifiedSquadCardGlow, + verifiedSquadCardShadow, +} from '../../../styles/custom'; + +export const verifiedSquadLabel = 'Verified Company Squad'; + +// A scalloped seal with a check, the shape people read as verified. The +// check is cut in the page background so the seal works on any surface. +export const VerifiedSquadSeal = ({ + className, +}: { + className?: string; +}): ReactElement => ( + + + + +); + +export const VerifiedSquadBadge = ({ + className, +}: { + className?: string; +}): ReactElement => ( + + + +); + +export const VerifiedCompanySquadCard = (): ReactElement => ( +
+
+ + + {verifiedSquadLabel} + +
+); diff --git a/packages/shared/src/features/squads/components/feed/SquadComposer.tsx b/packages/shared/src/features/squads/components/feed/SquadComposer.tsx new file mode 100644 index 00000000000..9d6c3754016 --- /dev/null +++ b/packages/shared/src/features/squads/components/feed/SquadComposer.tsx @@ -0,0 +1,163 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React from 'react'; +import { SourcePostModerationStatus } from '../../../../graphql/squads'; +import type { ComposerKind } from '../../../../components/post/composer/types'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { + ArrowIcon, + LinkIcon, + LockIcon, + PollIcon, + TimerIcon, +} from '../../../../components/icons'; +import { IconSize } from '../../../../components/Icon'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../../../components/ProfilePicture'; +import Link from '../../../../components/utilities/Link'; +import { useAuthContext } from '../../../../contexts/AuthContext'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { useSquadPendingPosts } from '../../../../hooks/squads/useSquadPendingPosts'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { + getSquadPostingState, + isStaffViewer, + SquadViewer, +} from '../../lib/viewer'; +import { getSquadPendingPostsUrl } from '../../lib/routes'; + +const pendingStatus = [SourcePostModerationStatus.Pending]; + +const SquadOwnPendingPosts = (): ReactElement | null => { + const { squad, viewer } = useSquadPageContext(); + const { count } = useSquadPendingPosts({ + squadId: squad.id, + status: pendingStatus, + enabled: viewer === SquadViewer.Member && squad.moderationRequired, + }); + + if (!count) { + return null; + } + + const url = getSquadPendingPostsUrl(squad.handle); + + return ( + + + + + {count} of + your posts {count === 1 ? 'is' : 'are'} waiting for a moderator + + + + + ); +}; + +const shortcuts: { kind: ComposerKind; label: string; icon: ReactElement }[] = [ + { kind: 'link', label: 'Share a link', icon: }, + { kind: 'poll', label: 'Poll', icon: }, +]; + +const ComposerEntry = ({ + canPoll, + isReviewed, +}: { + canPoll: boolean; + isReviewed: boolean; +}): ReactElement => { + const { squad } = useSquadPageContext(); + const { user } = useAuthContext(); + const { openModal } = useLazyModal(); + const openComposer = (initialKind: ComposerKind) => + openModal({ + type: LazyModal.SmartComposer, + props: { initialSquadHandle: squad.handle, initialKind }, + }); + + return ( +
openComposer('text')} + className="mx-4 flex cursor-text flex-col rounded-16 border border-border-subtlest-tertiary bg-surface-float hover:border-border-subtlest-secondary tablet:mx-0" + > + +
+ {shortcuts + .filter(({ kind }) => canPoll || kind !== 'poll') + .map(({ kind, label, icon }) => ( + + ))} + {isReviewed && ( + + + Reviewed before it goes live + + )} +
+
+ ); +}; + +export const SquadComposer = (): ReactElement => { + const { squad, viewer } = useSquadPageContext(); + const { canPost, reason, isReviewed } = getSquadPostingState(squad, viewer); + + return ( +
+ + {canPost ? ( + + ) : ( +
+ + {reason} +
+ )} +
+ ); +}; diff --git a/packages/shared/src/features/squads/components/feed/SquadEmptyFeed.tsx b/packages/shared/src/features/squads/components/feed/SquadEmptyFeed.tsx new file mode 100644 index 00000000000..60e1274d43d --- /dev/null +++ b/packages/shared/src/features/squads/components/feed/SquadEmptyFeed.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { getSquadPostingState, isJoinedViewer } from '../../lib/viewer'; + +const getEmptyCopy = (canPost: boolean, isJoined: boolean): string => { + if (canPost) { + return 'Share the first post and it shows up here for every member.'; + } + + if (isJoined) { + return 'The team has not posted yet. You will hear when they do.'; + } + + return 'The team has not posted yet. Join to hear when they do.'; +}; + +export const SquadEmptyFeed = (): ReactElement => { + const { squad, viewer } = useSquadPageContext(); + const { canPost } = getSquadPostingState(squad, viewer); + + return ( +
+ + Nothing posted yet + + + {getEmptyCopy(canPost, isJoinedViewer(viewer))} + +
+ ); +}; diff --git a/packages/shared/src/features/squads/components/feed/SquadPinnedPosts.tsx b/packages/shared/src/features/squads/components/feed/SquadPinnedPosts.tsx new file mode 100644 index 00000000000..312577d9278 --- /dev/null +++ b/packages/shared/src/features/squads/components/feed/SquadPinnedPosts.tsx @@ -0,0 +1,148 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { Post } from '../../../../graphql/posts'; +import { + collapsePinnedPosts, + expandPinnedPosts, + squadPinnedPostsQueryOptions, +} from '../../../../graphql/squads'; +import { updateFlagsCache } from '../../../../graphql/source/common'; +import { + ArrowIcon, + DiscussIcon, + PinIcon, + UpvoteIcon, +} from '../../../../components/icons'; +import { IconSize } from '../../../../components/Icon'; +import Link from '../../../../components/utilities/Link'; +import { useAuthContext } from '../../../../contexts/AuthContext'; +import { useLogContext } from '../../../../contexts/LogContext'; +import { LogEvent, Origin } from '../../../../lib/log'; +import { postLogEvent } from '../../../../lib/feed'; +import { largeNumberFormat } from '../../../../lib/numberFormat'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { isJoinedViewer } from '../../lib/viewer'; +import { getSquadId } from '../../lib/features'; + +const PinnedCard = ({ + post, + index, +}: { + post: Post; + index: number; +}): ReactElement => { + const { logEvent } = useLogContext(); + const href = post.commentsPermalink; + + return ( +
  • + + + logEvent( + postLogEvent(LogEvent.Click, post, { + extra: { origin: Origin.SquadPage, pinned: true }, + index, + }), + ) + } + className="group relative flex h-40 w-52 flex-col justify-end overflow-hidden rounded-16 border border-border-subtlest-tertiary" + > + {post.image && ( + + )} + + + + {post.title ?? post.sharedPost?.title} + + + + + {largeNumberFormat(post.numUpvotes ?? 0) ?? 0} + + + + {largeNumberFormat(post.numComments ?? 0) ?? 0} + + + + + +
  • + ); +}; + +export const SquadPinnedPosts = (): ReactElement | null => { + const { squad, viewer } = useSquadPageContext(); + const { user } = useAuthContext(); + const client = useQueryClient(); + const { data: pins } = useQuery( + squadPinnedPostsQueryOptions({ squad, user }), + ); + const [isLocallyCollapsed, setIsLocallyCollapsed] = useState(false); + const isJoined = isJoinedViewer(viewer); + // Members keep their choice on the membership; everyone else per visit. + const isCollapsed = isJoined + ? !!squad.currentMember?.flags?.collapsePinnedPosts + : isLocallyCollapsed; + + const { mutate: togglePinned, isPending } = useMutation({ + mutationFn: () => + (isCollapsed ? expandPinnedPosts : collapsePinnedPosts)( + getSquadId(squad), + ), + onSuccess: () => { + if (user) { + updateFlagsCache(client, squad, user, { + collapsePinnedPosts: !isCollapsed, + }); + } + }, + }); + + if (!pins?.length) { + return null; + } + + return ( +
    + + {!isCollapsed && ( +
      + {pins.map((post, index) => ( + + ))} +
    + )} +
    + ); +}; diff --git a/packages/shared/src/features/squads/components/feed/SquadSearchHeader.tsx b/packages/shared/src/features/squads/components/feed/SquadSearchHeader.tsx new file mode 100644 index 00000000000..edf201945be --- /dev/null +++ b/packages/shared/src/features/squads/components/feed/SquadSearchHeader.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { MiniCloseIcon, MoveToIcon } from '../../../../components/icons'; +import { SquadImage } from '../../../../components/squads/SquadImage'; +import { useSpotlight } from '../../../../components/spotlight/SpotlightContext'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { getSquadSpotlightSource } from '../../lib/spotlight'; + +interface SquadSearchHeaderProps { + query: string; + onClear: () => void; +} + +// The page gives way to the results under a back button and the query, the +// way a profile answers a search. The query text reopens Spotlight to edit it. +export const SquadSearchHeader = ({ + query, + onClear, +}: SquadSearchHeaderProps): ReactElement => { + const { squad } = useSquadPageContext(); + const { openWithSource } = useSpotlight(); + + return ( +
    + +
    + ); +}; diff --git a/packages/shared/src/features/squads/components/header/SquadActions.tsx b/packages/shared/src/features/squads/components/header/SquadActions.tsx new file mode 100644 index 00000000000..6ec37de1086 --- /dev/null +++ b/packages/shared/src/features/squads/components/header/SquadActions.tsx @@ -0,0 +1,206 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { SourcePermissions } from '../../../../graphql/sources'; +import { verifyPermission } from '../../../../graphql/squads'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { + BellIcon, + EditIcon, + LinkIcon, + SearchIcon, +} from '../../../../components/icons'; +import { Tooltip } from '../../../../components/tooltip/Tooltip'; +import { SquadActionButton } from '../../../../components/squads/SquadActionButton'; +import { BoostSourceButton } from '../../../boost/BoostSourceButton'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { useShareOrCopyLink } from '../../../../hooks/useShareOrCopyLink'; +import { useSpotlight } from '../../../../components/spotlight/SpotlightContext'; +import { LogEvent, Origin } from '../../../../lib/log'; +import { ReferralCampaignKey } from '../../../../lib/referral'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { isJoinedViewer, SquadViewer } from '../../lib/viewer'; +import { getSquadManageUrl, SquadManageSection } from '../../lib/routes'; +import { getSquadSpotlightSource } from '../../lib/spotlight'; +import { getSquadShareText } from '../widgets/SquadShareWidget'; +import { SquadOptionsMenu } from './SquadOptionsMenu'; + +const useSquadShare = () => { + const { squad } = useSquadPageContext(); + + return useShareOrCopyLink({ + link: squad.permalink, + text: getSquadShareText(squad), + cid: ReferralCampaignKey.ShareSource, + logObject: (provider) => ({ + event_name: LogEvent.ShareSource, + target_id: squad.id, + extra: JSON.stringify({ provider, origin: Origin.SquadPage }), + }), + }); +}; + +const SquadJoinButton = ({ + size, + className, +}: { + size: ButtonSize; + className?: string; +}): ReactElement | null => { + const { squad, viewer, isPreviewing } = useSquadPageContext(); + + if (viewer === SquadViewer.Admin || !squad.public) { + return null; + } + + // The preview renders the visitor's button without letting staff act on it. + if (isPreviewing) { + return ( + + ); + } + + return ( + + ); +}; + +const canBoost = ( + squad: ReturnType['squad'], +): boolean => + squad.public && verifyPermission(squad, SourcePermissions.BoostSquad); + +export const SquadActions = (): ReactElement => { + const router = useRouter(); + const { squad, viewer } = useSquadPageContext(); + const { openModal, modal } = useLazyModal(); + const { openWithSource } = useSpotlight(); + const [, onShare] = useSquadShare(); + const isAdminView = viewer === SquadViewer.Admin; + const canEdit = verifyPermission(squad, SourcePermissions.Edit); + const editUrl = getSquadManageUrl(squad.handle, SquadManageSection.Details); + + return ( +
    + {canEdit && ( + +
    + ); +}; + +/** On phones the primary actions leave the icon row for full width buttons. */ +export const SquadPhoneActions = (): ReactElement => { + const { squad, viewer } = useSquadPageContext(); + const [, onShare] = useSquadShare(); + + if (viewer !== SquadViewer.Admin) { + return ( +
    + +
    + ); + } + + return ( +
    + {canBoost(squad) && ( + + + + )} + +
    + ); +}; diff --git a/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx b/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx new file mode 100644 index 00000000000..74cfb98a78b --- /dev/null +++ b/packages/shared/src/features/squads/components/header/SquadOptionsMenu.tsx @@ -0,0 +1,289 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import { useRouter } from 'next/router'; +import { useMutation } from '@tanstack/react-query'; +import type { Squad } from '../../../../graphql/sources'; +import { + SourceMemberRole, + SourcePermissions, +} from '../../../../graphql/sources'; +import { verifyPermission } from '../../../../graphql/squads'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { useLeaveSquad } from '../../../../hooks/useLeaveSquad'; +import { useSquadInvitation } from '../../../../hooks/useSquadInvitation'; +import { useContentPreference } from '../../../../hooks/contentPreference/useContentPreference'; +import { useGetSquadAwardAdmin } from '../../../../hooks/useCoresFeature'; +import { useAuthContext } from '../../../../contexts/AuthContext'; +import { ContentPreferenceType } from '../../../../graphql/contentPreference'; +import { squadFeedback } from '../../../../lib/constants'; +import { Origin } from '../../../../lib/log'; +import type { LoggedUser } from '../../../../lib/user'; +import { + AnalyticsIcon, + ExitIcon, + FeedbackIcon, + FlagIcon, + HashtagIcon, + LinkIcon, + MedalBadgeIcon, + MenuIcon, + SettingsIcon, + TimerIcon, + TourIcon, + UserIcon, +} from '../../../../components/icons'; +import { IconSize } from '../../../../components/Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../../../../components/dropdown/DropdownMenu'; +import type { MenuItemProps } from '../../../../components/dropdown/common'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import Link from '../../../../components/utilities/Link'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { isJoinedViewer, isStaffViewer, SquadViewer } from '../../lib/viewer'; +import { getSquadManageUrl, SquadManageSection } from '../../lib/routes'; +import { getSquadId } from '../../lib/features'; + +interface ManageEntry { + label: string; + icon: ReactNode; + href: string; + badge?: number; +} + +const getManageEntries = (squad: Squad): ManageEntry[] => { + const entries: ManageEntry[] = []; + const { handle } = squad; + + if (verifyPermission(squad, SourcePermissions.ModeratePost)) { + entries.push({ + label: 'Moderation', + icon: , + href: getSquadManageUrl(handle, SquadManageSection.Moderation), + badge: squad.moderationPostCount, + }); + } + + entries.push({ + label: 'Members', + icon: , + href: getSquadManageUrl(handle, SquadManageSection.Members), + }); + + if (verifyPermission(squad, SourcePermissions.ViewAnalytics)) { + entries.push({ + label: 'Analytics', + icon: , + href: getSquadManageUrl(handle, SquadManageSection.Analytics), + }); + } + + if (verifyPermission(squad, SourcePermissions.Edit)) { + entries.push({ + label: 'Settings', + icon: , + href: getSquadManageUrl(handle, SquadManageSection.Details), + }); + } + + return entries; +}; + +export const SquadOptionsMenu = (): ReactElement => { + const router = useRouter(); + const { squad, viewer } = useSquadPageContext(); + const { user, isLoggedIn } = useAuthContext(); + const { openModal } = useLazyModal(); + const { follow, unfollow } = useContentPreference(); + const { logAndCopyLink } = useSquadInvitation({ + squad, + origin: Origin.SquadPage, + }); + const awardAdmin = useGetSquadAwardAdmin({ sendingUser: user, squad }); + const { mutateAsync: onLeaveSquad } = useMutation({ + mutationFn: useLeaveSquad({ squad }), + onSuccess: (left) => { + if (left) { + router.replace('/'); + } + }, + }); + const isStaff = isStaffViewer(viewer); + const isJoined = isJoinedViewer(viewer); + const manageEntries = useMemo( + () => (isStaff ? getManageEntries(squad) : []), + [isStaff, squad], + ); + + const options = useMemo(() => { + const list: MenuItemProps[] = [ + { + icon: , + label: 'Add to custom feed', + action: () => + openModal({ + type: LazyModal.AddToCustomFeed, + props: { + onAdd: (feedId) => + follow({ + id: getSquadId(squad), + entity: ContentPreferenceType.Source, + entityName: squad.handle, + feedId, + }), + onUndo: (feedId) => + unfollow({ + id: getSquadId(squad), + entity: ContentPreferenceType.Source, + entityName: squad.handle, + feedId, + }), + onCreateNewFeed: () => + router.push( + `/feeds/new?entityId=${squad.id}&entityType=${ContentPreferenceType.Source}`, + ), + }, + }), + }, + ]; + + const canInviteAsVisitor = + isLoggedIn && squad.public && viewer === SquadViewer.Visitor; + if ( + canInviteAsVisitor || + (isJoined && verifyPermission(squad, SourcePermissions.Invite)) + ) { + list.push({ + icon: , + label: 'Invitation link', + action: () => logAndCopyLink(), + }); + } + + if (awardAdmin && viewer !== SquadViewer.Blocked) { + list.push({ + icon: , + label: 'Award the Squad', + action: () => + openModal({ + type: LazyModal.GiveAward, + props: { + type: 'SQUAD', + entity: { + id: getSquadId(squad), + receiver: { + ...awardAdmin, + name: squad.name, + image: squad.image, + } as LoggedUser, + }, + }, + }), + }); + } + + list.push({ + icon: , + label: 'Learn how Squads work', + action: () => openModal({ type: LazyModal.SquadTour }), + }); + + if (isJoined) { + list.push({ + icon: , + label: 'Feedback', + anchorProps: { + href: `${squadFeedback}#user_id=${squad.currentMember?.user?.id}&squad_id=${squad.id}`, + target: '_blank', + }, + }); + } + + list.push({ + icon: , + label: 'Report Squad', + action: () => + openModal({ type: LazyModal.ReportSource, props: { squad } }), + }); + + if (isJoined && squad.currentMember?.role !== SourceMemberRole.Admin) { + list.push({ + icon: , + label: 'Leave Squad', + action: () => onLeaveSquad({}), + }); + } + + return list; + }, [ + awardAdmin, + follow, + isJoined, + isLoggedIn, + logAndCopyLink, + onLeaveSquad, + openModal, + router, + squad, + unfollow, + viewer, + ]); + + return ( + + + + )} +
    + ); +}; + +export const SquadProfileHeader = (): ReactElement => { + const { squad, viewer, isViewerReady } = useSquadPageContext(); + + return ( +
    +
    + {squad.headerImage && ( + {`${squad.name} + )} +
    +
    +
    + + {isViewerReady && } +
    + {viewer === SquadViewer.Blocked && ( +
    + + You no longer have access to this Squad. Contact a moderator if you + think this is a mistake. +
    + )} +
    +

    + {squad.name} + {hasSquadFeature(squad, 'verified') && ( + + )} +

    + {!!squad.description && ( +

    {squad.description}

    + )} +
    + + + {isViewerReady && } +
    +
    + ); +}; diff --git a/packages/shared/src/features/squads/components/manage/SquadManageAnalytics.tsx b/packages/shared/src/features/squads/components/manage/SquadManageAnalytics.tsx new file mode 100644 index 00000000000..0633017c765 --- /dev/null +++ b/packages/shared/src/features/squads/components/manage/SquadManageAnalytics.tsx @@ -0,0 +1,123 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { DataTile } from '../../../../components/DataTile'; +import { IconSize } from '../../../../components/Icon'; +import { + BookmarkIcon, + ClickIcon, + DiscussIcon, + EyeIcon, + MedalBadgeIcon, + MergeIcon, + ShareIcon, + UpvoteIcon, +} from '../../../../components/icons'; +import type { AnalyticsNumberList } from '../../../../components/analytics/common'; +import { AnalyticsNumbersList } from '../../../../components/analytics/AnalyticsNumbersList'; +import { CombinedImpressionsChart } from '../../../../components/analytics/CombinedImpressionsChart'; +import { HorizontalSeparator } from '../../../../components/utilities/common'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { + SQUAD_ANALYTICS_HISTORY_DAYS, + useSquadAnalytics, +} from '../../hooks/useSquadAnalytics'; +import { SquadManageSection } from '../../lib/routes'; +import { SquadManageSectionPanel } from './SquadManageLayout'; + +const Section = ({ + title, + children, +}: { + title: string; + children: ReactNode; +}): ReactElement => ( +
    + + {title} + + {children} +
    +); + +export const SquadManageAnalytics = (): ReactElement => { + const { squad } = useSquadPageContext(); + const { analytics, impressions, hasImpressions } = useSquadAnalytics(squad); + const engagement: AnalyticsNumberList = [ + { icon: , label: 'Upvotes', value: analytics?.upvotes ?? 0 }, + { + icon: , + label: 'Upvotes ratio', + value: `${analytics?.upvotesRatio ?? 0}%`, + tooltip: 'The percentage of upvotes out of total votes.', + }, + { + icon: , + label: 'Comments', + value: analytics?.comments ?? 0, + }, + { + icon: , + label: 'Bookmarks', + value: analytics?.bookmarks ?? 0, + }, + { + icon: , + label: 'Awards', + value: analytics?.awards ?? 0, + }, + { icon: , label: 'Shares', value: analytics?.shares ?? 0 }, + { icon: , label: 'Clicks', value: analytics?.clicks ?? 0 }, + ]; + + return ( + +
    +
    +
    + + } + /> + + } + /> +
    +
    + +
    + {hasImpressions ? ( + + ) : ( + + {`No impressions data in the last ${SQUAD_ANALYTICS_HISTORY_DAYS} days.`} + + )} +
    + +
    + +
    +
    +
    + ); +}; diff --git a/packages/shared/src/features/squads/components/manage/SquadManageDetails.tsx b/packages/shared/src/features/squads/components/manage/SquadManageDetails.tsx new file mode 100644 index 00000000000..84836c18e8c --- /dev/null +++ b/packages/shared/src/features/squads/components/manage/SquadManageDetails.tsx @@ -0,0 +1,204 @@ +import type { FormEvent, ReactElement } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import { TextField } from '../../../../components/fields/TextField'; +import Textarea from '../../../../components/fields/Textarea'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { AtIcon, CameraIcon, SquadIcon } from '../../../../components/icons'; +import { IconSize } from '../../../../components/Icon'; +import { HorizontalSeparator } from '../../../../components/utilities/common'; +import { SquadImage } from '../../../../components/squads/SquadImage'; +import { + PrivacyOption, + SquadPrivacySection, +} from '../../../../components/squads/settings/SquadPrivacySection'; +import { useFileInput } from '../../../../hooks/utils/useFileInput'; +import { formToJson } from '../../../../lib/form'; +import { acceptedTypesList, ACCEPTED_TYPES } from '../../../../graphql/posts'; +import { useSquadPageContext } from '../../SquadPageContext'; +import { useEditSquad } from '../../hooks/useEditSquad'; +import { getSquadManageUrl, SquadManageSection } from '../../lib/routes'; +import { + SquadManageSaveButton, + SquadManageSectionPanel, +} from './SquadManageLayout'; + +const squadDetailsFormId = 'squad-manage-details'; + +const IMAGE_SIZE_LIMIT_MB = 2; + +type DetailsFields = { + name: string; + handle: string; + description: string; + status: PrivacyOption; + categoryId?: string; +}; + +const UploadButton = ({ + label, + onFile, + className, +}: { + label: string; + onFile: (preview: string, file: File) => void; + className?: string; +}): ReactElement => { + const inputRef = useRef(null); + const { onFileChange } = useFileInput({ + limitMb: IMAGE_SIZE_LIMIT_MB, + acceptedTypes: acceptedTypesList, + onChange: onFile, + }); + + return ( +
    +
    + ); +}; + +export const SquadManageDetails = (): ReactElement => { + const router = useRouter(); + const { squad } = useSquadPageContext(); + const { onEdit, isPending } = useEditSquad(squad); + const [cover, setCover] = useState<{ preview: string; file: File }>(); + const [image, setImage] = useState<{ preview: string; file: File }>(); + const [categoryHint, setCategoryHint] = useState(''); + const onCategoryChange = useCallback(() => setCategoryHint(''), []); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + const fields = formToJson(event.currentTarget); + + if (!fields.name?.trim() || !fields.handle?.trim()) { + return; + } + + if (fields.status === PrivacyOption.Public && !fields.categoryId) { + setCategoryHint('Please select a category'); + return; + } + + const updated = await onEdit({ + ...fields, + file: image?.file, + header: cover?.file, + }).catch(() => null); + + if (updated && updated.handle !== squad.handle) { + router.replace( + getSquadManageUrl(updated.handle, SquadManageSection.Details), + ); + } + }; + + const coverImage = cover?.preview ?? squad.headerImage; + + return ( + + } + > +
    +
    +
    + {coverImage && ( + Squad cover + )} + setCover({ preview, file })} + className="absolute right-6 top-1/2 -translate-y-1/2" + /> +
    +
    + {image ? ( + Squad logo + ) : ( + + )} + setImage({ preview, file })} + className="absolute inset-0 items-center justify-center" + /> +
    +
    +
    + } + defaultValue={squad.name} + className={{ container: 'w-full' }} + /> + } + defaultValue={squad.handle} + className={{ container: 'w-full' }} + /> +