Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,7 @@ async function handleMessages(

browser.runtime.onMessage.addListener(handleMessages);

// since we are using V2 on FF / V3 on Chrome,
// we need to support both action (V3) & browserAction (V2) APIs
(browser.action || browser.browserAction).onClicked.addListener(() => {
browser.action.onClicked.addListener(() => {
const url = browser.runtime.getURL('index.html?source=button');
browser.tabs.create({ url, active: true });
});
Expand Down
55 changes: 37 additions & 18 deletions packages/extension/src/companion/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,42 @@ import { GrowthBookProvider } from '@dailydotdev/shared/src/components/GrowthBoo
import { NotificationsContextProvider } from '@dailydotdev/shared/src/contexts/NotificationsContext';
import { useEventListener } from '@dailydotdev/shared/src/hooks';
import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone';
import { REQUEST_PROTOCOL_KEY } from '@dailydotdev/shared/src/graphql/common';
import Companion from './Companion';
import CustomRouter from '../lib/CustomRouter';
import { companionFetch } from './companionFetch';
import { version } from '../../package.json';
import { MessageSuggestion } from './suggestion/MessageSuggestion';
import { companionRequest } from './companionRequest';

structuredCloneJsonPolyfill();

const queryClient = new QueryClient(defaultQueryClientConfig);

queryClient.setQueryData(REQUEST_PROTOCOL_KEY, {
requestMethod: companionRequest,
fetchMethod: companionFetch,
isCompanion: true,
});

const router = new CustomRouter();

export type CompanionData = { url: string; deviceId: string } & Pick<
Boot,
| 'postData'
| 'settings'
| 'alerts'
| 'user'
| 'visit'
| 'accessToken'
| 'squads'
| 'exp'
>;
export interface CompanionData
extends Pick<
Boot,
| 'postData'
| 'settings'
| 'alerts'
| 'user'
| 'visit'
| 'accessToken'
| 'squads'
| 'exp'
> {
url: string;
deviceId: string;
messageSuggestionsEnabled?: boolean;
}

const app = BootApp.Companion;

Expand All @@ -59,6 +74,7 @@ export default function App({
accessToken,
squads,
exp,
messageSuggestionsEnabled,
}: CompanionData): ReactElement {
useError();
const [token, setToken] = useState(accessToken);
Expand Down Expand Up @@ -123,13 +139,16 @@ export default function App({
isNotificationsReady={false}
unreadCount={0}
>
<Companion
postData={postData}
companionHelper={alerts?.companionHelper}
companionExpanded={settings?.companionExpanded}
onOptOut={() => setIsOptOutCompanion(true)}
onUpdateToken={setToken}
/>
{postData && (
<Companion
postData={postData}
companionHelper={alerts?.companionHelper}
companionExpanded={settings?.companionExpanded}
onOptOut={() => setIsOptOutCompanion(true)}
onUpdateToken={setToken}
/>
)}
{messageSuggestionsEnabled && <MessageSuggestion />}
</NotificationsContextProvider>
<PromptElement parentSelector={getCompanionWrapper} />
<Toast
Expand Down
14 changes: 1 addition & 13 deletions packages/extension/src/companion/Companion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import classNames from 'classnames';
import Modal from 'react-modal';
import { isTesting } from '@dailydotdev/shared/src/lib/constants';
import { REQUEST_PROTOCOL_KEY } from '@dailydotdev/shared/src/graphql/common';
import '@dailydotdev/shared/src/styles/globals.css';
import type {
AccessToken,
Expand All @@ -21,8 +20,6 @@ import {
import { getCompanionWrapper } from '@dailydotdev/shared/src/lib/extension';
import CompanionMenu from './CompanionMenu';
import CompanionContent from './CompanionContent';
import { companionRequest } from './companionRequest';
import { companionFetch } from './companionFetch';

if (!isTesting) {
Modal.setAppElement('daily-companion-app');
Expand Down Expand Up @@ -95,16 +92,7 @@ export default function Companion({
},
[client],
);
const [companionState, setCompanionState] =
useState<boolean>(companionExpanded);
useQuery({
queryKey: REQUEST_PROTOCOL_KEY,
queryFn: () => ({
requestMethod: companionRequest,
fetchMethod: companionFetch,
isCompanion: true,
}),
});
const [companionState, setCompanionState] = useState(companionExpanded);
const [assetsLoadedDebounce] = useDebounceFn(() => setAssetsLoaded(true), 10);
const routeChangedCallbackRef = useLogPageView();

Expand Down
18 changes: 15 additions & 3 deletions packages/extension/src/companion/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,28 @@ const renderApp = (props: CompanionData) => {
root.render(<App {...props} />);
};

const validOrigins = ['https://www.linkedin.com'];

const isValidUrl = (url: string) => {
try {
const parsedUrl = new URL(url);
return validOrigins.includes(parsedUrl.origin);
} catch {
return false;
}
};

browser.runtime.onMessage.addListener((props) => {
const { settings, postData } = props;
const { settings, postData, url } = props;
if (!settings || settings?.optOutCompanion) {
return;
}

const container = getCompanionWrapper();
const isValid = isValidUrl(url);

if (postData) {
renderApp(props);
if (postData || isValid) {
renderApp({ ...props, messageSuggestionsEnabled: isValid });
} else if (container && root) {
root.unmount();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ONE_SECOND, sleep } from '@dailydotdev/shared/src/lib/func';
import { TargetId } from '@dailydotdev/shared/src/lib/log';
import { useThreadPageObserver } from './useThreadPageObserver';
import { MessageSuggestionPortal } from './MessageSuggestionPortal';

const bubbleClass = 'msg-overlay-conversation-bubble';
const titleClass = 'msg-overlay-bubble-header__title';

interface MessagePopup {
id: string;
bubble: HTMLElement;
}

export function MessageSuggestion() {
const { id: threadUserId } = useThreadPageObserver();
const [popups, setPopups] = useState<MessagePopup[]>([]);
const [observer] = useState(
new MutationObserver(async () => {
await sleep(10); // wait for the dom to update
const bubbles = document.querySelectorAll(`.${bubbleClass}`);
const toUpdate = [];

bubbles.forEach((bubble) => {
Comment on lines +23 to +25
Copy link
Member

Choose a reason for hiding this comment

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

does bubbles.map work? Then we can just do const toUpdate = bubbles.map.....

Copy link
Member Author

@sshanzel sshanzel Oct 29, 2025

Choose a reason for hiding this comment

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

Yeah that can work. Will just have to add .filter for ids that are not found. The additional iteration shouldn't impace the performance that much.

const header = bubble.querySelector(`.${titleClass}`);
const anchor = header?.firstElementChild as HTMLAnchorElement;
const id = anchor?.getAttribute('href')?.split('/in/')[1];
const cleanId = id?.replace(/\//g, '');

if (cleanId) {
toUpdate.push({ id: cleanId, bubble });
}
});

setPopups(toUpdate);
}),
);

useQuery({
queryKey: ['msg-overlay'],
queryFn: () => null,
refetchInterval: (cache) => {
const retries = cache.state.dataUpdateCount;

if (retries >= 3) {
return false;
}

const container = document.querySelector('#msg-overlay');

if (!container) {
return ONE_SECOND;
}

observer.observe(container, { childList: true, subtree: false });

return false;
},
});

return (
<>
{popups.map(({ id, bubble }) => (
<MessageSuggestionPortal
key={id}
id={id}
bubble={bubble}
target_id={TargetId.ThreadPopup}
/>
))}
{threadUserId && (
<MessageSuggestionPortal
key={threadUserId}
id={threadUserId}
bubble={globalThis?.document}
target_id={TargetId.ThreadPage}
/>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { inviteRecruiterFeature } from '@dailydotdev/shared/src/lib/featureManagement';
import { useConditionalFeature } from '@dailydotdev/shared/src/hooks';
import { useRequestProtocol } from '@dailydotdev/shared/src/hooks/useRequestProtocol';
import user from '@dailydotdev/shared/__tests__/fixture/loggedUser';
import {
generateQueryKey,
RequestKey,
} from '@dailydotdev/shared/src/lib/query';
import { USER_REFERRAL_RECRUITER_QUERY } from '@dailydotdev/shared/src/graphql/users';
import { useMutation } from '@tanstack/react-query';
import { useBackgroundRequest } from '@dailydotdev/shared/src/hooks/companion';
import { CoreIcon } from '@dailydotdev/shared/src/components/icons';
import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext';
import type { TargetId } from '@dailydotdev/shared/src/lib/log';
import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log';
import { useGenerateSuggestionContainer } from './useGenerateSuggestionContainer';

interface MessageSuggestionPortalProps {
bubble: HTMLElement | Document;
id: string;
target_id: TargetId.ThreadPage | TargetId.ThreadPopup;
}

const input = '.msg-form__contenteditable';

export function MessageSuggestionPortal({
bubble,
id,
target_id,
}: MessageSuggestionPortalProps) {
const { logEvent } = useLogContext();
const { injectedElement } = useGenerateSuggestionContainer({
id,
container: bubble,
});
const { value, isLoading } = useConditionalFeature({
feature: inviteRecruiterFeature,
shouldEvaluate: !!injectedElement,
});
const { cta, message } = value;

const mutationKey = generateQueryKey(RequestKey.InviteRecruiter, user, id);
const { requestMethod } = useRequestProtocol();
const { mutateAsync } = useMutation({
mutationFn: () =>
requestMethod(
USER_REFERRAL_RECRUITER_QUERY,
{ toReferExternalId: id },
{ requestKey: JSON.stringify(mutationKey) },
),
});

useBackgroundRequest(mutationKey, {
callback: async ({ res }) => {
const url = res?.userReferralRecruiter?.url;

if (!url) {
return;
}

const formattedMessage = message.replace('{{url}}', url);
const replyBox = bubble.querySelector(input) as HTMLElement;
const p = document.createElement('p');
p.innerText = formattedMessage;

replyBox.innerText = '';
replyBox.appendChild(p);

const inputEvent = new InputEvent('input', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: formattedMessage,
});

replyBox.dispatchEvent(inputEvent);
replyBox.focus();
},
});

const shouldShow = !!injectedElement && !isLoading && !!id;
const isLoggedRef = React.useRef(false);

useEffect(() => {
if (shouldShow && !isLoggedRef.current) {
logEvent({
event_name: LogEvent.Impression,
target_type: TargetType.InviteRecruiter,
target_id,
});
isLoggedRef.current = true;
}
}, [logEvent, target_id, shouldShow]);

if (!injectedElement || isLoading || !id) {
return null;
}

const handleClick = () => {
logEvent({
event_name: LogEvent.Click,
target_type: TargetType.InviteRecruiter,
target_id,
});
mutateAsync();
};

return createPortal(
<button
type="button"
className="artdeco-button artdeco-button--2 artdeco-button--primary"
style={{ fontSize: '14px' }}
onClick={handleClick}
>
<CoreIcon style={{ width: '20px', height: '20px', marginRight: '4px' }} />
{cta}
</button>,
injectedElement,
);
}
Loading