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
42 changes: 29 additions & 13 deletions components/stream/AddToCalendarButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { CalendarPlus } from 'lucide-react';
import { buildIcsEvent, downloadIcsFile } from '@/lib/calendar';
import { CalendarPlus, ChevronDown, Download } from 'lucide-react';
import { buildGoogleCalendarUrl, buildIcsEvent, downloadIcsFile } from '@/lib/calendar';

interface AddToCalendarButtonProps {
streamId: string;
Expand All @@ -12,25 +12,41 @@ interface AddToCalendarButtonProps {

/** Downloads a .ics file reminding the user when a stream ends (#566). */
export function AddToCalendarButton({ streamId, streamAddress, endTime }: AddToCalendarButtonProps) {
const contractUrl = `https://stellar.expert/explorer/public/contract/${streamAddress}`;
const title = `Stream #${streamId} ends`;
const description = `Conduit stream #${streamId} completes at this time.\n\nContract: ${contractUrl}`;

function handleClick() {
const ics = buildIcsEvent({
id: streamAddress,
title: `Stream #${streamId} ends`,
title,
timestamp: endTime,
description: `Conduit stream #${streamId} completes at this time.`,
description,
});
downloadIcsFile(`conduit-stream-${streamId}.ics`, ics);
}

return (
<button
type="button"
onClick={handleClick}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-700 text-black dark:text-white hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors print:hidden"
aria-label="Add stream end date to calendar"
>
<CalendarPlus className="w-3.5 h-3.5" aria-hidden="true" />
Add to calendar
</button>
<details className="relative inline-block print:hidden">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded border border-gray-300 px-3 py-1.5 text-xs font-medium text-black transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-white dark:hover:bg-gray-900 [&::-webkit-details-marker]:hidden">
<CalendarPlus className="w-3.5 h-3.5" aria-hidden="true" />
Add to calendar
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</summary>
<div className="absolute right-0 z-20 mt-1 w-48 rounded border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-800 dark:bg-gray-950">
<a
href={buildGoogleCalendarUrl({ title, timestamp: endTime, description, url: contractUrl })}
target="_blank"
rel="noreferrer"
className="block rounded px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-900"
>
Add to Google Calendar
</a>
<button type="button" onClick={handleClick} className="flex w-full items-center gap-1.5 rounded px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-900">
<Download className="w-3.5 h-3.5" aria-hidden="true" />
Download .ics file
</button>
</div>
</details>
);
}
27 changes: 22 additions & 5 deletions components/stream/__tests__/AddToCalendarButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AddToCalendarButton } from '../AddToCalendarButton';

vi.mock('@/lib/calendar', () => ({
buildGoogleCalendarUrl: vi.fn(() => 'https://calendar.google.com/calendar/render?event'),
buildIcsEvent: vi.fn(() => 'ICS_CONTENT'),
downloadIcsFile: vi.fn(),
}));

import { buildIcsEvent, downloadIcsFile } from '@/lib/calendar';
import { buildGoogleCalendarUrl, buildIcsEvent, downloadIcsFile } from '@/lib/calendar';

const mockBuildIcsEvent = vi.mocked(buildIcsEvent);
const mockBuildGoogleCalendarUrl = vi.mocked(buildGoogleCalendarUrl);
const mockDownloadIcsFile = vi.mocked(downloadIcsFile);

describe('AddToCalendarButton', () => {
Expand All @@ -29,16 +31,16 @@ describe('AddToCalendarButton', () => {
container.remove();
});

it('renders a labeled button', () => {
it('renders a calendar menu with Google and iCalendar options', () => {
act(() => {
root.render(
<AddToCalendarButton streamId="42" streamAddress="CSTREAMADDR" endTime={1_700_000_000} />,
);
});

const button = container.querySelector('button');
expect(button).toBeTruthy();
expect(button?.textContent).toContain('Add to calendar');
expect(container.querySelector('summary')?.textContent).toContain('Add to calendar');
expect(container.querySelector('a')?.textContent).toContain('Google Calendar');
expect(container.querySelector('button')?.textContent).toContain('Download .ics');
});

it('builds and downloads an .ics file with the stream end date on click', () => {
Expand All @@ -61,4 +63,19 @@ describe('AddToCalendarButton', () => {
});
expect(mockDownloadIcsFile).toHaveBeenCalledWith('conduit-stream-42.ics', 'ICS_CONTENT');
});

it('links to a Google Calendar event including the stream contract URL', () => {
act(() => {
root.render(
<AddToCalendarButton streamId="42" streamAddress="CSTREAMADDR" endTime={1_700_000_000} />,
);
});

expect(container.querySelector('a')?.getAttribute('href')).toContain('calendar.google.com');
expect(mockBuildGoogleCalendarUrl).toHaveBeenCalledWith(expect.objectContaining({
title: 'Stream #42 ends',
timestamp: 1_700_000_000,
url: expect.stringContaining('CSTREAMADDR'),
}));
});
});
21 changes: 20 additions & 1 deletion lib/calendar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { toIcsUtcDate, buildIcsEvent, downloadIcsFile } from './calendar';
import { buildGoogleCalendarUrl, toIcsUtcDate, buildIcsEvent, downloadIcsFile } from './calendar';

describe('toIcsUtcDate', () => {
it('formats a unix timestamp as YYYYMMDDTHHMMSSZ in UTC', () => {
Expand Down Expand Up @@ -70,6 +70,25 @@ describe('buildIcsEvent', () => {
});
});

describe('buildGoogleCalendarUrl', () => {
it('creates a pre-filled event with UTC dates and the contract URL in its details', () => {
const timestamp = Date.UTC(2024, 5, 1, 12, 0, 0) / 1000;
const url = buildGoogleCalendarUrl({
id: 'CSTREAMADDR',
title: 'Stream #42 ends',
timestamp,
description: 'The stream completes at this time.',
url: 'https://stellar.expert/explorer/public/contract/CSTREAMADDR',
});

const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://calendar.google.com/calendar/render');
expect(parsed.searchParams.get('action')).toBe('TEMPLATE');
expect(parsed.searchParams.get('dates')).toBe('20240601T120000Z/20240601T120000Z');
expect(parsed.searchParams.get('details')).toContain('CSTREAMADDR');
});
});

describe('downloadIcsFile', () => {
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
Expand Down
27 changes: 27 additions & 0 deletions lib/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface CalendarEventInput {
description?: string;
}

/** Details needed to open a pre-filled Google Calendar event. */
export interface GoogleCalendarEventInput extends CalendarEventInput {
/** A URL to the on-chain stream contract. */
url: string;
}

function pad(n: number): string {
return n.toString().padStart(2, '0');
}
Expand Down Expand Up @@ -67,6 +73,27 @@ export function buildIcsEvent({ id, title, timestamp, description }: CalendarEve
return lines.join('\r\n') + '\r\n';
}

/**
* Return a Google Calendar "create event" URL for a point-in-time stream
* reminder. Google uses the same UTC timestamps as an iCalendar event, so
* recipients see the completion time in their own timezone.
*/
export function buildGoogleCalendarUrl({
title,
timestamp,
description,
url,
}: GoogleCalendarEventInput): string {
const date = toIcsUtcDate(timestamp);
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${date}/${date}`,
details: [description, url].filter(Boolean).join('\n\n'),
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}

/**
* Trigger a browser download of the given .ics content. No-op outside the
* browser (SSR) since there's no `document` to build an anchor from.
Expand Down