Compare commits

..

13 Commits

Author SHA1 Message Date
V
95b12f109a Add README 2023-09-05 18:29:00 +02:00
Nuckyz
f40ab912eb Fix discord horrors 2023-09-05 01:37:24 -03:00
Nuckyz
700301ffbb Patch guild header popout context too 2023-09-05 01:37:06 -03:00
Nuckyz
d92894697b Improve timestamps and vanity link 2023-09-05 01:36:32 -03:00
V
47569d2ffa cleanup 2023-09-05 06:00:47 +02:00
V
d3b18bbcd2 Add relationship counts 2023-09-05 05:58:02 +02:00
V
eeedc531a3 fix explosive issues 2023-09-05 05:35:10 +02:00
V
59a2b834a6 bleh 2023-09-05 05:34:03 +02:00
V
1cfeaf77c1 add friends & blocked users 2023-09-05 05:18:26 +02:00
V
1757d17661 add channel and role count 2023-09-05 04:23:21 +02:00
V
9485d2457a improve owner 2023-09-05 04:03:31 +02:00
V
3fd2fc1d61 nicer modal 2023-09-05 03:53:33 +02:00
V
49bc6b8fd6 new plugin: server profile 2023-09-04 23:36:48 +02:00
14 changed files with 49 additions and 150 deletions

View File

@ -65,7 +65,7 @@ Also pay attention to the following:
- Match any but a guaranteed terminating character: `[^;]+`, for example to match the entire assigned value in `var a=b||c||func();`, - Match any but a guaranteed terminating character: `[^;]+`, for example to match the entire assigned value in `var a=b||c||func();`,
`var .{1,2}=([^;]+);` `var .{1,2}=([^;]+);`
- If you don't care about that part, just match a bunch of chars: `.{0,50}`, for example to extract the variable "b" in `createElement("div",{a:"foo",c:"bar"},b)`, `createElement\("div".{0,30},(.{1,2})\),`. Note the `.{0,30}`, this is essentially the same as `.+`, but safer as you can't end up accidently eating thousands of characters - If you don't care about that part, just match a bunch of chars: `.{0,50}`, for example to extract the variable "b" in `createElement("div",{a:"foo",c:"bar"},b)`, `createElement\("div".{0,30},(.{1,2})\),`. Note the `.{0,30}`, this is essentially the same as `.+`, but safer as you can't end up accidently eating thousands of characters
- Additionally, as you might have noticed, all of the above approaches use regex groups (`(...)`) to capture the variable name. You can then use those groups in your replacement to access those variables dynamically - Additionally, as you might have noticed, all of the appove approaches use regex groups (`(...)`) to capture the variable name. You can then use those groups in your replacement to access those variables dynamically
#### "replace" #### "replace"

View File

@ -63,7 +63,7 @@ Then fully close Discord from your taskbar or task manager, and restart it. Venc
If you're using Discord already, go into the `Updater` tab in settings. If you're using Discord already, go into the `Updater` tab in settings.
Sometimes it may be necessary to manually update if the GUI updater fails. Sometimes it may be neccessary to manually update if the GUI updater fails.
To pull latest changes: To pull latest changes:

View File

@ -1,7 +1,7 @@
{ {
"name": "vencord", "name": "vencord",
"private": "true", "private": "true",
"version": "1.4.6", "version": "1.4.5",
"description": "The cutest Discord client mod", "description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme", "homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": { "bugs": {

View File

@ -23,7 +23,7 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { proxyLazy } from "@utils/lazy"; import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { LazyComponent } from "@utils/react"; import { LazyComponent } from "@utils/react";
import { OptionType, Plugin } from "@utils/types"; import { OptionType, Plugin } from "@utils/types";
@ -89,7 +89,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
const canSubmit = () => Object.values(errors).every(e => !e); const canSubmit = () => Object.values(errors).every(e => !e);
const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options)); const hasSettings = Boolean(pluginSettings && plugin.options);
React.useEffect(() => { React.useEffect(() => {
enableStyle(hideBotTagStyle); enableStyle(hideBotTagStyle);

View File

@ -28,7 +28,7 @@ import { SettingsTab } from "@components/VencordSettings/shared";
import { ChangeList } from "@utils/ChangeList"; import { ChangeList } from "@utils/ChangeList";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes } from "@utils/misc";
import { openModalLazy } from "@utils/modal"; import { openModalLazy } from "@utils/modal";
import { LazyComponent, useAwaiter } from "@utils/react"; import { LazyComponent, useAwaiter } from "@utils/react";
import { Plugin } from "@utils/types"; import { Plugin } from "@utils/types";
@ -161,7 +161,7 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
infoButton={ infoButton={
<button role="switch" onClick={() => openModal()} className={classes(ButtonClasses.button, cl("info-button"))}> <button role="switch" onClick={() => openModal()} className={classes(ButtonClasses.button, cl("info-button"))}>
{plugin.options && !isObjectEmpty(plugin.options) {plugin.options
? <CogWheel /> ? <CogWheel />
: <InfoIcon width="24" height="24" />} : <InfoIcon width="24" height="24" />}
</button> </button>

View File

@ -49,7 +49,7 @@ export default definePlugin({
await this.buildCss(); await this.buildCss();
addButton("HideAttachments", msg => { addButton("HideAttachments", msg => {
if (!msg.attachments.length && !msg.embeds.length && !msg.stickerItems.length) return null; if (!msg.attachments.length && !msg.embeds.length) return null;
const isHidden = hiddenMessages.has(msg.id); const isHidden = hiddenMessages.has(msg.id);
@ -72,7 +72,7 @@ export default definePlugin({
async buildCss() { async buildCss() {
const elements = [...hiddenMessages].map(id => `#message-accessories-${id}`).join(","); const elements = [...hiddenMessages].map(id => `#message-accessories-${id}`).join(",");
style.textContent = ` style.textContent = `
:is(${elements}) :is([class*="embedWrapper", [class*"clickableSticker"]) { :is(${elements}) [class*="embedWrapper"] {
/* important is not necessary, but add it to make sure bad themes won't break it */ /* important is not necessary, but add it to make sure bad themes won't break it */
display: none !important; display: none !important;
} }

View File

@ -30,9 +30,6 @@ const ChannelMemberStore = findStoreLazy("ChannelMemberStore") as FluxStore & {
getProps(guildId: string, channelId: string): { groups: { count: number; id: string; }[]; }; getProps(guildId: string, channelId: string): { groups: { count: number; id: string; }[]; };
}; };
const sharedIntlNumberFormat = new Intl.NumberFormat();
const numberFormat = (value: number) => sharedIntlNumberFormat.format(value);
function MemberCount() { function MemberCount() {
const { id: channelId, guild_id: guildId } = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); const { id: channelId, guild_id: guildId } = useStateFromStores([SelectedChannelStore], () => getCurrentChannel());
const { groups } = useStateFromStores( const { groups } = useStateFromStores(
@ -60,7 +57,7 @@ function MemberCount() {
alignContent: "center", alignContent: "center",
gap: 0 gap: 0
}}> }}>
<Tooltip text={`${numberFormat(online)} online in this channel`} position="bottom"> <Tooltip text={`${online} Online in this Channel`} position="bottom">
{props => ( {props => (
<div {...props}> <div {...props}>
<span <span
@ -73,11 +70,11 @@ function MemberCount() {
marginRight: "0.5em" marginRight: "0.5em"
}} }}
/> />
<span style={{ color: "var(--green-360)" }}>{numberFormat(online)}</span> <span style={{ color: "var(--green-360)" }}>{online}</span>
</div> </div>
)} )}
</Tooltip> </Tooltip>
<Tooltip text={`${numberFormat(total)} total server members`} position="bottom"> <Tooltip text={`${total} Total Server Members`} position="bottom">
{props => ( {props => (
<div {...props}> <div {...props}>
<span <span
@ -91,7 +88,7 @@ function MemberCount() {
marginLeft: "1em" marginLeft: "1em"
}} }}
/> />
<span style={{ color: "var(--primary-400)" }}>{numberFormat(total)}</span> <span style={{ color: "var(--primary-400)" }}>{total}</span>
</div> </div>
)} )}
</Tooltip> </Tooltip>

View File

@ -169,14 +169,21 @@ export default definePlugin({
try { try {
if (cache == null || (!isBulk && !cache.has(data.id))) return cache; if (cache == null || (!isBulk && !cache.has(data.id))) return cache;
const mutate = (id: string) => { const { ignoreBots, ignoreSelf, ignoreUsers, ignoreChannels, ignoreGuilds } = Settings.plugins.MessageLogger;
const myId = UserStore.getCurrentUser().id;
function mutate(id: string) {
const msg = cache.get(id); const msg = cache.get(id);
if (!msg) return; if (!msg) return;
const EPHEMERAL = 64; const EPHEMERAL = 64;
const shouldIgnore = data.mlDeleted || const shouldIgnore = data.mlDeleted ||
(msg.flags & EPHEMERAL) === EPHEMERAL || (msg.flags & EPHEMERAL) === EPHEMERAL ||
this.shouldIgnore(msg); ignoreBots && msg.author?.bot ||
ignoreSelf && msg.author?.id === myId ||
ignoreUsers.includes(msg.author?.id) ||
ignoreChannels.includes(msg.channel_id) ||
ignoreGuilds.includes(ChannelStore.getChannel(msg.channel_id)?.guild_id);
if (shouldIgnore) { if (shouldIgnore) {
cache = cache.remove(id); cache = cache.remove(id);
@ -185,7 +192,7 @@ export default definePlugin({
.set("deleted", true) .set("deleted", true)
.set("attachments", m.attachments.map(a => (a.deleted = true, a)))); .set("attachments", m.attachments.map(a => (a.deleted = true, a))));
} }
}; }
if (isBulk) { if (isBulk) {
data.ids.forEach(mutate); data.ids.forEach(mutate);
@ -198,17 +205,6 @@ export default definePlugin({
return cache; return cache;
}, },
shouldIgnore(message: any) {
const { ignoreBots, ignoreSelf, ignoreUsers, ignoreChannels, ignoreGuilds } = Settings.plugins.MessageLogger;
const myId = UserStore.getCurrentUser().id;
return ignoreBots && message.author?.bot ||
ignoreSelf && message.author?.id === myId ||
ignoreUsers.includes(message.author?.id) ||
ignoreChannels.includes(message.channel_id) ||
ignoreGuilds.includes(ChannelStore.getChannel(message.channel_id)?.guild_id);
},
// Based on canary 9ab8626bcebceaea6da570b9c586172d02b9c996 // Based on canary 9ab8626bcebceaea6da570b9c586172d02b9c996
patches: [ patches: [
{ {
@ -241,7 +237,7 @@ export default definePlugin({
match: /(MESSAGE_UPDATE:function\((\w)\).+?)\.update\((\w)/, match: /(MESSAGE_UPDATE:function\((\w)\).+?)\.update\((\w)/,
replace: "$1" + replace: "$1" +
".update($3,m =>" + ".update($3,m =>" +
" (($2.message.flags & 64) === 64 || $self.shouldIgnore($2.message)) ? m :" + " (($2.message.flags & 64) === 64 || (Vencord.Settings.plugins.MessageLogger.ignoreBots && $2.message.author?.bot) || (Vencord.Settings.plugins.MessageLogger.ignoreSelf && $2.message.author?.id === Vencord.Webpack.Common.UserStore.getCurrentUser().id)) ? m :" +
" $2.message.content !== m.editHistory?.[0]?.content && $2.message.content !== m.content ?" + " $2.message.content !== m.editHistory?.[0]?.content && $2.message.content !== m.content ?" +
" m.set('editHistory',[...(m.editHistory || []), $self.makeEdit($2.message, m)]) :" + " m.set('editHistory',[...(m.editHistory || []), $self.makeEdit($2.message, m)]) :" +
" m" + " m" +

View File

@ -122,14 +122,6 @@ export default definePlugin({
// ....concat(pins).concat(toArray(channelIds).filter(c => !isPinned(c))) // ....concat(pins).concat(toArray(channelIds).filter(c => !isPinned(c)))
replace: ".concat($self.getSnapshot()).concat($2.filter(c=>!$self.isPinned(c)))" replace: ".concat($self.getSnapshot()).concat($2.filter(c=>!$self.isPinned(c)))"
} }
}, }
// fix alt+shift+up/down
{
find: '"alt+shift+down"',
replacement: {
match: /(?<=return \i===\i\.ME\?)\i\.\i\.getPrivateChannelIds\(\)/,
replace: "$self.getSnapshot().concat($&.filter(c=>!$self.isPinned(c)))"
}
},
] ]
}); });

View File

@ -20,7 +20,7 @@ import { sendBotMessage } from "@api/Commands";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { Button, ButtonLooks, ButtonWrapperClasses, DraftStore, DraftType, SelectedChannelStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common"; import { Button, ButtonLooks, ButtonWrapperClasses, DraftStore, DraftType, SelectedChannelStore, Tooltip, UserStore } from "@webpack/common";
interface Props { interface Props {
type: { type: {
@ -31,9 +31,10 @@ interface Props {
const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage); const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage);
export function PreviewButton(chatBoxProps: Props) { export function PreviewButton(chatBoxProps: Props) {
const channelId = SelectedChannelStore.getChannelId();
const draft = useStateFromStores([DraftStore], () => getDraft(channelId));
if (chatBoxProps.type.analyticsName !== "normal") return null; if (chatBoxProps.type.analyticsName !== "normal") return null;
const channelId = SelectedChannelStore.getChannelId();
const draft = getDraft(channelId);
if (!draft) return null; if (!draft) return null;
return ( return (

View File

@ -1,35 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "ShowTimeouts",
description: "Display member timeout icons in chat regardless of permissions.",
authors: [Devs.Dolfies],
patches: [
{
find: "showCommunicationDisabledStyles",
replacement: {
match: /&&\i\.\i\.canManageUser\(\i\.\i\.MODERATE_MEMBERS,\i\.author,\i\)/,
replace: "",
},
},
],
});

View File

@ -16,7 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { addButton, removeButton } from "@api/MessagePopover"; import { addButton, removeButton } from "@api/MessagePopover";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
@ -26,11 +25,9 @@ import { Margins } from "@utils/margins";
import { copyWithToast } from "@utils/misc"; import { copyWithToast } from "@utils/misc";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { Button, ChannelStore, Forms, Parser, Text } from "@webpack/common";
import { Button, ChannelStore, Forms, Menu, Parser, Text } from "@webpack/common";
import { Message } from "discord-types/general"; import { Message } from "discord-types/general";
const CodeContainerClasses = findByPropsLazy("markup", "codeContainer");
const CopyIcon = () => { const CopyIcon = () => {
return <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" width="22" height="22"> return <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" width="22" height="22">
@ -63,13 +60,17 @@ function cleanMessage(msg: Message) {
function CodeBlock(props: { content: string, lang: string; }) { function CodeBlock(props: { content: string, lang: string; }) {
return ( return (
<div className={CodeContainerClasses.markup}> // make text selectable
<div style={{ userSelect: "text" }}>
{Parser.defaultRules.codeBlock.react(props, null, {})} {Parser.defaultRules.codeBlock.react(props, null, {})}
</div> </div>
); );
} }
function openViewRawModal(json: string, type: string, msgContent?: string) { function openViewRawModal(msg: Message) {
msg = cleanMessage(msg);
const msgJson = JSON.stringify(msg, null, 4);
const key = openModal(props => ( const key = openModal(props => (
<ErrorBoundary> <ErrorBoundary>
<ModalRoot {...props} size={ModalSize.LARGE}> <ModalRoot {...props} size={ModalSize.LARGE}>
@ -79,28 +80,26 @@ function openViewRawModal(json: string, type: string, msgContent?: string) {
</ModalHeader> </ModalHeader>
<ModalContent> <ModalContent>
<div style={{ padding: "16px 0" }}> <div style={{ padding: "16px 0" }}>
{!!msgContent && ( {!!msg.content && (
<> <>
<Forms.FormTitle tag="h5">Content</Forms.FormTitle> <Forms.FormTitle tag="h5">Content</Forms.FormTitle>
<CodeBlock content={msgContent} lang="" /> <CodeBlock content={msg.content} lang="" />
<Forms.FormDivider className={Margins.bottom20} /> <Forms.FormDivider className={Margins.bottom20} />
</> </>
)} )}
<Forms.FormTitle tag="h5">{type} Data</Forms.FormTitle> <Forms.FormTitle tag="h5">Message Data</Forms.FormTitle>
<CodeBlock content={json} lang="json" /> <CodeBlock content={msgJson} lang="json" />
</div> </div>
</ModalContent > </ModalContent >
<ModalFooter> <ModalFooter>
<Flex cellSpacing={10}> <Flex cellSpacing={10}>
<Button onClick={() => copyWithToast(json, `${type} data copied to clipboard!`)}> <Button onClick={() => copyWithToast(msgJson, "Message data copied to clipboard!")}>
Copy {type} JSON Copy Message JSON
</Button>
<Button onClick={() => copyWithToast(msg.content, "Content copied to clipboard!")}>
Copy Raw Content
</Button> </Button>
{!!msgContent && (
<Button onClick={() => copyWithToast(msgContent, "Content copied to clipboard!")}>
Copy Raw Content
</Button>
)}
</Flex> </Flex>
</ModalFooter> </ModalFooter>
</ModalRoot > </ModalRoot >
@ -108,13 +107,6 @@ function openViewRawModal(json: string, type: string, msgContent?: string) {
)); ));
} }
function openViewRawModalMessage(msg: Message) {
msg = cleanMessage(msg);
const msgJson = JSON.stringify(msg, null, 4);
return openViewRawModal(msgJson, "Message", msg.content);
}
const settings = definePluginSettings({ const settings = definePluginSettings({
clickMethod: { clickMethod: {
description: "Change the button to view the raw content/data of any message.", description: "Change the button to view the raw content/data of any message.",
@ -126,33 +118,10 @@ const settings = definePluginSettings({
} }
}); });
function MakeContextCallback(name: string) {
const callback: NavContextMenuPatchCallback = (children, props) => () => {
const lastChild = children.at(-1);
if (lastChild?.key === "developer-actions") {
const p = lastChild.props;
if (!Array.isArray(p.children))
p.children = [p.children];
({ children } = p);
}
children.splice(-1, 0,
<Menu.MenuItem
id={`vc-view-${name.toLowerCase()}-raw`}
label="View Raw"
action={() => openViewRawModal(JSON.stringify(props[name.toLowerCase()], null, 4), name)}
icon={CopyIcon}
/>
);
};
return callback;
}
export default definePlugin({ export default definePlugin({
name: "ViewRaw", name: "ViewRaw",
description: "Copy and view the raw content/data of any message, channel or guild", description: "Copy and view the raw content/data of any message.",
authors: [Devs.KingFish, Devs.Ven, Devs.rad, Devs.ImLvna], authors: [Devs.KingFish, Devs.Ven, Devs.rad],
dependencies: ["MessagePopoverAPI"], dependencies: ["MessagePopoverAPI"],
settings, settings,
@ -162,7 +131,7 @@ export default definePlugin({
if (settings.store.clickMethod === "Right") { if (settings.store.clickMethod === "Right") {
copyWithToast(msg.content); copyWithToast(msg.content);
} else { } else {
openViewRawModalMessage(msg); openViewRawModal(msg);
} }
}; };
@ -174,7 +143,7 @@ export default definePlugin({
} else { } else {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
openViewRawModalMessage(msg); openViewRawModal(msg);
} }
}; };
@ -191,16 +160,9 @@ export default definePlugin({
onContextMenu: handleContextMenu onContextMenu: handleContextMenu
}; };
}); });
addContextMenuPatch("guild-context", MakeContextCallback("Guild"));
addContextMenuPatch("channel-context", MakeContextCallback("Channel"));
addContextMenuPatch("user-context", MakeContextCallback("User"));
}, },
stop() { stop() {
removeButton("CopyRawMessage"); removeButton("CopyRawMessage");
removeContextMenuPatch("guild-context", MakeContextCallback("Guild"));
removeContextMenuPatch("channel-context", MakeContextCallback("Channel"));
removeContextMenuPatch("user-context", MakeContextCallback("User"));
} }
}); });

View File

@ -355,10 +355,6 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "bb010g", name: "bb010g",
id: 72791153467990016n, id: 72791153467990016n,
}, },
Dolfies: {
name: "Dolfies",
id: 852892297661906993n,
},
RuukuLada: { RuukuLada: {
name: "RuukuLada", name: "RuukuLada",
id: 119705748346241027n, id: 119705748346241027n,

View File

@ -74,16 +74,6 @@ export function isObject(obj: unknown): obj is object {
return typeof obj === "object" && obj !== null && !Array.isArray(obj); return typeof obj === "object" && obj !== null && !Array.isArray(obj);
} }
/**
* Check if an object is empty or in other words has no own properties
*/
export function isObjectEmpty(obj: object) {
for (const k in obj)
if (Object.hasOwn(obj, k)) return false;
return true;
}
/** /**
* Returns null if value is not a URL, otherwise return URL object. * Returns null if value is not a URL, otherwise return URL object.
* Avoids having to wrap url checks in a try/catch * Avoids having to wrap url checks in a try/catch