Plugin

OpenInApp

Open links in their respective apps instead of your browser

Utility
index.ts
Download

Source

src/plugins/openInApp/index.ts
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { definePluginSettings } from "@api/Settings";
8import { Devs } from "@utils/constants";
9import definePlugin, { OptionType, PluginNative, SettingsDefinition } from "@utils/types";
10import { showToast, Toasts } from "@webpack/common";
11import type { MouseEvent } from "react";
12
13interface URLReplacementRule {
14 match: RegExp;
15 replace: (...matches: string[]) => string;
16 displayName?: string;
17 description: string;
18 shortlinkMatch?: RegExp;
19 accountViewReplace?: (userId: string) => string;
20}
21
22// Do not forget to add protocols to the ALLOWED_PROTOCOLS constant
23const UrlReplacementRules: Record<string, URLReplacementRule> = {
24 spotify: {
25 match: /^https:\/\/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?(track|album|artist|playlist|user|episode|prerelease)\/(.+)(?:\?.+?)?$/,
26 replace: (_, type, id) => `spotify:class="ts-cmt">//${type}/${id}`,
27 description: "Open Spotify links in the Spotify app",
28 shortlinkMatch: /^https:\/\/spotify\.link\/.+$/,
29 accountViewReplace: userId => `spotify:user:${userId}`,
30 },
31 steam: {
32 match: /^https:\/\/(steamcommunity\.com|(?:help|store)\.steampowered\.com)\/.+$/,
33 replace: match => `steam:class="ts-cmt">//openurl/${match}`,
34 description: "Open Steam links in the Steam app",
35 shortlinkMatch: /^https:\/\/s.team\/.+$/,
36 accountViewReplace: userId => `steam:class="ts-cmt">//openurl/https://steamcommunity.com/profiles/${userId}`,
37 },
38 epic: {
39 match: /^https:\/\/store\.epicgames\.com\/(.+)$/,
40 replace: (_, id) => `com.epicgames.launcher:class="ts-cmt">//store/${id}`,
41 description: "Open Epic Games links in the Epic Games Launcher",
42 },
43 tidal: {
44 match: /^https:\/\/(?:listen\.)?tidal\.com\/(?:browse\/)?(track|album|artist|playlist|user|video|mix)\/([a-f0-9-]+).*/,
45 replace: (_, type, id) => `tidal:class="ts-cmt">//${type}/${id}`,
46 description: "Open Tidal links in the Tidal app",
47 },
48 itunes: {
49 match: /^https:\/\/(?:geo\.)?music\.apple\.com\/([a-z]{2}\/)?(album|artist|playlist|song|curator)\/([^/?#]+)\/?([^/?#]+)?(?:\?.*)?(?:#.*)?$/,
50 replace: (_, lang, type, name, id) => id ? `itunes:class="ts-cmt">//music.apple.com/us/${type}/${name}/${id}` : `itunes://music.apple.com/us/${type}/${name}`,
51 displayName: "iTunes",
52 description: "Open Apple Music links in the iTunes app"
53 },
54};
55
56const pluginSettings = definePluginSettings(
57 Object.entries(UrlReplacementRules).reduce((acc, [key, rule]) => {
58 acc[key] = {
59 type: OptionType.BOOLEAN,
60 displayName: rule.displayName,
61 description: rule.description,
62 default: true,
63 };
64 return acc;
65 }, {} as SettingsDefinition)
66);
67
68
69const Native = VencordNative.pluginHelpers.OpenInApp as PluginNative<typeof import("./native")>;
70
71export default definePlugin({
72 name: "OpenInApp",
73 description: "Open links in their respective apps instead of your browser",
74 tags: ["Utility"],
75 authors: [Devs.Ven, Devs.surgedevs],
76 settings: pluginSettings,
77
78 patches: [
79 {
80 find: "trackAnnouncementMessageLinkClicked({",
81 replacement: {
82 match: /function (\i\(\i,\i\)\{)(?=.{0,150}trusted:)/,
83 replace: "async function $1 if(await $self.handleLink(...arguments)) return;"
84 }
85 },
86 {
87 find: "no artist ids in metadata",
88 predicate: () => !IS_DISCORD_DESKTOP && pluginSettings.store.spotify,
89 replacement: [
90 {
91 match: /\i\.\i\.isProtocolRegistered\(\)/g,
92 replace: "true"
93 },
94 {
95 match: /\(0,\i\.isDesktop\)\(\)/,
96 replace: "true"
97 }
98 ]
99 },
100
101 // User Profile Modal & User Profile Modal v2
102 ...[".__invalid_connectedAccountOpenIconContainer", ".BLUESKY||"].map(find => ({
103 find,
104 replacement: {
105 match: /(?<=onClick:(\i)=>\{)(?=.{0,100}\.CONNECTED_ACCOUNT_VIEWED)(?<==(\i)\.metadata.+?)/,
106 replace: "if($self.handleAccountView($1,$2.type,$2.id)) return;"
107 }
108 }))
109 ],
110
111 async handleLink(data: { href: string; }, event?: MouseEvent) {
112 if (!data) return false;
113
114 let url = data.href;
115 if (!url) return false;
116
117 for (const [key, rule] of Object.entries(UrlReplacementRules)) {
118 if (!pluginSettings.store[key]) continue;
119
120 if (rule.shortlinkMatch?.test(url)) {
121 event?.preventDefault();
122 url = await Native.resolveRedirect(url);
123 }
124
125 if (rule.match.test(url)) {
126 showToast("Opened link in native app", Toasts.Type.SUCCESS);
127
128 const newUrl = url.replace(rule.match, rule.replace);
129 VencordNative.native.openExternal(newUrl);
130
131 event?.preventDefault();
132 return true;
133 }
134 }
135
136 // in case short url didn't end up being something we can handle
137 if (event?.defaultPrevented) {
138 window.open(url, "_blank");
139 return true;
140 }
141
142 return false;
143 },
144
145 handleAccountView(e: MouseEvent, platformType: string, userId: string) {
146 const rule = UrlReplacementRules[platformType];
147 if (rule?.accountViewReplace && pluginSettings.store[platformType]) {
148 VencordNative.native.openExternal(rule.accountViewReplace(userId));
149 e.preventDefault();
150 return true;
151 }
152 }
153});
154