Skip to content

feat: add speech bubble notifications#7066

Merged
Danziger merged 22 commits intodevelopfrom
feat/speech-bubble-notifications
Mar 4, 2026
Merged

feat: add speech bubble notifications#7066
Danziger merged 22 commits intodevelopfrom
feat/speech-bubble-notifications

Conversation

@Danziger
Copy link
Copy Markdown
Contributor

@Danziger Danziger commented Feb 23, 2026

Summary

Add a "speechBubble"-type notification.

To use them, simply create a new notification in the CMS with the location field set to "speechBubble". These will be shown one by one in the cow speech bubble instead of on the sidebar, and are only dismissed when clicking the "x" icon in the speech bubble.

CMS PR: cowprotocol/cms#86

localhost_3000_ (10)

To Test

  1. Create a "Notification template" in the CMS with the location field set to "speechBubble", or use the existing "Test cow speech bubble notification" template. See https://www.notion.so/cownation/Send-a-CoW-Notification-to-users-fec6aa0a78584475b54d0c9a266a67d8
  2. Create at least one "Notification" in the CMS using the template from above.
  3. Load CoW Swap, connect the target account, and verify that the most recent notification appears as a speech bubble.
  4. Click the "x" icon in the speech bubble and the next most recent notification should appear instead.
  5. After dismissing all of them, the "careers" speech bubble will appear, as usual (if enabled).
  6. Reload the page and verify the dismissed notifications are not shown again.
  7. Also verify that these speech bubble notifications are not included in the notification count in the bell icon on the top-right.

Also note:

  • If you reload the page without dismissing a notification, it will show up again.
  • The refresh interval for notifications is 5 minutes. If you create a new notification in the CMS and reload the page, it won't be refetched until 5 minutes have passed.

Summary by CodeRabbit

  • New Features

    • Closable hiring speech bubble with delayed typing reveal and "View jobs" link.
    • Closable notification speech bubble showing title, description and optional link.
    • New hooks surface the current speech-bubble notification and sidebar unread counts.
  • Refactor

    • Simplified speech-bubble component API to accept flexible content and explicit close label.
    • Notification flow split between speech-bubble vs sidebar, with filtered read-tracking.
  • Utilities/Types

    • New notification-location type and small notification-filter utilities.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cowfi Ready Ready Preview Mar 4, 2026 11:45am
explorer-dev Ready Ready Preview Mar 4, 2026 11:45am
swap-dev Ready Ready Preview Mar 4, 2026 11:45am
widget-configurator Ready Ready Preview Mar 4, 2026 11:45am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
cosmos Ignored Ignored Mar 4, 2026 11:45am
sdk-tools Ignored Ignored Preview Mar 4, 2026 11:45am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds typing and delay hooks, splits the speech-bubble UI into separate hiring and notification banners, introduces notification location typing, new notification filters/hooks/atoms, updates sidebar notification handling and header integration, and adjusts bubble styling and CowSpeechBubble public props.

Changes

Cohort / File(s) Summary
Animation Hooks
apps/cowswap-frontend/src/common/hooks/useDelay.ts, apps/cowswap-frontend/src/common/hooks/useTypingProgress.ts
Add `useDelay(delayMs: number
Typing Utility
apps/cowswap-frontend/src/common/utils/startTypingAnimation.ts
Removed exported TYPING_MESSAGE; changed type-only Dispatch/SetStateAction import to a runtime import.
Speech Bubble Core
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx, apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.styled.tsx
Simplified CowSpeechBubble to render children with new props (padding, closeButtonAriaLabel); updated styling (padding, border-radius, width, tail positioning) and typed $padding prop.
Hiring Banner
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx
New CowSpeechBubbleHiringBanner component with delayed typing animation, reduced-motion consideration, “View jobs” link, GTM event emission, and ClosableBanner integration.
Notification Banner
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx
New CowSpeechBubbleNotificationBanner component showing notification title/description/optional link; instruments clicks with GTM and exposes currentNotification + onClose API.
Notification Hook & Filtering
apps/cowswap-frontend/src/modules/notifications/hooks/useSpeechBubbleNotification.ts, apps/cowswap-frontend/src/modules/notifications/utils/filterNotifications.utils.ts
Add useSpeechBubbleNotification() to select/sort unread speech-bubble notifications; add predicates isSidebarNotification and isSpeechBubbleNotification.
Unread Count Hook
apps/cowswap-frontend/src/modules/notifications/hooks/useUnreadSidebarNotificationsCount.ts
Add useUnreadSidebarNotificationsCount() to compute unread count for sidebar-only notifications.
Notification State
apps/cowswap-frontend/src/modules/notifications/state/readNotificationsAtom.ts
Add markNotificationsAsReadCloneArrayAtom which merges ids using Set semantics to deduplicate when marking read.
Notifications List
apps/cowswap-frontend/src/modules/notifications/containers/NotificationsList/index.tsx
Filter notifications to sidebar-only (isSidebarNotification) for grouping and update mark-read effect to operate on sidebar notifications.
App Container Integration
apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx
Replace previous ClosableBanner usage with notification-driven logic via useSpeechBubbleNotification; conditionally render CowSpeechBubbleNotificationBanner or CowSpeechBubbleHiringBanner.
Core Types
libs/core/src/cms/types.ts
Add `NotificationLocation = 'default'
Exports
apps/cowswap-frontend/src/modules/notifications/index.ts
Export useSpeechBubbleNotification from notifications module.
Header Adjustment
apps/cowswap-frontend/src/legacy/components/Header/AccountElement/index.tsx
Switch unread hook to useUnreadSidebarNotificationsCount and adjust dismissal/feature-flag/subscription logic and ordering.

Sequence Diagram

sequenceDiagram
    participant AppContainer as AppContainer
    participant Hook as useSpeechBubbleNotification
    participant Store as NotificationsStore
    participant BannerH as HiringBanner
    participant BannerN as NotificationBanner
    participant UI as User

    AppContainer->>Hook: request currentNotification & dismiss()
    Hook->>Store: read notifications + unread set
    Store-->>Hook: notifications list
    Hook->>Hook: filter by location='speechBubble'\n sort by createdAt desc
    Hook-->>AppContainer: currentNotification or null

    alt currentNotification exists
        AppContainer->>BannerN: render NotificationBanner(currentNotification)
        BannerN->>UI: display title, description, optional link
    else no currentNotification
        AppContainer->>BannerH: render HiringBanner()
        BannerH->>BannerH: useDelay(delayMs) -> hasDelayElapsed
        BannerH->>BannerH: useTypingProgress(message, hasDelayElapsed) -> charIndex
        BannerH->>UI: animated typing, reveal "View jobs" link
    end

    UI->>BannerN: user closes or clicks link
    UI->>BannerH: user closes or clicks link
    BannerN->>Hook: dismiss() (mark as read)
    Hook->>Store: markNotificationsAsRead(...)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • elena-zh
  • shoom3301

"🐰
I paused my hop to watch letters flow,
A bubble types — soft, steady, slow,
Notifications dance, the hiring link gleams,
I twitch my nose and dream in beams,
Hooray — the UI hums and onward goes!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add speech bubble notifications' clearly and concisely summarizes the main feature added in the changeset.
Description check ✅ Passed The PR description includes a clear summary, comprehensive testing steps with checkboxes, background context, and a referenced CMS PR link. All key information is present and well-structured.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/speech-bubble-notifications

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/cowswap-frontend/src/modules/notifications/state/readNotificationsAtom.ts (1)

12-26: ⚠️ Potential issue | 🟠 Major

markNotificationsAsReadAtom mutates persisted state in-place; two atoms with diverging semantics now coexist

state.push(...) on line 15 mutates the array retrieved from readNotificationsAtom and then calls set(readNotificationsAtom, state) with the same reference. Referential equality is important in Jotai, and since Object.is(state, state) === true, the store treats the atom as unchanged and will skip notifying dependents — meaning sidebar-notification read state may silently fail to propagate to subscribers (e.g. unread badge counts).

markNotificationsAsReadCloneArrayAtom (introduced here) is the correct approach — it creates a new Set/array so Jotai sees a fresh reference. However, NotificationsList/index.tsx still imports and uses the old, mutating atom, leaving the sidebar path broken while speech-bubble notifications use the correct one.

Recommended resolution: replace the body of markNotificationsAsReadAtom with the clone approach and delete markNotificationsAsReadCloneArrayAtom, then update all callers. If the two atoms really must coexist for a transitional period, rename markNotificationsAsReadCloneArrayAtom to something that describes intent, not implementation (e.g. markSpeechBubbleNotificationsAsReadAtom).

🔧 Proposed fix — merge both atoms into one correct implementation
 export const markNotificationsAsReadAtom = atom(null, (get, set, ids: number[]) => {
-  const state = get(readNotificationsAtom)
-
-  state.push(...ids.filter((id) => !state.includes(id)))
-
-  set(readNotificationsAtom, state)
+  const state = new Set(get(readNotificationsAtom))
+
+  ids.forEach((id) => state.add(id))
+
+  set(readNotificationsAtom, [...state])
 })
-
-export const markNotificationsAsReadCloneArrayAtom = atom(null, (get, set, ids: number[]) => {
-  const state = new Set(get(readNotificationsAtom))
-
-  ids.forEach((id) => state.add(id))
-
-  set(readNotificationsAtom, [...state])
-})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/notifications/state/readNotificationsAtom.ts`
around lines 12 - 26, markNotificationsAsReadAtom currently mutates the array
in-place (state.push(...)) causing Jotai to skip updates; replace its body with
the clone/set approach used in markNotificationsAsReadCloneArrayAtom (create new
Set(get(readNotificationsAtom)), add ids, then set(readNotificationsAtom,
[...state])) to ensure a new reference is written, remove
markNotificationsAsReadCloneArrayAtom and update all callers (e.g.
NotificationsList/index.tsx and any speech-bubble notification callers) to use
markNotificationsAsReadAtom — if you must keep both during a transition, rename
markNotificationsAsReadCloneArrayAtom to a clear intent-based name like
markSpeechBubbleNotificationsAsReadAtom and update callers accordingly.
🧹 Nitpick comments (4)
libs/core/src/cms/types.ts (1)

40-42: Add a constant for 'speechBubble' to match NOTIFICATION_LOCATION_DEFAULT

NOTIFICATION_LOCATION_DEFAULT is exported for 'default' but there is no corresponding constant for 'speechBubble'. As a result, filter predicates in filterNotifications.utils.ts and any future code hardcode the raw string 'speechBubble'. Adding the companion constant keeps naming consistent and prevents string drift.

🔧 Proposed addition
 export const NOTIFICATION_LOCATION_DEFAULT: NotificationLocation = 'default'
+export const NOTIFICATION_LOCATION_SPEECH_BUBBLE: NotificationLocation = 'speechBubble'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/core/src/cms/types.ts` around lines 40 - 42, Add a companion exported
constant for the 'speechBubble' NotificationLocation to match
NOTIFICATION_LOCATION_DEFAULT: define and export e.g.
NOTIFICATION_LOCATION_SPEECH_BUBBLE with type NotificationLocation and value
'speechBubble' so callers (and filterNotifications.utils.ts) can reference the
constant instead of hardcoding the string; update any internal usages to use
NOTIFICATION_LOCATION_SPEECH_BUBBLE where appropriate.
apps/cowswap-frontend/src/modules/notifications/utils/filterNotifications.utils.ts (1)

3-9: Use the NOTIFICATION_LOCATION_SPEECH_BUBBLE constant and define isSidebarNotification as the complement of isSpeechBubbleNotification

Both predicates hardcode 'speechBubble' instead of importing the constant from @cowprotocol/core. Additionally, defining both predicates independently means a future addition to NotificationLocation (e.g. 'modal') could silently route unexpected locations to the sidebar without a compile-time warning.

🔧 Proposed refactor
-import { NotificationModel } from '@cowprotocol/core'
+import { NOTIFICATION_LOCATION_SPEECH_BUBBLE, NotificationModel } from '@cowprotocol/core'

