Refactor Signal app routing
This commit is contained in:
parent
86e6c2499c
commit
3ef0d221d1
28 changed files with 1347 additions and 1044 deletions
739
ts/util/signalRoutes.ts
Normal file
739
ts/util/signalRoutes.ts
Normal file
|
@ -0,0 +1,739 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import 'urlpattern-polyfill';
|
||||
// We need to use the Node.js version of `URL` because chromium's `URL` doesn't
|
||||
// support custom protocols correctly.
|
||||
import { URL } from 'url';
|
||||
import { z } from 'zod';
|
||||
import { strictAssert } from './assert';
|
||||
|
||||
function toUrl(input: URL | string): URL | null {
|
||||
if (input instanceof URL) {
|
||||
return input;
|
||||
}
|
||||
try {
|
||||
return new URL(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List of protocols that are used by Signal routes.
|
||||
*/
|
||||
const SignalRouteProtocols = ['https:', 'sgnl:', 'signalcaptcha:'] as const;
|
||||
|
||||
/**
|
||||
* List of hostnames that are used by Signal routes.
|
||||
* This doesn't include app-only routes like `linkdevice` or `verify`.
|
||||
*/
|
||||
const SignalRouteHostnames = [
|
||||
'signal.me',
|
||||
'signal.group',
|
||||
'signal.link',
|
||||
'signal.art',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Type to help maintain {@link SignalRouteHostnames}, real hostnames should go there.
|
||||
*/
|
||||
type AllHostnamePatterns =
|
||||
| typeof SignalRouteHostnames[number]
|
||||
| 'verify'
|
||||
| 'linkdevice'
|
||||
| 'addstickers'
|
||||
| 'art-auth'
|
||||
| 'joingroup'
|
||||
| 'show-conversation'
|
||||
| 'start-call-lobby'
|
||||
| 'show-window'
|
||||
| 'set-is-presenting'
|
||||
| ':captchaId'
|
||||
| '';
|
||||
|
||||
/**
|
||||
* Uses the `URLPattern` syntax to match URLs.
|
||||
*/
|
||||
type PatternString = string & { __pattern?: never };
|
||||
|
||||
type PatternInput = {
|
||||
hash?: PatternString;
|
||||
search?: PatternString;
|
||||
};
|
||||
|
||||
type URLMatcher = (input: URL) => URLPatternResult | null;
|
||||
|
||||
function _pattern(
|
||||
protocol: typeof SignalRouteProtocols[number],
|
||||
hostname: AllHostnamePatterns,
|
||||
pathname: PatternString,
|
||||
init: PatternInput
|
||||
): URLMatcher {
|
||||
strictAssert(protocol.endsWith(':'), 'protocol must end with `:`');
|
||||
strictAssert(!hostname.endsWith('/'), 'hostname must not end with `/`');
|
||||
strictAssert(
|
||||
!(hostname === '' && pathname !== ''),
|
||||
'hostname cannot be empty string if pathname is not empty string'
|
||||
);
|
||||
strictAssert(
|
||||
!pathname.endsWith('/'),
|
||||
'pathname trailing slash must be optional `{/}?`'
|
||||
);
|
||||
const urlPattern = new URLPattern({
|
||||
username: '',
|
||||
password: '',
|
||||
port: '',
|
||||
// any of these can be patterns
|
||||
hostname,
|
||||
pathname,
|
||||
search: init.search ?? '',
|
||||
hash: init.hash ?? '',
|
||||
} satisfies Omit<Required<URLPatternInit>, 'baseURL' | 'protocol'>);
|
||||
return function match(input) {
|
||||
const url = toUrl(input);
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
// We need to check protocol separately because `URL` and `URLPattern` don't
|
||||
// properly support custom protocols
|
||||
if (url.protocol !== protocol) {
|
||||
return null;
|
||||
}
|
||||
return urlPattern.exec(url);
|
||||
};
|
||||
}
|
||||
|
||||
type PartialNullable<T> = {
|
||||
[P in keyof T]?: T[P] | null;
|
||||
};
|
||||
|
||||
type RouteConfig<Args extends object> = {
|
||||
patterns: Array<URLMatcher>;
|
||||
schema: z.ZodType<Args>;
|
||||
parse(result: URLPatternResult): PartialNullable<Args>;
|
||||
toWebUrl?(args: Args): URL;
|
||||
toAppUrl?(args: Args): URL;
|
||||
};
|
||||
|
||||
type SignalRoute<Key extends string, Args extends object> = {
|
||||
isMatch(input: URL | string): boolean;
|
||||
fromUrl(input: URL | string): RouteResult<Key, Args> | null;
|
||||
toWebUrl(args: Args): URL;
|
||||
toAppUrl(args: Args): URL;
|
||||
};
|
||||
|
||||
type RouteResult<Key extends string, Args extends object> = {
|
||||
key: Key;
|
||||
args: Args;
|
||||
};
|
||||
|
||||
let _routeCount = 0;
|
||||
|
||||
function _route<Key extends string, Args extends object>(
|
||||
key: Key,
|
||||
config: RouteConfig<Args>
|
||||
): SignalRoute<Key, Args> {
|
||||
_routeCount += 1;
|
||||
return {
|
||||
isMatch(input) {
|
||||
const url = toUrl(input);
|
||||
if (url == null) {
|
||||
return false;
|
||||
}
|
||||
return config.patterns.some(matcher => {
|
||||
return matcher(url) != null;
|
||||
});
|
||||
},
|
||||
fromUrl(input) {
|
||||
const url = toUrl(input);
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
for (const matcher of config.patterns) {
|
||||
const result = matcher(url);
|
||||
if (result) {
|
||||
return {
|
||||
key,
|
||||
args: config.schema.parse(config.parse(result)),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
toWebUrl(args) {
|
||||
if (config.toWebUrl) {
|
||||
return config.toWebUrl(config.schema.parse(args));
|
||||
}
|
||||
throw new Error('Route does not support web URLs');
|
||||
},
|
||||
toAppUrl(args) {
|
||||
if (config.toAppUrl) {
|
||||
return config.toAppUrl(config.schema.parse(args));
|
||||
}
|
||||
throw new Error('Route does not support app URLs');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramSchema = z.string().min(1);
|
||||
const optionalParamSchema = paramSchema.nullish().default(null);
|
||||
|
||||
/**
|
||||
* signal.me by phone number
|
||||
* @example
|
||||
* ```ts
|
||||
* contactByPhoneNumberRoute.toWebUrl({
|
||||
* phoneNumber: "+1234567890",
|
||||
* })
|
||||
* // URL { "https://signal.me/#p/+1234567890" }
|
||||
* ```
|
||||
*/
|
||||
export const contactByPhoneNumberRoute = _route('contactByPhoneNumber', {
|
||||
patterns: [
|
||||
_pattern('https:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }),
|
||||
_pattern('sgnl:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }),
|
||||
],
|
||||
schema: z.object({
|
||||
phoneNumber: paramSchema, // E164 (with +)
|
||||
}),
|
||||
parse(result) {
|
||||
return {
|
||||
phoneNumber: paramSchema.parse(result.hash.groups.phoneNumber),
|
||||
};
|
||||
},
|
||||
toWebUrl(args) {
|
||||
return new URL(`https://signal.me/#p/${args.phoneNumber}`);
|
||||
},
|
||||
toAppUrl(args) {
|
||||
return new URL(`sgnl://signal.me/#p/${args.phoneNumber}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* signal.me by encrypted username
|
||||
* @example
|
||||
* ```ts
|
||||
* contactByEncryptedUsernameRoute.toWebUrl({
|
||||
* encryptedUsername: "123",
|
||||
* })
|
||||
* // URL { "https://signal.me/#eu/123" }
|
||||
* ```
|
||||
*/
|
||||
export const contactByEncryptedUsernameRoute = _route(
|
||||
'contactByEncryptedUsername',
|
||||
{
|
||||
patterns: [
|
||||
_pattern('https:', 'signal.me', '{/}?', {
|
||||
hash: 'eu/:encryptedUsername',
|
||||
}),
|
||||
_pattern('sgnl:', 'signal.me', '{/}?', { hash: 'eu/:encryptedUsername' }),
|
||||
],
|
||||
schema: z.object({
|
||||
encryptedUsername: paramSchema, // base64url (32 bytes of entropy + 16 bytes of big-endian UUID)
|
||||
}),
|
||||
parse(result) {
|
||||
return {
|
||||
encryptedUsername: result.hash.groups.encryptedUsername,
|
||||
};
|
||||
},
|
||||
toWebUrl(args) {
|
||||
return new URL(`https://signal.me/#eu/${args.encryptedUsername}`);
|
||||
},
|
||||
toAppUrl(args) {
|
||||
return new URL(`sgnl://signal.me/#eu/${args.encryptedUsername}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Group invites
|
||||
* @example
|
||||
* ```ts
|
||||
* groupInvitesRoute.toWebUrl({
|
||||
* inviteCode: "123",
|
||||
* })
|
||||
* // URL { "https://signal.group/#123" }
|
||||
* ```
|
||||
*/
|
||||
export const groupInvitesRoute = _route('groupInvites', {
|
||||
patterns: [
|
||||
_pattern('https:', 'signal.group', '{/}?', {
|
||||
hash: ':inviteCode([^\\/]+)',
|
||||
}),
|
||||
_pattern('sgnl:', 'signal.group', '{/}?', {
|
||||
hash: ':inviteCode([^\\/]+)',
|
||||
}),
|
||||
_pattern('sgnl:', 'joingroup', '{/}?', { hash: ':inviteCode([^\\/]+)' }),
|
||||
],
|
||||
schema: z.object({
|
||||
inviteCode: paramSchema, // base64url (GroupInviteLink proto)
|
||||
}),
|
||||
parse(result) {
|
||||
return {
|
||||
inviteCode: result.hash.groups.inviteCode,
|
||||
};
|
||||
},
|
||||
toWebUrl(args) {
|
||||
return new URL(`https://signal.group/#${args.inviteCode}`);
|
||||
},
|
||||
toAppUrl(args) {
|
||||
return new URL(`sgnl://signal.group/#${args.inviteCode}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Device linking QR code
|
||||
* @example
|
||||
* ```ts
|
||||
* linkDeviceRoute.toAppUrl({
|
||||
* uuid: "123",
|
||||
* pubKey: "abc",
|
||||
* })
|
||||
* // URL { "sgnl://linkdevice?uuid=123&pub_key=abc" }
|
||||
* ```
|
||||
*/
|
||||
export const linkDeviceRoute = _route('linkDevice', {
|
||||
patterns: [_pattern('sgnl:', 'linkdevice', '{/}?', { search: ':params' })],
|
||||
schema: z.object({
|
||||
uuid: paramSchema, // base64url?
|
||||
pubKey: paramSchema, // percent-encoded base64 (with padding) of PublicKey with type byte included
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(result.search.groups.params);
|
||||
return {
|
||||
uuid: params.get('uuid'),
|
||||
pubKey: params.get('pub_key'),
|
||||
};
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
uuid: args.uuid,
|
||||
pub_key: args.pubKey,
|
||||
});
|
||||
return new URL(`sgnl://linkdevice?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Captchas
|
||||
* @example
|
||||
* ```ts
|
||||
* captchaRoute.toAppUrl({
|
||||
* captchaId: "123",
|
||||
* })
|
||||
* // URL { "signalcaptcha://123" }
|
||||
* ```
|
||||
*/
|
||||
export const captchaRoute = _route('captcha', {
|
||||
patterns: [_pattern('signalcaptcha:', ':captchaId', '', {})],
|
||||
schema: z.object({
|
||||
captchaId: paramSchema, // opaque
|
||||
}),
|
||||
parse(result) {
|
||||
return {
|
||||
captchaId: result.hostname.groups.captchaId,
|
||||
};
|
||||
},
|
||||
toAppUrl(args) {
|
||||
return new URL(`signalcaptcha://${args.captchaId}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Join a call with a link.
|
||||
* @example
|
||||
* ```ts
|
||||
* linkCallRoute.toWebUrl({
|
||||
* key: "123",
|
||||
* })
|
||||
* // URL { "https://signal.link/call#key=123" }
|
||||
*/
|
||||
export const linkCallRoute = _route('linkCall', {
|
||||
patterns: [
|
||||
_pattern('https:', 'signal.link', '/call{/}?', { hash: ':params' }),
|
||||
_pattern('sgnl:', 'signal.link', '/call{/}?', { hash: ':params' }),
|
||||
],
|
||||
schema: z.object({
|
||||
key: paramSchema, // ConsonantBase16
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(result.hash.groups.params);
|
||||
return {
|
||||
key: params.get('key'),
|
||||
};
|
||||
},
|
||||
toWebUrl(args) {
|
||||
const params = new URLSearchParams({ key: args.key });
|
||||
return new URL(`https://signal.link/call#${params.toString()}`);
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({ key: args.key });
|
||||
return new URL(`sgnl://signal.link/call#${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sticker packs
|
||||
* @example
|
||||
* ```ts
|
||||
* artAddStickersRoute.toWebUrl({
|
||||
* packId: "123",
|
||||
* packKey: "abc",
|
||||
* })
|
||||
* // URL { "https://signal.art/addstickers#pack_id=123&pack_key=abc" }
|
||||
* ```
|
||||
*/
|
||||
export const artAddStickersRoute = _route('artAddStickers', {
|
||||
patterns: [
|
||||
_pattern('https:', 'signal.art', '/addstickers{/}?', { hash: ':params' }),
|
||||
_pattern('sgnl:', 'addstickers', '{/}?', { search: ':params' }),
|
||||
],
|
||||
schema: z.object({
|
||||
packId: paramSchema, // hexadecimal
|
||||
packKey: paramSchema, // hexadecimal
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(
|
||||
result.hash.groups.params ?? result.search.groups.params
|
||||
);
|
||||
return {
|
||||
packId: params.get('pack_id'),
|
||||
packKey: params.get('pack_key'),
|
||||
};
|
||||
},
|
||||
toWebUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
pack_id: args.packId,
|
||||
pack_key: args.packKey,
|
||||
});
|
||||
return new URL(`https://signal.art/addstickers#${params.toString()}`);
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
pack_id: args.packId,
|
||||
pack_key: args.packKey,
|
||||
});
|
||||
return new URL(`sgnl://addstickers?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Art Service Authentication
|
||||
* @example
|
||||
* ```ts
|
||||
* artAuthRoute.toAppUrl({
|
||||
* token: "123",
|
||||
* pubKey: "abc",
|
||||
* })
|
||||
* // URL { "sgnl://art-auth?token=123&pub_key=abc" }
|
||||
*/
|
||||
export const artAuthRoute = _route('artAuth', {
|
||||
patterns: [_pattern('sgnl:', 'art-auth', '{/}?', { search: ':params' })],
|
||||
schema: z.object({
|
||||
token: paramSchema, // opaque
|
||||
pubKey: paramSchema, // base64url
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(result.search.groups.params);
|
||||
return {
|
||||
token: params.get('token'),
|
||||
pubKey: params.get('pub_key'),
|
||||
};
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
token: args.token,
|
||||
pub_key: args.pubKey,
|
||||
});
|
||||
return new URL(`sgnl://art-auth?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Show a conversation
|
||||
* @example
|
||||
* ```ts
|
||||
* showConversationRoute.toAppUrl({
|
||||
* conversationId: "123",
|
||||
* messageId: "abc",
|
||||
* storyId: "def",
|
||||
* })
|
||||
* // URL { "sgnl://show-conversation?conversationId=123&messageId=abc&storyId=def" }
|
||||
* ```
|
||||
*/
|
||||
export const showConversationRoute = _route('showConversation', {
|
||||
patterns: [
|
||||
_pattern('sgnl:', 'show-conversation', '{/}?', { search: ':params' }),
|
||||
],
|
||||
schema: z.object({
|
||||
conversationId: paramSchema,
|
||||
messageId: optionalParamSchema,
|
||||
storyId: optionalParamSchema,
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(result.search.groups.params);
|
||||
return {
|
||||
conversationId: params.get('conversationId'),
|
||||
messageId: params.get('messageId'),
|
||||
storyId: params.get('storyId'),
|
||||
};
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
conversationId: args.conversationId,
|
||||
});
|
||||
if (args.messageId != null) {
|
||||
params.set('messageId', args.messageId);
|
||||
}
|
||||
if (args.storyId != null) {
|
||||
params.set('storyId', args.storyId);
|
||||
}
|
||||
return new URL(`sgnl://show-conversation?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Start a call lobby
|
||||
* @example
|
||||
* ```ts
|
||||
* startCallLobbyRoute.toAppUrl({
|
||||
* conversationId: "123",
|
||||
* })
|
||||
* // URL { "sgnl://start-call-lobby?conversationId=123" }
|
||||
* ```
|
||||
*/
|
||||
export const startCallLobbyRoute = _route('startCallLobby', {
|
||||
patterns: [
|
||||
_pattern('sgnl:', 'start-call-lobby', '{/}?', { search: ':params' }),
|
||||
],
|
||||
schema: z.object({
|
||||
conversationId: paramSchema,
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(result.search.groups.params);
|
||||
return {
|
||||
conversationId: params.get('conversationId'),
|
||||
};
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
conversationId: args.conversationId,
|
||||
});
|
||||
return new URL(`sgnl://start-call-lobby?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Show window
|
||||
* @example
|
||||
* ```ts
|
||||
* showWindowRoute.toAppUrl({})
|
||||
* // URL { "sgnl://show-window" }
|
||||
*/
|
||||
export const showWindowRoute = _route('showWindow', {
|
||||
patterns: [_pattern('sgnl:', 'show-window', '{/}?', {})],
|
||||
schema: z.object({}),
|
||||
parse() {
|
||||
return {};
|
||||
},
|
||||
toAppUrl() {
|
||||
return new URL('sgnl://show-window');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Set is presenting
|
||||
* @example
|
||||
* ```ts
|
||||
* setIsPresentingRoute.toAppUrl({})
|
||||
* // URL { "sgnl://set-is-presenting" }
|
||||
* ```
|
||||
*/
|
||||
export const setIsPresentingRoute = _route('setIsPresenting', {
|
||||
patterns: [_pattern('sgnl:', 'set-is-presenting', '{/}?', {})],
|
||||
schema: z.object({}),
|
||||
parse() {
|
||||
return {};
|
||||
},
|
||||
toAppUrl() {
|
||||
return new URL('sgnl://set-is-presenting');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Should include all routes for matching purposes.
|
||||
* @internal
|
||||
*/
|
||||
const _allSignalRoutes = [
|
||||
contactByPhoneNumberRoute,
|
||||
contactByEncryptedUsernameRoute,
|
||||
groupInvitesRoute,
|
||||
linkDeviceRoute,
|
||||
captchaRoute,
|
||||
linkCallRoute,
|
||||
artAddStickersRoute,
|
||||
artAuthRoute,
|
||||
showConversationRoute,
|
||||
startCallLobbyRoute,
|
||||
showWindowRoute,
|
||||
setIsPresentingRoute,
|
||||
] as const;
|
||||
|
||||
strictAssert(
|
||||
_allSignalRoutes.length === _routeCount,
|
||||
'Forgot to add route to routes list'
|
||||
);
|
||||
|
||||
/**
|
||||
* A parsed route with the `key` of the route and its parsed `args`.
|
||||
* @example
|
||||
* ```ts
|
||||
* parseSignalRoute(new URL("https://signal.me/#p/+1234567890"))
|
||||
* // {
|
||||
* // key: "contactByPhoneNumber",
|
||||
* // args: { phoneNumber: "+1234567890" },
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export type ParsedSignalRoute = NonNullable<
|
||||
ReturnType<typeof _allSignalRoutes[number]['fromUrl']>
|
||||
>;
|
||||
|
||||
/** @internal */
|
||||
type MatchedSignalRoute = {
|
||||
route: SignalRoute<string, object>;
|
||||
parsed: ParsedSignalRoute;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
function _matchSignalRoute(input: URL | string): MatchedSignalRoute | null {
|
||||
const url = toUrl(input);
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
for (const route of _allSignalRoutes) {
|
||||
const parsed = route.fromUrl(url);
|
||||
if (parsed != null) {
|
||||
return { route, parsed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function _normalizeUrl(url: URL | string): URL | null {
|
||||
const newUrl = toUrl(url);
|
||||
if (newUrl == null) {
|
||||
return null;
|
||||
}
|
||||
newUrl.port = '';
|
||||
newUrl.username = '';
|
||||
newUrl.password = '';
|
||||
if (newUrl.protocol === 'http:') {
|
||||
newUrl.protocol = 'https:';
|
||||
}
|
||||
return newUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL matches a route.
|
||||
* @example
|
||||
* ```ts
|
||||
* isSignalRoute(new URL("https://signal.me/#p/+1234567890")) // true
|
||||
* isSignalRoute(new URL("sgnl://signal.me/#p/+1234567890")) // true
|
||||
* isSignalRoute(new URL("https://signal.me")) // false
|
||||
* isSignalRoute(new URL("https://example.com")) // false
|
||||
* ```
|
||||
*/
|
||||
export function isSignalRoute(input: URL | string): boolean {
|
||||
return _matchSignalRoute(input) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe parse a URL into a matching route with the 'key' of the route and its
|
||||
* parsed args.
|
||||
* If it we can't match it to a route, return null.
|
||||
* @example
|
||||
* ```ts
|
||||
* parseSignalRoute(new URL("https://signal.me/#p/+1234567890"))
|
||||
* // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } }
|
||||
* parseSignalRoute(new URL("sgnl://signal.me/#p/+1234567890"))
|
||||
* // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } }
|
||||
* parseSignalRoute(new URL("https://example.com"))
|
||||
* // null
|
||||
* ```
|
||||
*/
|
||||
export function parseSignalRoute(
|
||||
input: URL | string
|
||||
): ParsedSignalRoute | null {
|
||||
return _matchSignalRoute(input)?.parsed ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe normalize a URL into a matching route URL.
|
||||
* If it we can't match it to a route, return null.
|
||||
* @example
|
||||
* ```ts
|
||||
* toSignalRouteUrl(new URL("http://username:password@signal.me/#p/+1234567890"))
|
||||
* // URL { "https://signal.me/#p/+1234567890" }
|
||||
* toSignalRouteUrl(new URL("sgnl://signal.me/#p/+1234567890"))
|
||||
* // URL { "sgnl://signal.me/#p/+1234567890" }
|
||||
* toSignalRouteUrl(new URL("https://example.com"))
|
||||
* // null
|
||||
* ```
|
||||
*/
|
||||
export function toSignalRouteUrl(input: URL | string): URL | null {
|
||||
const normalizedUrl = _normalizeUrl(input);
|
||||
if (normalizedUrl == null) {
|
||||
return null;
|
||||
}
|
||||
return _matchSignalRoute(normalizedUrl) != null ? normalizedUrl : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe normalize a URL into a matching route **App** URL.
|
||||
* If it we can't match it to a route, return null.
|
||||
* @example
|
||||
* ```ts
|
||||
* toSignalRouteAppUrl(new URL("https://signal.me/#p/+1234567890"))
|
||||
* // URL { "sgnl://signal.me/#p/+1234567890" }
|
||||
* toSignalRouteAppUrl(new URL("https://example.com"))
|
||||
* // null
|
||||
* ```
|
||||
*/
|
||||
export function toSignalRouteAppUrl(input: URL | string): URL | null {
|
||||
const normalizedUrl = _normalizeUrl(input);
|
||||
if (normalizedUrl == null) {
|
||||
return null;
|
||||
}
|
||||
const match = _matchSignalRoute(normalizedUrl);
|
||||
try {
|
||||
return match?.route.toAppUrl(match.parsed.args) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe normalize a URL into a matching route **Web** URL.
|
||||
* If it we can't match it to a route, return null.
|
||||
* @example
|
||||
* ```ts
|
||||
* toSignalRouteWebUrl(new URL("sgnl://signal.me/#p/+1234567890"))
|
||||
* // URL { "https://signal.me/#p/+1234567890" }
|
||||
* toSignalRouteWebUrl(new URL("https://example.com"))
|
||||
* // null
|
||||
* ```
|
||||
*/
|
||||
export function toSignalRouteWebUrl(input: URL | string): URL | null {
|
||||
const normalizedUrl = _normalizeUrl(input);
|
||||
if (normalizedUrl == null) {
|
||||
return null;
|
||||
}
|
||||
const match = _matchSignalRoute(normalizedUrl);
|
||||
try {
|
||||
return match?.route.toWebUrl(match.parsed.args) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue