sparkletown/sparkle

View on GitHub
src/utils/url.ts

Summary

Maintainability
A
45 mins
Test Coverage
import { generatePath } from "react-router";
import Bugsnag from "@bugsnag/js";

import {
  ADMIN_IA_SPACE_EDIT_PARAM_URL,
  ATTENDEE_INSIDE_URL,
  ATTENDEE_LANDING_URL,
  DEFAULT_MISSING_PARAM_URL,
  VALID_URL_PROTOCOLS,
} from "settings";

import { Room } from "types/rooms";
import { SpaceSlug } from "types/venues";
import { WorldSlug } from "types/world";

// @debt most of these (a,b,c)=>generatePath(PATH,{}) functions should be replaced with inlined  generateUrl

type GenerateUrlParams = Record<string, string | undefined> | undefined;
type GenerateUrlOptions<T = GenerateUrlParams> = {
  route: string;
  fallback?: string;
  required?: string[];
  absolute?: boolean;
  params: T;
};

export const generateUrl: <T = GenerateUrlParams>(
  options: GenerateUrlOptions<T>
) => string = ({
  route,
  fallback = DEFAULT_MISSING_PARAM_URL,
  required = [],
  absolute = false,
  params,
}) => {
  // out of all the provided params
  const haystack = Object.entries(params);

  // check the required is there and has non-empty string as a value
  const invalidParam = (needle: string) =>
    !haystack.find(([name, value]) => name === needle && value);

  // and prevent generatePath blowing up with an error on missing or invalid param
  if (required.some(invalidParam)) {
    return fallback;
  }

  // NOTE: ?? {} stops TS from crying and the check makes the shorter generatePath is used
  const relativePath = params
    ? generatePath(route, params ?? {})
    : generatePath(route);

  // also be helpful with generating external links
  return absolute
    ? new URL(relativePath, window.location.origin).href
    : relativePath;
};

/** @deprecated use generateUrl instead */
export const adminNGVenueUrl = (
  worldSlug?: WorldSlug,
  spaceSlug?: SpaceSlug,
  selectedTab?: string
) =>
  !worldSlug || !spaceSlug
    ? DEFAULT_MISSING_PARAM_URL
    : generatePath(ADMIN_IA_SPACE_EDIT_PARAM_URL, {
        worldSlug,
        spaceSlug,
        selectedTab,
      });

type generateAttendeeInsideUrlOptions = {
  worldSlug?: WorldSlug;
  spaceSlug?: SpaceSlug;
  absoluteUrl?: boolean;
};

// @debt These being optional is a problem waiting to happen. We need a better
// way of making world / space slug mandatory
/** @deprecated use generateUrl instead */
export const generateAttendeeInsideUrl = ({
  worldSlug,
  spaceSlug,
  absoluteUrl = false,
}: generateAttendeeInsideUrlOptions) => {
  const relativePath = generatePath(ATTENDEE_INSIDE_URL, {
    worldSlug,
    spaceSlug,
  });
  if (absoluteUrl) {
    return new URL(relativePath, window.location.origin).href;
  } else {
    return relativePath;
  }
};

// @debt These being optional is a problem waiting to happen. We need a better
// way of making world / space slug mandatory
/** @deprecated use generateUrl instead */
export const generateAttendeeSpaceLandingUrl = (
  worldSlug?: WorldSlug,
  spaceSlug?: SpaceSlug
) => generatePath(ATTENDEE_LANDING_URL, { worldSlug, spaceSlug });

export const isExternalUrl = (url: string) => {
  try {
    return new URL(url, window.location.origin).host !== window.location.host;
  } catch (error) {
    Bugsnag.notify(new Error(error), (event) => {
      event.severity = "info";
      event.addMetadata("utils::url::isExternalUrl", { url });
    });
    return false;
  }
};

export const isExternalPortal: (portal: Room) => boolean = (portal) =>
  portal?.template === "external" || !portal?.spaceId;

export const openRoomUrl = (url: string, options?: OpenUrlOptions) => {
  // @debt I feel like we could construct this url in a better way
  openUrl(url.includes("http") ? url : "//" + url, options);
};

export const enterSpace = (
  worldSlug?: WorldSlug,
  spaceSlug?: SpaceSlug,
  options?: OpenUrlOptions
) => openUrl(generateAttendeeInsideUrl({ worldSlug, spaceSlug }), options);

export interface OpenUrlOptions {
  customOpenRelativeUrl?: (url: string) => void;
  customOpenExternalUrl?: (url: string) => void;
}

export const openUrl = (url: string, options?: OpenUrlOptions) => {
  const { customOpenExternalUrl, customOpenRelativeUrl } = options ?? {};

  // @debt possible replace with isValidUrl, see isCurrentLocationValidUrl for deprecation comments
  if (!isCurrentLocationValidUrl(url)) {
    Bugsnag.notify(
      // new Error(`Invalid URL ${url} on page ${window.location.href}; ignoring`),
      new Error(
        `Invalid URL ${url} on page ${window.location.href}; allowing for now (workaround)`
      ),
      (event) => {
        event.addMetadata("context", { func: "utils/url::openUrl", url });
      }
    );
    // @debt keep the checking in place so we can debug further, but don't block attempts to open
    // return;
  }

  if (isExternalUrl(url)) {
    customOpenExternalUrl
      ? customOpenExternalUrl(url)
      : window.open(url, "_blank", "noopener,noreferrer");
  } else {
    // @debt Is this a decent enough way to use react router here? Should we just use it always and get rid of window.location.href?
    customOpenRelativeUrl
      ? customOpenRelativeUrl(url)
      : (window.location.href = url);
  }
};

/**
 * @deprecated This function doesn't perform a url check and returns true each time;
 * Use isValidUrl instead if you want to validate that URL is correct
 */
export const isCurrentLocationValidUrl = (url: string): boolean => {
  try {
    return VALID_URL_PROTOCOLS.includes(
      new URL(url, window.location.origin).protocol
    );
  } catch (e) {
    if (e.name === "TypeError") {
      return false;
    }
    throw e;
  }
};

export const isValidUrl = (urlString: string) => {
  if (!urlString) return false;

  try {
    const url = new URL(urlString);

    return VALID_URL_PROTOCOLS.includes(url.protocol);
  } catch (e) {
    if (e.name === "TypeError") {
      return false;
    }
    throw e;
  }
};

export const reloadUrlAdditionalProps = {
  rel: "noopener noreferrer",
};

export const externalUrlAdditionalProps = {
  rel: "noopener noreferrer",
  target: "_blank",
};

export const getExtraLinkProps = (isExternal: boolean) =>
  isExternal ? externalUrlAdditionalProps : {};

export const getUrlWithoutTrailingSlash = (url: string) => {
  return url.endsWith("/") ? url.slice(0, -1) : url;
};

export const getLastUrlParam = (url: string) => {
  return url.split("/").slice(-1);
};

export const getUrlParamFromString = (data: string) => {
  return data.replaceAll(" ", "").toLowerCase();
};

export const resolveUrlPath: (path: string) => string = (path) => {
  const base = window.location.href;
  try {
    return new URL(path, base).href;
  } catch (error) {
    Bugsnag.notify(new Error(error), (event) => {
      event.severity = "info";
      event.addMetadata("utils/url::resolveUrlPath", { path, base });
    });
    return "";
  }
};