Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 28 additions & 2 deletions __tests__/components/waves/drops/WaveDropActionsCopyLink.test.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,54 @@
import WaveDropActionsCopyLink from "@/components/waves/drops/WaveDropActionsCopyLink";
import { ApiDropType } from "@/generated/models/ApiDropType";
import "@testing-library/jest-dom";
import { fireEvent, render } from "@testing-library/react";

const mockIsMemesWave = jest.fn();

jest.mock("@/config/env", () => ({
publicEnv: {
BASE_ENDPOINT: "https://base",
},
}));
jest.mock("@/contexts/SeizeSettingsContext", () => ({
useSeizeSettings: () => ({ isMemesWave: mockIsMemesWave }),
}));

const writeText = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

describe("WaveDropActionsCopyLink", () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsMemesWave.mockReturnValue(false);
});

it("copies drop link when clicked", () => {
const drop: any = { id: "d1", wave: { id: "w1" }, serial_no: 5 };
it("copies serial jump links for non-memes drops", () => {
const drop: any = {
id: "d1",
wave: { id: "w1" },
serial_no: 5,
drop_type: ApiDropType.Chat,
};
const { getByRole } = render(<WaveDropActionsCopyLink drop={drop} />);
fireEvent.click(getByRole("button"));
expect(writeText).toHaveBeenCalledWith("https://base/waves/w1?serialNo=5");
});

it("copies canonical drop links for memes submissions", () => {
mockIsMemesWave.mockReturnValue(true);

const drop: any = {
id: "d1",
wave: { id: "w1" },
serial_no: 5,
drop_type: ApiDropType.Participatory,
};
const { getByRole } = render(<WaveDropActionsCopyLink drop={drop} />);
fireEvent.click(getByRole("button"));
expect(writeText).toHaveBeenCalledWith("https://base/waves/w1?drop=d1");
});

it("disables button for temporary drop", () => {
const drop: any = { id: "temp-1", wave: { id: "w1" }, serial_no: 1 };
const { getByRole } = render(<WaveDropActionsCopyLink drop={drop} />);
Expand Down
53 changes: 47 additions & 6 deletions __tests__/components/waves/drops/WaveDropMobileMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const mockIsMemesWave = jest.fn();
const writeText = jest.fn().mockResolvedValue(undefined);

jest.mock("@/hooks/drops/useDropInteractionRules", () => ({
useDropInteractionRules: jest.fn(),
}));
Expand Down Expand Up @@ -44,7 +47,7 @@ jest.mock(
);

jest.mock("@/contexts/SeizeSettingsContext", () => ({
useSeizeSettings: () => ({ isMemesWave: jest.fn().mockReturnValue(true) }),
useSeizeSettings: () => ({ isMemesWave: mockIsMemesWave }),
}));
jest.mock("@/contexts/EmojiContext", () => ({
useEmoji: () => ({
Expand All @@ -57,20 +60,21 @@ jest.mock("@/contexts/EmojiContext", () => ({
}),
EmojiProvider: ({ children }: any) => children,
}));

jest.doMock("@/config/env", () => ({
jest.mock("@/config/env", () => ({
publicEnv: { BASE_ENDPOINT: "https://base" },
}));

beforeAll(() => {
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
clipboard: { writeText },
});
});

const mockedUseDropInteractionRules = jest.mocked(useDropInteractionRules);

beforeEach(() => {
writeText.mockClear();
mockIsMemesWave.mockReturnValue(false);
mockedUseDropInteractionRules.mockReturnValue({
canShowVote: true,
canVote: true,
Expand All @@ -83,7 +87,7 @@ beforeEach(() => {
});
});

test("copies link and shows feedback", async () => {
test("copies serial jump links for non-memes drops", async () => {
const drop = {
id: "1",
serial_no: 1,
Expand Down Expand Up @@ -113,10 +117,47 @@ test("copies link and shows feedback", async () => {
</AuthContext.Provider>
);
await userEvent.click(screen.getByText("Copy link"));
expect(navigator.clipboard.writeText).toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith("https://base/waves/w?serialNo=1");
});

test("copies canonical drop links for memes submissions", async () => {
mockIsMemesWave.mockReturnValue(true);

const drop = {
id: "1",
serial_no: 1,
wave: { id: "w" },
drop_type: ApiDropType.Participatory,
author: { handle: "alice" },
} as any;
render(
<AuthContext.Provider
value={
{
connectedProfile: { handle: "alice" },
activeProfileProxy: null,
} as any
}
>
<WaveDropMobileMenu
drop={drop}
isOpen
showReplyAndQuote
longPressTriggered={false}
setOpen={jest.fn()}
onReply={jest.fn()}
onQuote={jest.fn()}
onAddReaction={jest.fn()}
/>
</AuthContext.Provider>
);
await userEvent.click(screen.getByText("Copy link"));
expect(writeText).toHaveBeenCalledWith("https://base/waves/w?drop=1");
});

test("hides follow and clap when author and memes wave", () => {
mockIsMemesWave.mockReturnValue(true);

const drop = {
id: "1",
serial_no: 1,
Expand Down
2 changes: 1 addition & 1 deletion components/user/layout/userPageVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

export const normalizeCountry = (
const normalizeCountry = (
country: string | null | undefined
): string | null => {
if (typeof country !== "string") {
Expand Down
14 changes: 7 additions & 7 deletions components/waves/drops/WaveDropActionsCopyLink.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { publicEnv } from "@/config/env";
import { getWaveRoute } from "@/helpers/navigation.helpers";
import { useSeizeSettings } from "@/contexts/SeizeSettingsContext";
import { getCopiedDropLink } from "@/helpers/waves/drop-copy-link.helpers";
import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers";
import React, { useState } from "react";
import { Tooltip } from "react-tooltip";
Expand All @@ -20,6 +20,7 @@ const WaveDropActionsCopyLink: React.FC<WaveDropActionsCopyLinkProps> = ({
onCopy,
}) => {
const [copied, setCopied] = useState(false);
const { isMemesWave } = useSeizeSettings();
const myStream = useMyStreamOptional();
const directMessageWaves = myStream?.directMessages.list ?? [];

Expand Down Expand Up @@ -49,12 +50,11 @@ const WaveDropActionsCopyLink: React.FC<WaveDropActionsCopyLinkProps> = ({
waveDetails,
directMessageWaves
);
const dropLink = `${publicEnv.BASE_ENDPOINT}${getWaveRoute({
waveId: drop.wave.id,
serialNo: drop.serial_no,
const dropLink = getCopiedDropLink({
drop,
isDirectMessage,
isApp: false,
})}`;
isMemesWave,
});
navigator.clipboard.writeText(dropLink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
Expand Down
14 changes: 7 additions & 7 deletions components/waves/drops/WaveDropMobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import { AuthContext } from "@/components/auth/Auth";
import CommonDropdownItemsMobileWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper";
import { publicEnv } from "@/config/env";
import { useSeizeSettings } from "@/contexts/SeizeSettingsContext";
import type { ApiDrop } from "@/generated/models/ApiDrop";
import { ApiDropType } from "@/generated/models/ApiDropType";
import { getWaveRoute } from "@/helpers/navigation.helpers";
import { getCopiedDropLink } from "@/helpers/waves/drop-copy-link.helpers";
import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import { DropSize } from "@/helpers/waves/drop.helpers";
import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules";
Expand Down Expand Up @@ -50,6 +50,7 @@ const WaveDropMobileMenu: FC<WaveDropMobileMenuProps> = ({
showCopyOption = true,
}) => {
const { connectedProfile, activeProfileProxy } = useContext(AuthContext);
const { isMemesWave } = useSeizeSettings();
const isTemporaryDrop = drop.id.startsWith("temp-");
const { canDelete, canSetPinnedDrop } = useDropInteractionRules(drop);

Expand Down Expand Up @@ -83,12 +84,11 @@ const WaveDropMobileMenu: FC<WaveDropMobileMenuProps> = ({
};
const isDirectMessage =
waveDetails.chat?.scope?.group?.is_direct_message ?? false;
const dropLink = `${publicEnv.BASE_ENDPOINT}${getWaveRoute({
waveId: drop.wave.id,
serialNo: drop.serial_no,
const dropLink = getCopiedDropLink({
drop,
isDirectMessage,
isApp: false,
})}`;
isMemesWave,
});

if (typeof navigator.clipboard.writeText === "function") {
void navigator.clipboard.writeText(dropLink).then(() => {
Expand Down
2 changes: 2 additions & 0 deletions components/waves/memes/submission/utils/buildPreviewDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ export const buildPreviewDrop = ({
forbid_negative_votes: wave.voting.forbid_negative_votes,
pinned: wave.pinned,
submission_type: wave.participation.submission_strategy?.type ?? null,
selections: wave.selections,
},
selections: [],
author: {
id: connectedProfile?.id ?? "preview-user",
handle: connectedProfile?.handle ?? "preview-user",
Expand Down
2 changes: 2 additions & 0 deletions components/waves/utils/getOptimisticDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ export const getOptimisticDrop = (
authenticated_user_admin: false,
forbid_negative_votes: wave.voting.forbid_negative_votes,
submission_type: wave.participation.submission_strategy?.type ?? null,
selections: wave.selections,
},
selections: [],
author: {
id: connectedProfile.id,
handle: connectedProfile.handle,
Expand Down
12 changes: 8 additions & 4 deletions docs/waves/drop-actions/feature-open-and-copy-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ Drop-level `Open` / `Open drop` actions and link-card `Open link` /
current thread.
- Preview cards and quote cards expose a `Link actions` button with
`Copy link` and `Open link`.
- `Copy link` copies an absolute share URL that targets the drop with
`serialNo={serialNo}`.
- `Copy link` copies an absolute share URL that targets the drop with:
- `serialNo={serialNo}` for most drops
- `drop={dropId}` for memes submission drops
- Link-card `Copy link` copies the original referenced URL shown by that card.

## Location in the Site
Expand Down Expand Up @@ -59,6 +60,8 @@ Drop-level `Open` / `Open drop` actions and link-card `Open link` /
- Desktop copy can still infer DM routes from stream context when DM scope data
on the drop is incomplete.
- Mobile copy uses Clipboard API when available and falls back to textarea copy.
- Memes submission drop copy links use canonical wave drop URLs and open the
single-drop overlay when opened.
Comment thread
simo6529 marked this conversation as resolved.

## Edge Cases

Expand All @@ -81,8 +84,9 @@ Drop-level `Open` / `Open drop` actions and link-card `Open link` /

## Limitations / Notes

- `Open` (`drop` query), copied drop links (`serialNo` query), and link-card
`Open link` are different navigation mechanisms.
- `Open` (`drop` query), most copied drop links (`serialNo` query), memes
submission copied links (`drop` query), and link-card `Open link` are
different navigation mechanisms.
- Opening a copied link does not force single-drop overlay mode.
- `Copy link` is unavailable for temporary drops.
- Link-card actions depend on the preview or quote card being rendered. Once a
Expand Down
8 changes: 8 additions & 0 deletions generated/models/ApiDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ApiMentionedWave } from '../models/ApiMentionedWave';
import { ApiProfileMin } from '../models/ApiProfileMin';
import { ApiReplyToDropResponse } from '../models/ApiReplyToDropResponse';
import { ApiWaveMin } from '../models/ApiWaveMin';
import { ApiWaveSelection } from '../models/ApiWaveSelection';
import { HttpFile } from '../http/http';

export class ApiDrop {
Expand Down Expand Up @@ -65,6 +66,7 @@ export class ApiDrop {
'raters_count': number;
'context_profile_context': ApiDropContextProfileContext | null;
'subscribed_actions': Array<ApiDropSubscriptionTargetAction>;
'selections': Array<ApiWaveSelection>;
'is_signed': boolean;
'reactions': Array<ApiDropReaction>;
'boosts': number;
Expand Down Expand Up @@ -220,6 +222,12 @@ export class ApiDrop {
"type": "Array<ApiDropSubscriptionTargetAction>",
"format": ""
},
{
"name": "selections",
"baseName": "selections",
"type": "Array<ApiWaveSelection>",
"format": ""
},
{
"name": "is_signed",
"baseName": "is_signed",
Expand Down
8 changes: 8 additions & 0 deletions generated/models/ApiDropWithoutWave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ApiDropWinningContext } from '../models/ApiDropWinningContext';
import { ApiMentionedWave } from '../models/ApiMentionedWave';
import { ApiProfileMin } from '../models/ApiProfileMin';
import { ApiReplyToDropResponse } from '../models/ApiReplyToDropResponse';
import { ApiWaveSelection } from '../models/ApiWaveSelection';
import { HttpFile } from '../http/http';

export class ApiDropWithoutWave {
Expand Down Expand Up @@ -63,6 +64,7 @@ export class ApiDropWithoutWave {
'raters_count': number;
'context_profile_context': ApiDropContextProfileContext | null;
'subscribed_actions': Array<ApiDropSubscriptionTargetAction>;
'selections': Array<ApiWaveSelection>;
'is_signed': boolean;
'reactions': Array<ApiDropReaction>;
'boosts': number;
Expand Down Expand Up @@ -212,6 +214,12 @@ export class ApiDropWithoutWave {
"type": "Array<ApiDropSubscriptionTargetAction>",
"format": ""
},
{
"name": "selections",
"baseName": "selections",
"type": "Array<ApiWaveSelection>",
"format": ""
},
{
"name": "is_signed",
"baseName": "is_signed",
Expand Down
8 changes: 8 additions & 0 deletions generated/models/ApiWave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ApiWaveContributorOverview } from '../models/ApiWaveContributorOverview
import { ApiWaveDecisionPause } from '../models/ApiWaveDecisionPause';
import { ApiWaveMetrics } from '../models/ApiWaveMetrics';
import { ApiWaveParticipationConfig } from '../models/ApiWaveParticipationConfig';
import { ApiWaveSelection } from '../models/ApiWaveSelection';
import { ApiWaveSubscriptionTargetAction } from '../models/ApiWaveSubscriptionTargetAction';
import { ApiWaveVisibilityConfig } from '../models/ApiWaveVisibilityConfig';
import { ApiWaveVotingConfig } from '../models/ApiWaveVotingConfig';
Expand Down Expand Up @@ -57,6 +58,7 @@ export class ApiWave {
'subscribed_actions': Array<ApiWaveSubscriptionTargetAction>;
'metrics': ApiWaveMetrics;
'pauses': Array<ApiWaveDecisionPause>;
'selections': Array<ApiWaveSelection>;
'pinned': boolean;

static readonly discriminator: string | undefined = undefined;
Expand Down Expand Up @@ -166,6 +168,12 @@ export class ApiWave {
"type": "Array<ApiWaveDecisionPause>",
"format": ""
},
{
"name": "selections",
"baseName": "selections",
"type": "Array<ApiWaveSelection>",
"format": ""
},
{
"name": "pinned",
"baseName": "pinned",
Expand Down
Loading
Loading