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
25 changes: 20 additions & 5 deletions src/lib/event-card-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,38 @@ export interface EventCardAction {
label: string;
}

export function getEventCardAction(
export function getEventPrimaryAction(
event: WebsiteEvent,
status: EventStatus,
): EventCardAction | null {
if (event.primaryAction) {
return {
href: event.primaryAction.url,
label: event.primaryAction.label,
};
}

if (status === 'past') {
return event.streamUrl && !event.embedStream
? { href: event.streamUrl, label: 'Watch recording' }
? { href: event.streamUrl, label: 'Watch recording' }
: null;
}

if (status === 'live') {
if (event.streamUrl) return { href: event.streamUrl, label: 'Watch live' };
if (event.onlineUrl) return { href: event.onlineUrl, label: 'Join online' };
if (event.streamUrl) return { href: event.streamUrl, label: 'Watch live' };
if (event.onlineUrl) return { href: event.onlineUrl, label: 'Join online' };
return null;
}

return event.registrationUrl
? { href: event.registrationUrl, label: 'Register' }
? { href: event.registrationUrl, label: 'Register' }
: null;
}

export function getEventCardAction(
event: WebsiteEvent,
status: EventStatus,
): EventCardAction | null {
const action = getEventPrimaryAction(event, status);
return action ? { ...action, label: `${action.label} →` } : null;
}
8 changes: 8 additions & 0 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const EVENTS_API_TIMEOUT_MS = 8_000;
export type EventLocationType = "in_person" | "online" | "hybrid";
export type EventClassification = "official" | "community";

export interface WebsiteEventPrimaryAction {
kind: "slack_profile";
label: string;
url: string;
}

export interface WebsiteEvent {
id: string;
slug: string;
Expand All @@ -47,6 +53,7 @@ export interface WebsiteEvent {
streamUrl: string | null;
embedStream: boolean;
registrationUrl: string | null;
primaryAction: WebsiteEventPrimaryAction | null;
organizerName: string;
organizerWebsite: string | null;
coverUrl: string | null;
Expand Down Expand Up @@ -115,6 +122,7 @@ async function fetchMeetupFallback(): Promise<WebsiteEvent[]> {
streamUrl: meetup.data.stream_url ?? null,
embedStream: meetup.data.embed_stream,
registrationUrl: meetup.data.registration_url ?? null,
primaryAction: null,
organizerName: "DevCongress",
organizerWebsite: "https://devcongress.org",
coverUrl: meetup.data.cover,
Expand Down
27 changes: 26 additions & 1 deletion src/lib/public-event-contract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { z } from 'zod';
import type { WebsiteEvent } from './events';
import { normalizePublicHttpUrl, normalizePublicWebsiteUrl } from './public-url';
import {
normalizePublicHttpUrl,
normalizePublicSlackProfileUrl,
normalizePublicWebsiteUrl,
} from './public-url';

const EVENTS_MANAGEMENT_ORIGIN = new URL('https://em.devcongress.org');
const MAX_EVENTS_RESPONSE_BYTES = 2 * 1024 * 1024;
Expand All @@ -21,6 +25,15 @@ const publicWebsiteUrlSchema = z
'Expected a public website URL',
);

const publicPrimaryActionSchema = z.object({
kind: z.literal('slack_profile'),
label: z.string().trim().min(1).max(80),
url: z.string().trim().max(2_048).refine(
(value) => normalizePublicSlackProfileUrl(value) !== null,
'Expected a Slack member profile URL',
),
}).nullable().optional().default(null);

export const eventFormatSchema = z.enum([
'meetup',
'conference',
Expand Down Expand Up @@ -53,6 +66,7 @@ const publicEventSchema = z
stream_url: publicHttpUrlSchema.nullable().optional(),
embed_stream: z.boolean().optional().default(false),
registration_url: publicWebsiteUrlSchema.nullable(),
primary_action: publicPrimaryActionSchema,
organizer_name: z.string().trim().min(1).max(300),
organizer_website: publicHttpUrlSchema.nullable(),
cover_url: publicWebsiteUrlSchema.nullable(),
Expand Down Expand Up @@ -176,6 +190,10 @@ export async function readPublicEventJson(response: Response): Promise<unknown>
}

function mapPublicEvent(event: PublicEvent): WebsiteEvent {
const primaryActionUrl = event.primary_action
? normalizePublicSlackProfileUrl(event.primary_action.url)
: null;

return {
id: event.id,
slug: event.slug,
Expand All @@ -194,6 +212,13 @@ function mapPublicEvent(event: PublicEvent): WebsiteEvent {
streamUrl: normalizePublicHttpUrl(event.stream_url),
embedStream: event.embed_stream,
registrationUrl: normalizePublicWebsiteUrl(event.registration_url, EVENTS_MANAGEMENT_ORIGIN),
primaryAction: event.primary_action && primaryActionUrl
? {
kind: event.primary_action.kind,
label: event.primary_action.label,
url: primaryActionUrl,
}
: null,
organizerName: event.organizer_name,
organizerWebsite: normalizePublicHttpUrl(event.organizer_website),
coverUrl: normalizePublicWebsiteUrl(event.cover_url, EVENTS_MANAGEMENT_ORIGIN),
Expand Down
33 changes: 33 additions & 0 deletions src/lib/public-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@ export function normalizePublicWebsiteUrl(value: unknown, relativeOrigin: URL):
return normalizePublicHttpUrl(candidate);
}

export function normalizePublicSlackProfileUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const candidate = value.trim();
if (!candidate || candidate.length > MAX_PUBLIC_URL_LENGTH) return null;

try {
const url = new URL(candidate);
const entries = [...url.searchParams.entries()];
const team = url.searchParams.get('team');
const member = url.searchParams.get('id');
const hasOnlyExpectedParameters = entries.length === 2
&& entries.every(([key]) => key === 'team' || key === 'id');

if (
url.protocol !== 'slack:'
|| url.hostname !== 'user'
|| (url.pathname !== '' && url.pathname !== '/')
|| url.username
|| url.password
|| url.hash
|| !hasOnlyExpectedParameters
|| !team
|| !/^T[A-Z0-9]{8,}$/.test(team)
|| !member
|| !/^[UW][A-Z0-9]{8,}$/.test(member)
) return null;

return `slack://user?team=${encodeURIComponent(team)}&id=${encodeURIComponent(member)}`;
} catch {
return null;
}
}

function isPrivateOrLocalHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
Expand Down
32 changes: 4 additions & 28 deletions src/pages/events/[slug].astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import { getEvents, type WebsiteEvent } from '../../lib/events';
import { getEventPrimaryAction } from '../../lib/event-card-actions';

export async function getStaticPaths() {
const events = await getEvents();
Expand Down Expand Up @@ -40,22 +41,8 @@ function getStatus(value: WebsiteEvent): EventStatus {
return now < start ? 'upcoming' : now <= end ? 'live' : 'past';
}

function getPrimaryAction(value: WebsiteEvent, valueStatus: EventStatus): { href: string; label: string } | null {
if (valueStatus === 'past') {
return value.streamUrl && !value.embedStream ? { href: value.streamUrl, label: 'Watch recording' } : null;
}

if (valueStatus === 'live') {
if (value.streamUrl) return { href: value.streamUrl, label: 'Watch live' };
if (value.onlineUrl) return { href: value.onlineUrl, label: 'Join online' };
return null;
}

return value.registrationUrl ? { href: value.registrationUrl, label: 'Register' } : null;
}

const status = getStatus(event);
const action = getPrimaryAction(event, status);
const action = getEventPrimaryAction(event, status);
const statusLabel = status === 'live' ? 'Live' : status === 'upcoming' ? 'Upcoming' : 'Past';
const externalAction = action?.href.startsWith('http') ?? false;
---
Expand Down Expand Up @@ -111,6 +98,7 @@ const externalAction = action?.href.startsWith('http') ?? false;

<script>
import { fetchLivePublicEvent } from '../../lib/live-public-events';
import { getEventPrimaryAction } from '../../lib/event-card-actions';
import { getEventReturnLink } from '../../lib/event-navigation';

const page = document.querySelector('[data-event-detail]');
Expand All @@ -132,22 +120,10 @@ const externalAction = action?.href.startsWith('http') ?? false;
return now < start ? 'upcoming' : now <= end ? 'live' : 'past';
}

function liveAction(event, status) {
if (status === 'past') {
return event.streamUrl && !event.embedStream ? { href: event.streamUrl, label: 'Watch recording' } : null;
}
if (status === 'live') {
if (event.streamUrl) return { href: event.streamUrl, label: 'Watch live' };
if (event.onlineUrl) return { href: event.onlineUrl, label: 'Join online' };
return null;
}
return event.registrationUrl ? { href: event.registrationUrl, label: 'Register' } : null;
}

function updateAction(event) {
if (!(actionLink instanceof HTMLAnchorElement) || !(state instanceof HTMLElement)) return;
const status = currentStatus(event);
const action = liveAction(event, status);
const action = getEventPrimaryAction(event, status);
actionLink.hidden = !action;
state.textContent = status === 'past' && !action ? 'This event has ended.' : '';
if (!action) {
Expand Down
25 changes: 10 additions & 15 deletions src/pages/events/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ const calendarEvents = events.map((event) => {
<script>
import { fetchLivePublicEvent, fetchLivePublicEvents } from '../../lib/live-public-events';
import type { WebsiteEvent } from '../../lib/events';
import { getEventCardAction } from '../../lib/event-card-actions';
import { getEventCardAction, getEventPrimaryAction } from '../../lib/event-card-actions';
import { getEventReturnLink } from '../../lib/event-navigation';

const statusConfig = {
Expand Down Expand Up @@ -475,22 +475,13 @@ const calendarEvents = events.map((event) => {
return event.detailsUrl ?? event.registrationUrl ?? event.onlineUrl;
}

function liveDetailAction(event, status) {
if (status === 'past') {
return event.streamUrl && !event.embedStream ? { href: event.streamUrl, label: 'Watch recording' } : null;
}
if (status === 'live') {
if (event.streamUrl) return { href: event.streamUrl, label: 'Watch live' };
if (event.onlineUrl) return { href: event.onlineUrl, label: 'Join online' };
return null;
}
return event.registrationUrl ? { href: event.registrationUrl, label: 'Register' } : null;
}

function applyExternalLinkAttributes(element, url) {
if (url?.startsWith('http')) {
element.target = '_blank';
element.rel = 'noopener noreferrer';
} else {
element.removeAttribute('target');
element.removeAttribute('rel');
}
}

Expand Down Expand Up @@ -674,7 +665,7 @@ const calendarEvents = events.map((event) => {
if (!detailPanel || !detailTitle || !detailSummary || !detailMeta || !detailStatus || !detailState) return;
const status = getLiveStatus(event);
const config = statusConfig[status];
const action = liveDetailAction(event, status);
const action = getEventPrimaryAction(event, status);
setDetailMode(true);
if (eventsPageTitle) eventsPageTitle.textContent = event.title;
if (eventsPageSub) eventsPageSub.textContent = `${formatLiveDate(event.startsAt, event.timezone)} · ${liveLocation(event)}`;
Expand All @@ -683,7 +674,7 @@ const calendarEvents = events.map((event) => {
detailMeta.textContent = `${formatLiveDate(event.startsAt, event.timezone)} · ${formatLiveTime(event.startsAt, event.timezone)} · ${liveLocation(event)}`;
detailTitle.textContent = event.title;
detailSummary.textContent = event.summary;
detailState.textContent = status === 'past' && !(event.streamUrl && !event.embedStream) ? 'This event has ended.' : '';
detailState.textContent = status === 'past' && !action ? 'This event has ended.' : '';
if (detailCover instanceof HTMLImageElement) {
detailCover.hidden = !event.coverUrl;
if (event.coverUrl) detailCover.src = event.coverUrl;
Expand All @@ -694,6 +685,10 @@ const calendarEvents = events.map((event) => {
detailCta.href = action.href;
detailCta.textContent = `${action.label} →`;
applyExternalLinkAttributes(detailCta, action.href);
} else {
detailCta.removeAttribute('href');
detailCta.removeAttribute('target');
detailCta.removeAttribute('rel');
}
}
document.title = `${event.title} — DevCongress`;
Expand Down