-export function isSidebarNotification(notification: NotificationModel): boolean {
-  return notification.location !== 'speechBubble'
-}
-
 export function isSpeechBubbleNotification(notification: NotificationModel): boolean {
-  return notification.location === 'speechBubble'
+  return notification.location === NOTIFICATION_LOCATION_SPEECH_BUBBLE
+}
+
+export function isSidebarNotification(notification: NotificationModel): boolean {
+  return !isSpeechBubbleNotification(notification)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/notifications/utils/filterNotifications.utils.ts`
around lines 3 - 9, Replace the hardcoded 'speechBubble' checks by importing
NOTIFICATION_LOCATION_SPEECH_BUBBLE from `@cowprotocol/core` and use it inside
isSpeechBubbleNotification(notification: NotificationModel); then implement
isSidebarNotification(notification: NotificationModel) as the logical complement
of isSpeechBubbleNotification (e.g., return
!isSpeechBubbleNotification(notification)) so future NotificationLocation
variants (like 'modal') won't be silently misrouted; update the functions
isSpeechBubbleNotification and isSidebarNotification accordingly.
apps/cowswap-frontend/src/common/utils/startTypingAnimation.ts (1)

1-1: Revert the type import regression

Dispatch and SetStateAction are TypeScript interfaces with no runtime representation. The AI summary notes this was intentionally changed from import type to a value import — that's a step backward: value imports for type-only constructs increase bundle metadata and obscure intent.

🔧 Proposed fix
-import { Dispatch, SetStateAction } from 'react'
+import type { Dispatch, SetStateAction } from 'react'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cowswap-frontend/src/common/utils/startTypingAnimation.ts` at line 1,
The import of React types is currently a runtime value import; change it to a
type-only import so Dispatch and SetStateAction are erased at runtime—replace
the value import of Dispatch and SetStateAction with a type import (i.e., use a
type-only import for Dispatch and SetStateAction) in the file containing
startTypingAnimation to avoid bundling runtime metadata while keeping the same
identifiers.
apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx (1)

186-201: Hoist useSpeechBubbleNotification() from FooterSection into AppContainer and thread results down as props.

FooterSection is a module-internal helper, but invoking a domain-specific hook here means it always runs even when show === false (injected widget mode), and it couples the helper to the notification layer. Per the guideline, non-built-in state should be hoisted to the caller and passed via props.

♻️ Proposed refactor sketch

In FooterSectionProps:

 interface FooterSectionProps {
   show: boolean
   showCowSpeechBubble: boolean
   pageScene: ReactNode | null
+  currentNotification: NotificationModel | null
+  onDismissNotification: () => void
 }

In AppContainer:

+  const { currentNotification, dismiss } = useSpeechBubbleNotification()
   // ...
   <FooterSection
     show={!isInjectedWidgetMode}
     showCowSpeechBubble={shouldRenderCowSpeechBubble}
     pageScene={pageScene}
+    currentNotification={currentNotification}
+    onDismissNotification={dismiss}
   />

In FooterSection: remove the useSpeechBubbleNotification() call and use props instead.

As per coding guidelines, "Pure React components can use only built-in hooks, hooks that provide state of the current module, and common state providing hooks like useTheme(); hoist other dependencies into callers and pass results via props."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx`
around lines 186 - 201, The FooterSection helper currently calls the domain hook
useSpeechBubbleNotification() internally which runs even when show === false and
couples the helper to notification state; move the hook call up into
AppContainer, add currentNotification and dismiss to FooterSectionProps, and
pass those props into FooterSection so FooterSection no longer invokes
useSpeechBubbleNotification(); inside FooterSection replace the local hook usage
with the passed-in currentNotification and dismiss when rendering
CowSpeechBubbleNotificationBanner or CowSpeechBubbleHiringBanner (also ensure
showCowSpeechBubble prop is still honored).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cowswap-frontend/src/common/hooks/useDelay.ts`:
- Around line 6-14: The effect in useDelay does not reset hasDelayElapsed when
delayMs changes, so update the useEffect in the useDelay hook to explicitly
reset state when starting a new positive delay: if delayMs is a positive number
call setHasDelayElapsed(false) at the top of the effect before creating the
timeout (keeping the existing typeof/<=0 guard), then start the timeout which
will call setHasDelayElapsed(true) and still clear the timeout in the existing
cleanup; this ensures hasDelayElapsed (and the setHasDelayElapsed state updates)
correctly reflect new delayMs changes for the useDelay hook.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx`:
- Line 52: The current substring check in CowSpeechBubbleNotificationBanner's
linkTarget calculation wrongly classifies external URLs as internal; replace the
url.includes(window.location.host) test by parsing the URL with the URL
constructor (use new URL(url, window.location.origin) to handle relative URLs)
and compare its hostname or host strictly to window.location.hostname/host (or
window.location.origin for scheme+host checks); keep the existing relative-path
check (url.startsWith('/')) and set target to '_parent' only when the parsed URL
is same-origin, otherwise '_blank'.
- Around line 61-73: Add a descriptive aria-label to the NotificationLink so
screen readers hear context (e.g., aria-label={t`View: ${title}`}) and change
the rel prop to use undefined instead of '' when linkTarget !== '_blank' so
React omits the attribute (i.e., set rel={linkTarget === '_blank' ? 'noopener
noreferrer' : undefined}); update the NotificationLink JSX where href={url},
target={linkTarget}, title and id are available and the data-click-event uses
toCowSwapGtmEvent.

In
`@apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx`:
- Around line 193-201: Replace the mutable let reassignment for bubbleElement
with an immutable const using a nested ternary and the imported ReactNode type:
declare const bubbleElement: ReactNode = showCowSpeechBubble ?
(currentNotification ? <CowSpeechBubbleNotificationBanner
currentNotification={currentNotification} onClose={dismiss} /> :
<CowSpeechBubbleHiringBanner />) : null; ensure you reference the existing
symbols showCowSpeechBubble, currentNotification, dismiss,
CowSpeechBubbleNotificationBanner and CowSpeechBubbleHiringBanner and remove
React.ReactNode in favor of the imported ReactNode.

In
`@apps/cowswap-frontend/src/modules/notifications/containers/NotificationsList/index.tsx`:
- Around line 88-91: The fallback "|| []" is dead code because the early guard
"if (!sidebarNotifications) return" ensures sidebarNotifications is non-null;
remove the redundant "|| []" in the setTimeout call so markNotificationsAsRead
receives the mapped array directly (i.e., call
markNotificationsAsRead(sidebarNotifications.map(({ id }) => id))). This touches
the setTimeout invocation where sidebarNotifications and markNotificationsAsRead
are used.

In
`@apps/cowswap-frontend/src/modules/notifications/hooks/useSpeechBubbleNotification.ts`:
- Around line 20-26: In useSpeechBubbleNotification, the memoized computation
for speechBubbleNotifications redundantly uses (notifications || []) despite an
early guard returning [] when !notifications; update the callback to use
notifications directly (e.g., notifications.filter(...).sort(...)) and remove
the dead `|| []` expression so the code is simpler and avoids unnecessary
fallback logic while preserving the filter by isSpeechBubbleNotification and the
sort by createdAt.
- Line 40: The hook useSpeechBubbleNotification returns a new object each render
which breaks reference equality; wrap the returned container object in useMemo
so { currentNotification, dismiss } is memoized and only changes when
currentNotification or dismiss change, ensuring consumers that rely on stable
refs (e.g., React.memo, atoms, effect deps) do not re-run unnecessarily.
- Around line 28-32: The null guard inside the useMemo that computes
currentNotification is unreachable because speechBubbleNotifications is always
an array; remove the if (!speechBubbleNotifications) return null check and
simply return speechBubbleNotifications.find(({ id }) =>
unreadNotifications[id]) || null so currentNotification (in the useMemo) relies
directly on the array, keeping the dependency list [speechBubbleNotifications,
unreadNotifications] unchanged.

---

Outside diff comments:
In
`@apps/cowswap-frontend/src/modules/notifications/state/readNotificationsAtom.ts`:
- Around line 12-26: markNotificationsAsReadAtom currently mutates the array
in-place (state.push(...)) causing Jotai to skip updates; replace its body with
the clone/set approach used in markNotificationsAsReadCloneArrayAtom (create new
Set(get(readNotificationsAtom)), add ids, then set(readNotificationsAtom,
[...state])) to ensure a new reference is written, remove
markNotificationsAsReadCloneArrayAtom and update all callers (e.g.
NotificationsList/index.tsx and any speech-bubble notification callers) to use
markNotificationsAsReadAtom — if you must keep both during a transition, rename
markNotificationsAsReadCloneArrayAtom to a clear intent-based name like
markSpeechBubbleNotificationsAsReadAtom and update callers accordingly.

---

Nitpick comments:
In `@apps/cowswap-frontend/src/common/utils/startTypingAnimation.ts`:
- Line 1: The import of React types is currently a runtime value import; change
it to a type-only import so Dispatch and SetStateAction are erased at
runtime—replace the value import of Dispatch and SetStateAction with a type
import (i.e., use a type-only import for Dispatch and SetStateAction) in the
file containing startTypingAnimation to avoid bundling runtime metadata while
keeping the same identifiers.

In
`@apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx`:
- Around line 186-201: The FooterSection helper currently calls the domain hook
useSpeechBubbleNotification() internally which runs even when show === false and
couples the helper to notification state; move the hook call up into
AppContainer, add currentNotification and dismiss to FooterSectionProps, and
pass those props into FooterSection so FooterSection no longer invokes
useSpeechBubbleNotification(); inside FooterSection replace the local hook usage
with the passed-in currentNotification and dismiss when rendering
CowSpeechBubbleNotificationBanner or CowSpeechBubbleHiringBanner (also ensure
showCowSpeechBubble prop is still honored).

In
`@apps/cowswap-frontend/src/modules/notifications/utils/filterNotifications.utils.ts`:
- Around line 3-9: Replace the hardcoded 'speechBubble' checks by importing
NOTIFICATION_LOCATION_SPEECH_BUBBLE from `@cowprotocol/core` and use it inside
isSpeechBubbleNotification(notification: NotificationModel); then implement
isSidebarNotification(notification: NotificationModel) as the logical complement
of isSpeechBubbleNotification (e.g., return
!isSpeechBubbleNotification(notification)) so future NotificationLocation
variants (like 'modal') won't be silently misrouted; update the functions
isSpeechBubbleNotification and isSidebarNotification accordingly.

In `@libs/core/src/cms/types.ts`:
- Around line 40-42: Add a companion exported constant for the 'speechBubble'
NotificationLocation to match NOTIFICATION_LOCATION_DEFAULT: define and export
e.g. NOTIFICATION_LOCATION_SPEECH_BUBBLE with type NotificationLocation and
value 'speechBubble' so callers (and filterNotifications.utils.ts) can reference
the constant instead of hardcoding the string; update any internal usages to use
NOTIFICATION_LOCATION_SPEECH_BUBBLE where appropriate.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0df7b00 and e62e82f.

📒 Files selected for processing (12)
  • apps/cowswap-frontend/src/common/hooks/useDelay.ts
  • apps/cowswap-frontend/src/common/hooks/useTypingProgress.ts
  • apps/cowswap-frontend/src/common/utils/startTypingAnimation.ts
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx
  • apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx
  • apps/cowswap-frontend/src/modules/notifications/containers/NotificationsList/index.tsx
  • apps/cowswap-frontend/src/modules/notifications/hooks/useSpeechBubbleNotification.ts
  • apps/cowswap-frontend/src/modules/notifications/state/readNotificationsAtom.ts
  • apps/cowswap-frontend/src/modules/notifications/utils/filterNotifications.utils.ts
  • libs/core/src/cms/types.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx (2)

62-74: rel="" (should be undefined) and non-descriptive "View" link text — previously flagged and still unresolved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx`
around lines 62 - 74, The NotificationLink usage sets rel to an empty string
when linkTarget !== '_blank' and uses non-descriptive link text "View"; change
rel to undefined instead of '' to avoid rendering an empty attribute (update the
rel prop in the NotificationLink JSX) and replace the generic link text with a
descriptive label (e.g., include the notification title or context such as `View
notification: ${title}` or add an aria-label that includes title) so the link is
meaningful for screen readers and accessibility; locate the NotificationLink
element that supplies href, target, rel, data-click-event (toCowSwapGtmEvent)
and title/id to implement these changes.

53-53: url.includes(window.location.host) false-positive internal URL classification — previously flagged and still unresolved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx`
at line 53, The current linkTarget calculation (const linkTarget = url &&
(url.includes(window.location.host) || url.startsWith('/')) ? '_parent' :
'_blank') misclassifies external URLs that merely contain the host string;
update it to safely parse the URL and compare hosts instead: in the code that
sets linkTarget, attempt to construct new URL(url, window.location.href) inside
a try/catch and treat the link as internal only if parsedUrl.host ===
window.location.host or url.startsWith('/'); otherwise fall back to '_blank' —
reference the linkTarget assignment to locate where to change.
🧹 Nitpick comments (3)
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx (1)

12-22: styled.span with display: flex / display: block — prefer div for layout containers.

NotificationText and NotificationDescription are rendered as <span> elements yet assigned block-level/flex display via CSS. This is valid but semantically inconsistent: <span> is phrasing content and block-level layout is better expressed with <div>.

♻️ Proposed fix
-const NotificationText = styled.span`
+const NotificationText = styled.div`
   display: flex;
   flex-direction: column;
   gap: 12px;
   text-align: left;
 `

-const NotificationDescription = styled.span`
+const NotificationDescription = styled.p`
   display: block;
   font-weight: 400;
 `
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx`
around lines 12 - 22, NotificationText and NotificationDescription are defined
with styled.span but given block/flex layout; change both component definitions
to use styled.div (i.e., replace styled.span with styled.div for
NotificationText and NotificationDescription) so the markup is semantically
correct for block-level layout while keeping the existing styles and names
unchanged.
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx (1)

15-15: Use namespace import for styled files instead of named imports.

Named destructuring from a .styled file deviates from the project convention.

♻️ Proposed fix
-import { Arrow, Cursor, JobsLink, TypingLine } from './CowSpeechBubble.styled'
+import * as styledEl from './CowSpeechBubble.styled'

Then update usages: styledEl.TypingLine, styledEl.Cursor, styledEl.JobsLink, styledEl.Arrow.

Based on learnings: "import styles as import * as styledEl from './X.styled' for styled files."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx`
at line 15, Replace the named destructured import from the styled module with a
namespace import and update all usages accordingly: change the import of Arrow,
Cursor, JobsLink, TypingLine from './CowSpeechBubble.styled' to a namespace
import (e.g., import * as styledEl from './CowSpeechBubble.styled') and then
update references in this file to use styledEl.TypingLine, styledEl.Cursor,
styledEl.JobsLink, and styledEl.Arrow wherever those symbols are used.
apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx (1)

3-3: Use namespace import for styled files instead of named imports.

♻️ Proposed fix
-import { Bubble, BubbleContent, CloseButton } from './CowSpeechBubble.styled'
+import * as styledEl from './CowSpeechBubble.styled'

Then update usages: styledEl.Bubble, styledEl.BubbleContent, styledEl.CloseButton.

Based on learnings: "import styles as import * as styledEl from './X.styled' for styled files."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx`
at line 3, Replace the named imports from the styled module with a namespace
import and update all uses accordingly: change the import of Bubble,
BubbleContent, CloseButton from './CowSpeechBubble.styled' to a single namespace
import (e.g., import * as styledEl from './CowSpeechBubble.styled') and then
update all occurrences of Bubble, BubbleContent, and CloseButton in this file to
styledEl.Bubble, styledEl.BubbleContent, and styledEl.CloseButton respectively
so the component references the namespaced styled exports.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx`:
- Around line 47-61: The JobsLink is hidden via CSS (opacity/pointer-events) but
remains keyboard-focusable; update the JobsLink render to remove it from the tab
order and hide it from assistive tech while typing is in progress by setting
tabIndex to -1 and aria-hidden to true when isTypingComplete is false (and
restore tabIndex/aria-hidden when true); reference the JobsLink element and the
isTypingComplete prop to implement this change so keyboard users cannot tab to
the invisible link during the typing animation.

---

Duplicate comments:
In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx`:
- Around line 62-74: The NotificationLink usage sets rel to an empty string when
linkTarget !== '_blank' and uses non-descriptive link text "View"; change rel to
undefined instead of '' to avoid rendering an empty attribute (update the rel
prop in the NotificationLink JSX) and replace the generic link text with a
descriptive label (e.g., include the notification title or context such as `View
notification: ${title}` or add an aria-label that includes title) so the link is
meaningful for screen readers and accessibility; locate the NotificationLink
element that supplies href, target, rel, data-click-event (toCowSwapGtmEvent)
and title/id to implement these changes.
- Line 53: The current linkTarget calculation (const linkTarget = url &&
(url.includes(window.location.host) || url.startsWith('/')) ? '_parent' :
'_blank') misclassifies external URLs that merely contain the host string;
update it to safely parse the URL and compare hosts instead: in the code that
sets linkTarget, attempt to construct new URL(url, window.location.href) inside
a try/catch and treat the link as internal only if parsedUrl.host ===
window.location.host or url.startsWith('/'); otherwise fall back to '_blank' —
reference the linkTarget assignment to locate where to change.

---

Nitpick comments:
In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx`:
- Line 3: Replace the named imports from the styled module with a namespace
import and update all uses accordingly: change the import of Bubble,
BubbleContent, CloseButton from './CowSpeechBubble.styled' to a single namespace
import (e.g., import * as styledEl from './CowSpeechBubble.styled') and then
update all occurrences of Bubble, BubbleContent, and CloseButton in this file to
styledEl.Bubble, styledEl.BubbleContent, and styledEl.CloseButton respectively
so the component references the namespaced styled exports.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx`:
- Line 15: Replace the named destructured import from the styled module with a
namespace import and update all usages accordingly: change the import of Arrow,
Cursor, JobsLink, TypingLine from './CowSpeechBubble.styled' to a namespace
import (e.g., import * as styledEl from './CowSpeechBubble.styled') and then
update references in this file to use styledEl.TypingLine, styledEl.Cursor,
styledEl.JobsLink, and styledEl.Arrow wherever those symbols are used.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx`:
- Around line 12-22: NotificationText and NotificationDescription are defined
with styled.span but given block/flex layout; change both component definitions
to use styled.div (i.e., replace styled.span with styled.div for
NotificationText and NotificationDescription) so the markup is semantically
correct for block-level layout while keeping the existing styles and names
unchanged.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e62e82f and 5de9e14.

📒 Files selected for processing (4)
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.styled.tsx
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx
  • apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleNotificationBanner.tsx

Comment on lines +47 to +61
<JobsLink
href={CAREERS_URL}
target="_blank"
rel="noopener noreferrer"
$visible={isTypingComplete}
aria-label={t`View jobs (opens in a new tab)`}
data-click-event={toCowSwapGtmEvent({
category: CowSwapAnalyticsCategory.COWSWAP,
action: 'Click speech bubble jobs link',
label: CAREERS_URL,
})}
>
<Trans>View jobs</Trans>
<Arrow aria-hidden="true">→</Arrow>
</JobsLink>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JobsLink is invisible but still keyboard-focusable while typing is in progress.

opacity: 0 + pointer-events: none hides and disables mouse interaction, but the link remains in the natural tab order. A keyboard user pressing Tab during the typing animation will land on an invisible, unfocusable-looking element.

♿ Proposed fix — remove from tab order when not yet visible
       <JobsLink
         href={CAREERS_URL}
         target="_blank"
         rel="noopener noreferrer"
         $visible={isTypingComplete}
+        tabIndex={isTypingComplete ? undefined : -1}
+        aria-hidden={!isTypingComplete}
         aria-label={t`View jobs (opens in a new tab)`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<JobsLink
href={CAREERS_URL}
target="_blank"
rel="noopener noreferrer"
$visible={isTypingComplete}
aria-label={t`View jobs (opens in a new tab)`}
data-click-event={toCowSwapGtmEvent({
category: CowSwapAnalyticsCategory.COWSWAP,
action: 'Click speech bubble jobs link',
label: CAREERS_URL,
})}
>
<Trans>View jobs</Trans>
<Arrow aria-hidden="true"></Arrow>
</JobsLink>
<JobsLink
href={CAREERS_URL}
target="_blank"
rel="noopener noreferrer"
$visible={isTypingComplete}
tabIndex={isTypingComplete ? undefined : -1}
aria-hidden={!isTypingComplete}
aria-label={t`View jobs (opens in a new tab)`}
data-click-event={toCowSwapGtmEvent({
category: CowSwapAnalyticsCategory.COWSWAP,
action: 'Click speech bubble jobs link',
label: CAREERS_URL,
})}
>
<Trans>View jobs</Trans>
<Arrow aria-hidden="true"></Arrow>
</JobsLink>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubbleHiringBanner.tsx`
around lines 47 - 61, The JobsLink is hidden via CSS (opacity/pointer-events)
but remains keyboard-focusable; update the JobsLink render to remove it from the
tab order and hide it from assistive tech while typing is in progress by setting
tabIndex to -1 and aria-hidden to true when isTypingComplete is false (and
restore tabIndex/aria-hidden when true); reference the JobsLink element and the
isTypingComplete prop to implement this change so keyboard users cannot tab to
the invisible link during the typing animation.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx (1)

194-202: 🛠️ Refactor suggestion | 🟠 Major

Replace let reassignment with const + nested ternary.

This pattern was flagged in a previous review but remains unaddressed. The sequential let reassignment violates the coding guideline favoring const with ternaries/early returns.

♻️ Proposed fix
-  let bubbleElement: ReactNode = null
-
-  if (showCowSpeechBubble) {
-    if (currentNotification) {
-      bubbleElement = <CowSpeechBubbleNotificationBanner currentNotification={currentNotification} onClose={dismiss} />
-    } else {
-      bubbleElement = <CowSpeechBubbleHiringBanner />
-    }
-  }
+  const bubbleElement: ReactNode = showCowSpeechBubble
+    ? currentNotification
+      ? <CowSpeechBubbleNotificationBanner currentNotification={currentNotification} onClose={dismiss} />
+      : <CowSpeechBubbleHiringBanner />
+    : null

As per coding guidelines: "Never reassign via let; favor const and ternaries/early returns in TypeScript."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx`
around lines 194 - 202, Replace the mutable let bubbleElement with an immutable
const using a nested ternary: compute bubbleElement from showCowSpeechBubble ?
(currentNotification ? <CowSpeechBubbleNotificationBanner
currentNotification={currentNotification} onClose={dismiss} /> :
<CowSpeechBubbleHiringBanner />) : null so you no longer reassign; keep
references to showCowSpeechBubble, currentNotification,
CowSpeechBubbleNotificationBanner, CowSpeechBubbleHiringBanner, and dismiss to
preserve behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx`:
- Around line 194-202: Replace the mutable let bubbleElement with an immutable
const using a nested ternary: compute bubbleElement from showCowSpeechBubble ?
(currentNotification ? <CowSpeechBubbleNotificationBanner
currentNotification={currentNotification} onClose={dismiss} /> :
<CowSpeechBubbleHiringBanner />) : null so you no longer reassign; keep
references to showCowSpeechBubble, currentNotification,
CowSpeechBubbleNotificationBanner, CowSpeechBubbleHiringBanner, and dismiss to
preserve behavior.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7223df6 and aca9b91.

📒 Files selected for processing (1)
  • apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx

Copy link
Copy Markdown
Contributor

@elena-zh elena-zh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Danziger , great jib, thank you!
Some nitpicks from my side:

  1. original bubble has an animated text, however, a notification one does not have. Might it be possible to implement it?
  2. UI properties of the default 'Hiring' bubble have need changed:
  • it became wider
  • the arrow's position has been changed
Image

See the comparison with the prod version:

Image
  1. Notification template: there is no point in 'empty' state for it: I'd pick 'default' as a default value since an empty option duplicates a default behavior.
Image
  1. On some screen resolutions it is not clear that a bubble comes from the cow (might be related to the p.2 above)
Image

Thanks.

@Danziger
Copy link
Copy Markdown
Contributor Author

Danziger commented Feb 27, 2026

@elena-zh I'm aware of those, but I don't think at this point it makes more sense to invest more time into this feature, but feel free to create an issue with those in case we want to refine it later. To add more context:

  • 1: I chose not to implement that effect as we rely on a rather basic/manual implementation, and the notification can have different sizes, so the right way to do this would be to first move to a better implementation like https://www.npmjs.com/package/typewriter-effect, and then update the current implementation so that the text is rendered twice (transparent and with color on top), so that the speech bubble is not constantly changing in size while the text is being typed.
  • 2 and 4: I made some changes to make the bubble work better with messages from various sizes in different screen sizes. Still, not perfect, but doing it properly will involve refactoring the footer so that the bubble is relatively positioned (instead of absolutely positioned), so that it takes as much vertical space as it needs. Then, the resizing of the graphics at the bottom would also need to be adjusted to better position the cow to the right of the speech bubble (after this, the speech bubble arrow can be moved back to the bottom, as it was before).
  • 3: default is already set as default in the CMS, and I also tried to make it required, but either way, it won't get prepopulated, so nothing we can do. If you leave it empty, the frontend will treat the notification as "default".

@elena-zh
Copy link
Copy Markdown
Contributor

Hey @Danziger

@elena-zh I'm aware of those,

If you aware of some issues, please report them in the description so I will not spend my time on reporting them back you.

Thank you for understanding.

As for the

1, 2 and 4:

I asked @fairlighteth to take a look at this component

@fairlighteth
Copy link
Copy Markdown
Contributor

@Danziger thanks for this PR. I've working on addressing @elena-zh 's points

  1. I think we can keep it out of scope for this. The notification text is highly variable, we can have long and short texts. With the hiring speech the text is predictable and fixed.

3 + 4> Addressed in #7087

* feat: implement CowSpeechBubble component and related notifications
* feat: enhance CowSpeechBubble styling and layout for better responsiveness
* fix: adjust max-height of CowSpeechBubble for improved layout
Copy link
Copy Markdown
Contributor

@elena-zh elena-zh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants